Close the ANR dialog that was hiding every window from UiAutomator #96

Merged
JMR-dev merged 1 commits from fix/saf-picker-root-discovery into main 2026-08-25 13:42:08 +00:00
JMR-dev commented 2026-08-25 04:47:05 +00:00 (Migrated from github.com)

Closes #93.

The root cause: an ANR dialog nobody was looking at

Not SAF, not root discovery, not the picker. The launcher ANRs on a loaded runner
emulator, and the "Application Not Responding" dialog it leaves behind never goes
away.
That dialog belongs to system_server, and it is opaque and fullscreen — so
AccessibilityWindowManager drops every application window beneath it. The app is
Displayed and unreadable at the same time, which is the contradiction that made
this look like a SAF bug across six PRs.

ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
Reason: Input dispatching timed out (Application does not have a focused window)
Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}

Nothing in the picker was ever wrong.

How it was found, since none of it was visible from the original failure

1. The failure was not what it said. On the API 34 leg of the run #93 cites
(32806342548), DocumentsUI's own ProvidersAccess: Matched roots names the fixture
root five times inside the sixty seconds the test spent failing, the provider process
started on cue, and the PickActivity was logged as Displayed. Meanwhile
By.desc("Show roots") — the toolbar button, present whether the roots list is stale
or not — was equally invisible. A stale roots list loses rows; it does not lose the
toolbar.

2. UiAutomator never read anything at all:

Retrieving node with selector Node not found with selector
failing API 34 leg 1095 1095
green API 34 leg (32800638011) 7 2

3. So the test was made to ask a question it could answer.
requireAReadableScreen() asks whether this process can see the app's own window,
while the app is in front and before anything is tapped. Run 32811493607 (API 35 and
37) then failed in 12 s instead of 60 with UiAutomator cannot see this app's own window … This is not a SAF failure.

4. And then to say what it could see. describeWindows(), run 32812248131
(API 34) and 32812892103 (API 33), character for character the same on both:

What it could see: com.android.systemui[type=3], android[type=3]

type=3 is TYPE_SYSTEM. Not one TYPE_APPLICATION window. android is
system_server — and grepping the same logcats for what it was holding produced the
ANR dialog above, present on both legs, minutes before the class ran.

The change

  • dismissASystemErrorDialog() — by resource id (aerr_wait, aerr_close,
    button1) rather than localised button text, aerr_wait first so the app under
    the dialog is left alone. Called at the start of each pick attempt and inside the
    back-out loop
    : an app-error dialog swallows key events, so a back press aimed at
    the picker lands on the dialog and nothing moves. That second call site is not
    speculative — run 32813885120 exhausted all four back presses with android in
    front.
  • requireAReadableScreen() — the probe that made the diagnosis possible, kept,
    and now the thing that stops a future occurrence being mistaken for SAF.
  • unlockTheDevice() and rebuildUiAutomation() — kept behind the dialog
    dismissal and documented as measured non-causes, not fixes.
  • The whole pick is retried (dismiss, tap "Choose file" again). A smaller and
    separate claim: it answers a picker whose lists were built before their data
    arrived, not the occlusion above. The KDoc says so rather than letting one fix take
    credit for both.
  • walkThePickerToTheFixture is a when over three things in order — picker,
    root, file — returning whichever was missing, so those stop arriving as one message.
  • Back presses are counted against Activity.hasWindowFocus, not against UiAutomator:
    the failure being worked around is a window UiAutomator cannot see, so a probe
    through the same list would report "the picker is gone" about the window still in
    front.

Your question: the asymmetric call sites

You are right that the file selector gets no ifAbsent, and that is deliberate —
now stated in the KDoc. openTheRootsDrawer exists because a root has a second
place
it can be shown; a document in a directory listing has no second place, so an
in-picker action would have nothing to do. Its recovery is the outer loop: a fresh
pick re-walks from Recent into the root, rebuilding the directory listing as well as
the roots strip. So it is not a bare retry overall.

Variant B was not reproduced and not diagnosed. I am not claiming it is fixed. It
is covered because a fresh pick re-walks, and because the ANR dialog explains that
shape as readily as the other one — but that is an inference, not a measurement.

On #80's "no drawer navigation needed"

#80 was right, and it is now measured. A hierarchy dump on a cold API 34 emulator
during a passing run has the fixture root on the landing screen — text="LMC R38 fixtures" at android:id/title, under a BROWSE FILES IN OTHER APPS header — with
the drawer shut (Show roots present, Hide roots absent). The drawer path never
runs; it is kept as a widening for a device whose populated Recent pushes the strip
off screen. Now a line in the KDoc so the next person does not rediscover it.

#64's MIME mutation still goes red

The check that mattered most: a retry tolerating a missing root would have destroyed
it silently. ConverterScreen.kt:95, arrayOf("*/*") ->
arrayOf("application/x-lmc-no-such-type"), on a cold local API 34 emulator,
tests=2 failures=2:

java.lang.AssertionError: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E'], in 3 separate pickers (the last one left org.libremediaconverter in front)
	at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:268)
	at org.libremediaconverter.saf.SafPickerRoundTripTest.thePickedInputSurvivesARealRotation(SafPickerRoundTripTest.kt:200)

and the same on pickingAFileThroughTheSystemPickerFillsInTheFileCard. The sentence
#64 quotes survives verbatim. Cost: 126 s and 127 s for the two tests, against the
1200 s wrapper timeout in .github/scripts/e2e-run.sh.

Every new path was forced on and measured, not trusted

  • The reopen recovers, not just fails correctly: a temporary field made the first
    walk of each test return a selector nothing matches. Both tests passed, with
    ActivityTaskManager logging four OPEN_DOCUMENT starts between them — two
    pickers each. That says pickInput.launch is not refused from the re-resumed
    Activity, and that the second test's reopen (which lands in the last-accessed stack
    rather than on Recent) still walks to the file.
  • The rebuild does not poison the connection: forced on unconditionally, suite
    green, with logcat showing the connection really replaced (Init UiAutomation[id=2, flags=0], id=4, flags=1, id=6, flags=0). A rebuilt connection that came back
    without FLAG_RETRIEVE_INTERACTIVE_WINDOWS would have caused the very emptiness it
    is meant to cure.
  • The dialog dismissal does no harm: forced on with no dialog present, suite green
    — ruling out a blind click() breaking a healthy run.

Evidence

Local, cold emulators (fresh AVD each time, swangle_indirect, 2 cores):

  • API 34, whole suite: 59/59
  • API 35, whole suite: 59/59
  • API 35, class only: 5/5, and green on every run after each subsequent change
  • API 34, class only: green on every run

CI on this branch — six runs, and the red ones are the valuable half:

commit result what it told us
606dfcb 5/5 green nothing; ~1-in-4 by luck at the measured base rate
6cfe43b red 35, 37 "cannot see this app's own window" — not SAF
9cc55bc red 34 com.android.systemui[type=3], android[type=3]
8d475e9 red 33, 37 same string again — not one level's quirk
6832e28 red 34 back presses exhausted with android in front
25f1629 5/5 gating green the shipping commit (run 32814569871)

The advisory API 37 job is red, as it is on every PR by design.

The fault never reproduced locally — not on six warm runs, not on the cold
full-suite runs, not under host load. This workstation is much faster than a runner
and the launcher does not ANR on it. Every diagnostic step above came from CI.

Not recommending the non-gating fallback. The cause is identified with a mechanism
and a remedy that matches it, and the gating legs are green. If it recurs, the failure
message names the window list and the dialog, so the next step is a measurement rather
than another theory.

Gate

./gradlew :app:assembleDebug :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt :app:lintDebug --continue — BUILD SUCCESSFUL.

🤖 Generated with Claude Code

Closes #93. ## The root cause: an ANR dialog nobody was looking at Not SAF, not root discovery, not the picker. **The launcher ANRs on a loaded runner emulator, and the "Application Not Responding" dialog it leaves behind never goes away.** That dialog belongs to `system_server`, and it is opaque and fullscreen — so `AccessibilityWindowManager` drops every application window beneath it. The app is `Displayed` and unreadable at the same time, which is the contradiction that made this look like a SAF bug across six PRs. ``` ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity) Reason: Input dispatching timed out (Application does not have a focused window) Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher} ``` Nothing in the picker was ever wrong. ## How it was found, since none of it was visible from the original failure **1. The failure was not what it said.** On the API 34 leg of the run #93 cites (32806342548), DocumentsUI's own `ProvidersAccess: Matched roots` names the fixture root five times inside the sixty seconds the test spent failing, the provider process started on cue, and the `PickActivity` was logged as `Displayed`. Meanwhile `By.desc("Show roots")` — the toolbar button, present whether the roots list is stale or not — was equally invisible. A stale roots list loses rows; it does not lose the toolbar. **2. UiAutomator never read anything at all:** | | `Retrieving node with selector` | `Node not found with selector` | |---|---|---| | failing API 34 leg | 1095 | **1095** | | green API 34 leg (32800638011) | 7 | 2 | **3. So the test was made to ask a question it could answer.** `requireAReadableScreen()` asks whether this process can see the app's *own* window, while the app is in front and before anything is tapped. Run 32811493607 (API 35 and 37) then failed in 12 s instead of 60 with `UiAutomator cannot see this app's own window … This is not a SAF failure.` **4. And then to say what it *could* see.** `describeWindows()`, run 32812248131 (API 34) and 32812892103 (API 33), character for character the same on both: ``` What it could see: com.android.systemui[type=3], android[type=3] ``` `type=3` is `TYPE_SYSTEM`. Not one `TYPE_APPLICATION` window. `android` is `system_server` — and grepping the same logcats for what it was holding produced the ANR dialog above, present on both legs, minutes before the class ran. ## The change - **`dismissASystemErrorDialog()`** — by resource id (`aerr_wait`, `aerr_close`, `button1`) rather than localised button text, `aerr_wait` first so the app under the dialog is left alone. Called at the start of each pick attempt **and inside the back-out loop**: an app-error dialog swallows key events, so a back press aimed at the picker lands on the dialog and nothing moves. That second call site is not speculative — run 32813885120 exhausted all four back presses with `android` in front. - **`requireAReadableScreen()`** — the probe that made the diagnosis possible, kept, and now the thing that stops a future occurrence being mistaken for SAF. - **`unlockTheDevice()` and `rebuildUiAutomation()`** — kept behind the dialog dismissal and documented as **measured non-causes**, not fixes. - **The whole pick is retried** (dismiss, tap "Choose file" again). A smaller and separate claim: it answers a picker whose *lists* were built before their data arrived, not the occlusion above. The KDoc says so rather than letting one fix take credit for both. - **`walkThePickerToTheFixture` is a `when` over three things in order** — picker, root, file — returning whichever was missing, so those stop arriving as one message. - Back presses are counted against `Activity.hasWindowFocus`, not against UiAutomator: the failure being worked around is a window UiAutomator cannot see, so a probe through the same list would report "the picker is gone" about the window still in front. ## Your question: the asymmetric call sites You are right that the file selector gets no `ifAbsent`, and **that is deliberate** — now stated in the KDoc. `openTheRootsDrawer` exists because a root has a *second place* it can be shown; a document in a directory listing has no second place, so an in-picker action would have nothing to do. Its recovery is the outer loop: a fresh pick re-walks from Recent into the root, rebuilding the directory listing as well as the roots strip. So it is not a bare retry overall. **Variant B was not reproduced and not diagnosed.** I am not claiming it is fixed. It is covered because a fresh pick re-walks, and because the ANR dialog explains that shape as readily as the other one — but that is an inference, not a measurement. ## On #80's "no drawer navigation needed" #80 was right, and it is now measured. A hierarchy dump on a cold API 34 emulator during a passing run has the fixture root on the landing screen — `text="LMC R38 fixtures"` at `android:id/title`, under a `BROWSE FILES IN OTHER APPS` header — with the drawer shut (`Show roots` present, `Hide roots` absent). The drawer path never runs; it is kept as a widening for a device whose populated Recent pushes the strip off screen. Now a line in the KDoc so the next person does not rediscover it. ## #64's MIME mutation still goes red The check that mattered most: a retry tolerating a missing root would have destroyed it silently. `ConverterScreen.kt:95`, `arrayOf("*/*")` -> `arrayOf("application/x-lmc-no-such-type")`, on a cold local API 34 emulator, `tests=2 failures=2`: ``` java.lang.AssertionError: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E'], in 3 separate pickers (the last one left org.libremediaconverter in front) at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:268) at org.libremediaconverter.saf.SafPickerRoundTripTest.thePickedInputSurvivesARealRotation(SafPickerRoundTripTest.kt:200) ``` and the same on `pickingAFileThroughTheSystemPickerFillsInTheFileCard`. The sentence #64 quotes survives verbatim. Cost: 126 s and 127 s for the two tests, against the 1200 s wrapper timeout in `.github/scripts/e2e-run.sh`. ## Every new path was forced on and measured, not trusted - **The reopen recovers**, not just fails correctly: a temporary field made the first walk of each test return a selector nothing matches. Both tests passed, with `ActivityTaskManager` logging **four** `OPEN_DOCUMENT` starts between them — two pickers each. That says `pickInput.launch` is not refused from the re-resumed Activity, and that the second test's reopen (which lands in the last-accessed stack rather than on Recent) still walks to the file. - **The rebuild does not poison the connection**: forced on unconditionally, suite green, with logcat showing the connection really replaced (`Init UiAutomation[id=2, flags=0]`, `id=4, flags=1`, `id=6, flags=0`). A rebuilt connection that came back without `FLAG_RETRIEVE_INTERACTIVE_WINDOWS` would have caused the very emptiness it is meant to cure. - **The dialog dismissal does no harm**: forced on with no dialog present, suite green — ruling out a blind `click()` breaking a healthy run. ## Evidence **Local, cold emulators** (fresh AVD each time, `swangle_indirect`, 2 cores): - API 34, whole suite: 59/59 - API 35, whole suite: 59/59 - API 35, class only: 5/5, and green on every run after each subsequent change - API 34, class only: green on every run **CI on this branch — six runs, and the red ones are the valuable half:** | commit | result | what it told us | |---|---|---| | `606dfcb` | 5/5 green | nothing; ~1-in-4 by luck at the measured base rate | | `6cfe43b` | red 35, 37 | "cannot see this app's own window" — not SAF | | `9cc55bc` | red 34 | `com.android.systemui[type=3], android[type=3]` | | `8d475e9` | red 33, 37 | same string again — not one level's quirk | | `6832e28` | red 34 | back presses exhausted with `android` in front | | **`25f1629`** | **5/5 gating green** | the shipping commit (run 32814569871) | The advisory API 37 job is red, as it is on every PR by design. **The fault never reproduced locally** — not on six warm runs, not on the cold full-suite runs, not under host load. This workstation is much faster than a runner and the launcher does not ANR on it. Every diagnostic step above came from CI. **Not recommending the non-gating fallback.** The cause is identified with a mechanism and a remedy that matches it, and the gating legs are green. If it recurs, the failure message names the window list and the dialog, so the next step is a measurement rather than another theory. ## Gate `./gradlew :app:assembleDebug :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt :app:lintDebug --continue` — BUILD SUCCESSFUL. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.