Channel Category Reordering: The Row Moved, Focus Left Behind


Background

Because automated tests cannot catch focus and remote control key events on the TV box settings screen, acceptance testing for this project relies on a physical device walkthrough: I operate the remote control and describe the screen out loud, while Claude handles adb logcat on the other end, matching what I report happening against the trace in the logs. Only when they match is a test item marked as passed.

The feature in question is the “Channel Categories” screen: 11 rows of channel categories that can be reordered. Upon entering the screen, focus lands on the first row. Pressing the yellow button enters reorder mode, where UP/DOWN swaps the currently selected row with the row above or below it.

This screen had a core requirement explicitly written in the plan (explicitly stated in plan D10/D11 and step 4 of the execution flow):

Focus must follow the row being moved (rather than staying at its original visual position).

The plan specifically avoided reusing the shared settings list component and built this screen from scratch precisely to avoid the shared component’s defect where “the row moved, but focus was left behind.” This requirement was the design starting point for the entire screen.

The Issue

The walkthrough reached reorder mode (D7), which required me to press UP repeatedly to move a row all the way to the top. After a few presses, I reported:

Pressing up/down swaps with the item above or below, but the focus doesn’t change, so continuous movement is impossible.

This was the exact symptom the plan had originally aimed to eliminate.

Root Cause Analysis

Claude deduced the following by reading GenreSettingScreen.kt:

  • focusRequesters used remember(totalRows) { List(totalRows) { FocusRequester() } }—binding the requesters to position index.
  • However, item identity was bound to row identity (itemsIndexed(rows, key = { _, r -> r.group.name })). The two were bound to completely different things.
  • During a swap, focusRequesters.getOrNull(newIdx)?.requestFocus() executed synchronously inside onPreviewKeyEvent—at this moment, recomposition had not yet occurred, so focusRequesters[newIdx] was still bound to the row at that position before recomposition. Once recomposition completed, that row moved away carrying its focus because of key = identity, causing focus to stick to its original visual position.

Step 4 of the execution flow originally stated:

That one-time flag is bound to the ‘row’, not to the ‘position’; once consumed, it will no longer request focus.

This was a clear warning to “bind to the row, not the position.” The implementation bound the requester to the position—the exact opposite of what was specified—recreating the exact symptom the plan set out to eliminate.

The pre-fix device logs confirmed this: while reordering, the targeted row repeatedly ping-ponged between positions 9, 10, and 11, unable to break free.

Why Static Review Missed It

In the previous phase, this screen had already undergone a round of AI code review (Opus). That review flagged a Critical issue, alleging that onPreviewKeyEvent on each row’s Box could not intercept the yellow button.

Claude refuted this claim—the rule in question (guide R1) pertained to modifier ordering on the same node, not ancestor Box containers. During the key capture phase, traversal upwards from the focused node inevitably passes through the parent container’s onPreviewKeyEvent. Furthermore, this codebase contained over a dozen device-verified instances of the exact same pattern (LockedChannelsDialog, PinAuthDialog, SettingsScreen, etc.), all attaching onPreviewKeyEvent to a container Box. The flagged Critical issue was invalid.

Yet the actual bug—focus failing to follow the row—was missed entirely by the review. It confidently pointed to something that wasn’t a bug while remaining completely blind to the actual Critical issue.

Solution and Implementation

I decided on the spot to pause and fix it immediately (since this bug was blocking subsequent test cases), letting Claude apply the fix directly instead of handing it back to the implementation pipeline. My reasoning: the root cause was deduced by Claude (and the original implementation agent had followed the plan without catching it, so sending it back wasn’t necessarily more reliable), the change involved only two edits in a single file, and this was the fastest way to resume testing.

The fix (commit 2ccf2af):

  • focusRequesters was changed to remember { mutableStateMapOf<String, FocusRequester>() }, with items calling getOrPut(row.group.name)—binding requesters to row identity.
  • During a swap, the identity of the moved row is captured first via movedGroup = rows[focusedIndex].group.name (while still in the old order). After swapping, focusRequesters[movedGroup]?.requestFocus() is called—because identity remains constant, the requester always points to the exact same row, completely bypassing recomposition timing issues.
  • The LaunchedEffect for initial focus was updated in sync to retrieve the requester by identity.

focusedIndex retained its position-based semantics (used for scrolling, directional arrows, and page numbering). The build succeeded, all tests passed green, and static analysis confirmed that only this single file was modified without impacting any other flows.

Device Verification

After rebuilding and reinstalling, I repeatedly pressed DOWN to move the row originally in the first position all the way to the bottom. The log sequence:

[5,4,6,…] → [5,6,4,…] → [5,6,7,4,…] → … → [5,6,7,8,9,10,11,12,13,14,4]

The row moved all the way from position 0 to position 10—something that was impossible with the pre-fix ping-pong behavior. The bug was confirmed resolved on the spot, and the remaining test cases were completed.

Conclusion and Takeaways

  • This bug was ridiculously simple: swap the contents of two rows, then move focus. That was it. The fix took only a moment. Almost the entire difficulty of the incident lay in finding it, not fixing it.
  • Yet finding it was precisely where both the written plan (which even explicitly warned to “bind to the row, not the position”) and static code review fell short: the plan contained the exact warning, but implementation still fell into the trap; code review was highly confident, but pointed to the wrong line of code. Neither managed to surface this bug ahead of time.
  • What finally brought it to light was me pressing a few buttons on the remote control and observing the single-sentence symptom: “It swapped, but focus didn’t change.” For this class of focus and key-event timing bugs, physical device testing paired with human intervention remains the only definitive validation loop.