From 76bab5fb445a01ddf6419441c176de36c0f3a98e Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 26 Aug 2026 08:45:56 +0000 Subject: [PATCH 1/3] fix(tv): guard focus requests against uninitialized FocusRequester Wrap composable FocusRequester.requestFocus() calls in runCatching so a requester that fires before its focusable node is attached no longer crashes the app (IllegalStateException: FocusRequester is not initialized). The live tv package already followed this pattern; apply it to the remaining overlays/menus that become visible on TV playback and search navigation. --- .../main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt | 2 +- .../main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt | 2 +- .../kotlin/com/arflix/tv/ui/components/MediaContextMenu.kt | 2 +- .../kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt | 2 +- .../main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt | 2 +- .../main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt | 4 ++-- .../kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt index 674872a11..fc7bccac6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt @@ -117,7 +117,7 @@ fun AppUpdateModal( val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { - focusRequester.requestFocus() + runCatching { focusRequester.requestFocus() } } BackHandler { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt index ee539c8c6..785c73a2a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt @@ -113,7 +113,7 @@ fun ContextMenu( if (isVisible) { focusedIndex = 0 // Reset to first item if (!isMobile) { - focusRequester.requestFocus() + runCatching { focusRequester.requestFocus() } } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaContextMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaContextMenu.kt index f6a2f09a4..bbf0f0bc7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaContextMenu.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaContextMenu.kt @@ -124,7 +124,7 @@ fun MediaContextMenu( if (isVisible) { focusedIndex = 0 if (!isMobile) { - focusRequester.requestFocus() + runCatching { focusRequester.requestFocus() } } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt index 7c6f4fe07..0a8229eef 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt @@ -67,7 +67,7 @@ fun QuickActionMenu( if (isVisible) { focusedIndex = 0 ignoreNextEnter = true - focusRequester.requestFocus() + runCatching { focusRequester.requestFocus() } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index d1d68a4c2..c94913be8 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -257,7 +257,7 @@ fun StreamSelector( // Request focus when visible LaunchedEffect(isVisible) { if (isVisible) { - focusRequester.requestFocus() + runCatching { focusRequester.requestFocus() } focusedIndex = 0 focusedTabIndex = 0 selectedTabIndex = 0 diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt index 856a24ed2..3e913ce28 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt @@ -118,7 +118,7 @@ fun TextInputModal( // Request focus on input when modal becomes visible and show keyboard LaunchedEffect(isVisible) { if (isVisible) { - inputFocusRequester.requestFocus() + runCatching { inputFocusRequester.requestFocus() } focusedButton = -1 // Delay to ensure focus is set before showing keyboard kotlinx.coroutines.delay(200) @@ -160,7 +160,7 @@ fun TextInputModal( Key.DirectionUp -> { if (focusedButton >= 0) { focusedButton = -1 - inputFocusRequester.requestFocus() + runCatching { inputFocusRequester.requestFocus() } showKeyboard() } true diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index a9093ce9a..60b56799c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -312,12 +312,12 @@ fun SearchScreen( try { filtersFocusRequester.requestFocus() } catch (_: Exception) {} } else { focusZone = FocusZone.SEARCH_INPUT - searchFocusRequester.requestFocus() + runCatching { searchFocusRequester.requestFocus() } } } FocusZone.FILTERS -> { focusZone = FocusZone.SEARCH_INPUT - searchFocusRequester.requestFocus() + runCatching { searchFocusRequester.requestFocus() } } FocusZone.SEARCH_INPUT -> { focusZone = FocusZone.SIDEBAR From b61be42c6aff36846075c30c1cebc282e3e46fa0 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 26 Aug 2026 10:36:14 +0000 Subject: [PATCH 2/3] fix(tv): guard focusRestorer fallback against uninitialized FocusRequester MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose's focusRestorer(onRestoreFailed = { requester }) captures the requester at modifier-application time and, on a focus enter whose automatic restore fails, calls requestFocus() on it ITSELF — deep inside the modifier, without a try/catch. When the restore target lives in a LazyColumn row that is not yet composed/attached (the case right after categories finish loading on a fresh TV entry into Live TV), that internal requestFocus() throws IllegalStateException: FocusRequester is not initialized and crashes the app — unreachable by the runCatching guards wrapping direct requestFocus() calls (commit 3fffc695). arvioDpadFocusGroup now keeps the safe, default focusRestorer() (which only saves/restores Compose's own focus history and never calls a caller-supplied requester) and, when a restore target is requested, drives its focus itself on focus enter via onFocusChanged with a runCatching guard. Behaviour for all other callers is unchanged. Co-authored-by: openhands --- .../com/arflix/tv/ui/focus/ArvioDpadFocus.kt | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/focus/ArvioDpadFocus.kt b/app/src/main/kotlin/com/arflix/tv/ui/focus/ArvioDpadFocus.kt index 977d07a37..7b11fd08c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/focus/ArvioDpadFocus.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/focus/ArvioDpadFocus.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.key.Key import kotlin.Unit @@ -21,12 +22,30 @@ fun Modifier.arvioDpadFocusGroup( restoreFocusRequester: FocusRequester? = null, enableFocusRestorer: Boolean = true ): Modifier { - val restorer = when { - !enableFocusRestorer -> Modifier - restoreFocusRequester != null -> Modifier.focusRestorer { restoreFocusRequester } - else -> Modifier.focusRestorer() + // Compose's focusRestorer(onRestoreFailed = { restoreFocusRequester }) captures the + // requester at modifier-application time and, on a focus enter whose automatic restore + // fails, calls requestFocus() ITSELF — deep inside the modifier, without a try/catch. + // When the restore target lives in a LazyColumn row that is not yet composed/attached + // (the case right after categories finish loading on a fresh TV entry), that internal + // requestFocus() throws IllegalStateException: FocusRequester is not initialized and + // crashes the app, unreachable by the runCatching guards wrapping direct calls. + // + // To keep the "land on the previously focused row on re-entry" behaviour without the + // unguarded internal requestFocus, we enable the safe, default focusRestorer() (which + // only saves/restores Compose's own focus history and never calls a caller-supplied + // requester) and, when a restore target is requested, drive its focus ourselves on + // focus enter via onFocusChanged with a runCatching guard. + val restorer = if (enableFocusRestorer) Modifier.focusRestorer() else Modifier + val restoreGuard = if (restoreFocusRequester != null) { + Modifier.onFocusChanged { state -> + if (state.hasFocus) { + runCatching { restoreFocusRequester.requestFocus() } + } + } + } else { + Modifier } - return this.then(restorer).focusGroup() + return this.then(restorer).then(restoreGuard).focusGroup() } @OptIn(ExperimentalFoundationApi::class) From 211bbba5c3e23eff26642500f7000a0cafae17fb Mon Sep 17 00:00:00 2001 From: Arvin Date: Fri, 28 Aug 2026 10:32:49 +0200 Subject: [PATCH 3/3] fix(tv): preserve remembered sidebar focus --- .../com/arflix/tv/ui/focus/ArvioDpadFocus.kt | 22 ++++--------------- .../tv/ui/screens/tv/live/CategorySidebar.kt | 4 +--- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/focus/ArvioDpadFocus.kt b/app/src/main/kotlin/com/arflix/tv/ui/focus/ArvioDpadFocus.kt index 7b11fd08c..d53b8a542 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/focus/ArvioDpadFocus.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/focus/ArvioDpadFocus.kt @@ -10,16 +10,13 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.key.Key import kotlin.Unit @OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) fun Modifier.arvioDpadFocusGroup( - restoreFocusRequester: FocusRequester? = null, enableFocusRestorer: Boolean = true ): Modifier { // Compose's focusRestorer(onRestoreFailed = { restoreFocusRequester }) captures the @@ -30,22 +27,11 @@ fun Modifier.arvioDpadFocusGroup( // requestFocus() throws IllegalStateException: FocusRequester is not initialized and // crashes the app, unreachable by the runCatching guards wrapping direct calls. // - // To keep the "land on the previously focused row on re-entry" behaviour without the - // unguarded internal requestFocus, we enable the safe, default focusRestorer() (which - // only saves/restores Compose's own focus history and never calls a caller-supplied - // requester) and, when a restore target is requested, drive its focus ourselves on - // focus enter via onFocusChanged with a runCatching guard. + // Keep focus restoration limited to Compose's own saved focus history. Callers that + // need an initial focus target should request it after the target is attached instead + // of supplying a fallback that Compose can invoke before a lazy item is composed. val restorer = if (enableFocusRestorer) Modifier.focusRestorer() else Modifier - val restoreGuard = if (restoreFocusRequester != null) { - Modifier.onFocusChanged { state -> - if (state.hasFocus) { - runCatching { restoreFocusRequester.requestFocus() } - } - } - } else { - Modifier - } - return this.then(restorer).then(restoreGuard).focusGroup() + return this.then(restorer).focusGroup() } @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index c73432b91..d598650eb 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt @@ -260,9 +260,7 @@ fun CategorySidebar( // hands it the selector on entry and again every time the lazy list // recomposes underneath the focused row — which is what pinned the // selector in the search box while the playlist loaded. - .arvioDpadFocusGroup( - restoreFocusRequester = if (categoriesLoaded) selectedCategoryFocusRequester else null, - ) + .arvioDpadFocusGroup() .onFocusChanged { focusState -> if (focusState.hasFocus) { onFocusEnter()