From 522cff0d84382015c21ee1bdc28ff0d411084b0b Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:13:07 +0200 Subject: [PATCH] fix(tv): ignore an OK held from opening a menu until it is released --- .../2026-11-07-fix-tv-held-ok-in-menu.md | 2 + .../shared/quickactions/QuickActionsSheet.kt | 25 +++++ .../quickactions/QuickActionsSheetKeyTest.kt | 100 ++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 .github/nightly-notes/2026-11-07-fix-tv-held-ok-in-menu.md create mode 100644 app/src/test/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheetKeyTest.kt diff --git a/.github/nightly-notes/2026-11-07-fix-tv-held-ok-in-menu.md b/.github/nightly-notes/2026-11-07-fix-tv-held-ok-in-menu.md new file mode 100644 index 0000000000..d62ed02b64 --- /dev/null +++ b/.github/nightly-notes/2026-11-07-fix-tv-held-ok-in-menu.md @@ -0,0 +1,2 @@ +## Fixed +- Android TV: holding OK on a video to open its menu no longer selects an item in the menu when you let go diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheet.kt index 23bf92929a..48af9cc8aa 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheet.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheet.kt @@ -30,14 +30,21 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType +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.LocalHapticFeedback import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics @@ -149,6 +156,23 @@ fun QuickActionsSheet( } } +/** The keys a focused item takes as a click, which a remote's OK sends. */ +private val OK_KEYS = setOf(Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.Spacebar) + +/** + * Holding OK on an item opens its menu, and the key's repeats and release then click whatever took focus + * there. So OK counts only from a press that starts in the menu. + */ +@Composable +private fun Modifier.ignoreHeldOk(): Modifier { + var pressedHere by remember { mutableStateOf(false) } + return onPreviewKeyEvent { event -> + if (event.key !in OK_KEYS) return@onPreviewKeyEvent false + if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) pressedHere = true + !pressedHere + } +} + /** A presenter has no hide animation of its own to wait for, so every way out closes at once. */ @Composable private fun PresentedQuickActionsSheet( @@ -165,6 +189,7 @@ private fun PresentedQuickActionsSheet( Column( modifier = Modifier + .ignoreHeldOk() .fillMaxWidth() .verticalScroll(scrollState) .padding(bottom = QuickActionsDefaults.BottomPadding), diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheetKeyTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheetKeyTest.kt new file mode 100644 index 0000000000..4ba34ef09a --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheetKeyTest.kt @@ -0,0 +1,100 @@ +package io.github.aedev.flow.ui.components.shared.quickactions + +import android.app.Application +import android.view.KeyEvent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyPress +import androidx.compose.ui.test.requestFocus +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.aedev.flow.ui.components.shared.FlowSheetPresenter +import io.github.aedev.flow.ui.components.shared.LocalFlowSheetPresenter +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** The remote's OK in a menu that a long press of OK opened. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp") +class QuickActionsSheetKeyTest { + @get:Rule + val rule = createComposeRule() + + private var clicks = 0 + + /** The sheet in the test's own window, where keys and focus work as they do in a dialog's. */ + private object InPlace : FlowSheetPresenter { + @Composable + override fun Present( + onDismiss: () -> Unit, + onBack: (() -> Unit)?, + content: @Composable ColumnScope.() -> Unit, + ) = Column(content = content) + } + + private fun show() { + rule.setContent { + CompositionLocalProvider(LocalFlowSheetPresenter provides InPlace) { + QuickActionsSheet(onDismiss = {}) { + val inputMode = LocalInputModeManager.current + SideEffect { inputMode.requestInputMode(InputMode.Keyboard) } + Text("Item", Modifier.clickable { clicks++ }) + } + } + } + rule.waitForIdle() + rule.onNodeWithText("Item").requestFocus().assertIsFocused() + } + + private fun ok( + action: Int, + repeat: Int = 0, + ) { + val event = + androidx.compose.ui.input.key + .KeyEvent(KeyEvent(0L, 0L, action, KeyEvent.KEYCODE_DPAD_CENTER, repeat)) + rule.onNodeWithText("Item").performKeyPress(event) + rule.waitForIdle() + } + + @Test + fun an_ok_still_held_from_opening_the_menu_selects_nothing() { + show() + ok(KeyEvent.ACTION_DOWN, repeat = 20) + ok(KeyEvent.ACTION_DOWN, repeat = 21) + ok(KeyEvent.ACTION_UP) + assertEquals(0, clicks) + } + + @Test + fun the_next_press_of_ok_selects() { + show() + ok(KeyEvent.ACTION_DOWN, repeat = 20) + ok(KeyEvent.ACTION_UP) + ok(KeyEvent.ACTION_DOWN) + ok(KeyEvent.ACTION_UP) + assertEquals(1, clicks) + } + + @Test + fun a_press_that_starts_in_the_menu_selects_even_when_held() { + show() + ok(KeyEvent.ACTION_DOWN) + ok(KeyEvent.ACTION_DOWN, repeat = 1) + ok(KeyEvent.ACTION_UP) + assertEquals(1, clicks) + } +}