Fix twelve defects in the untested framework edge #8

Merged
JMR-dev merged 20 commits from feat/defect-fixes-base into main 2026-08-23 02:05:45 +00:00
JMR-dev commented 2026-08-23 01:45:28 +00:00 (Migrated from github.com)

Ten fixes from the audit in #7, each its own commit with its own tests. 242 JVM unit tests, 0 failures (up from 180), detekt 0 findings, lint 0 errors, 0 warnings, 1 hint — the pre-existing UsableSpace informational, which belongs to a defect deliberately not fixed here.

Commit Defect
cfd705a D2 staged output the user never saved is deleted, instead of waiting for the OS
ec969c4 D3 reattach to conversions and joins the ViewModel did not start
ce4d0ff D6 the selected tab survives the recreation targetSdk 37 guarantees
dbfc463 D4 a half-written destination is deleted instead of left under the user's name
97fdc49 D14 the native boundary is guarded against what it actually throws
7db3200 D11 the JDK claim is corrected and the backup rules decided
5c27801 D13 work the system refused to start is retried, not failed terminally
dcdcbfd D10 a cancelled conversion stays cancelled
2a68f03 D8 every job gets a staging path of its own
159320d D9 a finished file is named after the job that made it

The one to read first

D13 is the reason this branch exists. Work interrupted by process death was failing terminally — confirmed on a Pixel 10 Pro XL, API 37, on a natural dispatch 119 s after kill -9, with reschedule = false and empty output Data. setForeground sat outside doWork's try, so a ForegroundServiceStartNotAllowedException bypassed the retry, the error message and staged.delete() all at once. MediaProbe.probe was outside for the same reason.

A denied start now returns Result.retry(), bounded at 10 attempts — derived from the default backoff read out of WorkRequest.kt (30 s, doubling, clamped at 5 h) ≈ 8 h 30 m. The exception is matched on its exact class, never on IllegalStateException, which plenty of ordinary muxer failures also are.

Test infrastructure this adds

Robolectric 4.16.1, pinned — org.robolectric is outside the componentSelection prerelease guard, so 4.+ would have taken 4.17-beta-3. It needs testOptions { unitTests.isIncludeAndroidResources = true } (the module had no testOptions at all), --enable-native-access=ALL-UNNAMED for Java 25, and robolectric.properties with sdk=36 — Robolectric defaults to the manifest's targetSdk of 37, no android-all jar exists for 37, and the class fails to initialise before any test body runs.

Both ViewModels now resolve through the existing ConversionDependencies seam rather than constructing their own OutputPublisher, which is what makes them testable at all.

Known gaps

  • D15 and D16 were introduced or exposed by this work and are not fixed — both recorded in #7. D16 is the notable one: D13's bound reports through a FAILED job, and D3's reattachment excludes FAILED, so a user not watching at the eleventh attempt still sees an empty screen. Neither commit owns that gap.
  • Nothing here ran on hardware. All 242 tests are JVM; every androidTest was compile-verified only. Per CLAUDE.md, API 37 needs a manual check on the Pixel 10 Pro XL before release — and D13 in particular was only ever observable there.
  • hasSpaceFor still reads File.usableSpace. The lint hint is untouched on purpose: the device pass contradicted that finding's premise (see D1 in #7), so its fix is parked pending a near-full-disk measurement.
  • The merge commits name scratch/integrate-d2-d3, the branch the fixes were integrated on before it was renamed. Cosmetic; the ten commits above are the substance.

🤖 Generated with Claude Code

Ten fixes from the audit in #7, each its own commit with its own tests. **242 JVM unit tests, 0 failures** (up from 180), detekt 0 findings, lint `0 errors, 0 warnings, 1 hint` — the pre-existing `UsableSpace` informational, which belongs to a defect deliberately not fixed here. | Commit | Defect | |---|---| | `cfd705a` | **D2** staged output the user never saved is deleted, instead of waiting for the OS | | `ec969c4` | **D3** reattach to conversions and joins the ViewModel did not start | | `ce4d0ff` | **D6** the selected tab survives the recreation targetSdk 37 guarantees | | `dbfc463` | **D4** a half-written destination is deleted instead of left under the user's name | | `97fdc49` | **D14** the native boundary is guarded against what it actually throws | | `7db3200` | **D11** the JDK claim is corrected and the backup rules decided | | `5c27801` | **D13** work the system refused to start is retried, not failed terminally | | `dcdcbfd` | **D10** a cancelled conversion stays cancelled | | `2a68f03` | **D8** every job gets a staging path of its own | | `159320d` | **D9** a finished file is named after the job that made it | ## The one to read first **D13** is the reason this branch exists. Work interrupted by process death was failing terminally — confirmed on a Pixel 10 Pro XL, API 37, on a natural dispatch 119 s after `kill -9`, with `reschedule = false` and empty output `Data`. `setForeground` sat *outside* `doWork`'s `try`, so a `ForegroundServiceStartNotAllowedException` bypassed the retry, the error message and `staged.delete()` all at once. `MediaProbe.probe` was outside for the same reason. A denied start now returns `Result.retry()`, bounded at 10 attempts — derived from the default backoff read out of `WorkRequest.kt` (30 s, doubling, clamped at 5 h) ≈ 8 h 30 m. The exception is matched on its exact class, never on `IllegalStateException`, which plenty of ordinary muxer failures also are. ## Test infrastructure this adds Robolectric 4.16.1, **pinned** — `org.robolectric` is outside the `componentSelection` prerelease guard, so `4.+` would have taken `4.17-beta-3`. It needs `testOptions { unitTests.isIncludeAndroidResources = true }` (the module had no `testOptions` at all), `--enable-native-access=ALL-UNNAMED` for Java 25, and `robolectric.properties` with `sdk=36` — Robolectric defaults to the manifest's `targetSdk` of 37, no `android-all` jar exists for 37, and the class fails to initialise before any test body runs. Both ViewModels now resolve through the existing `ConversionDependencies` seam rather than constructing their own `OutputPublisher`, which is what makes them testable at all. ## Known gaps - **D15 and D16 were introduced or exposed by this work and are not fixed** — both recorded in #7. D16 is the notable one: D13's bound reports through a FAILED job, and D3's reattachment excludes FAILED, so a user not watching at the eleventh attempt still sees an empty screen. Neither commit owns that gap. - **Nothing here ran on hardware.** All 242 tests are JVM; every `androidTest` was compile-verified only. Per `CLAUDE.md`, **API 37 needs a manual check on the Pixel 10 Pro XL before release** — and D13 in particular was only ever observable there. - **`hasSpaceFor` still reads `File.usableSpace`.** The lint hint is untouched on purpose: the device pass contradicted that finding's premise (see D1 in #7), so its fix is parked pending a near-full-disk measurement. - The merge commits name `scratch/integrate-d2-d3`, the branch the fixes were integrated on before it was renamed. Cosmetic; the ten commits above are the substance. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
JMR-dev commented 2026-08-23 01:59:00 +00:00 (Migrated from github.com)

Two more fixes pushed — this branch now carries twelve, at 257 JVM tests, 0 failures (up from 242, and from 180 when the work started). detekt 0, lint 0 errors, 0 warnings, 1 hint.

Commit Defect
b86df47 D5 an unknown input size is told apart from an empty file
c2e6344 D7 WorkManager keeps sole ownership of the progress notification

D5 — InputFile.sizeBytes is now Long?, so "nobody told me" is absence rather than 0L. The two byte-identical copies of queryFile became one InputQuery, which asks twice: OpenableColumns.SIZE, then openFileDescriptor(uri, "r").statSize. That second question is the real behaviour change — it turns the confirmed file:// case measured on the Pixel from unknown into known.

Where the size genuinely cannot be determined, the check reserves headroom only, which is the same number the defect produced by accident. What changed is that it is now the answer to a question that was asked, so the next person can decide differently without first discovering that 0L meant two things.

D7 — publishProgress returns early on isStopped and publishes through setForegroundAsync rather than NotificationManager.notify(1001, …). The initial setForeground in doWork is deliberately left as a suspending call inside the try: converting it for symmetry would move ForegroundServiceStartNotAllowedException into an unobserved future and silently undo 5c27801.

The device's orphan notification now reproduces on the JVM — and must resurrect nothing expected:<0> but was:<1> before the fix. That was previously only observable on hardware, on attempt 3 of 12.

Two limits worth knowing

  • Work enqueued before b86df47 carries KEY_SIZE_BYTES = 0 present for an unmeasurable input, so the vacuous check survives for that job. Not fixable after the fact; self-clearing as WorkManager ages the queue out.
  • ConverterScreen's "Size unknown" has no test. The repo has no Compose test in either source set, so one would be new infrastructure rather than a test.

Rebased on main after #6 and #7 merged; re-gated green on the merged result.

🤖 Generated with Claude Code

Two more fixes pushed — this branch now carries **twelve**, at **257 JVM tests, 0 failures** (up from 242, and from 180 when the work started). detekt 0, lint `0 errors, 0 warnings, 1 hint`. | Commit | Defect | |---|---| | `b86df47` | **D5** an unknown input size is told apart from an empty file | | `c2e6344` | **D7** WorkManager keeps sole ownership of the progress notification | **D5** — `InputFile.sizeBytes` is now `Long?`, so "nobody told me" is absence rather than `0L`. The two byte-identical copies of `queryFile` became one `InputQuery`, which asks twice: `OpenableColumns.SIZE`, then `openFileDescriptor(uri, "r").statSize`. That second question is the real behaviour change — it turns the confirmed `file://` case measured on the Pixel from unknown into known. Where the size genuinely cannot be determined, the check reserves headroom only, which is **the same number the defect produced by accident**. What changed is that it is now the answer to a question that was asked, so the next person can decide differently without first discovering that `0L` meant two things. **D7** — `publishProgress` returns early on `isStopped` and publishes through `setForegroundAsync` rather than `NotificationManager.notify(1001, …)`. The initial `setForeground` in `doWork` is deliberately left as a *suspending* call inside the `try`: converting it for symmetry would move `ForegroundServiceStartNotAllowedException` into an unobserved future and silently undo `5c27801`. **The device's orphan notification now reproduces on the JVM** — `and must resurrect nothing expected:<0> but was:<1>` before the fix. That was previously only observable on hardware, on attempt 3 of 12. ## Two limits worth knowing - **Work enqueued before `b86df47`** carries `KEY_SIZE_BYTES = 0` *present* for an unmeasurable input, so the vacuous check survives for that job. Not fixable after the fact; self-clearing as WorkManager ages the queue out. - **`ConverterScreen`'s "Size unknown" has no test.** The repo has no Compose test in either source set, so one would be new infrastructure rather than a test. Rebased on `main` after #6 and #7 merged; re-gated green on the merged result. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.