Commit Graph
93 Commits
Author SHA1 Message Date
JMR-dev f8e6bfa2a3 Merge remote-tracking branch 'origin/main' into tools/api-37-emulator 2026-08-22 23:52:58 -05:00
Jason Ross 5a1b8832d3 Merge pull request #48 from JMR-dev/fix/review-app-gaps
Close eleven review findings in app code
2026-08-22 23:52:18 -05:00
JMR-dev 0896cef758 Merge remote-tracking branch 'origin/main' into fix/review-app-gaps 2026-08-22 23:26:03 -05:00
JMR-devandClaude Opus 5 d3975617a8 Put a gate on the three pieces of wiring that had none
Three separate mutations passed the full 257-test suite, all for the same reason: the tool
was tested and the thing that calls it was not.

The join half of per-job staging. Reverting ConcatWorker to the constant the audit's own
D8 table names -- "joined.<ext>", one string for every join of a format -- left everything
green: PerJobStagingTest drives only the conversion worker, and StagingNamesTest pins only
the pure function. The new case drives two real ConcatWorkers with different ids and reads
what they asked for rather than what is on disk, because ConcatEngine is native, so neither
join gets past it here and the catch on the way out deletes what it staged. The recorder
moves into WorkerStubs, which is what that file is for, and the enum test that already had
a private copy now uses it.

The process-start sweep. Deleting the one line in LibreMediaConverterApp.onCreate() -- the
only reason that class exists, and the backstop for every leak discardStaged cannot reach
-- left everything green too. The test stages one file a day old and one written now, calls
onCreate() again, and asserts both halves: the abandoned one is collected and the live one
is not. The second half is what says this is a sweep rather than the clearStaging() it
replaced, which could take a file out from under a running job. The mtime is set explicitly,
because "written long enough ago" is not something a test can wait for when the period is
twenty-four hours. Casting the Robolectric application to LibreMediaConverterApp is an
assertion in itself: it fails if android:name ever stops pointing here, in which case the
swept line would be correct code that never runs.

The backup and device-transfer exclusions. Reverting data_extraction_rules.xml to the
template's boilerplate left the unit tests green AND lintDebug green -- it is a resource,
so nothing was reading it -- and the failure it causes is one nobody meets in development.
WorkManager's queue is the app's whole backup payload, and its rows name content:// grants
and cacheDir paths that do not survive a transfer; reattachment queries by tag on launch,
so a fresh install would come up attached to a job the user never ran on it. The test reads
the compiled resource table, so what it pins is what the APK carries, and it asserts domain
and path for all four entries in both sections -- an <exclude> with no path is skipped
unchecked by lint's own detector, so half an entry could protect nothing. Its KDoc records
the one thing it does not cover: the manifest attribute that points the system at the file.

R8 / #17, R9 / #18, R11 / #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:25:06 -05:00
JMR-dev 7ae660ee37 Merge remote-tracking branch 'origin/main' into tools/api-37-emulator 2026-08-22 23:21:47 -05:00
Jason Ross e76547fe3c Merge pull request #45 from JMR-dev/docs/review-corrections
Correct six documentation claims the overnight review falsified
2026-08-22 23:21:32 -05:00
JMR-devandClaude Opus 5 775a44753b Say that the default sweep is red on purpose, and narrow two claims
R16 / #25 -- the branch put API 37 into the default APIS list, where it is permanently two
failures short of green, so a bare `run-e2e.sh` exits 1 by design and nothing said so.
Somebody running it from habit, a wrapper or a hook gets a red exit forever and either
stops reading exit codes or debugs a normal state.

Documented rather than suppressed. The script's own comment already argued that an
expected-red level belongs in the exit code -- reversing that is the branch owner's call,
not a correction -- and the review's alternative needs an exact-set comparison of the
failing test names before it can subtract 37's contribution, which is a new mechanism that
cannot be validated without a device. So:

- the header now states the exit code (0 all green / 1 any level red / 2 refused to
  start), says a bare run is 1 by design and why, and gives `run-e2e.sh 33 34 35 36` as
  the sweep that can be green;
- a red sweep prints one note after the summary saying the same thing, because the exit
  code is read in the terminal and not in the docs -- but ONLY when 37.x is the only level
  that went red. `overall` is set by any red level, so a note keyed on "37 was in the
  list" would have called a genuine API 34 failure "by design", which is the defect this
  is meant to prevent, one layer up. mark_red records which level it was, where the loop
  already knows;
- docs/local-emulator.md says it where the default is documented.

R27 / #36 -- 792286a appended the caveat that the harness path reproduces a rate collapse
rather than a clean zero, but left "that is the confirmation that region sampling is the
sole trigger" standing three lines above it, which the caveat contradicts. Now "the
strongest evidence that region sampling is the dominant trigger", with the residue named:
no measurement here separates a second caller of the readback path from a disable that did
not fully take, and the file says so rather than picking one.

R28 / #37 -- "the capability is negotiated regardless of renderer" leaned on the string
search, which shows only that `ANDROID_EMU_read_color_buffer_dma` is implemented in one
shared component, not that it is negotiated on every path. The aborts are the actual
evidence -- the assertion that fires is `!hasReadColorBufferDma` and it fires under ANGLE
too -- and they suffice alone; the string search is demoted to a supporting note. Worth
getting right because the doc says the upstream report should lead with this model.

Two follow-ons that belong with R17 / #26 and land here rather than in their own commit:
bash runs a trap only between commands, so the handler starts when the foreground command
returns -- immediate under Ctrl-C, which reaches that command too, but not under a `kill
-INT` aimed at the script alone; that is now written next to the handler. And
delete_created_avds no longer discards avdmanager's status: an emulator that was SIGKILLed
did not get to remove its own lock files, avdmanager can refuse over them, and silence
there would leak exactly what the trap exists to clean up.

`bash -n` clean; the stub smoke harness (real script, fake SDK binaries, boot-failure path,
no Gradle and no emulator) now also checks that a 37-only red prints the note after the
summary, that a red API 34 alongside it suppresses the note, that a 34-only sweep says
nothing, and that a refused AVD deletion is reported. shellcheck is not installed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:17:19 -05:00
JMR-devandClaude Opus 5 3534c6d996 Clean up the empty document a refused open leaves, and stop the space sums wrapping
publish() opened the destination stream outside its guarded region, justified by "nothing
has been written at that point, so there is nothing of ours to remove". That reasoning is
wrong about what exists: SAF's CreateDocument contract creates the document before
publish() is ever called -- which is why every fixture in OutputPublisherPublishTest
starts as an existing empty file. A provider that then hands out no stream, because it
dropped between the picker and the write or simply returns null, left a zero-byte file at
the name the user chose while the screen said the save had failed.

The open moves inside the try, so the same two bounds that already govern a failed copy
govern this: only a document URI, and only a destination positively known to be empty. The
dead-provider case is untouched and now demonstrably by the guard rather than by the
placement -- nothing answers for that authority, so no size can be read, and "I could not
tell" still refuses to authorise a delete. Its test comment said the old thing and now says
that one.

The space arithmetic overflows in two places, both live on main and independent of the
allocatable-versus-usable question that stays parked:

 - hasSpaceFor computed `free > required + headroom`. A request within 128 MiB of
   Long.MAX_VALUE wraps that sum negative, and every free-space measurement beats a
   negative number, so the check answers "plenty of room" to the largest request it can be
   handed. Rewritten as `free - headroom > required` with both operands clamped at zero,
   which is the form the parked branch's StagingSpace.hasRoomFor already argues for.
 - InputQuery.total folded a join's inputs with nothing stopping the sum from wrapping,
   and that is the reachable half: no single file overflows, three four-exabyte inputs do.
   It saturates at Long.MAX_VALUE now, which the check above then refuses.

SpaceArithmeticTest ties the two together in the shape the defect had -- the total that
came out negative is handed straight to the space check -- and keeps one allowed case so
the refusals cannot pass by refusing everything. The negative-size clamp is deliberately
left unasserted, with a comment saying why: it only changes the answer when free space is
below the headroom, which a test reading the host's real cache volume cannot arrange.

R6 / #15, R23 / #32

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:15:19 -05:00
JMR-devandClaude Opus 5 a6cf4f4ff4 Stop three enum reads escaping doWork, and pin what the attempt bound buys
Both workers read enums out of their input Data with Enum.valueOf, and all three reads sit
ABOVE the try. A name this build does not define -- which is what a downgrade or a
rollback with work still in the queue produces, since WorkManager keeps work for about a
week -- threw IllegalArgumentException straight out of doWork(). That is D13's signature
verbatim: FAILURE with reschedule = false, output Data with zero entries so the screen
says "Conversion failed." and nothing else, and no staged.delete(), so the partial stays
in cache. readSpec() twelve lines below already handles exactly this case, and its KDoc
says why.

So all three take readSpec's shape: entries.firstOrNull { it.name == name } ?: default.
Consistency argues for it as much as correctness does -- the file already contains the
right answer to this question, three times.

WorkerEnumFallbackTest reaches each read. Two of them pin the value that replaces the
unknown name rather than only that nothing threw: a quality tier falling back to something
arbitrary would convert at a setting nobody chose, and a join's format decides the
extension its output is staged with, which is where FFmpeg infers the container from. The
engine-preference case asserts through the space check instead, because predicting which
engine AUTO picks would tie the test to a routing rule it is not about. Against the
unfixed code all three fail with "No enum constant ...".

MAX_FOREGROUND_START_ATTEMPTS had no test of its value. Both existing cases are written
against the symbol, which pins the relationship and leaves the number free: changed to 2,
the job gives up about ninety seconds after process death -- exactly the long conversion
the retry exists to protect -- and all 257 tests stayed green.

The new assertion is the property the KDoc argues, not the literal: summed against
WorkRequest's own DEFAULT_BACKOFF_DELAY_MILLIS and MAX_BACKOFF_MILLIS, the attempts have
to span at least eight hours, which is what makes "the user will have opened the app by
then" a claim rather than a hope. A deliberate re-tune that keeps the property passes; the
accident does not, and reports the span it got (0.025 hours at 2).

R22 / #31, R10 / #19

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:11:03 -05:00
JMR-devandClaude Opus 5 961cfa72a2 Derive the suite size instead of writing it down in two documents
R4 / #13 and R20 / #29 are one defect: an absolute test total in an unregenerated
document, written the same day it went stale. This branch was cut at 22c7914, where
app/src/androidTest held 49 @Test methods; main is 57 (ReattachOnLaunchTest added eight
in ec969c4). So the release instruction "expect 49 / 0 / 0 / 2, and if you get 40 you are
on an old checkout" becomes false the moment this branch merges -- on the one check that
has no CI backstop -- and docs/local-emulator.md's headline promises a 49-test local
baseline main no longer produces.

Re-derived rather than renumbered, because a third total would go stale the same way:

- The total is the size of app/src/androidTest on the checkout that ran, and the reported
  total has equalled that checkout's @Test count everywhere it has been checked: 40 at
  edd6385 (the Pixel run), 49 at 22c7914 (the four local levels and API 37), 57 at
  18c53a3 (counted, not run). The new "Reading these totals" section states that, gives
  the one-line grep, and makes the *mismatch* the signal: a total that disagrees with
  your own checkout's count means an old checkout, a stale build or tests that never ran.
  The pre-release Pixel instruction now reads "that many tests, 0 failures, 0 errors, 2
  skipped" -- the invariant, not the total.
- Measurements are kept verbatim and anchored to 22c7914 (the sweep table, the API 35
  control, the tests="49" XML quote, the 51-on-screen console block). Only the claims
  built on top of them were rewritten.

Two claims went with the number, both of which a rebase would have preserved:

- "47 of 49" is not a defensible ratio when two of the 49 are skips. 49 = 45 passed + 2
  failed + 2 skipped, and that is what it now says.
- "against the Pixel's 49 of 49" and "matches the physical Pixel 10 Pro XL baseline of
  49 / 0 / 0 / 2 exactly" describe a run that never happened: the Pixel measured
  40 / 0 / 0 / 2 at edd6385, nine tests earlier, as the same file says a hundred lines
  further down. Both documents projected the local total onto the phone and called it a
  match. What compares between them is 0 failures and the same two skips.

Also re-derived in the CLAUDE.md wording docs/local-emulator.md proposes, since that text
is meant to be pasted out of the branch and would have carried "49 tests / 2 failures /
2 skipped" with it. CLAUDE.md itself is still untouched.

Counts re-checked with git grep at each of the three commits; nothing here needed a
device, and none was used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:09:30 -05:00
JMR-devandClaude Opus 5 c5c4c5323b Test the edge that feeds reattachment, and stop it reporting ENOENT
Reattachment.choose has twenty tests and every mutation aimed at it bites. Everything
that computes its inputs had none, and five mutations there passed the whole 257-test
suite. Four are closed here, each verified by applying the mutation and watching the new
test go red.

jobSnapshots() is the half that has to touch WorkManager and the filesystem, so it is
where the untested values live. JobSnapshotsTest drives it against a real WorkManager and
a real cacheDir:

 - A zero-byte staged file is not an output. Relaxing the filter to `exists()` -- which
   is what a job killed before its engine wrote anything leaves behind -- made the
   snapshot claim a result, and the user would meet a Save button for a zero-byte
   "conversion". The same case pins that the path is still reported and that the mtime
   stays 0 for a file that is not a result.
 - Each result carries its own file's mtime. Hardcoding it to zero starves the
   newest-file tie-break of the only data it has, which is precisely the failure the
   tie-break exists to prevent: the query has no ORDER BY, so an arbitrary winner keeps
   winning every launch. Timestamps are set with setLastModified and compared against what
   the filesystem stored, because mtime granularity is not this test's claim to make.

ReattachGuardsTest covers the two decisions the ViewModel makes that the pure rule cannot:

 - A file picked while the query was still in flight is not reattached over. Deleting the
   guard turns the user's pick into yesterday's job -- with the Save button pointing at a
   file the card does not name. Made deterministic by holding WorkManager's task executor
   rather than by racing two IO hops: the query cannot finish until the pick has landed.
   The test also asserts the brake really gripped, so a reattachment that never arrived
   cannot pass for one that was refused.
 - An Ambiguous result is offered without being attributed. Two finished jobs naming one
   staged file is what the device produced before staging was keyed on the job id; taking
   the first job's tags labels the file with the other conversion's name, which is the
   confident lie the KDoc rejects. The neutral label and the absent size are both pinned.

ReattachmentTest's FAILED exclusion was only ever tested with pathless FAILED jobs, so a
narrow regression ranking a FAILED job that carries a file like a result passed all 257
tests. The live shape is the 2 MB orphan the device pass found: a job killed mid-write
leaves a partial, and under that regression the user is offered a truncated file with a
Save button. One fixture with outputPath and outputExists set closes it.

save() re-checks the staged file, in both ViewModels. The check reattachment made ran
inside a tag query that can be hours older than the tap, and cacheDir is what the OS
empties when it wants space and what the sweep collects after a day. The file's absence
used to arrive as staged.inputStream() throwing, and e.message put
"/data/user/0/.../4b4882....mp4: open failed: ENOENT" on screen -- a true statement about
a path the user has never seen and cannot act on. It now reads as a sentence with an
action in it. The message is one constant next to OutputPublisher because both ViewModels
need it and staging is what it is about.

Reattachment's KDoc claimed a defect that was fixed in the commit before it -- that "Start
over" keeps its staged file -- which would send a maintainer to re-fix D2. Rewritten to
say what is actually true: the delete happens, and the gap it leaves is the reset() whose
delete is cancelled with the Activity, which is the sweep's job and is named in the
sweep's own KDoc.

R1 / #10, R2 / #11, R24 / #33, R25 / #34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:06:37 -05:00
JMR-devandClaude Opus 5 da6f2807e9 Stop an interrupted sweep leaking the emulator, the AVD and the port
Four corrections to the harness, none of which changes what a successful sweep does.

R17 / #26 -- no trap. Ctrl-C during a sweep (now up to five boots long) left headless
qemu on console port 5560 and an lmc_e2e_apiNN AVD behind. The next run's `emulator
-port` then collides with the orphan and `emu_adb` can resolve to it -- on a workstation
with the Pixel plugged in, exactly the ambiguity the ANDROID_SERIAL pinning exists to
prevent. `cleanup` (stop_emulator + delete_created_avds, KEEP_AVD honoured) is now on
EXIT, INT and TERM. It is idempotent and the normal path calls it explicitly before the
summary, so cleanup output cannot land after the summary and the EXIT trap finds nothing
to redo. The interrupt path passes a 6-second grace rather than 30: Ctrl-C has already
reached the emulator through the foreground process group, so that wait is only for it to
finish writing, and `kill -9` follows regardless. `exit "$overall"` stays the last line,
so the exit code an EXIT trap could have swallowed is still the one that escapes. The
emulator logs in $LOG_DIR are deliberately kept -- they are the only evidence a failed
boot leaves.

R31 / #40 -- `kill -9 "${EMU_PID:-0}"`. EMU_PID is empty, not unset, if the background
launch never produced a job, so `:-0` converted "nothing to kill" into pid 0, which POSIX
reads as the sender's whole process group. The `kill -0` wait loop had the same shape and
would have spent its full grace period testing the group. All three sites now take a bare
`$EMU_PID` behind one `[ -n ... ] || return 0` guard. boot_emulator's own `kill -0` is
left alone: it runs only after the assignment and cannot reach the group form.

R33 / #42 -- ensure_avd wrote CI's RAM and disk pins to a hardcoded
$HOME/.android/avd/... path and checked nothing. With ANDROID_AVD_HOME (or
ANDROID_USER_HOME, or ANDROID_SDK_HOME) set, the sed failed and the level ran on at
default RAM and userdata, which surfaces much later as "not enough space" and reads as a
device problem. `avd_config_path` now looks in every directory avdmanager honours -- no
precedence is asserted, the existence check decides -- and a level that cannot be found
or written fails instead of running unpinned.

R34 / #43 -- disable_region_sampling's "one blocking wait on the device" did not wait:
`adb shell stop` does not clear sys.boot_completed, so the property still read 1 and the
loop returned at once. Deleted, and the comment now names the service-check loop below it
as the actual wait -- which polls the better thing anyway, since `Can't find service:
package` is the failure it exists to prevent. That loop also says so when it gives up
after 150 s instead of proceeding silently. Deliberately not doing the `setprop
sys.boot_completed 0` variant: the loop tested for an empty value, so a 0 would not have
made it wait either, and the `!= 1` form it would need is an unbounded loop inside `adb
shell` with no timeout.

Checked with `bash -n` and with two stub harnesses in place of a device (shellcheck is
not installed here): one drives the extracted lifecycle functions against fake binaries
and asserts pid 0 really does hit the sender's process group, that an empty EMU_PID now
signals nothing and returns at once, that SIGINT cleans up once and exits 130 within
seconds, that KEEP_AVD survives the trap path, and that an explicit exit status survives
the EXIT trap; the other runs the real script end to end on the boot-failure path, which
stops short of e2e-run.sh, and checks the pins land in config.ini, the created AVD is
removed, a misplaced config.ini fails the level, and `set -u` is not tripped anywhere.
No emulator was booted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:06:24 -05:00
JMR-devandClaude Opus 5 614af35647 Hold the corrections themselves to the standard they impose
Three defects in the three preceding commits, found on review. A commit set
whose subject is stale dates and inferred status cannot carry either.

Dates. Both correction blocks were stamped 2026-08-23. The commits are dated
2026-08-22, as is every other date in these two files and the review that
produced them -- a day in the future, in the one place a reader checks to see
how fresh a correction is. Corrected to the commit date, and the D6 note now
carries one too.

Coherence. The Status line was changed to say fix status "tracks main,
re-checked at 18c53a3" while "Last verified: 2026-08-22, against main at
903b43c" stood two lines below it, unchanged. A reader would take the whole
document as anchored to 903b43c -- exactly the failure being corrected. The two
anchors now say what each covers and that they move independently: the as-found
bodies are frozen at 903b43c, the status is not.

Inferred count. "detekt 0, lint clean" was carried over from the old text on the
strength of a BUILD SUCCESSFUL, which means "nothing above threshold", not
"nothing found" -- and this project deliberately keeps a real lint finding
visible (`informational += "UsableSpace"`), so "clean" was wrong as well as
unmeasured. Read off the reports instead: detekt.xml has zero <error> elements,
lint-results-debug.txt says 0 errors, 0 warnings, 1 hint. Stated that way, which
is also the convention the audit's own opening table already uses.

README's correction note is tightened from nine lines to six. It sits on the
first screen and nothing load-bearing is dropped.

Gate green: ktlintCheck, detekt, lintDebug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:01:43 -05:00
JMR-devandClaude Opus 5 01cbc94888 Say what the FFmpeg format tests have actually been run against
R18 / #27. The front-page status line said the FFmpeg format tests "have been
written but not yet executed on a device". They have been executed, repeatedly
and green, and this is the one line a contributor uses to decide whether the
FFmpeg path is trustworthy -- understating it costs more than a stale detail
elsewhere would.

FFmpegEngineTest's nine format tests -- mp3, gif, matroska, flac, wav, opus,
H.264, H.265, and the one asserting a failure surfaces as an exception rather
than a silent empty file -- were present at every commit cited below, checked by
counting @Test in that file at each:

- Physical Pixel 10 Pro XL, API 37, 2026-08-21 at edd6385: 40 / 0 / 0 / 2.
- The audit's own Pixel pass, 2026-08-22: 49 / 0 / 0 / 2.
- Four local emulator levels, API 33-36, 2026-08-22 at 22c7914: 49 / 0 / 0 / 2
  each.

Zero failures in all of them, and the two skips are RealMediaBenchmark's
assumption-guarded tests, not these. CI's e2e matrix in status_check.yml covers
API 33-36 and the workflow triggers on pull_request against main, so "on every
pull request" is checked rather than assumed.

The replacement names the runs instead of a count, since a total is the part
that goes stale -- androidTest has moved 40 to 49 to 57 in a few days. It also
keeps the API 37 caveat visible: no CI row, so that level stays a manual Pixel
check before each release.

Held, not decided here: whether a front-page status line should carry a
verification claim at all. Fixing what it says is separable from deciding what
it should be for.

Gate green: ktlintCheck, detekt, lintDebug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 22:58:16 -05:00
JMR-devandClaude Opus 5 7e7f1301a7 Re-date the audit's testing section, which its own follow-up falsified
R14 / #23, R15 / #24. "On testing these" proposed a plan; the twelve fixes then
executed it, so the section describes a state that no longer exists. It is
re-dated rather than deleted -- the reasoning is why the test stack looks the
way it does -- with each stale claim marked where it stands.

R15 / #24, four statements, each checked against this checkout rather than
against another document:

- "exactly one dependency, testImplementation(libs.junit)". There are five:
  junit, robolectric, androidx.work.testing, the Compose BOM platform and
  compose-ui-test-junit4.
- "a testOptions { unitTests.isIncludeAndroidResources = true } block, which
  this module does not currently have at all". app/build.gradle.kts:105-110.
- "work-testing, compose-ui-test-junit4 and espresso-core ... have zero users."
  By import, work-testing has seven files under app/src/test and
  androidx.compose.ui.test has one. espresso-core really is still at zero, so
  that third is kept as the only part still standing.
- The preamble's "OutputPublisher, both ViewModels, both Workers and
  MainActivity have no JVM unit tests at all". 25 JVM test files were added over
  that set, 180 tests to 257.

That last one is also the derivation of CLAUDE.md's ~31% coverage figure, so the
reasoning is kept verbatim and only its tense and scope are fixed: the ~31% is
what those ~1,200 untested lines produced at 903b43c, it predates the new tests,
and jacoco has not been re-run. The figure itself is deliberately not touched
here -- re-measuring it and updating all three sites together is its own change.

R14 / #23. The audit asserted in two places that instrumented tests cannot run
on this host, and recorded the opposite in a third. 22c7914 is merged and
tools/local-emulator/run-e2e.sh runs API 33-36 locally, so the section's
impossibility argument for "pure seams plus Robolectric" is restated on the
grounds that survive -- speed and determinism, which is the weaker claim and
worth making honestly. D6's "it is an instrumented test, so it runs on CI, not
locally" gets the same treatment, and went further the other way: the
StateRestorationTester test that entry asked for is AppRootRestorationTest,
which runs under Robolectric on :app:testDebugUnitTest. The API 37 rule is
explicitly left standing -- that image is broken and the Pixel check before each
release is unaffected.

No as-found body was rewritten; D6's note is appended to its "Fix direction and
test" guidance, not to its description of the defect. Gate green: ktlintCheck,
detekt, lintDebug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 22:57:01 -05:00
JMR-devandClaude Opus 5 9f0bc9d19b Correct the defect audit's status metadata, which went stale in hours
R3 / #12, R12 / #21, R13 / #22. Three status claims in the audit were false
against `main` at 18c53a3. The audit is read as the work queue for the ticket
phase, so each correction is stated in the document rather than made quietly --
a reader who believed the old claim needs to see that it changed.

R3 / #12. D5 and D7 were marked `open` and "in progress on
fix/space-proxy-and-notification". Both merged hours before: b86df47 (D5) and
c2e6344 (D7) are ancestors of 18c53a3 (`git merge-base --is-ancestor`), and so
is the branch. Anyone working the table would have re-implemented merged work.
The header count was also wrong in its own arithmetic -- "ten fixed, two in
progress, one parked" covers thirteen of sixteen entries, dropping D12, D15 and
D16. It now states four numbers that sum, and says the Fix column tracks `main`
at a named commit so the next reader knows what it is relative to.

R12 / #21. "Where the fixes live" sent the reader to `feat/defect-fixes-base`,
for which `git show-ref` finds nothing -- no local ref, no remote, deleted when
it merged -- and quoted 242 JVM tests. A real run on this branch gives 257 / 0 /
0 / 0 across 34 classes; the 15 missing are UnknownInputSizeTest, SpaceCheckTest
and ProgressNotificationTest. The replacement names `main`, anchors the total to
this commit and gives the command to re-derive it, since the number is only as
fresh as the document.

The same paragraph's "API 33-36 now run locally, 49 tests each" is anchored to
22c7914, the commit that measured it: androidTest is 57 `@Test` on `main`, and
an unanchored total invites a reader to mistake drift for breakage. The
surviving invariant -- 0 failures, 0 errors, 2 skipped, same total at every
level and on the Pixel -- is stated instead.

R13 / #22. D11 was marked `merged`, but 7db3200's own body says one of its four
rows was deliberately skipped: "Not touched: OutputPublisher's hasSpaceFor
KDoc". Still true -- nothing in OutputPublisher.kt mentions D1 -- and that row
is the one place a reader of the code would learn the parked defect exists. The
summary row now says "less the OutputPublisher KDoc row -- held with D1", with
the reasoning at the entry.

The as-found bodies are untouched throughout, including D11's own item table:
corrections are carried as marked editorial notes beside them, the pattern D1's
entry already uses. Gate green: ktlintCheck, detekt, lintDebug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 22:54:33 -05:00
JMR-devandClaude Opus 5 792286a2d7 Stop three claims in the API 37 doc outrunning their evidence
Three corrections, all narrowing:

- angle_indirect and swangle_indirect are not two independent renderers here. Both
  logged gles_mode_selected:swangle with the same adapter, differing only in the
  Vulkan backend underneath -- unlike at API 33-36, where angle_indirect resolves to
  ANGLE on llvmpipe. What is 7-for-7 is the host-GLES-versus-not split, not "two
  renderers agree".

- "Disabling SystemUI stops the crashes entirely" was one 180-second measurement on a
  device that had been up twelve minutes. The harness path reproduces a rate collapse,
  not a zero: its own quiet check printed 1 abort in 45 s and 4 across the run. A
  47-second Gradle run survives that; a five-minute one might not.

- "Reproduced twice" conflated two routes. The 49/2/0/2 came back from a hand-driven
  sequence and from the harness, which corroborates the numbers, but the harness path
  itself has one green measurement.

Also records what the doc never said: from 37.1 onward Google ships only 16 KB-page
x86_64 images, so page-size alignment is a prerequisite for that path rather than a
detail. All 20 libraries in the committed FFmpeg AAR are 0x4000-aligned, checked
before the first ps16k boot -- which is why 37.1 reproducing the abort means the
gralloc bug and not a page-size mismatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 22:10:50 -05:00
JMR-devandClaude Opus 5 739bffa5a0 Re-derive the API 37 emulator failure: it is the renderer, not the image
docs/api-37-emulator-crash.md claimed "Both swiftshader_indirect and host crash...
The crash is in the gralloc mapper, below the renderer." Re-measured, seven runs,
one variable each: that is wrong. The mapper is below the renderer, but whether its
bad path is reached is not.

  -gpu host              gles_mode_selected:host    never boots  (57-71 aborts, looping)
  -gpu swangle_indirect  gles_mode_selected:swangle boots, 85 s  (1 abort)
  -gpu angle_indirect    gles_mode_selected:swangle boots, 112 s (2 aborts)

The old claim rested on two samples of two different things, neither of them ANGLE:
the local swiftshader_indirect sample was void, because on this host every
SwiftShader-GLES launch segfaults the emulator before the guest matters (the
execheap bug in docs/local-emulator.md, not understood when that file was written),
and the CI sample was a single swiftshader_indirect run.

Also re-derived, and null: android-37.1 rev 8 -- a stable REL image the doc's own
"new image revision" trigger was too narrow to catch -- fails identically;
-feature -GLDMA,-GLDMA2,-GLDirectMem is accepted and changes nothing; the image's
advancedFeatures.ini is byte-identical to API 36's but for one camera line; and
there is still no ATD image above API 36.

The mechanism, end to end: SystemUI registers a nav-bar luma-sampling listener,
SurfaceFlinger's RegionSamplingThread locks a GraphicBuffer, Gralloc5 routes into
GoldfishMapper::readFromHost, which asserts, and init SIGKILLs zygote in response --
so the framework restarts under the test run. Disabling SystemUI removes the
listener and the aborts stop dead: 0 in 180 s, against 10-11 per 150 s.

So run-e2e.sh now covers API 37: renderer chosen per level (33-36 need host, 37
must not have it), dotted image labels, SystemUI disabled followed by a deliberate
stop/start, and an abort count printed on every 37 row. The result is 49 tests, 2
failures, 0 errors, 2 skipped, reproduced twice. The two failures are
Media3EngineTest on c2.goldfish.h264.decoder; API 35 under the identical renderer is
49/0/0/2 green, so they are the image and not the renderer.

CI's matrix should still stop at 36, for reasons now written down rather than
assumed. CLAUDE.md is left alone; a replacement bullet is proposed in the doc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 22:09:19 -05:00
Jason Ross 18c53a3830 Merge pull request #9 from JMR-dev/tools/local-emulator
Find out why the emulators segfault, and make them run
2026-08-22 21:15:16 -05:00
Jason Ross fc2afff32e Merge branch 'main' into tools/local-emulator 2026-08-22 21:08:42 -05:00
Jason Ross 9c056f5f5d Merge pull request #8 from JMR-dev/feat/defect-fixes-base
Fix ten defects in the untested framework edge
2026-08-22 21:05:45 -05:00
JMR-dev 63d53b0bb8 Merge remote-tracking branch 'origin/feat/defect-fixes-base' into scratch/integrate-d2-d3 2026-08-22 20:58:32 -05:00
JMR-devandClaude Opus 5 c2e6344aad Let WorkManager keep sole ownership of the progress notification
`publishProgress` posted with `NotificationManager.notify(NOTIFICATION_ID, …)` directly, on the
very id WorkManager owns through `setForeground`, using a notification built `setOngoing(true)`.
Two posters for one id is a race about which of them wrote last, and on a Pixel 10 Pro XL it was
lost on attempt 3 of 12 while cancelling a `BEST`-tier job:

    attempt 3: terminal state = CANCELLED
    attempt 3: +300ms  active=0 id1001=false ongoing=null   <- WorkManager tore it down
    attempt 3: +700ms  active=1 id1001=true  ongoing=true   <- a progress tick put it back
    attempt 3: +5000ms active=1 id1001=true  ongoing=true

Still there ten minutes later, with no app process left in the world to withdraw it. The orphan's
record carried `flags=ONGOING_EVENT|ONLY_ALERT_ONCE` and **no `FOREGROUND_SERVICE`**, which is
what identifies the poster rather than merely suggesting one: WorkManager's own post goes out
with that flag and this one did not.

Whether the user could then swipe it away is deliberately not claimed here. An earlier reading
said "cannot dismiss", on the strength of `isClearable()` returning false — but that is false
purely because `FLAG_ONGOING_EVENT` is set, the record carries neither `FOREGROUND_SERVICE` nor
`NO_CLEAR`, and API 34+ lets an ongoing notification with no foreground service behind it be
swiped. The SystemUI test was impossible behind a secure lock screen and was never done. The
resurrection is what is reproduced, and it is what this fixes.

Progress now goes through `setForegroundAsync` — the non-suspending half of the same call
`doWork` already makes, which is the supported way to update a running worker's foreground
notification. That is not merely a different mechanism for the same post: it hands the id back to
its owner, so the notification WorkManager withdraws when the job ends is the same one the last
progress update wrote, and there is nothing left holding a second reference to it.

Two guards, and they are independent on purpose:

  - **`isStopped` short-circuits the whole function.** A tick arriving after the stop has nobody
    left to report to, so a stopped worker publishes nothing at all — the `setProgressAsync`
    included, which WorkManager refuses for finished work anyway.
  - **`setForegroundAsync` refuses on its own** for work whose state is already terminal. That
    closes the window between the `isStopped` check and the update reaching the task thread,
    which a check alone can only narrow.

The `~1/sec` throttle is unchanged, in effect and in reason: FFmpeg's statistics callback and
Media3's progress polling both fire several times a second, and pushing every one of them janks
the system UI. Routing them through WorkManager does not make them cheap, so the interval stays
exactly where it was. It is reordered into an early return rather than a nested `if`, which is
the only difference.

The initial `setForeground(...)` in `doWork` is untouched and stays a suspending call inside the
`try`. That placement is the whole of the previous commit on this file: a denied background
foreground-service start throws there, and `FailureOutcome` turns it into a retry rather than a
terminal failure. Converting it to `setForegroundAsync` for symmetry would have moved that
exception into an unobserved future and undone it silently.

The future `publishProgress` gets is not awaited — it is called from an engine callback, which is
not a coroutine, and a progress update is not worth blocking one for. Both of its failure modes
are benign, so a refusal is logged rather than propagated: dropping it entirely would make the
one that actually happens, a job finishing mid-update, invisible.

`ProgressNotificationTest` drives the real worker with a recording `ForegroundUpdater` — the same
seam `DeniedForegroundStartTest` uses — and asserts on both halves, because either alone passes
against something wrong. The positive half is that the update reached WorkManager on the id it
already holds, carrying `Notification.EXTRA_PROGRESS` of 42; a test that only checked nothing was
posted directly would pass just as well against a `publishProgress` that had been deleted. The
negative half is that Robolectric's notification manager holds nothing at all, asserted over the
whole manager rather than one id, so a renamed constant cannot make it pass by asking about a
notification nobody posts.

All three were red before the change: `one throttled progress update expected expected:<1> but
was:<0>` (nothing had reached WorkManager), `and must resurrect nothing expected:<0> but was:<1>`
(the device's resurrection, on the JVM), and `50 ticks inside one throttle window must not be 0
updates`. Each guard was then removed on its own to check the test that names it bites: without
`isStopped` the stopped worker publishes twice — `a stopped worker must publish nothing
expected:<1> but was:<2>` — and without the throttle fifty ticks become fifty updates.

`ConcatWorker` reports no progress at all and owns id 1002 by itself, so it has none of this and
none of it is added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:55:25 -05:00
JMR-devandClaude Opus 5 b86df47c43 Tell an unknown input size apart from an empty file
`queryFile` started at `var size = 0L` and only moved off it when a provider answered the
`OpenableColumns.SIZE` column, which the platform documents providers *may* omit. So "this file
is empty" and "nobody would tell me how big it is" reached `OutputPublisher.hasSpaceFor` as the
same number, and `hasSpaceFor(0)` is not a space check -- it is "is there 128 MB free", which any
phone with a working camera passes.

The reachable value is not an inference. On a Pixel 10 Pro XL, `contentResolver.query` on a
`file://` URI returns null outright, so the cursor block never runs at all and the default
survives untouched: `queryFile gave displayName='input' sizeBytes=0`. Robolectric reproduces that
exactly -- null query, and a descriptor that reports 4321 bytes for the same file -- which is why
every test here is a JVM test rather than a device one.

`InputQuery` replaces the two copies of `queryFile`, which were byte for byte identical in
`ConversionViewModel` and `JoinViewModel`, so a fix to either would have been a fix to half the
app. It asks for the size twice: what the provider says, and then what the file itself says
through `openFileDescriptor(uri, "r").statSize`. The second needs no cooperation beyond the input
being openable, which a conversion is about to require anyway, and it is what answers the device
case above. Only when both decline is the answer null, and `InputFile.sizeBytes` is `Long?` so
that null cannot be spelled the same way as zero again.

**What an unknown size does was the decision, and it is deliberately not a refusal.**
`OutputPublisher.hasSpaceForUnknownSize()` produces the same number the defect produced by
accident -- with no size to reserve for, the headroom is all there is left to check -- and that is
worth saying plainly rather than dressing up. What changed is that it is now the answer to a
question that was asked. `hasSpaceFor` means "there is room for this many bytes" and nothing else
claims it.

Refusing was the obvious alternative and would have been worse than the bug: it turns "no
provider answered the SIZE column" into "this file cannot be converted", for a user who can do
nothing about either. A fixed floor was the other, and there is no honest number for it -- a
1 GB floor refuses a 10 MB conversion on a device with 500 MB free, which is the same failure in
a costume. `SpaceCheckTest` pins the choice from both sides: an unmeasurable input must not end
the job, and a full disk must still refuse it.

That second half is why the default answers *through* `hasSpaceFor`. `FakeFailures.FullDisk` in
the instrumented suite overrides `hasSpaceFor` and nothing else, so the delegation is the only
reason it still refuses an unknown-size job. Replacing the delegation with a bare `true` leaves
the full-disk test red with `expected:<Failure {error : Not enough free space to convert.}> but
was:<Failure {error : FFmpegKit failed to start on brand: robolectric...}>` -- the job sailed past
the guard and died at the engine instead.

Both workers get the same shape. The size arrives as input `Data`, which has no null, so the
absence of the key *is* the unknown -- `getLong(key, 0L)` was the other half of the conflation.
When it is absent the worker measures the input itself, which it can do because it holds the URI:
that covers a `request(...)` built by hand and work enqueued before the size became optional, and
it costs an ordinary job nothing because it runs only on the fallback. `ConversionWorker.request`
writes neither the `Data` entry nor the `JobTags.sizeBytes` tag for a size nobody knows, since a
tag reading `size-bytes:0` would come back through `Reattachment` as a confident claim that the
user's file is empty -- and `reattach()`'s `?: 0L` is gone for the same reason.

A join's total is `InputQuery.total`, which is null the moment a *single* input cannot be sized.
Summing the ones that answered was the competing reading and is rejected: a lower bound is
indistinguishable from a real total once it reaches the space check, so the guard would reserve
for half the job and pass. Reverting it to `sumOf { it ?: 0L }` records `[1111]` for a two-file
join whose second input nothing can measure.

Work already in the queue keeps the old conflation, and there is no fixing it. The previous
`request()` always wrote `putLong(KEY_SIZE_BYTES, sizeBytes)`, so a job enqueued before this
commit for a file nothing could size carries the key *present* and set to zero -- which reads
back as a declared size of zero and is trusted, exactly as before. Its `lmc.size-bytes:0` tag
reads back the same way, so `reattach()` shows such a card "0 B" rather than "Size unknown".
Nothing can separate that from a genuinely empty file after the fact, and a rule that treated a
declared zero as suspect would only rebuild the conflation facing the other way. WorkManager
keeps finished work for about a week, so this is a bounded window that clears itself; new work
never enters it.

`hasSpaceFor`'s KDoc claimed peak usage was "roughly input + output at once" while the arithmetic
reserved `input + 128 MB`. The arithmetic is what stays and the doc now says why: `bytes` is the
input's size standing in for the output's, generous for the ordinary conversion (which is asked
for precisely because it shrinks its input) and short for a re-encode to a bulkier codec; the
128 MB absorbs that error and the transient double copy while `publish` runs. Reserving
`input + output` outright would refuse jobs that fit. This is a pre-flight check that stops an
obviously impossible job from spending minutes finding out, not a guarantee -- a conversion that
runs out of space anyway still fails through its engine.

The measurement side of that line is untouched on purpose. `StorageManager.getAllocatableBytes`
is a separate entry with its own device evidence and its own `informational += "UsableSpace"` in
the lint block; this commit is about the number going *in*. `hasSpaceFor(bytes: Long)` keeps its
signature and stays open, so nothing overriding it had to change.

The file card says "Size unknown" rather than `0 B`. Handled at the call site rather than inside
`formatBytes`, because a formatter that invented a number would be the defect on screen; the card
already degrades in words for a file nothing could read.

Five of the seven new tests were red before a line of production code moved, with the numbers
they were about: `expected:<4321> but was:<0>` for a picked file, `expected null, but was:<0>` for
one nothing can measure, `expected:<[1111, 2222]> but was:<[0, 0]>` for the join picker, and
`expected:<[4321]> but was:<[0]>` and `expected:<[3333]> but was:<[0]>` for what the two workers
asked the space check. The tests assert on the *question* rather than the verdict, which matters:
one that only checked whether the job ran would have passed against the defect, since the defect
is that the guard is vacuous rather than that it refuses.

The remaining two needed the new call to exist first, so each was proved by mutation instead.
Answering the unknown with `hasSpaceFor(0L)` inline leaves `expected:<[]> but was:<[0]>` in both
workers; refusing it instead leaves `an unknown size must not end the job; got Failure {error :
Not enough free space to convert.}`. Making the worker always measure rather than trust a declared
size leaves `expected:<[9999]> but was:<[4321]>`.

`join()`'s own use of `InputQuery.total` is tested separately from the function, because
`StagingCleanupSupport` already records what that distinction costs: a tool can be provably right
while nothing calls it. `SucceedingWorkerFactory` now keeps the input `Data` of every request that
reaches a worker -- the only place it is legible, since `WorkInfo` hands back a job's tags and its
output and never the `Data` it was built with -- and the test reads the enqueued total off it.
Restoring `inputs.sumOf { it.sizeBytes ?: 0L }` leaves every other test in the change green and
this one red with `a total that could not be worked out must not be enqueued as a number`.

`ConversionViewModelProbeFailureTest` expected `InputFile(INPUT, "input", 0L)` for an authority no
provider serves. It expects `sizeBytes = null` now, which is the behaviour change stated where a
reader will meet it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:54:46 -05:00
Jason Ross 37bf884c02 Merge branch 'main' into feat/defect-fixes-base 2026-08-22 20:53:22 -05:00
Jason Ross 5f1999ef6c Merge pull request #7 from JMR-dev/docs/defect-audit
Write down what is actually wrong with this app, and how we know
2026-08-22 20:52:59 -05:00
Jason Ross 6d250e6c8e Merge branch 'main' into docs/defect-audit 2026-08-22 20:52:43 -05:00
Jason Ross 9279816a0d Merge pull request #6 from JMR-dev/chore/ignore-claude-dir
Keep Claude Code's agent worktrees out of the repository
2026-08-22 20:52:16 -05:00
JMR-devandClaude Opus 5 cf13e0225c Keep Claude Code's agent worktrees out of the repository
.claude/worktrees/ holds complete working copies -- during a parallel agent run
there were six, each a full checkout with its own build output. None of it is
tracked, so it sat in `git status` as untracked noise, and a `git add -A` at the
wrong moment would have committed the repository into itself.
scheduled_tasks.lock is equally machine-local.

Both are named individually rather than ignoring .claude/ wholesale. That
directory is also where shared project config lives -- settings.json, agents/,
skills/ -- and ignoring the parent would have pre-emptively hidden files a
project would normally commit, for no benefit today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:42:15 -05:00
JMR-devandClaude Opus 5 ef3d87e12d Write down what is actually wrong with this app, and how we know
detekt reports zero findings and there is no baseline, no @Suppress and no
tools:ignore anywhere -- so the static-analysis gate is green and honest, and
it is not where the defects are. They are in the Android-framework edge the
linters cannot see into: OutputPublisher, both ViewModels, both Workers and
MainActivity, which between them have no JVM unit tests at all and account for
most of the ~31% coverage figure.

Sixteen entries. Each records what is wrong, how confident we are that it is
wrong, how to provoke it, and what a fix would have to decide. The confidence
labels are load-bearing: four entries were driven on a physical Pixel 10 Pro XL
running API 37, and they are marked differently from the ones that are still
inspection only.

The device pass earned its keep by contradicting us. D1 -- the one defect that
was already known and deferred, the UsableSpace lint finding -- did not
reproduce. getAllocatableBytes measured 500 MiB SMALLER than usableSpace, and
writing 3 GB into the app's own cache moved both numbers identically, so no
cache counted as reclaimable at 66% free. The entry keeps the falsified
prediction next to the measurement that killed it, because that is the useful
part.

Two entries, D15 and D16, were found while fixing others and are recorded
rather than folded in silently. D16 is the one worth reading: two individually
correct fixes compose into a gap neither of them owns.

Entry bodies describe each defect as found and are deliberately not rewritten
as fixes land. This is the record of what was wrong, not a changelog; the
summary table carries the fix status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:34:42 -05:00
JMR-dev 49535998b5 Merge branch 'fix/worker-durability-and-naming' into scratch/integrate-d2-d3 2026-08-22 20:18:29 -05:00
JMR-devandClaude Opus 5 159320dfa0 Name a finished file after the job that made it
Six places decided what a converted or joined file should be called, and not one of them asked
the job. `JoinViewModel` reported `JoinState.Saved("joined.mp4")` whatever the format;
`JoinScreen` opened `CreateDocument("video/mp4")` and launched it with `"joined.mp4"`. On the
convert side `save()` and `suggestedOutputName()` built the name from `_settings.value.spec`, and
the screen took the MIME type from the same place -- the picker as it stands *now*, which is not
the spec the job ran with.

All six are right today, and all six are right by accident. The join screen has no format picker,
so the three MP4 literals agree with `ConcatWorker.request`'s default. The conversion pickers are
drawn only in the `Ready` state, so the settings cannot move between enqueue and save. Neither
accident is load-bearing anywhere it is written down.

One of them has already stopped holding, quietly. A job picked up by `reattach()` ran with a spec
that was never in this ViewModel's settings, because those settings belong to a process that no
longer exists -- so a reattached MP3 conversion is offered `.mp4` and `video/mp4` today. The
previous commit made that path more reachable rather than less: `Reattachment` exists precisely
to find work this ViewModel did not start.

The fix is to ask the only thing that knows. The spec travels to the worker as input `Data` and
`WorkInfo` hands input `Data` back to nobody, so the worker is the single point at which the
input's name and the spec that ran are both in scope. Both workers now report the two derived
strings -- the name to suggest and the type to open the dialog with -- in their output `Data`,
and they ride on `ConversionState.Converted` and `JoinState.Joined` from there. `save()` reports
what it saved rather than recomputing it, and both screens read the name and the MIME type off
the state they already collect, remembering their `CreateDocument` contract against that type
instead of a literal. The MIME type is not cosmetic: some providers rewrite a document's
extension to match it, so an MP3 offered as `video/webm` can arrive with the wrong one.

`suggestedOutputName()` is deleted rather than repaired. With the answer on the state there is no
caller left for it, and an accessor recomputing the same string would only be a second place for
it to be wrong -- which is what it was.

`ConcatWorker` gains `DEFAULT_FORMAT` and `outputNameFor(format)`. Three copies of "MP4" is the
shape this entry is about, and the input-Data default, `request`'s parameter default and the
ViewModel's fallback for a job that predates this change are exactly three copies.

Work already in the queue carries neither string, and WorkManager keeps finished work for about a
week, so that is the ordinary case for a few days rather than a corner. Those fall back to the
old derivation, which is a guess -- but it is the same guess the app was already making, it is
confined to jobs enqueued before this commit, and for such a job there is genuinely nothing
better to hand. New work never reaches it. The join's fallback is not even a guess: the format
`ConcatWorker.request` has always defaulted to is the format such a job really used.

`reattach()`'s KDoc carried this as a known wart it was deliberately leaving alone. That
paragraph is now a description of the fix rather than of a defect.

Tested at both ends, since either alone would pass while the other was wrong.
`WorkerOutputNamingTest` runs the real worker on an MP3 job -- nothing like the default preset, so
a name built from the picker is visibly wrong rather than accidentally right -- and compares the
whole success `Result`, which is what makes a missing key fail rather than go unnoticed. The two
ViewModel tests drive a finished job and then move the picker, which is what a reattached job
amounts to from the ViewModel's point of view.

Restoring just the two name sources -- `save()`'s `outputNameFor(displayName, _settings.value.spec)`
and `JoinState.Saved("joined.mp4")` -- turns them red with
`expected:<holiday_converted.mp3> but was:<input_converted.webm>` and
`expected:<joined.mkv> but was:<joined.mp4>`. The convert side loses the extension *and* the name:
`_settings.value` had been moved to WebM, and the display name a reattached job cannot supply had
already fallen back to the placeholder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:15:08 -05:00
JMR-devandClaude Opus 5 2a68f03134 Give every job a staging path of its own
`<cacheDir>/conversions/` is shared by the convert tab, the join tab and `ConcatEngine`, and
until now none of the three named a file that belonged to one job. A conversion derived its name
from the input's display name, so two `holiday.mp4` from different folders wrote the same file.
A join used the constant `joined.<ext>`, so any two joins of one format did. The list file was
the constant `concat_list.txt`, so any two joins at all did, and one of them would read the
other's input list.

The naming half is not a hypothesis. Two independent conversions on a Pixel each computed
`cache/conversions/input_converted.mp4`, the second silently overwrote the first, and a tag query
in a fresh process then returned **two SUCCEEDED `WorkInfo`s naming that one file** with one file
on disk. That is the collision reaching the point where it makes a *fix* ambiguous rather than
just a file: `Reattachment` can offer the bytes, because they are the user's either way, but it
cannot say which job produced them.

`StagingNames` keys the name on the WorkManager request id. That id is what stays still across a
retry -- `WorkerWrapper` builds `WorkerParameters` from the `WorkSpec` id and only increments
`runAttemptCount` -- which matters more here than uniqueness does, and matters more since the
previous commit made retries routine. A failed attempt deletes its staged file on the way out,
and that only collects the partial the *previous* attempt left when the name has not moved.

Opaque rather than sanitised, deliberately. The staged name is never shown to anyone: `save()`
recomputes a suggested name and the user picks the real one in the SAF dialog. So there was
nothing to lose by dropping the display name, and something to gain -- a provider-supplied
display name can contain a separator, be empty, or be four kilobytes long, and `File(stagingDir,
"../escape_converted.mp4")` resolves to a path outside staging. That was reachable before this
commit and is now unreachable by construction rather than by a sanitiser that has to be right
about every case. There is a test for exactly that name.

The extension stays, and is not decoration: `FFmpegConcatCommand` names no output muxer, so
FFmpeg infers it from the output path. A fully opaque name would quietly produce the wrong
container.

`ConcatEngine`'s list file is derived from the output it belongs to rather than taking another
parameter, so the two cannot drift apart, a directory listing shows which list belongs to which
join, and the sweep ages them together.

Three neighbouring comments claimed things that are no longer true, and are corrected rather than
left to mislead the next reader:

  - `Reattachment.Ambiguous` said it "resolves on its own once each job stages under a name of
    its own". It now does -- for work enqueued from here on. The case is **kept**, because the
    queue outlives the change: WorkManager holds finished work for about a week, and the jobs
    likeliest to be sitting in it when this code first runs are the ones named the old way.
    Behaviour is unchanged and `ReattachmentTest` is untouched.
  - `OutputPublisher.sweepStaging` justified its age rule partly on there being "no per-job
    namespacing". There is now, and the rule still stands on its own: per-job names stop two jobs
    from sharing a file, and say nothing about whether a file's job is still running, which is the
    question a sweep actually asks. Same for `StagingSweep` and the note in
    `LibreMediaConverterApp`.
  - Both ViewModels' `reattach()` explained aliasing as something nothing prevented. Narrowed to
    what is still true of work already in the queue.

`ConcatEngineTest` asks `StagingNames` for the list file's name instead of spelling out
`concat_list.txt`. That is the difference between a test and a tautology: a literal there would
have gone on passing after the rename while asserting that a file nothing creates does not exist.
The same trap was live in the two worker tests from the previous commits, whose staged-file
assertions computed a path of their own -- they now assert on the staging directory being empty,
which cannot go vacuous when a name moves.

`PerJobStagingTest` drives the real worker, because the naming function was never the part that
was wrong: what was wrong was which name the worker asked for. Two jobs converting one file must
leave two files; a second attempt at one job must not leave a second; and a display name that
climbs out of staging must not. The first and third fail before the change with "each job must
have staged its own file, found [input_converted.mp4] expected:<2> but was:<1>" and "the output
belongs in staging expected:<1> but was:<0>" -- the latter because the file had landed in
`cacheDir` instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:11:44 -05:00
JMR-devandClaude Opus 5 dcdcbfd3af Let a cancelled conversion stay cancelled
Both workers' outer `catch (e: Throwable)` caught `CancellationException` along with everything
else and answered it with a `Result`. `runMedia3OrFallBack` goes out of its way to rethrow
cancellation rather than fall back to software, and then the catch above it converted it anyway.
A coroutine that reports completion inside a scope which has already been cancelled is
structured concurrency's one rule broken, and it is the kind of break that stays quiet: nothing
downstream complains, and the next thing to hold a resource across that boundary is the thing
that finds out.

Nothing on screen disagreed today, which is why this is a low-severity entry rather than a bug
report. WorkManager cancels the worker's coroutine through `WorkerWrapper.interrupt`, which
cancels `workerJob` with a `WorkerStoppedException`; the surrounding `withContext(workerJob)`
then throws that whatever the worker returned, and `launch()` resolves it as `ResetWorkerStatus`.
The returned `Result` is read only when nothing stopped the worker at all. So the change is
about the shape of the code rather than about a symptom.

One behaviour does move, and it is worth naming rather than discovering later. A cancellation
that is *not* WorkManager stopping us -- FFmpegKit reporting `ReturnCode.isCancel`, which cancels
the continuation -- now leaves `doWork` as a cancellation, and `WorkerWrapper` resolves a
self-cancelled worker as `Resolution.Failed()` with no output data instead of the
`Result.failure(KEY_ERROR ...)` it used to build. Both ViewModels already fall back on blank
output data, deliberately and with a test, so the user sees "Conversion failed." either way. That
is also the honest answer: the only route to `isCancel` is a cancellation someone asked for.

The `staged.delete()` on that path is kept, and moved into the new branch rather than left to the
one below it. A cancelled attempt leaves a partial in staging, the next attempt starts `doWork()`
from the top rather than resuming it, and this catch holds the only handle to the file.

Reaching any of this from a JVM test needed one more thing: `doWork` called `MediaProbe.probe`
directly, and it was the last caller bypassing `ConversionDependencies.probe`. FFprobe's loader
throws a bare `java.lang.Error` with no native library present, so no unit test could reach a
single line below it. The seam's own KDoc says this is what it is for -- coverage of the branches
that only run when something goes wrong -- and the default is the same real probe, so the app and
the instrumented tests are unchanged.

`WorkerCancellationTest` drives the real worker through a real `WorkManager` to the software
engine, forced with `FORCE_SOFTWARE` because it is the one preference that decides without
consulting the input, so the test does not depend on a routing rule it is not about. The engine
stub writes bytes before it throws, which is what makes the delete assertion mean something: a
stub that only threw would let a missing `delete()` pass. Three cases -- cancellation propagates,
cancellation still deletes, and an ordinary failure is still answered with a `Result` carrying
its message, which is the half that would break if the rethrow were widened past cancellation.

Before the fix the first of those failed with "cancellation must leave doWork as cancellation,
not as a Result; got null" -- `runCatching` had nothing to report, because `doWork` had returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:11:44 -05:00
JMR-devandClaude Opus 5 5c27801c77 Retry work the system refused to start, instead of failing it terminally
`ConversionWorker`'s KDoc says the queue survives process death, and that is the app's stated
reason for choosing WorkManager at all. A Pixel 10 Pro XL says otherwise. An 18-minute
transcode was killed mid-write with `kill -9`; 119 seconds later, on a natural dispatch with
no `cmd jobscheduler run` anywhere in the session, WorkManager recovered it by itself and the
system refused it:

    WM-ForceStopRunnable: Found unfinished work, scheduling it.
    ActivityManager: Background started FGS: Disallowed [callingPackage:
       org.libremediaconverter; uidState: CEM; BFGS denied: true; code:DENIED]
    WM-WorkerWrapper: android.app.ForegroundServiceStartNotAllowedException
    WM-WorkerWrapper: Worker result FAILURE
    WM-Processor: Processor 3d1c9862 executed; reschedule = false

`Found unfinished work` is the clean process-death recovery path, not a force-stop. The job was
ordinary -- `Priority: 300 [DEFAULT]`, not expedited -- so there was no allowance it could have
carried. `HAS_FOREGROUND_EXEMPTION` was set on it and it was still denied; that flag governs the
runtime guarantee once a service is running, not permission to start one.

The cause is structural rather than subtle. `setForeground(...)` sat above `return try {`, so its
throw escaped `doWork()` without reaching `handleTimeoutIfNeeded`, `Result.retry()`, the
`workDataOf(KEY_ERROR ...)` or `staged.delete()`. Three separate costs, all measured on the
device: `reschedule = false`, so nothing ran again despite `run_attempt_count=2`; output `Data`
of `X'ABEF000100000000'`, a header with zero entries, so the screen said "Conversion failed."
with nothing to add; and 2 MB of a partial `long_input2_converted.mp4` left in staging.
`ConcatWorker` had the same shape.

So `setForeground` moves inside the `try` in both workers, and the staging handle is named just
above it -- `createStagingFile` only builds a path, nothing is written until an engine opens it,
so naming it early costs nothing and makes the catch total. `MediaProbe.probe` was outside the
`try` for the same reason and had the same problem; it is now inside too. On a retry the staged
name is unchanged, so the delete on the way out collects the partial the killed attempt left
behind rather than orphaning it.

What to return was the real decision. `Result.retry()`, because the denial is about *when* the
job ran and not about the job: the file is fine, the settings are fine, and the one thing that
grants an app permission to start a foreground service is being in the foreground, which the
user supplies by opening the app. But WorkManager never gives up on its own, so an unbounded
retry means a job nobody comes back for waking the device forever while the screen says "paused"
and never explains itself. `FailureOutcome` therefore bounds it at ten attempts, chosen against
the default backoff rather than as a round number: exponential from
`WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS` (30 s), doubling, clamped at `MAX_BACKOFF_MILLIS`
(5 h), which spans about 8 h 30 m before the eleventh attempt fails with a message the user can
act on. The counter is `runAttemptCount`, which counts every attempt and not only denied ones --
WorkManager exposes no other -- so a transcode already retried ten times by the six-hour budget
will fail on its first denial rather than getting ten of its own. That is accepted rather than
overlooked, and the timeout branch ignores the count entirely so the budget's own retries are
untouched.

The rule goes in `FailureOutcome` rather than beside it, which is what its KDoc asks for: isolate
a decision whose triggering condition cannot be provoked in a test. It now reads the stop reason
*and* the cause, with precedence stated rather than left to branch order -- once something has
stopped the worker, the exception it was holding describes that stop and not a reason of its own.
The match is on `ForegroundServiceStartNotAllowedException` exactly, never on its
`IllegalStateException` supertype: a muxer that was never started throws one of those too, and
matching the supertype would retry every ordinary failure for eight hours.

Expedited work is still not used, and this is not an argument for it. It maps to JobScheduler
expedited jobs with a short quota, which is the wrong shape for a multi-minute transcode; the
exemption it buys is for starting, and the quota it costs would be paid by every job.

Tested at both levels, because only one of them bites. `FailureOutcomeTest` gains six cases for
the rule -- retry at the bound, give up past it, an unrelated `IllegalStateException` that must
not be mistaken for a denial, a stop reason winning over the exception, and the budget timeout
ignoring the count. `DeniedForegroundStartTest` covers the wiring, which is where the defect
actually was: a `ForegroundUpdater` whose future completes exceptionally makes `setForeground`
throw exactly what the platform throws, because `WorkForegroundUpdater`'s own comment says it
propagates the exception to the caller and `ListenableFuture.await()` unwraps the
`ExecutionException` on the way. Moving `setForeground` back above the `try` turns all four of
those red with a bare `android.app.ForegroundServiceStartNotAllowedException`, which is the same
line the device logged.

Four comments claimed the old behaviour and are corrected with it. `ConversionState.Waiting`
said the budget had run out; `observe()`'s ENQUEUED branch said the budget was the likely cause,
where a denied restart is now the likelier of the two; and `Reattachment.choose` justified
excluding FAILED partly on interrupted workers coming back FAILED "rather than retried", which is
the sentence this commit falsifies. The exclusion stands on its own and says so now.

The bound's own KDoc says what giving up does *not* buy, too. `FOREGROUND_DENIED` reports through
a FAILED job and `Reattachment` excludes FAILED, so a user who was not watching at the eleventh
attempt meets an empty screen rather than the message. What the bound reliably buys is the end of
the retrying.

Both `Waiting` screens change their wording. The state is `ENQUEUED` with a run attempt behind
it, which cannot distinguish the two causes that now reach it, and the old copy named only the
six-hour budget -- which is now the less likely of the two. "Keeping the app open helps it along"
covers both, and for a denied start it is not filler but the actual remedy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:11:39 -05:00
JMR-devandClaude Opus 5 22c7914395 Find out why the emulators segfault, and make them run
CLAUDE.md has said "Emulators segfault on this host -- qemu dies on every AVD"
since the E2E matrix landed, and the PR that introduced it called the failure
"exit 139 across three AVDs and both GPU backends, environmental". That is
accurate about the symptom and wrong about the cause, and the cost of being
wrong was the whole instrumented suite being unrunnable here.

SwiftShader's Reactor JIT writes generated GLES shader code onto the heap and
mprotects it executable. Fedora's SELinux policy denies that -- execheap is not
granted to unconfined_t and selinuxuser_execheap is off -- so the mprotect
fails and the emulator takes SIGSEGV the moment it calls the routine it just
generated. The AVC denial and the core are the same event, one second apart.

The predictor is mechanical and held 7 for 7 across every -gpu mode: a run
crashes if and only if it dlopens gles_swiftshader/libGLESv2.so. host,
angle_indirect and swangle_indirect boot. auto, off, guest and
swiftshader_indirect crash -- and auto is the default, which is why the failure
looked universal rather than renderer-specific.

tools/local-emulator/run-e2e.sh picks a renderer that works and refuses the
ones that do not. It reuses .github/scripts/e2e-run.sh rather than forking it,
so the local and CI diagnostics cannot drift; the one change there adds an
optional E2E_EXTRA_GRADLE_ARGS that is unset in CI, so CI runs byte-identical
commands.

The API 33-36 sweep has now been run and is written down. All four levels are green
on a local emulator and match the physical Pixel 10 Pro XL baseline exactly: 49 tests,
0 failures, 0 errors, 2 skipped, every level. Those counts come from the result XML,
not the UTP console counter, which double-counts skips and reported "Finished 51 tests"
on all four. No boot log dlopens SwiftShader GLES and the sweep window holds no AVC
denial and no qemu core -- which is confirmation of the mode matrix's first row rather
than new coverage, since every one of these runs is -gpu host. The table is still seven
modes measured once each.

Two things the sweep surfaced that the doc now records: pre-build before sweeping, or a
fresh checkout spends API 33's 20-minute wrapper budget compiling and wedges before a
test runs; and the device pinning is untested by this run, because the Pixel dropped off
USB five seconds before it started.

Still offered for review rather than applied: the CLAUDE.md correction the doc drafts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:02:46 -05:00
JMR-dev 07933c80c5 Merge branch 'fix/native-boundary-guards' into scratch/integrate-d2-d3 2026-08-22 19:57:11 -05:00
JMR-devandClaude Opus 5 7db320018a Correct the JDK claim and decide the backup rules
D11's documentation and scaffold items, less the one row that belongs to
another change stream.

README's "Requires JDK 17+ (AGP 9 will not run on older)" was wrong twice
over, and `f496291` already corrected the same claim in CLAUDE.md. The floor
is not AGP's, and 17 is not what compiles anything: Gradle 9.7.1's own
`SupportedJavaVersions` carries MINIMUM_CLIENT_JAVA_VERSION = 8 and
MINIMUM_DAEMON_JAVA_VERSION = 17, and this repo then overrides the daemon
upward to 25 in gradle-daemon-jvm.properties. So the honest statement is
that the launcher floor is 8, the daemon is 25 whatever JAVA_HOME says, and
the app's bytecode is 25 -- which is what `./gradlew --version` shows on
this machine right now, launcher 21 against daemon 25.

The data_extraction_rules TODO is filled in rather than deleted, because
`android:allowBackup="true"` makes it a live question and the answer is not
"nothing to say". The app stores nothing of its own -- no settings, no
history -- so WorkManager's queue is the entire backup payload, and
restoring it is wrong rather than merely useless: every row names a
content:// grant and a cacheDir path that do not survive reaching another
device, and cacheDir is not backed up at all. Since `ec969c4` the ViewModel
queries WorkManager by tag on launch, so those rows would not sit inert
either -- a fresh install would come up reattached to a job the user never
ran on it. WorkManager declares no exclusion of its own, so nothing upstream
prevents it.

allowBackup stays true. The decision belongs in the rules file, where it is
per-file and legible to whoever adds real user data later, rather than in an
app-wide switch that would also turn off device-to-device transfer.

Each file is named instead of excluding the "database" domain in one line.
Lint's FullBackupContent detector skips an <exclude> that carries no path
without checking it, so the one-line spelling could have silently protected
nothing; the enumerated paths are ones the gate actually verifies, and they
are present in the built APK's compiled resource.

backup_rules.xml and android:fullBackupContent are deleted rather than
filled in. That attribute is only read on Android 11 and lower and minSdk is
33, so it could never have applied here -- an equally empty template that,
unlike the other one, had no live question behind it.

Not touched: OutputPublisher's hasSpaceFor KDoc, which the audit lists under
D11. That code belongs to a parked branch and another change stream.

The stale com/example/androidmediaconverter package directory needs no
commit: it is empty, and git has never tracked it because git cannot track
an empty directory. Removed from the working copy directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:52:59 -05:00
JMR-devandClaude Opus 5 97fdc49f01 Guard the native boundary against what it actually throws
D14: picking a file died instead of reporting an unreadable one when
FFmpegKit's native library could not load. `probeWithFFprobe` guarded its
call with `catch (e: Exception)`, and `ConversionViewModel.onInputPicked`
guarded nothing, so the failure escaped a `viewModelScope.launch` -- which
has no handler, and on a device ends the process.

All three of the obvious narrow guards catch nothing, which is why this
needed reading the AAR rather than guessing. `NativeLoader.loadLibrary`
catches the `UnsatisfiedLinkError` that `System.loadLibrary` raises and
rethrows a *bare* `java.lang.Error` wrapping it, so `UnsatisfiedLinkError`
never escapes and the escaping type carries no information at all. Every
touch of the class after the first is a different type again --
`NoClassDefFoundError` -- so a guard written for the first shape lets the
second pick onwards crash, which is the harder half to notice. Both are in
the test output verbatim.

`catch (Throwable)` was the wrong answer for the reason the audit gave: it
would swallow a genuine `OutOfMemoryError` in a method that spawns a native
process, turning "this device is out of memory" into "this file looks
unreadable" and letting the app act on it. So the line is drawn by a named
predicate, `isNativeLoadFailure`, rather than by the catch clause -- every
class-loading shape is a `LinkageError`, and nothing that means the JVM is
failing is one. That disjointness is what makes the guard narrow.

This is consistent with the position `config/detekt/detekt.yml` already
takes for `TooGenericExceptionCaught`: the boundary's failure types are
undocumented, so guessing crashes the app on a file it could have reported.
One level up the opposite mistake is available too, and the predicate is
what lets both be avoided at once.

`TooGenericExceptionThrown` is relaxed for the test source sets only. A test
that reproduces a failed native load has to throw what the library throws,
and a tidier subclass would leave it passing against a defect it no longer
reproduces. Main source is untouched by that and throws nothing generic.

`ConversionDependencies.probe`'s KDoc is rewritten rather than left. It said
this hazard was "deliberately not fixed here ... its own commit, with its
own test", which this is -- leaving it would have replaced one true comment
with a false one, which is the same defect class as the D11 work.

Both halves are covered independently: reverting the `MediaProbe` catch
reds only the two `MediaProbeNativeLoadTest` cases, reverting the ViewModel
guard reds only the two injected-seam cases, and widening the ViewModel
guard to `Throwable` reds the OutOfMemoryError case -- so the narrowness is
pinned, not just the catch.

Audited the sibling boundaries named in the audit and left all three alone:
`FFmpegEngine` and `ConcatEngine` both construct and run under
`catch (e: Throwable)` in their workers, and `Media3Engine` has no native
loader of this kind and already routes failures through `runCatching`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:52:55 -05:00
JMR-dev a6baf41866 Merge branch 'fix/rotation-and-partial-publish' into scratch/integrate-d2-d3 2026-08-22 19:47:48 -05:00
JMR-devandClaude Opus 5 dbfc463c6d Delete the half-written destination instead of leaving it under the user's name
publish() streamed the staged file into the SAF destination with copyTo and had no
answer for a copy that failed partway. The destination volume filling up is the obvious
way in; a provider giving out mid-write is the other. Either way the bytes it had
managed stayed at the name the user picked, while the UI said "Could not save the file".
The user was left holding a truncated file they had just been told was never written,
and nothing in the app would ever tidy it up -- staging cleanup reaches
<cacheDir>/conversions and nowhere else, by design.

So a failed copy now deletes the document. The interesting part is not the delete, it is
what stops it, because removing a file the user already had would be a far worse defect
than the one being fixed.

  Only a document URI. DocumentsContract.deleteDocument is the only delete this code has
  any right to attempt and it is defined on document URIs, so isDocumentUri() gates it.
  That is not a formality: it asks the package manager whether anything answers
  ACTION_DOCUMENTS_PROVIDER for the authority, so a file:// path, a MediaStore item or a
  content URI from an ordinary provider all fall out here untouched.

  Only a destination that was empty when we started. The size is read BEFORE the stream
  is opened -- opening for write truncates, so afterwards the question can no longer be
  asked -- and the delete runs only when the answer was positively zero. Every
  destination that reaches publish() today comes from the SAF CreateDocument contract, so
  in practice it is a document this app created seconds earlier; but publish() cannot
  verify that from a Uri, and a provider that hands back an EXISTING document for a name
  the user re-picked would otherwise have its file deleted rather than merely truncated.
  Truncated is bad. Gone is worse, and it is the user's file either way.

  That guard fails towards doing nothing. A provider that does not report _size, a query
  that returns no row, a resolver call that throws -- all of them land in "not known to
  be empty", so the fix is conservative rather than universal: it will not clean up
  behind such a provider, and it will not delete anything of theirs either. The defect is
  closed for providers that answer a size query; ExternalStorageProvider backs its
  documents with real files, so a freshly created one reports 0, but that is reasoning
  about it rather than a run against it. Stated here rather than implied, because
  "fixed" would overclaim what was verified.

  The original failure is what the caller sees. Cleanup runs in its own runCatching and a
  throw from it is attached to the original exception as a suppressed one. deleteDocument
  reports failure two different ways -- false, or a rethrown RuntimeException -- and
  neither is worth failing the save over, because the save has already failed.
  ConversionViewModel.save() reports e.message, and "could not delete the half-written
  file" is not what to tell someone whose disk just filled up.

Two smaller decisions in the control flow. The whole `use` is guarded, not just copyTo:
a close() that throws while flushing IS the disk-full case and it arrives after copyTo
has returned successfully, so guarding only the copy would miss exactly the failure this
commit is about. The cost is that a file whose every byte reached the provider before a
failing flush is deleted too, which is the right way round -- a flush that failed means
the bytes are not durably there. And openOutputStream's own failure is deliberately
OUTSIDE the guard: nothing has been written at that point, so there is nothing of ours to
remove.

Nothing else in the class moves. hasSpaceFor, discardStaged and sweepStaging are
untouched, and so is save(): the staged file is still deliberately kept on a failed save,
for the reason its own comment gives -- it may be the only copy of an hour of
transcoding, and now the destination genuinely does not have it either.

Seven tests, JVM, Robolectric. The failure has to be injected, which is what shapes them:
Robolectric's ShadowContentResolver consults its registered-stream map before it reaches
any provider, so a test can hand out a stream that writes 512 bytes to a real file and
then throws "No space left on device" while a fake provider answers the size query and
the delete against that same file. The provider is registered twice under two authorities
and two component names, once with the ACTION_DOCUMENTS_PROVIDER intent filter and once
without, which is the only way to have a content URI that is not a document URI. The
delete is observed by the wire names DocumentsContract.deleteDocument actually sends --
"android:deleteDocument" and the "uri" extra -- because both constants are hidden from
the public SDK.

  fails partway leaves nothing        RED before the fix: expected the delete, got []
  close that fails while flushing     RED before: the file was still there
  cleanup that fails                  RED before: suppressed was []
  already held bytes is not deleted   green before the fix -- see below
  not a document is left alone        green before the fix -- see below
  cannot be opened at all             green before, and must stay so
  succeeds, every byte, no delete     green before, and must stay so

The last four are green against the unfixed code for a reason worth writing down: the old
publish() never deleted anything, so every guard passes trivially. They pin the guards
rather than the defect, and pinning is only worth something if the pin is real, so both
were reverted on their own:

  - dropping `if (destinationWasEmpty)` fails "a destination that already held bytes is
    not deleted" with "a document this app did not create must survive"
  - dropping the isDocumentUri() check fails "a destination that is not a document is
    left alone" with "deleteDocument has no business on a URI that is not a document
    expected:<[]> but was:<[content://...test.plain/document/holiday_plain.mp4]>"

Both were restored. 193 unit tests green.

UnopenableUriTest's unwritable-destination case (an authority with no provider behind it)
is unchanged and keeps passing by two independent routes: the size query on a dead
authority yields "not known to be empty", and the failure itself comes out of
openOutputStream, which sits outside the guard. It is an instrumented test and was
compile-verified here, not executed -- instrumented tests do not run on this host
(CLAUDE.md). Its JVM twin is in the list above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:45:17 -05:00
JMR-devandClaude Opus 5 ce4d0ff7d4 Keep the selected tab through the recreation targetSdk 37 guarantees
AppRoot held the selected tab in `remember`, which survives recomposition and nothing
else. MainActivity declares no configChanges, so every rotation and every resize
destroys and recreates the Activity, and the tab went back to Convert each time.

The KDoc directly above that line is the argument for why it matters: from targetSdk 37
Android ignores screenOrientation, resizableActivity and the aspect-ratio limits on any
display at least 600dp wide, and the Android 16 opt-out is gone, so the app is resized
and rotated whether or not it is ready. The shell was written for that case and then
lost its own state to it. Both ViewModels are Activity-scoped and come back intact, so
a conversion in flight was never at risk -- only the tab, which is what makes this
visibly wrong rather than merely stale.

rememberSaveable, with a Saver that writes the constant's NAME. Three ways to make an
enum saveable and the reasons for this one:

  - autoSaver already accepts it. An enum is Serializable, so plain
    `rememberSaveable { mutableStateOf(Destination.CONVERT) }` compiles, works, and
    passes the restoration test below unchanged. That is a reason to be explicit, not a
    reason not to be: nothing in the declaration says Destination has to stay
    Serializable, so the implicit route keeps working right up until someone makes it a
    value class or a sealed interface -- and then stops, silently, on a path only a
    rotation reaches.

  - The ordinal is a position, not an identity. Inserting a tab between Convert and Join
    would redefine every value already written down. A name only changes when someone
    renames a constant, which is an edit that shows up in a diff. It also reads as
    itself in a Bundle dump.

  - An unknown name restores to null, which rememberSaveable treats as "nothing saved"
    and falls back to Convert. That is exactly what a downgrade or a renamed constant
    leaves behind, and Convert is the right answer for it.

The test runs on the JVM, which took two changes to reach.

AppRoot and Destination are `internal` rather than `private` -- the unit test source set
is a friend of main, so this stays invisible outside the module -- and AppRoot takes its
`content` as a defaulted parameter instead of calling Content() directly. Nothing in the
app passes it. It is there because both screens resolve a ViewModel, which builds a
WorkManager and a media probe, and none of that has anything to do with which tab is
selected; the test hands in a tagged Box and drives the shell alone. Content() stays
private and is still what the app gets.

compose-ui-test-junit4 joins the JVM test source set. It was already in the catalog for
androidTest, it is inside the prerelease guard via its androidx. group, and its version
comes from the BOM, so this adds no new pinning argument. It is there because
createComposeRule() runs under Robolectric: a red test in androidTest is one nobody on
this host can execute (CLAUDE.md), which is not a loop anyone can work in.

ui-test-manifest is NOT repeated on that source set. It supplies the ComponentActivity
the rule launches, and the existing debugImplementation entry already puts it in the
merged manifest the unit tests build against -- checked by removing the line and
watching AppRootRestorationTest stay green, rather than assumed.

What the test does and does not prove. StateRestorationTester's
emulateSavedInstanceStateRestore() disposes the composition and rebuilds it, so anything
held only by `remember` is gone -- that is what makes it bite. It saves into an in-memory
map rather than parcelling through a Bundle, so it cannot tell a name from an ordinal
from autoSaver. The saved representation is pinned separately by three pure-JVM tests
over the Saver itself, which is where the choice above is actually held down.

Verified by writing it red first, against the restructured AppRoot with `remember` still
in place: all three restoration tests failed at the post-restore assertion with
"Expected exactly '1' node but could not find any node that satisfies:
(TestTag = 'content:JOIN')", while every assertion before the restore -- including the
bar's own selected state -- passed. Six new tests, 186 green in all.

Not addressed here, and deliberately: MainActivity still declares no configChanges, and
should not. Handling the configuration change is not the same as keeping one enum, and
Compose's saved-state machinery is the mechanism the platform intends for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:45:13 -05:00
JMR-dev 308333cb49 Merge commit '8bc2a33' into scratch/integrate-d2-d3 2026-08-22 19:09:09 -05:00
JMR-devandClaude Opus 5 8bc2a337d0 Merge the staging-cleanup fix, and pin the seam the two changes share
The two changes meet at one line. `pendingStaged` is recorded in the `SUCCEEDED` branch of
each ViewModel's `observe()` collector, and reattachment reaches `Converted`/`Joined`
through that same collector rather than by building the state itself -- so a job picked up
from a previous process arrives with its cleanup handle already set, and "Start over" on it
deletes the staged file exactly as it does for a conversion run in this process. Nothing had
to be added for that; it falls out of routing reattachment through `observe()`.

Which is precisely why it needed a test. The claim is structural -- one assignment, in one
function, that both changes assume -- and the conflict here was `JoinViewModel`, where the
cleanup change edits a collector body that the reattachment change had moved out of `join()`
into a private `observe()`. Resolving that by putting the assignment back in `join()` would
compile, pass every test either branch brought, and silently leak a full-size file on the one
path both changes were written for. `ReattachedCleanupTest` fails if it lands anywhere else.

Verified the way the cleanup commit verified its own wiring: deleting `pendingStaged = staged`
from `ConversionViewModel.observe()` fails "start over on a reattached conversion deletes the
staged file" alongside the two `ConversionViewModelCleanupTest` cases that share the line.
Restored, all 183 JVM tests pass.

The reattachment path also gains JVM coverage it could not have had before this merge, since
Robolectric and the `ConversionDependencies` seams arrived with it: a ViewModel constructed
after a job has already finished, with nothing left that observed it, now demonstrably reaches
`Converted` with the display name recovered from the job's tags -- on a machine where no
instrumented test can run.

Conflict resolution: both sides kept in `JoinViewModel`, with the cleanup handle declared
alongside the other fields and the reattachment `init` after them. Nothing else conflicted;
`ConversionViewModel` merged clean because the reattachment change touches the `CANCELLED` and
`FAILED` branches while the cleanup change touches `SUCCEEDED`. No build file, manifest or
`OutputPublisher` line in this merge is mine -- they arrive from the cleanup commit verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:51:42 -05:00
JMR-dev eb37f9ebea Merge branch 'fix/reattach-unfinished-work' into scratch/integrate-d2-d3
# Conflicts:
#	app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt
2026-08-22 18:45:03 -05:00
JMR-devandClaude Opus 5 ec969c41dc Reattach to conversions and joins the ViewModel did not start
The queue surviving process death is the stated reason this app uses WorkManager, and
the ViewModel was where that protection stopped. `activeWorkId` and `observer` are
plain fields, so a process reclaimed after a conversion finished came back to Idle
while the output sat in `cacheDir` with nothing in the UI able to reach it. The
realistic window is not a crash mid-transcode -- it is the job finishing, the user not
saving yet, and the process being reclaimed hours later as an ordinary background one.

Both ViewModels now query their own worker's class name on init and pick up what they
find. Nothing is persisted for it, and nothing needed to be: `WorkRequest.Builder`
seeds every request's tag set with `workerClass.name` (`tags = mutableSetOf(
workerClass.name)`, work-runtime 2.11.2), and R8 keeps those names through
work-runtime's own consumer rule, `-keepnames class * extends
androidx.work.ListenableWorker`. A UUID in a `SavedStateHandle` would have been both
more machinery and less: it cannot find work enqueued by a previous install.

What the query cannot return is the job's input. `WorkInfo` hands back id, state, tags,
progress, output and run-attempt count -- never the `Data` a request was enqueued with
-- so a ViewModel could see that a conversion existed and where its output went, but
not which file it was converting. The display name and size therefore ride on tags too,
which is the whole of the production change to the request builders. The picked `Uri`
deliberately does not: nothing in a reattached state reads it, and a `content://` grant
taken by a picker in a process that no longer exists is not something to hand back as
though it still worked.

The decision is a pure function on the JVM test stack, following `FailureOutcome`:
`Reattachment.choose` takes what WorkManager reported and answers which job, if any.
Cancelled work is excluded -- the user already said no. Failed work is excluded, which
matters more than it looks now that a device has shown an interrupted worker coming
back FAILED rather than retried, its restart's `setForeground` refused as a background
foreground-service start: nothing marks a failure as seen, so it would otherwise
reappear on every launch. A success whose staged file is gone is excluded, because a
Save button that fails on tap is worse than no button. Live work outranks a finished
result, since a running job holds a foreground notification and someone opening the app
while that notification is in the shade is looking for that conversion.

Where several jobs qualify, the newest staged file wins, and that is not a detail.
Losing a tie is not the same as waiting for the next launch: the tag query has no
`ORDER BY`, so its order is unspecified but stable, and an arbitrary winner would keep
winning every launch while the other result stayed unreachable for as long as its file
existed. It is reachable today -- dismiss one result with "Start over", which leaves its
file behind, then convert something else and do not save it. `WorkInfo` carries no
timestamp of any kind, but the edge is already stat'ing the file, so the file's own
mtime is the ordering. A clock that moves backwards makes it a heuristic; an order that
is unspecified and repeats itself is worse.

Aliases are the tie that does not resolve, and that case is not hypothetical. A tag
query on a device returned two SUCCEEDED jobs whose output paths were both
`.../conversions/input_converted.mp4`, with one file on disk -- the staging name is
derived from the input's display name, so a later job overwrites an earlier one's output
and both go on reporting it. Checking the file does not separate them, and neither does
its mtime, since they share it. So the two halves are separated instead. The file is
offered, because it is the user's file either way and losing it is the defect being
fixed; the label is not, because saying which job produced it would be a guess.
`Reattachment.Ambiguous` says so and the card falls back to a neutral name rather than
borrowing the other job's. Aliases whose tags are identical -- the ordinary case, the
same file converted twice -- stay attributed, since nothing turns on which wrote it.

Age is not filtered on, and cannot be: WorkManager keeps finished work about a week and
prunes on its own schedule, so a query in a fresh process routinely returns jobs from
earlier sessions. Whether the staged file is still there is the only signal separating a
result still worth offering from one already dealt with, which is why that check carries
the weight.

Two smaller behaviours fall out of reattaching rather than starting:

  - A reattached job that is cancelled lands on Idle rather than Ready. Ready would put
    a Convert button over an input URI that belongs to a dead process. `observe()` takes
    the cancelled destination as a defaulted parameter, so a job started here is
    unchanged.

  - A FAILED job's message falls back when blank, not only when absent. A worker killed
    before it can report leaves no output data at all, and an exception's message can be
    the empty string; both used to reach the screen as a failure with nothing said.

Deliberately not fixed here, each being its own change: the save dialog's suggested name
and MIME still come from the current picker rather than from the job that ran, so a
reattached job in a non-default format is offered the default extension; a result
dismissed with "Start over" still keeps its staged file, so it can be offered again next
launch -- the file check closes that for free once the file is deleted; and
`setForeground` still sits outside `doWork`'s try, so its throw bypasses the retry
decision entirely.

Tested where it can be. 28 JVM tests cover the choice and the tag round trip, including
the ordering, the aliasing rules and a display name that looks like another tag. The
reattachment itself is instrumented: a ViewModel constructed against the real
WorkManager is the next launch, with no memory of the work. `WorkManagerTestInitHelper`
is deliberately not used -- its `setDelegate` replaces the singleton for the whole
process, which would quietly turn `ConversionWorkerTest` into a synchronous test double
depending on class order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:43:10 -05:00
JMR-devandClaude Opus 5 cfd705af05 Delete staged output the user never saved, instead of waiting for the OS
Every conversion writes a full-size file into <cacheDir>/conversions/. save()
published it and deleted it, but reset() -- what the "Start over" button on the
Converted and Joined states calls -- dropped the File reference and left the file
behind. Converting something and deciding not to save it is an ordinary path
through the UI, so it leaked a full-size copy every time. cacheDir is evictable,
so this was never unbounded growth; it was the app relying on the OS to clean up
after it, and on a device under no storage pressure "eventually" means never.

OutputPublisher.clearStaging() was written for exactly this and called from
nowhere. It is NOT wired up here -- it is deleted. It emptied the directory
unconditionally, and the convert tab, the join tab and ConcatEngine's
concat_list.txt all share that directory with no per-job namespacing (D8), so a
blanket delete could take a file out from under a running job. Two narrower
methods replace it:

  discardStaged(file)  one file, guarded. The handle reaches the ViewModel as a
                       path string in WorkInfo.outputData and becomes a File with
                       nothing checking where it points, so this compares the
                       CANONICAL parent against the staging dir -- the naive
                       string comparison accepts conversions/../elsewhere.

  sweepStaging(now)    age-based, for orphans no ViewModel is left to clean up.

The cleanup handle is a ViewModel field, not something read back out of the state
machine, because the state machine cannot answer it on the path that needs it
most: a failed save lands on Failed(message), which carries no file reference at
all. On that path the file is deliberately kept -- it may be the only copy of an
hour of transcoding and the destination did not receive it, so deleting to tidy a
cache directory would destroy the work. It stays collectable by a later reset()
or by the sweep.

The sweep runs once per process from a new Application subclass, off the main
thread. The reason it cannot race a live job is the grace period, not ordering:
WorkManager initialises through androidx.startup's InitializationProvider, a
ContentProvider, so it is already up before onCreate() and can be resuming a
worker in this same process while the sweep runs. StagingSweep only collects a
file nothing has written to for 24 hours. Outputs are written continuously and
keep their own mtime fresh; concat_list.txt is the one file written once and then
only read, and a WorkManager attempt is capped by the six-hour foreground-service
budget with retries restarting doWork() from the top, so no attempt can hold a
file still for a day. sweepStaging() also re-reads each timestamp immediately
before deleting, closing the window between listing the directory and acting on
the list -- unlinking an inode a running job still holds open would end with the
job reporting success for a path that no longer exists.

The rule itself is a pure function over (name, lastModifiedMs) pairs and a clock.
Timestamps are values rather than Files so the tests measure the arithmetic --
the grace boundary, and a clock that moved backwards -- rather than the
filesystem's mtime granularity.

Tests cover the tool AND the wiring, because the wiring is where the defect was.
A pure rule test and a Robolectric test of OutputPublisher both stay green when
the discardStaged call is deleted from reset(), which would have made the number
read as coverage of a bug that was still there. So both ViewModels are driven --
through a real WorkManager, to Converted/Joined -- and then asserted on the
filesystem: Start over deletes the staged file; a successful save leaves nothing
to delete twice; a failed save keeps the file and a later reset collects it, which
pins the argued decision above rather than leaving it as a comment. Verified by
deleting the discardStaged line from both reset() methods: 4 of the 6 fail with
"reset() should have discarded exactly the staged file expected:<[...]> but
was:<[]>", and the two save-path tests correctly stay green.

Three things made that reachable, all reusing what was already here:

  - Both ViewModels now resolve their publisher through ConversionDependencies,
    like the workers already did. They were the only place bypassing the seam.
  - MediaProbe joins that seam too. It spawns FFprobe, and FFmpegKit's loader
    throws a bare java.lang.Error when the native library is absent -- which its
    own `catch (e: Exception)` cannot catch, so every JVM test died on the file
    pick. Instrumented tests are unaffected and still get the real probe.

    That error path is a LATENT PRODUCTION HAZARD, recorded in the KDoc and
    deliberately not fixed here: onInputPicked does not catch it either, so a
    missing .so would surface as an uncaught error rather than the "could not read
    this file" the code was written to give. It cannot fire on a device that ships
    the libraries, so widening MediaProbe's catch to Throwable would change the
    pick path on the strength of a condition no user meets. Its own commit.
  - reset()'s cleanup dispatcher is a constructor parameter defaulting to
    Dispatchers.IO, which makes the delete assertable and states the ordering --
    Idle is published synchronously, the delete is dispatched -- as a decision
    rather than an accident. @JvmOverloads keeps the single-argument constructor
    that viewModel()'s AndroidViewModelFactory looks up reflectively.

androidx-work-testing was already in the catalog and already inside the prerelease
guard via its androidx. group, so it needed no new pinning argument.

Robolectric is added for the one assertion no pure function can make: that the
file is really gone from a real cacheDir. It is PINNED at 4.16.1 and belongs with
ktlint/detekt/jacoco rather than the floating libraries. The prerelease guard in
app/build.gradle.kts only covers androidx., junit and com.arthenica, so
org.robolectric is unguarded and a "4.+" would resolve to 4.17-beta-3; beyond
that, a bump changes which android-all jar the tests execute against, which is the
same "a tool moved under a diff that cannot explain it" failure the linters are
pinned for.

Two things Robolectric needed. testOptions did not exist in this module at all;
it now sets isIncludeAndroidResources so the merged manifest and resource table
reach the JVM tests, and grants --enable-native-access, which Java 25 otherwise
warns about four times per run when Robolectric's native runtime calls
System.load(). And robolectric.properties pins sdk=36: Robolectric defaults to the
manifest's targetSdk of 37, there is no android-all jar for 37, and the class
fails to initialise before any test body runs. 36 is where CI's emulator matrix
already stops, so this does not widen the gap -- API 37 was already a manual check
on the Pixel 10 Pro XL before each release.

Known and left alone: a conversion that fails inside the worker never reaches
Converted, so pendingStaged is never set and any partial output relies on the
sweep alone. reset()'s delete is also fire-and-forget on viewModelScope, so it is
cancelled if the Activity finishes first. The sweep is the backstop for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:40:29 -05:00
Jason Ross 903b43c30a Merge pull request #5 from JMR-dev/chore/lint-and-format-parity
Add lint, formatting and static analysis; move the toolchain to Java 25
2026-08-22 16:22:39 -05:00
JMR-devandClaude Opus 5 39e0900928 Adapt LibreMail's emulator instrumentation for the E2E matrix
The E2E legs could fail with almost nothing to show for it. The previous handler
was a single line of semicolons printing meminfo and 60 lines of crash logcat,
and it only ran when gradle RETURNED non-zero -- a hang left nothing at all, and
`adb logcat -d` at the end only holds whatever survived in the ring buffer, which
a chatty run evicts.

The two failure shapes want different evidence, so they are handled separately:

  FAILED  -- gradle returned non-zero. The test reports already say which test and
             why, so this captures the surrounding state: guest memory and
             storage, whether the app even installed, native crashes, and the
             runner's own kvm/memory/disk.

  WEDGED  -- gradle never returned and the wrapper timeout killed it. There are no
             reports, so the evidence has to come off the live device: which test
             was in flight per the TestRunner logcat, whether the binder services
             are published, and SIGQUIT thread dumps of both processes. That last
             one is the point -- ART writes full stacks to logcat and /data/anr,
             which is what separates a deadlocked test from a stuck MediaCodec
             from a device that stopped answering. dumpsys media.player is in
             there because both engines transcode through MediaCodec, so a hung
             conversion shows up in it.

Logcat is now streamed to a file from the start of the step and uploaded whichever
way the leg goes, since the leg worth reading is usually the one that went red once
and green on re-run -- by which time the emulator is gone.

It is a script rather than inline YAML because it has to be. The action splits its
`script` input on newlines and runs each line as its own `sh -c`, so functions and
`if` blocks cannot survive there; that constraint is what produced the one-line
handler in the first place. One line calls the script now.

The wrapper timeout is 1200s against measured ~5-minute healthy legs, so it cannot
trip on a slow-but-working run, and sits far enough under the 60-minute cap to
leave room for the capture. It wraps only the foreground gradle client, never the
emulator the action owns, so it cannot hang the leg itself.

Not adopted from LibreMail: the hand-provisioned AVD boot, its SDK-integrity
installer and its focus gate. Those answer failures this repo has not had, and
replacing a boot path that works to fix problems we do not have is how a working
matrix breaks. Every emulator setting here -- ram-size, disk-size, the ABI filter,
swiftshader -- is untouched, along with the reasoning already written next to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:20:23 -05:00
JMR-devandClaude Opus 5 3c7b4b9063 Stop the prerelease guard from rejecting AGP's own test platform
All four E2E legs failed to resolve :app:connectedDebugAndroidTest:

  Could not find com.google.testing.platform:android-device-provider-local:0.0.9-alpha04

That is AGP's Unified Test Platform, the thing that actually runs instrumented
tests, and the guard added two commits ago was rejecting it. The guard was
written as a blanket rule over every configuration, and AGP resolves its own
tooling through this project's configurations.

There is no stable version to move to, and there never has been: every module in
com.google.testing.platform has only -dev and -alpha releases, going back to
0.0.1-dev. Nor is the version ours to choose -- AGP 9.3.1 pins it through
com.android.tools.utp:android-test-plugin-host-additional-test-output:32.3.1. So
an allowlist entry would have been the first of several, one per prerelease tool
AGP happens to depend on, discovered one red matrix at a time.

Scoping the guard to the groups this project actually floats fixes the class
rather than the instance. It also retires the detekt exception: we do not float
dev.detekt, so the guard now has no opinion about it, where before it passed only
because "alpha.6" has a dot the pattern missed.

Worth naming the shape of this bug, because it is the second time in this branch
that a change looked complete locally and was not. Nothing that runs without a
device resolves the UTP configurations -- unit tests, ktlint, detekt, lint,
assembleDebug and assembleRelease were all green while connectedAndroidTest could
not resolve at all. Verified now by resolving that exact configuration directly,
and by confirming the guard still does its job: lifecycle 2.+ resolves to 2.11.0,
not 2.12.0-alpha01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:20:23 -05:00