Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package com.arflix.tv.ui.screens.tv.live

import android.graphics.Bitmap
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.arflix.tv.data.model.IptvChannel
import com.arflix.tv.data.model.IptvNowNext
import com.arflix.tv.data.model.IptvProgram
import java.io.File
import kotlinx.coroutines.awaitCancellation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalComposeUiApi::class)
class LiveSearchSelectionDeviceTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private val channels = (1..40).map { index ->
IptvChannel(
id = "search:$index",
name = "Example ${index.toString().padStart(2, '0')}",
group = "News",
streamUrl = "https://example.invalid/live/$index",
).enrichForFastStartup(index)
}
private val picked = mutableListOf<String>()
private var dismissed = 0

@Test fun remoteSelectOpensChannelResultExactlyOnce() {
show()
query("Example 05")
input().performImeAction()
result(channels[4]).assertIsFocused()
remote(KeyEvent.KEYCODE_DPAD_CENTER)
compose.runOnIdle {
assertEquals(listOf("search:5"), picked)
assertEquals(0, dismissed)
}
}

@Test fun remoteSelectOpensProgrammeResult() {
show(guide = mapOf(channels[2].id to IptvNowNext(
now = IptvProgram("Match of the day", startUtcMillis = 0, endUtcMillis = Long.MAX_VALUE),
)))
query("Match of the day")
remote(KeyEvent.KEYCODE_DPAD_DOWN)
result(channels[2]).assertIsFocused()
capture("live-search-programme")
remote(KeyEvent.KEYCODE_DPAD_CENTER)
compose.runOnIdle { assertEquals(listOf("search:3"), picked) }
}

@Test fun keyboardEnterAndNumpadEnterSelectExactlyOncePerPress() {
show()
query("Example 09")
remote(KeyEvent.KEYCODE_DPAD_DOWN)
remote(KeyEvent.KEYCODE_ENTER)
remote(KeyEvent.KEYCODE_NUMPAD_ENTER)
compose.runOnIdle { assertEquals(listOf("search:9", "search:9"), picked) }
}

@Test fun imeSearchHandsFocusToSelectableProviderResult() {
val remoteChannel = IptvChannel(
id = "search:55000", name = "Remote sports", group = "Sports",
streamUrl = "https://example.invalid/live/55000",
).enrichForFastStartup(55000)
show(provider = { listOf(remoteChannel) })
query("Remote sports")
input().performImeAction()
result(remoteChannel).assertIsFocused()
remote(KeyEvent.KEYCODE_DPAD_CENTER)
compose.runOnIdle { assertEquals(listOf("search:55000"), picked) }
}

@Test fun resultsScrollAndReturnToInputWithoutLosingSelection() {
show()
input().assertIsFocused()
remote(KeyEvent.KEYCODE_DPAD_DOWN)
result(channels[0]).assertIsFocused()
repeat(12) { index ->
remote(KeyEvent.KEYCODE_DPAD_DOWN)
result(channels[index + 1]).assertIsFocused()
}
result(channels[12]).assertIsFocused().assertIsDisplayed()
remote(KeyEvent.KEYCODE_DPAD_CENTER)
compose.runOnIdle { assertEquals(listOf("search:13"), picked) }
repeat(12) { remote(KeyEvent.KEYCODE_DPAD_UP) }
result(channels[0]).assertIsFocused()
remote(KeyEvent.KEYCODE_DPAD_UP)
input().assertIsFocused()
}

@Test fun touchTapSelectsWithoutDismissingOverlay() {
show()
query("Example 07")
result(channels[6]).performTouchInput { click() }
compose.runOnIdle {
assertEquals(listOf("search:7"), picked)
assertEquals(0, dismissed)
}
}

@Test fun accessibilityClickSelectsChannelResult() {
show()
query("Example 12")
result(channels[11]).assertHasClickAction().performClick()
compose.runOnIdle {
assertEquals(listOf("search:12"), picked)
assertEquals(0, dismissed)
}
}

@Test fun selectingResultDoesNotLeakSelectReleaseIntoGuide() {
val open = mutableStateOf(true)
val guideFocus = FocusRequester()
var guideReleases = 0
compose.setContent {
InterceptPlatformTextInput(interceptor = { _, _ -> awaitCancellation() }) {
Box(Modifier.onPreviewKeyEvent { event ->
if (!open.value && event.key == Key.DirectionCenter && event.type == KeyEventType.KeyUp) {
guideReleases++
}
false
}) {
Box(Modifier.size(20.dp).focusRequester(guideFocus).focusable())
if (open.value) SearchOverlay(channels, onDismiss = {}, onPick = { channel ->
picked.add(channel.id)
open.value = false
guideFocus.requestFocus()
})
}
}
}
awaitWindowFocus()
input().performImeAction()
result(channels[0]).assertIsFocused()
remote(KeyEvent.KEYCODE_DPAD_CENTER)
compose.runOnIdle {
assertFalse(open.value)
assertEquals(listOf("search:1"), picked)
assertEquals("A search selection must not activate the guide beneath it", 0, guideReleases)
}
}

private fun show(
guide: Map<String, IptvNowNext> = emptyMap(),
provider: (suspend (String) -> List<EnrichedChannel>)? = null,
) {
compose.setContent {
// The system IME owns its own remote keys; these checks target the result list.
InterceptPlatformTextInput(interceptor = { _, _ -> awaitCancellation() }) {
SearchOverlay(channels, guide, provider,
onDismiss = { dismissed++ }, onPick = { picked.add(it.id) })
}
}
awaitWindowFocus()
}

private fun awaitWindowFocus() {
compose.waitUntil(5_000) { compose.activity.hasWindowFocus() }
compose.waitForIdle()
}

private fun input() = compose.onNode(hasSetTextAction())

private fun query(value: String) {
input().performTextReplacement(value)
compose.mainClock.advanceTimeBy(200)
compose.waitForIdle()
}

private fun result(channel: EnrichedChannel) = compose.onNode(
isFocusable() and !hasSetTextAction() and
(hasText(channel.name) or hasAnyDescendant(hasText(channel.name))),
)

// Inject Android DOWN/UP events through the activity, just as a Fire TV remote does.
private fun remote(code: Int) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.sendKeyDownUpSync(code)
instrumentation.waitForIdleSync()
compose.waitForIdle()
}

private fun capture(name: String) {
val image = compose.onRoot().captureToImage().asAndroidBitmap()
File(compose.activity.getExternalFilesDir(null), "$name.png").outputStream().use {
image.compress(Bitmap.CompressFormat.PNG, 100, it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4797,10 +4797,13 @@ fun LiveTvScreen(
},
onDismiss = { searchOpen = false },
onPick = { channel ->
retainedPlayingChannel = channel
selectedCategoryId = bestCategoryIdForChannel(channel, visibleEnrichedState.value.tree)
playingChannelId = channel.id
focusedChannelId = channel.id
epgPrefetchAnchorId = channel.id
playingCatchupProgram = null
catchupPlaybackOffsetMs = 0L
searchOpen = false
if (isTouchDevice) {
sportsSelected = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ package com.arflix.tv.ui.screens.tv.live
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
Expand All @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
Expand All @@ -28,10 +29,10 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -49,7 +50,9 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
Expand All @@ -63,6 +66,7 @@ import com.arflix.tv.data.model.IptvProgram
import com.arflix.tv.util.formatGenreName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

/**
Expand All @@ -83,8 +87,24 @@ fun SearchOverlay(
var results by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
val focusRequester = remember { FocusRequester() }
val firstResultFocus = remember { FocusRequester() }
val resultListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } }

fun focusFirstResult() {
if (results.isEmpty()) return
keyboardController?.hide()
if (resultListState.layoutInfo.visibleItemsInfo.any { it.key == results.first().channel.id }) {
runCatching { firstResultFocus.requestFocus() }
} else {
coroutineScope.launch {
resultListState.scrollToItem(0)
runCatching { firstResultFocus.requestFocus() }
}
}
}

// Resolved up front so they can be used inside the non-composable search LaunchedEffect.
val nowFormat = stringResource(R.string.live_search_now)
val guideLabels = GuideMatchLabels(
Expand Down Expand Up @@ -182,6 +202,7 @@ fun SearchOverlay(
.clip(RoundedCornerShape(16.dp))
.background(LiveColors.PanelRaised)
.border(1.dp, LiveColors.Divider, RoundedCornerShape(16.dp))
.focusGroup()
.padding(16.dp)
.pointerInput(Unit) { detectTapGestures(onTap = {}) },
verticalArrangement = Arrangement.spacedBy(10.dp),
Expand All @@ -205,7 +226,7 @@ fun SearchOverlay(
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = { runCatching { firstResultFocus.requestFocus() } },
onSearch = { focusFirstResult() },
),
cursorBrush = SolidColor(liveAccent()),
textStyle = TextStyle(
Expand All @@ -220,7 +241,7 @@ fun SearchOverlay(
ev.key == Key.DirectionDown &&
results.isNotEmpty()
) {
runCatching { firstResultFocus.requestFocus() }
focusFirstResult()
true
} else {
false
Expand Down Expand Up @@ -255,6 +276,7 @@ fun SearchOverlay(
.background(LiveColors.Divider),
)
LazyColumn(
state = resultListState,
modifier = Modifier.fillMaxWidth().height(440.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Expand Down Expand Up @@ -345,30 +367,20 @@ private fun SearchResultRow(
shape = RoundedCornerShape(10.dp),
)
.onFocusChanged { focused = it.hasFocus }
.focusable()
.onKeyEvent { ev ->
if (ev.type != KeyEventType.KeyDown) return@onKeyEvent false
when (ev.key) {
Key.DirectionCenter, Key.Enter -> {
onPick(channel)
true
}

Key.DirectionUp -> {
if (onMoveUp != null) {
onMoveUp()
true
} else {
false
}
}

else -> false
.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionUp && onMoveUp != null) {
onMoveUp()
true
} else {
false
}
}
.pointerInput(channel.id) {
detectTapGestures(onTap = { onPick(channel) })
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
role = Role.Button,
onClick = { onPick(channel) },
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
Expand Down
Loading