diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSearchSelectionDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSearchSelectionDeviceTest.kt new file mode 100644 index 000000000..33bc71477 --- /dev/null +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/tv/live/LiveSearchSelectionDeviceTest.kt @@ -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() + 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() + 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 = emptyMap(), + provider: (suspend (String) -> List)? = 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) + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index 767be638d..cecd16190 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt index b14796f78..a2d949d75 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 /** @@ -83,8 +87,24 @@ fun SearchOverlay( var results by remember { mutableStateOf>(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( @@ -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), @@ -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( @@ -220,7 +241,7 @@ fun SearchOverlay( ev.key == Key.DirectionDown && results.isNotEmpty() ) { - runCatching { firstResultFocus.requestFocus() } + focusFirstResult() true } else { false @@ -255,6 +276,7 @@ fun SearchOverlay( .background(LiveColors.Divider), ) LazyColumn( + state = resultListState, modifier = Modifier.fillMaxWidth().height(440.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { @@ -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),