Skip to content

fix(tv): guard focus restorer against uninitialized FocusRequester - #613

Open
ReichiMD wants to merge 2 commits into
ProdigyV21:mainfrom
ReichiMD:fix/tv-focus-restorer-crash
Open

fix(tv): guard focus restorer against uninitialized FocusRequester#613
ReichiMD wants to merge 2 commits into
ProdigyV21:mainfrom
ReichiMD:fix/tv-focus-restorer-crash

Conversation

@ReichiMD

Copy link
Copy Markdown

Summary

Fixes an IllegalStateException: FocusRequester is not initialized crash that occurs on a fresh TV entry into Live TV while categories are still loading.

This is a pre-existing issue in shared TV focus code (ArvioDpadFocus.kt, originally authored April/Mai 2026), not introduced by any Stalker/portal work. It was reported separately and kept out of the Stalker live-config PR (#607) per Prodigy's request, so this PR contains only the two focus fixes and nothing else.

Symptom

On the first D-pad navigation into Live TV right after categories finish loading, the app crashes:

IllegalStateException: FocusRequester is not initialized.

Reproducible on a real TV (D-pad) when entering Live TV while the category sidebar is still populating.

Root cause

Modifier.focusRestorer { restoreFocusRequester } (used by CategorySidebar via arvioDpadFocusGroup) captures the caller-supplied FocusRequester at modifier-application time. When a focus enter event's automatic restore fails (the common case on the very first entry, where there is no saved focus history), Compose's FocusRestorerNode calls fallback.requestFocus() itself, deep inside the modifier, with no try/catch. The fallback is the selectedCategoryFocusRequester, which is only bound to the LazyColumn row where selectedId == cat.id. Right after categoriesLoaded flips false→true, that row is often not yet composed/attached → requestFocus() throws → crash.

This internal requestFocus() lives in Compose code, so it is unreachable by the runCatching guards that wrap direct requestFocus() calls in app code.

Fixes (2 commits)

1. fix(tv): guard focus requests against uninitialized FocusRequester

Wraps the remaining unguarded direct FocusRequester.requestFocus() calls in runCatching { ... }, consistent with the pattern the Live TV package already followed. Affected files: overlays/menus (MediaContextMenu, ContextMenu, QuickActionMenu, StreamSelector, AppUpdateModal, TextInputModal) and SearchScreen. This is defensive hardening — it does not by itself fix the crash above, but it removes other latent crash paths on the same theme.

2. fix(tv): guard focusRestorer fallback against uninitialized FocusRequester (the actual root fix)

Reworks arvioDpadFocusGroup so Compose's unguarded internal fallback.requestFocus() path is never taken:

  • Uses the safe default Modifier.focusRestorer() (which only saves/restores Compose's own focus history and never calls a caller-supplied requester).
  • When a restoreFocusRequester is requested, drives its focus itself on focus enter via onFocusChanged { if (state.hasFocus) runCatching { restoreFocusRequester.requestFocus() } } — i.e. in app code, where the runCatching guard catches an uninitialized requester instead of crashing.
  • Behaviour for all other callers (no restoreFocusRequester, or enableFocusRestorer = false) is unchanged.

Verification

  • The root fix was tested on a real TV (TCL C7K): no more crash on fresh Live TV entry while categories load. (The runCatching guards alone did not fix it; only the focusRestorer rework did.)
  • Unit tests (:app:testSideloadDebugUnitTest) pass locally with no regressions. Focus-restorer behavior itself is Compose UI/integration runtime and not unit-testable.

Test request

As discussed: please test Live TV entry, category loading and guide navigation. Happy to adjust if any focus-restore behavior looks off.


This pull request was created by an AI agent (OpenHands) on behalf of @ReichiMD.

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.
…ester

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 3fffc69).

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 <openhands@all-hands.dev>
@github-actions github-actions Bot added the area: android Changes to the Android app or Gradle build label Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: android Changes to the Android app or Gradle build

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants