The verdict #64 asked for: Half B's mutation is constructible
The comment on #64 flagged the rotation half as "asserted, not verified" — that viewModel()
resolves through LocalViewModelStoreOwner, so "changing that scope may not be expressible
without restructuring the call site". It is expressible, and it was built and run before the
test was written, exactly as the ticket asked.
viewModel() takes an explicit viewModelStoreOwner. Swapping LocalViewModelStoreOwner.current
for a remember-scoped one is the change — plus a factory and creation extras, which is the
part that is not obvious and is probably why it looked inexpressible:
An owner that is not a HasDefaultViewModelProviderFactory contributes no creation extras, and
the default factory cannot construct an AndroidViewModel without APPLICATION_KEY. The owner
swap alone crashes on construction rather than demonstrating the scope — a genuine trap, and
the reason to state the working form here rather than "change the scope".
The ticket's second doubt — that a recreated ViewModel merely returns to Idle, "a different
assertion rather than a mutation" — is answered by what the run did. The mutation reddened one
instrumented test, left the other test in the same class green, and left the whole JVM suite
green. A mutation that reddens exactly the test that names it is a mutation.
Why it bites at all is a fact the ticket did not have: ConversionViewModel holds the picked
file in a plain MutableStateFlow with no SavedStateHandle. Nothing persists it. The only
thing carrying it across a rotation is the ViewModelStore the Activity retains — and nothing in
the repo asserted that. AppRootRestorationTest injects a stub content lambda specifically to
avoid standing up either ViewModel, and StateRestorationTester saves into an in-memory map. So
the bite is not a duplicate of that test's; it is a property no JVM test can reach.
The mutations, verbatim
Both run on a local API 34 emulator, reverted and restored after each.
Baseline — tests=2 failures=0 errors=0 skipped=0
1. The MIME filter.arrayOf("*/*") -> arrayOf("application/x-lmc-no-such-type") at the one
site in ConverterScreen.kt. DocumentsUI matches the request against the fixture root's Root.COLUMN_MIME_TYPES and drops roots that cannot answer, so the root leaves the picker
entirely — along with "Images", "Audio", "Videos" and "Documents". tests=2 failures=2:
java.lang.IllegalArgumentException: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E']
at org.libremediaconverter.saf.SafPickerRoundTripTest.awaitPickerNode(SafPickerRoundTripTest.kt:210)
at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:185)
at org.libremediaconverter.saf.SafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard(SafPickerRoundTripTest.kt:115)
Both tests fail because both drive the picker, which is right: the filter is upstream of
everything.
2. The ViewModel scope. The block quoted above. tests=2 failures=1 — only the rotation test:
androidx.compose.ui.test.ComposeTimeoutException: Condition (a node tagged converter.fileCard.name exists) still not satisfied after 30000 ms
at androidx.compose.ui.test.AndroidComposeUiTestEnvironment$AndroidComposeUiTestImpl.waitUntil(ComposeUiTest.android.kt:901)
at org.libremediaconverter.saf.SafPickerRoundTripTest.awaitNode(SafPickerRoundTripTest.kt:224)
at org.libremediaconverter.saf.SafPickerRoundTripTest.thePickedInputSurvivesARealRotation(SafPickerRoundTripTest.kt:164)
and, under the same mutation, ./gradlew :app:testDebugUnitTest -> BUILD SUCCESSFUL. That
divergence is the whole reason #64 exists.
Measured beforehand rather than assumed: no JVM test composes the public ConverterScreen at
all. Every one of them composes ConverterScreenContent, the seam #61 extracted. That is
structurally why the mutation cannot reach them.
Four things that were not what the ticket said
arrayOf("*/*") is at ONE site, not two.#61 unified the Idle "Choose file" and Ready
"Choose a different file" branches onto a single ConverterActions.onPickInput. The mutation is
simpler than advertised.
The fixture provider had to be Java — the only Java file in the module. A manifest-declared
provider is a component of the instrumentation package, so the system starts a plain org.libremediaconverter.test process for it with only the test APK on its dex path — and the
test APK is built without the Kotlin stdlib, because the app APK has it and duplicating it is
what checkDebugAndroidTestDuplicateClasses prevents. The Kotlin draft died on its first query:
The compiler emits that reference for the null checks on nearly every function, so no Kotlin
dialect avoids it. Same reason nothing in that file imports androidx. The comment in build.gradle.kts that said "all hand-written code in this module is Kotlin" is corrected.
The MIME mutation removes the ROOT, not just the document. That is what makes the fixture
provider worth its lines over writing a file into Downloads: Root.COLUMN_MIME_TYPES gives the
filter a failure with a shape.
The picker needs no drawer navigation on a stock emulator. DocumentsUI's landing screen
lists third-party roots under "BROWSE FILES IN OTHER APPS". The "Show roots" fallback is kept
for a device with a populated Recent; it widens where the root is looked for and does not weaken
what has to be found.
Two silent-pass guards, because a rotation test is easy to fake
thePickedInputSurvivesARealRotation asserts the display rotation actually changed, and that MainActivity is a different instance afterwards, before it asserts anything about the file.
Without the second, a configChanges attribute or an orientation lock would quietly turn this
into a recomposition test. Both were verified to hold on the baseline run.
The build change
androidx.test.uiautomatorfloats at 2.+, which is the argument the catalog already makes
for work and lifecycle rather than a new one: the group is inside floatedGroupPrefixes, so
the componentSelection guard makes + mean "newest RELEASED". That is load-bearing — this
library publishes 2.4.0-alphas above its stable, so without the guard the float would be a pin
to a prerelease. Resolved to 2.4.0 on debugAndroidTestRuntimeClasspath, checked rather than
assumed.
Deliberately not pinned alongside ktlint/detekt/JaCoCo/Robolectric. Those are pinned because a
new rule or a new runtime changes the verdict on files nobody touched; UiAutomator has no verdict.
The 2. rather than a bare + is the one thing held back — a major is where the selector API
would be free to change under exactly that assumption.
Where it ran
Every number below was measured on this workstation or read out of a CI artifact. None is inferred.
level
how
result
API 34
single class, -gpu host
green — the iteration loop, and where both mutations were run
API 33
run-e2e.sh 33, whole suite
59 / 0 / 0 / 2
API 36
run-e2e.sh 36, whole suite
59 / 0 / 0 / 2
API 34, cold picker ×5
pm clear com.google.android.documentsui between runs
5 / 5 green — the flake regression check, below
API 37.0
one method per fresh emulator, swangle_indirect, SystemUI disabled
picker passes; rotation aborts the framework
The whole-suite runs are not belt-and-braces: a single-class run structurally cannot show a
test-ordering interaction, and this class has an @After that moves the display. The suite was
57 before this PR and is 59 after.
The Pixel 10 Pro XL was not used and cannot be. It is secure-locked and cannot be unlocked
from a shell, so the picker cannot be driven on it. That is why this gap survived.
CI found a flake this workstation could not, and it changed a conclusion
The first push went red on API 33, 34 and 35 with:
androidx.test.uiautomator.StaleObjectException
at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1042)
at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:223)
UiObject2 caches the AccessibilityNodeInfo it was found with, and DocumentsUI is still
settling when a node first appears. On a cold emulator this is not intermittent — it hit
every one of those three legs on the first run. It never appeared locally because the emulator
had been warm for an hour. tapPickerNode now re-finds and re-taps, three attempts; verified
5 / 5 green on API 34 with pm clear on DocumentsUI between runs.
What retrying does not weaken: every attempt still goes through awaitPickerNode, which
fails outright if the node is absent. A root that is not in the picker is not found on any
attempt, so the MIME mutation still fails with the system picker never showed ….
The correction that flake forced
The commit before it marked the whole class@FailsOnEmulatorApi37, on the strength of two
measured API 37 failures. One of them was this bug. Re-measured with the fix, one method per
fresh android-37.0 emulator:
thePickedInputSurvivesARealRotation INSTRUMENTATION_ABORTED: System has crashed.
Expected 1 tests, received 0
pickingAFileThroughTheSystemPickerFillsInTheFileCard PASSED
So a rotation — which rebuilds every surface at once — is what the gralloc mapper does not
survive; starting another app's activity is not. The marker moved to the one method that earned
it, and the picker test runs on the gating API 37 leg like anything else.
The lesson is worth more than the measurement, and docs/api-37-emulator-crash.md keeps it: an annotation is a claim about an image, and a broken test makes every image look broken.
Both a framework abort and a stale node read as "the run fell over". Re-measure after fixing a
test before deciding what the platform did.
The annotation filter itself was measured, not assumed
It is what keeps the gating API 37 leg green, so it is not a thing to reason about. On API 34, annotation= selected exactly 4 tests (the two Media3EngineTest methods plus both of
these), and notAnnotation= selected 55 with neither of these in it — so the runner expands
a class-level marker to every method. CI's own gating E2E API 37 leg then reported 55 tests,
0 failures, zero SafPicker cases on the first push. Moving the marker to one method is
therefore a narrowing of something already known to work.
Stated plainly rather than left for a reviewer to find. .github/workflows/status_check.yml, tools/local-emulator/run-e2e.sh and docs/api-37-emulator-crash.md all asserted "nothing in
this suite touches system UI" — which is what makes the API 37 leg's SystemUI-disable deviation
defensible. This PR makes that false, so all three are corrected in it. The rule they state is
being applied, not broken: the test that does not survive the leg is excluded from it.
run-e2e.sh's summary also promised "exactly two failures" at API 37 and now says what it really
expects, including that the level no longer finishes — its totals come back short with an
arbitrary tail.
Deliberately not done: the advisory job is still named E2E API 37 Media3 hardware transcode (advisory) and now carries a test that is neither. Renaming a check touches branch protection,
so it is left for a separate decision; the doc records the staleness and the "when to revisit"
trigger now says the marker covers two unrelated bugs that can go green independently.
Exemptions, named
OpenMultipleDocuments is not covered. It is JoinScreen's picker; #64 scopes this to the
converter's input. The same machinery would drive it, and it is a different screen's ticket.
CreateDocument is not covered. Reaching the Save button needs ConversionState.Converted,
which needs a worker run that has already succeeded — a full transcode inside a UI test, on five
gating CI legs, to assert one launcher line. What it leads to is already covered by OutputPublisherPublishTest.
The advanced panel's rememberSaveable is deliberately not re-asserted here. AdvancedPanelSavedStateTest already drives it through a real Bundle/Parcel round trip on
the JVM, and a second test whose bite duplicates an existing one is the failure #52 exists to
prevent.
No coverage number is quoted. JaCoCo does not measure the instrumented suite at all here.
BUILD SUCCESSFUL. assembleDebugAndroidTest is in there on purpose and is not in CLAUDE.md's
list: compileDebugAndroidTestKotlin does not run manifest merging, and this PR adds the first androidTest manifest. It caught the first draft of it — -- is illegal inside an XML comment.
Pinned shellcheck 0.11.0 clean on the edited run-e2e.sh.
Closes #64. Last of the eight children of #52.
## The verdict #64 asked for: Half B's mutation **is** constructible
The comment on #64 flagged the rotation half as *"asserted, not verified"* — that `viewModel()`
resolves through `LocalViewModelStoreOwner`, so "changing that scope may not be expressible
without restructuring the call site". It is expressible, and it was built and run **before** the
test was written, exactly as the ticket asked.
`viewModel()` takes an explicit `viewModelStoreOwner`. Swapping `LocalViewModelStoreOwner.current`
for a `remember`-scoped one is the change — **plus** a factory and creation extras, which is the
part that is not obvious and is probably why it looked inexpressible:
```kotlin
viewModel: ConversionViewModel = viewModel(
viewModelStoreOwner = remember {
object : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
},
factory = ViewModelProvider.AndroidViewModelFactory(),
extras = MutableCreationExtras().apply {
set(
ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY,
LocalContext.current.applicationContext as Application,
)
},
)
```
An owner that is not a `HasDefaultViewModelProviderFactory` contributes no creation extras, and
the default factory cannot construct an `AndroidViewModel` without `APPLICATION_KEY`. The owner
swap **alone** crashes on construction rather than demonstrating the scope — a genuine trap, and
the reason to state the working form here rather than "change the scope".
The ticket's second doubt — that a recreated ViewModel merely returns to `Idle`, "a different
assertion rather than a mutation" — is answered by what the run did. The mutation reddened one
instrumented test, left the other test **in the same class** green, and left the whole JVM suite
green. A mutation that reddens exactly the test that names it is a mutation.
**Why it bites at all** is a fact the ticket did not have: `ConversionViewModel` holds the picked
file in a plain `MutableStateFlow` with **no `SavedStateHandle`**. Nothing persists it. The only
thing carrying it across a rotation is the `ViewModelStore` the Activity retains — and nothing in
the repo asserted that. `AppRootRestorationTest` injects a stub `content` lambda specifically to
avoid standing up either ViewModel, and `StateRestorationTester` saves into an in-memory map. So
the bite is not a duplicate of that test's; it is a property no JVM test can reach.
## The mutations, verbatim
Both run on a local API 34 emulator, reverted and restored after each.
**Baseline** — `tests=2 failures=0 errors=0 skipped=0`
**1. The MIME filter.** `arrayOf("*/*")` -> `arrayOf("application/x-lmc-no-such-type")` at the one
site in `ConverterScreen.kt`. DocumentsUI matches the request against the fixture root's
`Root.COLUMN_MIME_TYPES` and drops roots that cannot answer, so the root leaves the picker
entirely — along with "Images", "Audio", "Videos" and "Documents". `tests=2 failures=2`:
```
java.lang.IllegalArgumentException: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E']
at org.libremediaconverter.saf.SafPickerRoundTripTest.awaitPickerNode(SafPickerRoundTripTest.kt:210)
at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:185)
at org.libremediaconverter.saf.SafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard(SafPickerRoundTripTest.kt:115)
```
Both tests fail because both drive the picker, which is right: the filter is upstream of
everything.
**2. The ViewModel scope.** The block quoted above. `tests=2 failures=1` — only the rotation test:
```
androidx.compose.ui.test.ComposeTimeoutException: Condition (a node tagged converter.fileCard.name exists) still not satisfied after 30000 ms
at androidx.compose.ui.test.AndroidComposeUiTestEnvironment$AndroidComposeUiTestImpl.waitUntil(ComposeUiTest.android.kt:901)
at org.libremediaconverter.saf.SafPickerRoundTripTest.awaitNode(SafPickerRoundTripTest.kt:224)
at org.libremediaconverter.saf.SafPickerRoundTripTest.thePickedInputSurvivesARealRotation(SafPickerRoundTripTest.kt:164)
```
and, under the same mutation, `./gradlew :app:testDebugUnitTest` -> **BUILD SUCCESSFUL**. That
divergence is the whole reason #64 exists.
Measured beforehand rather than assumed: **no JVM test composes the public `ConverterScreen` at
all.** Every one of them composes `ConverterScreenContent`, the seam #61 extracted. That is
structurally why the mutation cannot reach them.
## Four things that were not what the ticket said
- **`arrayOf("*/*")` is at ONE site, not two.** #61 unified the `Idle` "Choose file" and `Ready`
"Choose a different file" branches onto a single `ConverterActions.onPickInput`. The mutation is
simpler than advertised.
- **The fixture provider had to be Java** — the only Java file in the module. A manifest-declared
provider is a component of the instrumentation *package*, so the system starts a plain
`org.libremediaconverter.test` process for it with only the test APK on its dex path — and the
test APK is built without the Kotlin stdlib, because the app APK has it and duplicating it is
what `checkDebugAndroidTestDuplicateClasses` prevents. The Kotlin draft died on its first query:
```
FATAL EXCEPTION: binder:6369_2
Process: org.libremediaconverter.test, PID: 6369
java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics;
at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument
```
The compiler emits that reference for the null checks on nearly every function, so no Kotlin
dialect avoids it. Same reason nothing in that file imports `androidx`. The comment in
`build.gradle.kts` that said "all hand-written code in this module is Kotlin" is corrected.
- **The MIME mutation removes the ROOT, not just the document.** That is what makes the fixture
provider worth its lines over writing a file into Downloads: `Root.COLUMN_MIME_TYPES` gives the
filter a failure with a shape.
- **The picker needs no drawer navigation on a stock emulator.** DocumentsUI's landing screen
lists third-party roots under "BROWSE FILES IN OTHER APPS". The "Show roots" fallback is kept
for a device with a populated Recent; it widens where the root is looked for and does not weaken
what has to be found.
## Two silent-pass guards, because a rotation test is easy to fake
`thePickedInputSurvivesARealRotation` asserts the display rotation actually changed, and that
`MainActivity` is a **different instance** afterwards, before it asserts anything about the file.
Without the second, a `configChanges` attribute or an orientation lock would quietly turn this
into a recomposition test. Both were verified to hold on the baseline run.
## The build change
`androidx.test.uiautomator` **floats at `2.+`**, which is the argument the catalog already makes
for `work` and `lifecycle` rather than a new one: the group is inside `floatedGroupPrefixes`, so
the `componentSelection` guard makes `+` mean "newest RELEASED". That is load-bearing — this
library publishes `2.4.0-alpha`s above its stable, so without the guard the float would be a pin
to a prerelease. Resolved to **2.4.0** on `debugAndroidTestRuntimeClasspath`, checked rather than
assumed.
Deliberately **not** pinned alongside ktlint/detekt/JaCoCo/Robolectric. Those are pinned because a
new rule or a new runtime changes the verdict on files nobody touched; UiAutomator has no verdict.
The `2.` rather than a bare `+` is the one thing held back — a major is where the selector API
would be free to change under exactly that assumption.
## Where it ran
Every number below was measured on this workstation or read out of a CI artifact. None is inferred.
| level | how | result |
|---|---|---|
| **API 34** | single class, `-gpu host` | green — the iteration loop, and where both mutations were run |
| **API 33** | `run-e2e.sh 33`, **whole suite** | **59 / 0 / 0 / 2** |
| **API 36** | `run-e2e.sh 36`, **whole suite** | **59 / 0 / 0 / 2** |
| **API 34, cold picker ×5** | `pm clear com.google.android.documentsui` between runs | 5 / 5 green — the flake regression check, below |
| **API 37.0** | one method per fresh emulator, `swangle_indirect`, SystemUI disabled | picker **passes**; rotation **aborts the framework** |
The whole-suite runs are not belt-and-braces: a single-class run structurally cannot show a
test-ordering interaction, and this class has an `@After` that moves the display. The suite was
57 before this PR and is 59 after.
**The Pixel 10 Pro XL was not used and cannot be.** It is secure-locked and cannot be unlocked
from a shell, so the picker cannot be driven on it. That is why this gap survived.
## CI found a flake this workstation could not, and it changed a conclusion
The first push went red on API 33, 34 and 35 with:
```
androidx.test.uiautomator.StaleObjectException
at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1042)
at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:223)
```
`UiObject2` caches the `AccessibilityNodeInfo` it was found with, and DocumentsUI is still
settling when a node first appears. **On a cold emulator this is not intermittent** — it hit
every one of those three legs on the first run. It never appeared locally because the emulator
had been warm for an hour. `tapPickerNode` now re-finds and re-taps, three attempts; verified
5 / 5 green on API 34 with `pm clear` on DocumentsUI between runs.
**What retrying does *not* weaken:** every attempt still goes through `awaitPickerNode`, which
fails outright if the node is absent. A root that is not in the picker is not found on any
attempt, so the MIME mutation still fails with `the system picker never showed …`.
### The correction that flake forced
The commit before it marked the **whole class** `@FailsOnEmulatorApi37`, on the strength of two
measured API 37 failures. **One of them was this bug.** Re-measured with the fix, one method per
fresh `android-37.0` emulator:
```
thePickedInputSurvivesARealRotation INSTRUMENTATION_ABORTED: System has crashed.
Expected 1 tests, received 0
pickingAFileThroughTheSystemPickerFillsInTheFileCard PASSED
```
So a rotation — which rebuilds every surface at once — is what the gralloc mapper does not
survive; starting another app's activity is not. The marker moved to the one method that earned
it, and the picker test runs on the gating API 37 leg like anything else.
The lesson is worth more than the measurement, and `docs/api-37-emulator-crash.md` keeps it:
**an annotation is a claim about an image, and a broken test makes every image look broken.**
Both a framework abort and a stale node read as "the run fell over". Re-measure after fixing a
test before deciding what the platform did.
### The annotation filter itself was measured, not assumed
It is what keeps the gating API 37 leg green, so it is not a thing to reason about. On API 34,
`annotation=` selected exactly **4** tests (the two `Media3EngineTest` methods plus both of
these), and `notAnnotation=` selected **55** with neither of these in it — so the runner expands
a class-level marker to every method. CI's own gating `E2E API 37` leg then reported **55 tests,
0 failures, zero `SafPicker` cases** on the first push. Moving the marker to one method is
therefore a narrowing of something already known to work.
## Scope: three files #64 did not name
Stated plainly rather than left for a reviewer to find. `.github/workflows/status_check.yml`,
`tools/local-emulator/run-e2e.sh` and `docs/api-37-emulator-crash.md` all asserted **"nothing in
this suite touches system UI"** — which is what makes the API 37 leg's SystemUI-disable deviation
defensible. This PR makes that false, so all three are corrected in it. The rule they state is
being *applied*, not broken: the test that does not survive the leg is excluded from it.
`run-e2e.sh`'s summary also promised "exactly two failures" at API 37 and now says what it really
expects, including that **the level no longer finishes** — its totals come back short with an
arbitrary tail.
**Deliberately not done:** the advisory job is still named `E2E API 37 Media3 hardware transcode
(advisory)` and now carries a test that is neither. Renaming a check touches branch protection,
so it is left for a separate decision; the doc records the staleness and the "when to revisit"
trigger now says the marker covers two unrelated bugs that can go green independently.
## Exemptions, named
- **`OpenMultipleDocuments` is not covered.** It is `JoinScreen`'s picker; #64 scopes this to the
converter's input. The same machinery would drive it, and it is a different screen's ticket.
- **`CreateDocument` is not covered.** Reaching the Save button needs `ConversionState.Converted`,
which needs a worker run that has already succeeded — a full transcode inside a UI test, on five
gating CI legs, to assert one launcher line. What it leads to is already covered by
`OutputPublisherPublishTest`.
- **The advanced panel's `rememberSaveable` is deliberately not re-asserted here.**
`AdvancedPanelSavedStateTest` already drives it through a real `Bundle`/`Parcel` round trip on
the JVM, and a second test whose bite duplicates an existing one is the failure #52 exists to
prevent.
- **No coverage number is quoted.** JaCoCo does not measure the instrumented suite at all here.
## Gate
```
./gradlew :app:assembleDebug :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin \
:app:assembleDebugAndroidTest :app:ktlintCheck :app:detekt :app:lintDebug --continue
```
BUILD SUCCESSFUL. `assembleDebugAndroidTest` is in there on purpose and is not in `CLAUDE.md`'s
list: `compileDebugAndroidTestKotlin` does not run manifest merging, and this PR adds the first
`androidTest` manifest. It caught the first draft of it — `--` is illegal inside an XML comment.
Pinned shellcheck 0.11.0 clean on the edited `run-e2e.sh`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #64. Last of the eight children of #52.
The verdict #64 asked for: Half B's mutation is constructible
The comment on #64 flagged the rotation half as "asserted, not verified" — that
viewModel()resolves through
LocalViewModelStoreOwner, so "changing that scope may not be expressiblewithout restructuring the call site". It is expressible, and it was built and run before the
test was written, exactly as the ticket asked.
viewModel()takes an explicitviewModelStoreOwner. SwappingLocalViewModelStoreOwner.currentfor a
remember-scoped one is the change — plus a factory and creation extras, which is thepart that is not obvious and is probably why it looked inexpressible:
An owner that is not a
HasDefaultViewModelProviderFactorycontributes no creation extras, andthe default factory cannot construct an
AndroidViewModelwithoutAPPLICATION_KEY. The ownerswap alone crashes on construction rather than demonstrating the scope — a genuine trap, and
the reason to state the working form here rather than "change the scope".
The ticket's second doubt — that a recreated ViewModel merely returns to
Idle, "a differentassertion rather than a mutation" — is answered by what the run did. The mutation reddened one
instrumented test, left the other test in the same class green, and left the whole JVM suite
green. A mutation that reddens exactly the test that names it is a mutation.
Why it bites at all is a fact the ticket did not have:
ConversionViewModelholds the pickedfile in a plain
MutableStateFlowwith noSavedStateHandle. Nothing persists it. The onlything carrying it across a rotation is the
ViewModelStorethe Activity retains — and nothing inthe repo asserted that.
AppRootRestorationTestinjects a stubcontentlambda specifically toavoid standing up either ViewModel, and
StateRestorationTestersaves into an in-memory map. Sothe bite is not a duplicate of that test's; it is a property no JVM test can reach.
The mutations, verbatim
Both run on a local API 34 emulator, reverted and restored after each.
Baseline —
tests=2 failures=0 errors=0 skipped=01. The MIME filter.
arrayOf("*/*")->arrayOf("application/x-lmc-no-such-type")at the onesite in
ConverterScreen.kt. DocumentsUI matches the request against the fixture root'sRoot.COLUMN_MIME_TYPESand drops roots that cannot answer, so the root leaves the pickerentirely — along with "Images", "Audio", "Videos" and "Documents".
tests=2 failures=2:Both tests fail because both drive the picker, which is right: the filter is upstream of
everything.
2. The ViewModel scope. The block quoted above.
tests=2 failures=1— only the rotation test:and, under the same mutation,
./gradlew :app:testDebugUnitTest-> BUILD SUCCESSFUL. Thatdivergence is the whole reason #64 exists.
Measured beforehand rather than assumed: no JVM test composes the public
ConverterScreenatall. Every one of them composes
ConverterScreenContent, the seam #61 extracted. That isstructurally why the mutation cannot reach them.
Four things that were not what the ticket said
arrayOf("*/*")is at ONE site, not two. #61 unified theIdle"Choose file" andReady"Choose a different file" branches onto a single
ConverterActions.onPickInput. The mutation issimpler than advertised.
The fixture provider had to be Java — the only Java file in the module. A manifest-declared
provider is a component of the instrumentation package, so the system starts a plain
org.libremediaconverter.testprocess for it with only the test APK on its dex path — and thetest APK is built without the Kotlin stdlib, because the app APK has it and duplicating it is
what
checkDebugAndroidTestDuplicateClassesprevents. The Kotlin draft died on its first query:The compiler emits that reference for the null checks on nearly every function, so no Kotlin
dialect avoids it. Same reason nothing in that file imports
androidx. The comment inbuild.gradle.ktsthat said "all hand-written code in this module is Kotlin" is corrected.The MIME mutation removes the ROOT, not just the document. That is what makes the fixture
provider worth its lines over writing a file into Downloads:
Root.COLUMN_MIME_TYPESgives thefilter a failure with a shape.
The picker needs no drawer navigation on a stock emulator. DocumentsUI's landing screen
lists third-party roots under "BROWSE FILES IN OTHER APPS". The "Show roots" fallback is kept
for a device with a populated Recent; it widens where the root is looked for and does not weaken
what has to be found.
Two silent-pass guards, because a rotation test is easy to fake
thePickedInputSurvivesARealRotationasserts the display rotation actually changed, and thatMainActivityis a different instance afterwards, before it asserts anything about the file.Without the second, a
configChangesattribute or an orientation lock would quietly turn thisinto a recomposition test. Both were verified to hold on the baseline run.
The build change
androidx.test.uiautomatorfloats at2.+, which is the argument the catalog already makesfor
workandlifecyclerather than a new one: the group is insidefloatedGroupPrefixes, sothe
componentSelectionguard makes+mean "newest RELEASED". That is load-bearing — thislibrary publishes
2.4.0-alphas above its stable, so without the guard the float would be a pinto a prerelease. Resolved to 2.4.0 on
debugAndroidTestRuntimeClasspath, checked rather thanassumed.
Deliberately not pinned alongside ktlint/detekt/JaCoCo/Robolectric. Those are pinned because a
new rule or a new runtime changes the verdict on files nobody touched; UiAutomator has no verdict.
The
2.rather than a bare+is the one thing held back — a major is where the selector APIwould be free to change under exactly that assumption.
Where it ran
Every number below was measured on this workstation or read out of a CI artifact. None is inferred.
-gpu hostrun-e2e.sh 33, whole suiterun-e2e.sh 36, whole suitepm clear com.google.android.documentsuibetween runsswangle_indirect, SystemUI disabledThe whole-suite runs are not belt-and-braces: a single-class run structurally cannot show a
test-ordering interaction, and this class has an
@Afterthat moves the display. The suite was57 before this PR and is 59 after.
The Pixel 10 Pro XL was not used and cannot be. It is secure-locked and cannot be unlocked
from a shell, so the picker cannot be driven on it. That is why this gap survived.
CI found a flake this workstation could not, and it changed a conclusion
The first push went red on API 33, 34 and 35 with:
UiObject2caches theAccessibilityNodeInfoit was found with, and DocumentsUI is stillsettling when a node first appears. On a cold emulator this is not intermittent — it hit
every one of those three legs on the first run. It never appeared locally because the emulator
had been warm for an hour.
tapPickerNodenow re-finds and re-taps, three attempts; verified5 / 5 green on API 34 with
pm clearon DocumentsUI between runs.What retrying does not weaken: every attempt still goes through
awaitPickerNode, whichfails outright if the node is absent. A root that is not in the picker is not found on any
attempt, so the MIME mutation still fails with
the system picker never showed ….The correction that flake forced
The commit before it marked the whole class
@FailsOnEmulatorApi37, on the strength of twomeasured API 37 failures. One of them was this bug. Re-measured with the fix, one method per
fresh
android-37.0emulator:So a rotation — which rebuilds every surface at once — is what the gralloc mapper does not
survive; starting another app's activity is not. The marker moved to the one method that earned
it, and the picker test runs on the gating API 37 leg like anything else.
The lesson is worth more than the measurement, and
docs/api-37-emulator-crash.mdkeeps it:an annotation is a claim about an image, and a broken test makes every image look broken.
Both a framework abort and a stale node read as "the run fell over". Re-measure after fixing a
test before deciding what the platform did.
The annotation filter itself was measured, not assumed
It is what keeps the gating API 37 leg green, so it is not a thing to reason about. On API 34,
annotation=selected exactly 4 tests (the twoMedia3EngineTestmethods plus both ofthese), and
notAnnotation=selected 55 with neither of these in it — so the runner expandsa class-level marker to every method. CI's own gating
E2E API 37leg then reported 55 tests,0 failures, zero
SafPickercases on the first push. Moving the marker to one method istherefore a narrowing of something already known to work.
Scope: three files #64 did not name
Stated plainly rather than left for a reviewer to find.
.github/workflows/status_check.yml,tools/local-emulator/run-e2e.shanddocs/api-37-emulator-crash.mdall asserted "nothing inthis suite touches system UI" — which is what makes the API 37 leg's SystemUI-disable deviation
defensible. This PR makes that false, so all three are corrected in it. The rule they state is
being applied, not broken: the test that does not survive the leg is excluded from it.
run-e2e.sh's summary also promised "exactly two failures" at API 37 and now says what it reallyexpects, including that the level no longer finishes — its totals come back short with an
arbitrary tail.
Deliberately not done: the advisory job is still named
E2E API 37 Media3 hardware transcode (advisory)and now carries a test that is neither. Renaming a check touches branch protection,so it is left for a separate decision; the doc records the staleness and the "when to revisit"
trigger now says the marker covers two unrelated bugs that can go green independently.
Exemptions, named
OpenMultipleDocumentsis not covered. It isJoinScreen's picker; #64 scopes this to theconverter's input. The same machinery would drive it, and it is a different screen's ticket.
CreateDocumentis not covered. Reaching the Save button needsConversionState.Converted,which needs a worker run that has already succeeded — a full transcode inside a UI test, on five
gating CI legs, to assert one launcher line. What it leads to is already covered by
OutputPublisherPublishTest.rememberSaveableis deliberately not re-asserted here.AdvancedPanelSavedStateTestalready drives it through a realBundle/Parcelround trip onthe JVM, and a second test whose bite duplicates an existing one is the failure #52 exists to
prevent.
Gate
BUILD SUCCESSFUL.
assembleDebugAndroidTestis in there on purpose and is not inCLAUDE.md'slist:
compileDebugAndroidTestKotlindoes not run manifest merging, and this PR adds the firstandroidTestmanifest. It caught the first draft of it —--is illegal inside an XML comment.Pinned shellcheck 0.11.0 clean on the edited
run-e2e.sh.🤖 Generated with Claude Code