Commit Graph
80 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
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
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
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
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
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
JMR-devandClaude Opus 5 8727fccba1 Declare read-only permissions on the status-check workflow
CodeQL flagged the new static-analysis job for relying on the repository's
default GITHUB_TOKEN scope. Fair, and the repo already holds the opposite
opinion elsewhere: build.yml's release job spells out contents: write with a
comment saying the token's reach should be visible at the point of use.

Set at workflow level rather than on the one job that was flagged, because none
of these four write anything -- they read the code, build it and attach reports.
It also means a repository default that widens later cannot quietly widen these
jobs with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:09:41 -05:00
JMR-devandClaude Opus 5 fd6e5325cf Raise Kotlin to 2.4.10 so the bytecode can join the toolchain on Java 25
The previous commit settled for Java 24 everywhere because Kotlin 2.2.10 refuses
jvmTarget 25. That was the wrong constraint to accept, for two reasons.

The first is that 24 turned out to be unbuyable. Adoptium's repository carries
8, 11, 17, 21, 25 and 26 -- no 24, because it is a non-LTS that went end of life
in July 2025. The builds passed only because Gradle quietly auto-provisioned
24.0.2+12 through foojay, and .idea/misc.xml had been pointed at a temurin-24
that cannot be installed. A toolchain nobody can install is not pinned, it is
lucky.

The second is that the cap was never on the toolchain at all. Kotlin's ceiling
applies to jvmTarget -- the bytecode -- and the JDK running the build is a
separate axis. Conflating them is what steered this at 24 in the first place.

So the fix is the one the sibling repo already uses: put KGP on the root
buildscript classpath, where AGP's built-in Kotlin picks it up instead of the
2.2.10 it bundles. Kotlin 2.4.10 supports jvmTarget through 26, which lifts the
ceiling above the toolchain rather than under it. The Compose compiler plugin is
versioned in lockstep and reads the same catalog entry, so the two cannot drift,
and the module now applies both by id() because they come from the classpath
rather than from plugin resolution.

Checked rather than assumed, since a silent downgrade would look identical to
success: compiled classes report major version 69, which is Java 25. D8 dexes
them, R8 minifies them, and ktlint, detekt, lint, the unit tests and the
androidTest compile are all green on top.

25 is the right landing place independent of all this: it is LTS, it is in the
Adoptium repository, and temurin-25-jdk is already installed here -- so the
daemon runs on a real system JDK rather than a provisioned copy of an unpatched
one.

Two catalog plugin aliases went with it. android-application and kotlin-compose
now resolve from the buildscript classpath, so leaving aliases behind would have
left two entries that read like the source of truth and control nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:05:54 -05:00
JMR-devandClaude Opus 5 a7aa003020 Fix three ways the floating versions could have gone wrong quietly
All three shared a failure mode: the build stays green while doing something
other than what the config says.

composeBom was "2026.+". The Compose BOM numbers as YYYY.MM.PP, so the year is
the major -- that float stops finding releases on 1 January 2027 and keeps
building happily against a frozen BOM, with nothing in CI or the diff to say so.
Bare "+" now, which is safe only because the prerelease guard is there.

smart-exception was floating on "0.+". Under semver a 0.x minor may break, and
this library is load-bearing precisely where breakage hides: the ffmpeg-kit
wrapper reaches for smartexception.java.Exceptions only when a call FAILS, so a
moved class shows up as an R8 missing-class error at release, or as a crash on
the error path -- the least-exercised code in the app, by its own comment.
Pinned, with that written down. It was noticed while the float was being written
and shipped anyway, which is the actual mistake here.

The prerelease guard permitted detekt's alpha by accident. The pattern wanted
digits straight after the marker word, and detekt reads "2.0.0-alpha.6" with a
dot -- so it passed on punctuation. Had it read "alpha6" the build would have
broken with no way to see why from the config. There is now an explicit
prereleasePermitted set, and the pattern tolerates both spellings, so the
exemption is a decision instead of a coincidence.

Also corrects a comment that was confidently wrong: componentSelection rejects
STATIC prerelease versions too, not only floating ones. Naming "2.12.0-alpha01"
in the catalog does not get you that alpha, it fails to resolve -- verified, not
assumed, because the obvious guess is the opposite. Prereleases are taken by
adding the group to prereleasePermitted.

Two stale references to Gradle 9.5 updated to 9.7.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:56:18 -05:00
JMR-devandClaude Opus 5 e9542d2223 Let the libraries float on minor and patch
Library versions now read "1.+" instead of "1.19.0". Three groups stay pinned,
and the reasons differ:

  agp/kotlin/ksp are version-locked to each other -- AGP 9.3.1's POM declares
  kotlin-gradle-plugin 2.2.10, so a float that picked up Kotlin 2.4.x would put
  the Compose compiler ahead of the Kotlin AGP actually compiles with.

  ktlint/detekt/jacoco because a linter is not a library. A library bump that
  misbehaves usually still compiles; a new lint rule makes files nobody touched
  stop passing, turning a PR red for something absent from its diff. Upgrading
  those is worth a commit that reads the new findings.

  The FFmpeg AAR is a committed file, not a coordinate.

The componentSelection block is the part that makes this safe rather than the
part that makes it work. Gradle resolves "+" to the highest version it can find
and does not skip prereleases, and androidx routinely publishes alphas numbered
above the current stable: lifecycle 2.12.0-alpha01, work 2.12.0-rc01, navigation
2.10.0-rc01, datastore 1.3.0-alpha10, annotation 1.11.0-alpha01 all outrank the
releases this app uses. Without the guard, five dependencies would have moved
onto unreleased code on the next build with nothing in the diff to say so. With
it, every float resolves to exactly the version that was pinned before -- checked
against :app:dependencies, not assumed.

So this changes nothing today. Every library was already at its newest stable
when the catalog was audited; floating is about what happens next month, not
this commit.

Trying a prerelease is still possible: name the exact version, which pins it
rather than floating it. That is the right way round -- an alpha should be a
deliberate act with a version number attached to it.

Verified: ktlint, detekt, lint, unit tests, androidTest compile, assembleDebug
all green, and the configuration cache still reuses across runs of the same task
set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:46:59 -05:00
JMR-devandClaude Opus 5 3841f58c74 Put the whole toolchain on Java 24, and take Gradle to 9.7.1
Java was scattered across four numbers that nobody had chosen together: the
daemon ran on 25 (pinned in gradle-daemon-jvm.properties), CI installed 17, the
IDE was set to 25, and the app compiled to 17 bytecode. Now all four say 24.

24 rather than 25 because 25 is not reachable end to end. Kotlin 2.2.10 refuses
jvmTarget 25 outright -- "available targets are 1.8 ... 23, 24" -- so the app's
bytecode could never have joined a 25 toolchain, and "everything on the same
version" would have stayed false in the one place it is hardest to notice. 24 is
the highest number all four can actually hold. Checked, not assumed: D8 dexes
Java 24 class files, and R8 full mode minifies them, so the shipped artifact
builds on this too.

Floating where floating is native:

  - java-version: '24' -- setup-java resolves the newest 24.x at run time.
  - toolchainVersion=24 -- Gradle reports it as "Compatible with Java 24, any
    vendor", and provisions whatever 24.x it finds or downloads.

The Gradle wrapper deliberately does NOT float, because it cannot: distributionUrl
names one archive and distributionSha256Sum is the checksum of that exact file.
That pairing is the wrapper's integrity check, and it is the same reasoning the
workflows already apply to action SHAs. Set via `./gradlew wrapper`, not by hand,
so the checksum matches the URL.

Dependencies were audited against Google Maven and Maven Central rather than
guessed at, and almost everything was already current: AGP, the Compose BOM,
core-ktx, activity, lifecycle, navigation, work, datastore, media3, room,
documentfile, annotation, espresso, androidx-junit, junit and ktlint are all at
their newest stable. Only two had moved -- detekt to 2.0.0-alpha.6 and JaCoCo to
0.8.15 -- and both are here.

Kotlin stays at 2.2.10 and that is now recorded as a verified fact rather than a
warning: the AGP 9.3.1 POM declares kotlin-gradle-plugin 2.2.10 at runtime scope,
which is what AGP's built-in Kotlin actually compiles with. Android lint suggests
2.4.10 and taking that suggestion breaks the build unless KGP is also forced onto
the root buildscript classpath. agp, kotlin and ksp move together or not at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:42:41 -05:00
JMR-devandClaude Opus 5 f4962913e1 Correct the JDK claim, and finish the @UnstableApi propagation
Two things the first pass got wrong.

CLAUDE.md said "use a JDK 17-21, AGP 9 does not support 25+". That was carried
over from the sibling repo and is not true here: gradle-daemon-jvm.properties
pins toolchainVersion=25, so Gradle provisions and runs the daemon on Java 25
whatever JAVA_HOME says -- JAVA_HOME only picks the launcher. `gradlew --version`
prints both, and shows them differing on this machine right now. It also means
CI's java-version: '17' is not the JDK that compiles anything, and that the
daemon JVM is the same on a laptop as on a runner, which is a better guarantee
than the one the file claimed.

Marking ConversionDependencies @UnstableApi propagates to its callers, and
FakeFailures in androidTest calls it. That is a warning rather than an error in
Kotlin, and lint does not read the androidTest source set, so the previous
commit compiled clean while leaving one file inconsistent with the very pattern
it described. Marked now.

Left alone deliberately: gradlew.bat. The new `*.bat text eol=crlf` attribute
governs how it is checked out from here on, which is the point of adding it,
and the file already has CRLF in both the tree and the index. Rewriting the
stored bytes of the wrapper script to prove the attribute works is not this
branch's business.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:24:16 -05:00
JMR-devandClaude Opus 5 65a94b4ec1 Clear the 35 findings the new tools reported
detekt found 29 and Android lint 6, on a codebase neither had ever seen. Each
one was either fixed or relaxed with the reason written next to it; nothing was
suppressed to make the build quiet.

Fixed, because the tool was right:

  - ConversionDependencies constructs Media3Engine, which is @UnstableApi, and
    was not marked. Every other type here that touches Media3 propagates the
    marker rather than swallowing it with @OptIn, so this one does too. Lint
    was the only thing that had ever noticed.
  - MediaProbe converted microseconds to milliseconds with a bare 1000, twice,
    in a file that also handles a seconds-based duration from a different API.
    US_PER_MS and MS_PER_SECOND now say which is which -- that confusion is a
    real bug source in media code, not a style question.
  - Foreground service types compared SDK_INT against 34 and 35 as raw ints
    while the doc comment above spelled the version names out. VERSION_CODES
    says it in the code.
  - take(3) is a product decision about how many alternatives an error offers.
    It means nothing until it is named; MAX_SUGGESTIONS does.
  - setProgress(100, ...) is a percentage max, now PERCENT_MAX.

Relaxed, because the rule did not fit:

  - The model package is excluded from ReturnCount and CyclomaticComplexMethod
    ONLY. It is the decision layer: ConversionRouter.route scores 17 because
    the app can give 17 distinct answers to "which engine, and why", each with
    its own user-visible reason, and route's own comment records that their
    ORDER decides which message is shown. Counting those as complexity measures
    how many answers exist, not how hard the code is to follow. Everything else
    -- LongMethod, NestedBlockDepth, ComplexCondition -- still applies there.
  - A flat `when` used as a lookup table scores a point per entry, so
    MediaProbe's demuxer-name to Container map read as complexity 21 with no
    nesting and no state. ignoreSingleWhenExpression is the rule's own answer.
  - TooGenericExceptionCaught off. MediaProbe, ConversionWorker and ConcatWorker
    sit in front of native code that reports a malformed file as anything from
    IllegalArgumentException to a bare RuntimeException, undocumented.
    Enumerating that list means guessing, and a wrong guess crashes the app on a
    file it could have reported as unreadable. SwallowedException stays on, so
    these still have to log and handle.
  - SI thresholds in the byte formatter, via ignoreNumbers. Each literal sits on
    the line with the unit string it belongs to; BYTES_PER_MB would need a Long
    and a Double and say nothing the line does not.
  - allowedFunctionsPerObject, which the first pass simply missed.

Lint's three version-freshness nags are off. They do not describe this code --
they go red the day someone else publishes a release, which turns a PR red for
something its author cannot see in their diff, and they want the network at
lint time. Upgrades here are deliberate; Kotlin in particular is pinned to AGP's
bundled KGP and is not free to follow the newest release.

UsableSpace is informational rather than disabled, because it is a real finding
that this commit is choosing not to act on. hasSpaceFor reads File.usableSpace,
which ignores reclaimable cache, so the app can refuse a conversion it had room
for. StorageManager.getAllocatableBytes is the better answer, but it changes
when a job is rejected and can throw -- a behaviour change to a safety check,
which deserves its own commit and its own test rather than a drive-by here.
informational keeps it in every lint report instead of hiding it.

Also adds the CI gate and a CLAUDE.md. Coverage is reported and not gated: the
measured baseline is 31% of lines, which is exactly why LibreMail's 0.84 floor
was evidence about LibreMail and not a number to copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:21:15 -05:00
JMR-devandClaude Opus 5 496f1c7e73 Apply ktlintFormat
Tool output only, no hand edits, so this is safe to read with whitespace
diffing off. It is its own commit for exactly that reason: a whole-repo
reformat folded into the commit that configured the formatter would have
made both unreviewable.

What it did, mostly: trailing commas on wrapped argument lists, signatures
collapsed onto one line where they fit inside 120 columns, import order, and
four genuinely unused imports removed. Unit tests pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:10:44 -05:00
JMR-devandClaude Opus 5 93b1fbd5a9 Give this project the lint and formatting setup LibreMail already has
There was none: no .editorconfig, no static analysis, and CI ran only tests.
Style was whatever the IDE happened to do, which is fine until two of them
disagree.

The split is LibreMail's, because it is the one that avoids arguments between
tools: ktlint owns formatting, detekt owns static analysis with its formatting
ruleset left off. Neither can contradict the other about the same line.

Adapted rather than copied. LibreMail is Gradle 9.6 / JDK 21 with the
configuration cache off; this is Gradle 9.5 / JDK 17 with it on, and Kotlin
lives under src/main/java rather than src/main/kotlin -- so its plugin versions
were evidence, not proof. Verified here before committing: both plugins
resolve, ktlint reads src/main/java, and the run stores a configuration cache
entry rather than tripping over it.

detekt.yml carries only what applies. The Compose relaxations transfer intact
-- a @Composable function is legitimately long, PascalCase, and full of dp
literals no matter which app it is in. LibreMail's ForbiddenImport guard and
its LargeClass exclusions do not: they name an AppLog facade and two test
files that exist over there and nowhere here, and config that guards nothing
is worse than no config, because the next reader has to work out that it is
dead.

detekt 2.0 is an alpha. That is not a preference: stable 1.23.x stops at
Gradle 8.12 and this project is on 9.5, so there is no other line to be on.

Coverage is reported, not gated. A floor needs a measured baseline, and the
JVM test stack here is still junit-only -- a number picked before measuring
would either fail on day one or mean nothing.

Android lint gets warningsAsErrors because the other two tools fail on any
finding, and a gate that stays green while its report fills up is not a gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:09:31 -05:00
JMR-devandClaude Opus 5 b5d5fcfeff Merge Media3's MP4-only correction into the remux branch
CI on the parent branch proved that four of the five containers the router
claimed for Media3 cannot be written by Transformer at all: WebmMuxer, OggMuxer,
WavMuxer and AacMuxer each throw UnsupportedOperationException from
addMetadataEntry, which MuxerWrapper calls for every metadata entry on the track
format.

Consequences here beyond the merge itself:

- MEDIA3_MUXABLE_VIDEO and MEDIA3_MUXABLE_AUDIO drop to a single MP4 entry.
  Every other container is already on its way to FFmpeg before those maps are
  consulted.
- Reason.WEBM_CODEC_UNSUPPORTED is removed. WebM now fails the container check
  first, so nothing could ever produce that reason, and a routing reason no code
  path can reach is worse than no reason at all.
- Media3Muxers gains null branches for the six containers this branch adds. MOV
  is among them despite being MP4's own family: Mp4Muxer exposes no QuickTime
  file format.
- The README no longer claims Media3 writes five containers.

The remux behaviour this branch exists for is unaffected: MKV -> MP4 was always
the hardware direction, because Media3 reads Matroska but has never been able to
write it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 07:28:59 -05:00
JMR-devandClaude Opus 5 92b7395ba9 Media3 can only write MP4, so stop claiming otherwise
CI proved the WAV and Ogg exports this branch added cannot work, and the reason
generalises further than those two.

media3-muxer 1.11.0 ships WebmMuxer, OggMuxer, WavMuxer and AacMuxer, which is
why MEDIA3_CONTAINERS listed the matching containers. But all four throw
UnsupportedOperationException from addMetadataEntry, and
MuxerWrapper.addTrackFormat calls it for every metadata entry on the track
format. Any real recording carries at least a creation timestamp, so the export
dies partway through:

    Caused by: java.lang.UnsupportedOperationException
        at androidx.media3.muxer.OggMuxer.addMetadataEntry(OggMuxer.java:123)
        at androidx.media3.transformer.MuxerWrapper.addTrackFormat(MuxerWrapper.java:488)

They are standalone muxers, not Transformer-compatible ones. WAV fails a second
way before even reaching that: DefaultEncoderFactory has no PCM encoder, so
Transformer reports "No MIME type is supported by both encoder and muxer"
instead of passing raw samples through.

Both observed on an API 35 emulator in CI, not inferred. The tests that found
them were written on the assumption these containers worked.

So MEDIA3_CONTAINERS becomes {MP4}. That the set was wrong went unnoticed
because the engine ignored the container and wrote MP4 regardless — the set
being wrong and the engine being wrong cancelled out. WAV, Opus and raw AAC move
to FFmpeg, which already produces all three with instrumented coverage asserting
the produced files.

WEBM_VP9's routing reason changes from NO_PLATFORM_ENCODER to
CONTAINER_UNSUPPORTED. Both were always true; the container is the more
fundamental, since even given a VP9 encoder the file could not be written.

The audio-only regression guard this branch exists for passed on API 35: M4A
output now carries exactly one AAC track and no video.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 07:17:11 -05:00
JMR-devandClaude Opus 5 2e0cf2f5d6 Let the user pick a container and codecs independently, and remux without re-encoding
OutputFormat was a closed enum of twelve (container, videoCodec, audioCodec)
triples, defended on the grounds that a closed set was what made routing
decidable. Two things it could not express: changing the container while copying
the streams, and choosing codecs per track.

OutputSpec replaces it as the vocabulary; OutputFormat stays as presets over it.
Decidability moves to ContainerCapabilities, which is explicit and unit-tested
rather than implicit in whichever combinations somebody enumerated.

The matrix is indexed by (container, codec, trackType, mode), not one boolean.
"Can MP4 carry AV1" and "can this app encode AV1" have different answers, and
copy is where the difference shows: a single flag would refuse a legitimate
remux or promise an encode neither engine can deliver.

COPY is a codec value rather than a flag, so every exhaustive `when` in the
codebase had to say what it does about copying. CopyPlanner resolves it before
anything else reads the request, and inherits ConcatPlanner's rule that an
unproven match is never a copy — a needless re-encode costs time, a wrong stream
copy costs a file that will not play.

Container now drives -f, the extension and the SAF MIME type, so Matroska
without video is .mka and MP4 without video is .m4a without a preset for each.
FLAC was declared as Container.MKV with a .flac extension, inert only while
nothing read the container; it now has its own. Six containers added: MOV, MKV
audio, MPEG-TS, AVI, FLV and WMV/ASF.

Routing asks the plan, never the request. COPY belongs to none of the capability
sets, so testing the request directly sends every remux to FFmpeg on the first
check — and nothing notices, because -c copy produces a correct file, just on
the CPU. The router also learns what Media3 can *carry* as opposed to encode:
its MP4 muxer takes AAC, Opus, Vorbis and PCM but neither MP3 nor FLAC.

MediaProbe now separates "no video track" from "could not parse" and reports the
source container, which MediaExtractor cannot supply at all. FFprobe runs on
every pick for that reason, not as a fallback.

The Advanced picker shows the whole matrix and lets an impossible combination be
selected on purpose, then explains it and offers alternatives. Convert is what
blocks the job. ConversionWorker validates too, so a stale queued spec fails with
the reason rather than being coerced into something else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 07:07:52 -05:00
JMR-devandClaude Opus 5 00c422f317 Make Media3 write the container and codec it was asked for
Media3Engine never called setMuxerFactory or setAudioMimeType, and built a bare
EditedMediaItem, so it always produced MP4 with an H.265 video track. The router
meanwhile sends it WebM, Ogg, WAV and AAC-ADTS jobs, plus audio-only M4A, Opus
and WAV — and ConversionWorker.media3MimeType() mapped VideoCodec.NONE through
its else branch to VIDEO_H265.

The visible result: "extract audio to M4A" transcoded the video to HEVC and
named the file .m4a. Nothing failed, and nothing caught it, because
Media3EngineTest had no audio-only case at all.

media3-muxer already ships WebmMuxer, OggMuxer, WavMuxer and AacMuxer; only the
MP4 ones come pre-wrapped as a Muxer.Factory. Media3Muxers supplies the rest.
Their reported sample MIME types are read from each muxer's own support check
rather than assumed, because Transformer uses those lists to decide whether a
track needs re-encoding.

HardwareTranscoder.transcode now takes the OutputFormat instead of a video MIME
string, which is what gives the container, the audio codec and "this output has
no video" somewhere to travel.

MEDIA3_CONTAINERS stops being private so a test can assert it agrees with the
factories. Those two drifted once already: the router's set was right the whole
time the engine was ignoring it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 07:07:06 -05:00
JMR-devandClaude Opus 5 edd6385bf7 Record that the suite passes on real API 37 hardware
The doc reasoned that the WorkManager and lateinit failures in CI were
downstream of the broken framework rather than real defects, but said so as
inference and flagged that only a healthy API 37 device could settle it.

One was available. The full instrumented suite runs green on a Pixel 10 Pro XL
on Android 17 -- a release build, not a preview -- with 40 tests, 0 failures,
2 skipped, both skips being benchmarks that assume sample files present.
ConversionWorkerTest and ConcatWorkerTest drive a real WorkManager round trip
and are among the tests that failed that way in CI; they pass on hardware.

So the bug is confined to the emulator image, and the gap left by the missing
matrix row is automated coverage rather than confidence in the app. Noted that
the suite should be run on a physical API 37 device before each release while
the row is absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:37:49 -05:00
JMR-devandClaude Opus 5 4e6fe6b75a Drop API 37 from the E2E matrix and write down why
The android-37.0 emulator image crash-loops surfaceflinger inside its own
gralloc mapper: RegionSamplingThread calls GraphicBuffer::lock, which reaches
GoldfishMapper::readFromHost, which asserts that the host has not negotiated
ReadColorBufferDma. It has, so surfaceflinger aborts, restarts, and aborts
again. Nothing this app does can survive that, and it reproduces on a GitHub
runner under swiftshader_indirect and on a workstation under -gpu host alike.

There is no ATD image at android-37.0 to fall back to, and -feature -GLDMA is
accepted by the emulator but does not prevent the assertion.

Correcting the previous commit, which is already pushed so its message stands:
ram-size was not the cause of that failure. Setting it did move the job from
failing at install to failing during the test run, which is how the real
crash became visible, but at 2560M the guest had 1.5 GB free when it died.
The setting is kept because the emulator's own floor varies by API level --
2048M at 33, 2560M at 34 to 36 -- and pinning it makes the matrix uniform.

Also corrected: a comment claiming this could not be reproduced locally. It
can, and the local crash was the same one all along.

Dropped the dmesg probe. adb shell is not root, so klogctl is denied and it
only ever printed a permission error -- which a later reader would reasonably
misread as "no OOM kills".

docs/api-37-emulator-crash.md carries the evidence, the ruled-out fixes, the
reproduction, and how to file it upstream, so re-adding the row later starts
from what is already known rather than from scratch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:35:04 -05:00
JMR-devandClaude Opus 5 2fe1aa9f9c Stop the memory probe from being able to fail the run
The probe line runs before the tests and its exit status is grep's, so a run
where adb returned nothing would have exited 1 on the first line and reded the
job before Gradle started -- on all five levels, four of them currently green.
The action passes no ignoreReturnCode, so exec throws straight into
setFailed.

A diagnostic must never be the thing that turns a run red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:12:43 -05:00
JMR-devandClaude Opus 5 a79ff62b61 Give the API 37 emulator the RAM every other level already gets
API 37 was the only red job in the matrix, and the last two fixes each
corrected a real problem only to reveal the next one. This is the cause of
the third failure.

The emulator raises an undersized guest to 2560M on its own, but only for API
levels it recognises, and it does not recognise "37.0". Comparing the two CI
logs from the same emulator binary (37.1.11.0) shows the asymmetry directly:
the API 36 job logs "Increasing RAM size to 2560MB" and the API 37 job has no
such line. So four levels were quietly running at 2560M while API 37 ran at
the pixel_6 default of 1536M, lost system_server partway through installing
the 82 MB APK, and surfaced it as "Can't find service: package".

2560M is not a guess at a sufficient value -- it is the value the other four
levels already pass at, so this makes the matrix uniform rather than
introducing a fifth configuration.

Verified that the setting actually lands: the action appends hw.ramSize to a
config.ini that already has one from the profile, so the fix only works if the
later key wins. Appending a distinctive 3072M to an API 36 AVD produced
MemTotal 3047924 kB and suppressed the automatic bump, confirming it does.

This failure cannot be reproduced locally -- API 37 will not boot on a
workstation under either GPU mode, aborting surfaceflinger in the goldfish
mapper under -gpu host and segfaulting the emulator under swiftshader_indirect
-- so the job now reports guest memory on every run and dumps OOM kills and
native crashes on failure. That makes the next run conclusive either way
instead of producing another bare "Can't find service: package".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:10:38 -05:00
JMR-devandClaude Opus 5 97cddea973 Pin build.yml's actions and verify what it publishes
Brings the release workflow in line with the status check. It matters more
here, not less: these jobs publish the artifacts people install, so running
whatever a mutable tag points at on the day is a worse bargain than it is on
a pull request.

Every action is pinned to a commit with its release in a trailing comment,
and each hash was checked to resolve to the tag it claims. gradle/actions is
dropped for the same reason as before -- its v6 caching component is closed
source and carries separate terms -- with Gradle running through the
committed wrapper, which verifies its own distribution against
distributionSha256Sum.

The release job now checks what it is about to publish. A release that
shipped a single ABI, or that lost 16 KB alignment in a rebuild, installs
fine on a test device and then fails for users or at Play submission. Both
are cheap to assert and expensive to discover afterwards. It deliberately
does not pass -PabiFilters: that override exists so emulator jobs skip
libraries they cannot execute, and a released artifact must carry every ABI.

The contents permission is declared explicitly rather than inherited from the
repository default, so the token's reach is visible in the file that uses it.

The corresponding-source tarball now includes bin/README.md as PREBUILT.md,
so the GPL source drop carries the shipped binary's SHA-256 and configure
line rather than only the recipe that produces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:08:51 -05:00
JMR-devandClaude Opus 5 5b47764c70 Build only the emulator's own ABI for instrumented tests
API 37 got as far as running the suite this time and then failed to install:

  'package install-create ... -S 117817978'
  java.io.IOException: Requested internal only, but not enough space

The 117 MB debug APK did not fit on the emulator's data partition. The
DELETE_FAILED_INTERNAL_ERROR that followed was the same exhaustion, not a
second problem.

It surfaced on API 37 because that system image is the largest and leaves the
least free userdata. The margin was thin at every level, so this was never
really an API 37 bug -- the others were simply further from the edge and would
have caught up as the APK grew.

Roughly half that APK is arm64-v8a FFmpeg libraries that an x86_64 emulator
can never load. abiFilters is now overridable, so a test run builds only what
it will execute: 114 MB becomes 80 MB. Release builds ignore the property and
still ship both ABIs, so nothing about what gets distributed changes.

disk-size is raised to 8G for every level rather than only the one that
failed, since fixing just API 37 would leave the rest waiting their turn.

Verified locally on an API 36 emulator with an x86_64-only APK: 40
instrumented tests, 0 failures, and the installed APK contains lib/x86_64
only. 66 unit tests still pass, and a release build still carries both ABIs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:04:07 -05:00
JMR-devandClaude Opus 5 2d2687aa3c Pin CI actions to commit hashes and fix the API 37 emulator run
The API 37 job failed after 23 seconds, before an emulator ever started. The
CI log names the cause exactly:

  sdkmanager --install 'build-tools;37.0.0' platform-tools 'platforms;android-37'
  Warning: Failed to find package 'platforms;android-37'

There is no platforms;android-37. The release is published as android-37.0,
alongside 37.1 and the 37.2 betas. The earlier attempt to fix this with
system-image-api-level was aimed at the wrong package: that input only names
the system image, while the platform is installed from api-level directly.
Setting api-level to 37.0 resolves all three packages, and build-tools is a
hardcoded constant in the action rather than derived from api-level, so it is
unaffected. A separate label field keeps the job name reading "API 37".

Every action is now pinned to a commit hash with its release in a trailing
comment. A tag is mutable: the owner can repoint v4 at new code whenever they
like, so a tag reference amounts to running whatever that repository contains
tomorrow. Each hash was verified to resolve to the tag its comment claims,
because a wrong hash is worse than a tag -- it looks deliberate.

Versions moved a long way in the process: checkout v4 -> v7.0.1, setup-java
v4 -> v5.7.0, upload-artifact v4 -> v7.0.1.

gradle/actions is gone rather than upgraded. Its v6 release moved the caching
component closed-source and states that upgrading accepts Gradle's Terms of
Use for it. That has no bearing on the project's own licence -- a CI tool is
never combined with or distributed alongside the app, unlike the FFmpeg
libraries that make the APK GPL -- but it is a component in the build path
that cannot be audited or forked. Gradle now runs through the committed
wrapper, which verifies its own distribution against distributionSha256Sum,
and caching is a handful of lines of actions/cache.

The rest of the matrix passed on this run: API 33, 34, 35 and 36 all green,
along with the unit tests and the FFmpeg archive check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:48:09 -05:00
JMR-devandClaude Opus 5 dd2fcf0a19 Rename the project to LibreMediaConverter
Done now rather than later: the application ID is permanent once published --
Play treats a change as an entirely different app -- so this is the last
cheap moment to choose it.

  applicationId / namespace  dev.jasonmross.mediaconverter -> org.libremediaconverter
  source tree                java/dev/jasonmross/mediaconverter -> java/org/libremediaconverter
  gradle project             AndroidMediaConverter -> LibreMediaConverter
  theme                      Theme.MediaConverter -> Theme.LibreMediaConverter
  compose theme              MediaConverterTheme -> LibreMediaConverterTheme
  display name               "Media Converter" -> "LibreMediaConverter"

org.* rather than dev.jasonmross.* because "Libre" signals a project rather
than a personal app, and a project-owned namespace lets maintainership move
later without the identifier contradicting reality.

The source trees moved with git mv so history follows the files instead of
showing 42 deletions beside 42 additions.

Verified after the rename: 66 unit tests, and 40 instrumented tests on an
API 36 emulator, 0 failures. The built APK reports org.libremediaconverter,
and no stale jasonmross, AndroidMediaConverter or MediaConverterTheme
identifiers remain anywhere in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:31:43 -05:00
JMR-devandClaude Opus 5 128763e99c Commit the FFmpeg binary so test runs stop depending on a rebuild
CI rebuilt FFmpeg on every cold cache, which made results ambiguous: a red run
could mean the code was broken or that a forty-minute cross-compile of FFmpeg,
x264, x265 and SVT-AV1 had hiccuped. Those are not the same signal, and only
one of them is worth a developer's attention. The archive is now checked in
under bin/, so a failing run points at code.

It also removes roughly forty minutes from a cold run and lets a fresh clone
build without a container toolchain.

bin/README.md records provenance -- upstream tag, FFmpeg version, NDK, ABIs,
SHA-256 and the full configure line read back out of the shipped libavutil --
so the binary is auditable rather than opaque. The recipe in tools/ffmpeg
remains the authority: this archive is its output, and is also what satisfies
the GPL corresponding-source obligation.

The status check is now seven independent runners: one validating the archive,
one for the JVM tests, and one per API level from 33 to 37. The FFmpeg job
verifies rather than builds. It asserts native libraries are present for both
ABIs and that every one is 16 KB aligned, which is a Play requirement that is
easy to lose in a rebuild and expensive to discover at submission. Checking
for file existence alone would not do: a Git LFS pointer checked out without
LFS passes that and then surfaces as an obscure linker error much later.

It is a separate job rather than a step in each emulator run so a bad archive
reports once, clearly, instead of five confusing emulator failures.

build.yml no longer builds FFmpeg either, and keeps only its post-merge and
release duties.

Two costs, deliberately accepted. The repository goes from about 1 MB to
35 MB, and every future rebuild adds another 35 MB blob to history
permanently, so bin/README.md says to regenerate only when the FFmpeg version
or the configure flags actually change. And F-Droid's scanner flags checked-in
native libraries, so submitting there needs a scandelete entry for bin/ --
noted in bin/README.md, and nothing prevents a from-source build.

Verified against the relocated archive: 66 unit tests, and 40 instrumented
tests on an API 36 emulator, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:18:10 -05:00
JMR-devandClaude Opus 5 54d6e9c57b Add a pull-request status check across API 33-37
Runs the JVM tests and the instrumented suite on every pull request to main,
with one emulator job per supported API level.

The matrix is the whole range rather than a single level because the
foreground-service type differs across it -- none below 34, dataSync at 34,
mediaProcessing from 35 -- so testing one level would leave two thirds of that
branch unexercised. Running the range locally is what caught a test that had
baked in an assumption about the host's encoders.

API 37 needs its image level stated separately. It is published as
android-37.0, not android-37, so a plain integer resolves to nothing and the
image download silently finds no package.

FFmpeg is built once and shared. The AAR is not committed -- 35 MB of native
code, and F-Droid strips checked-in binaries -- but every job needs it, since
the app compiles against it and the instrumented tests exercise it for real.
Building it is a full cross-compile of FFmpeg, x264, x265 and SVT-AV1, so it
is cached on the contents of tools/ffmpeg, which is what actually determines
the output. The job also asserts the AAR carries native libraries for both
ABIs: a truncated or stub archive would otherwise pass a file-exists check and
send the matrix off to fail confusingly five times over.

fail-fast is off. Knowing whether a failure is universal or specific to one
API level is most of the diagnosis.

build.yml no longer runs on pull requests. It triggered on every PR with no
branch filter, so both workflows would have run, and its unit job falls back
to a stub AAR -- a weaker check that could mask a compile break the real one
would catch. It keeps its post-merge and release duties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:08:06 -05:00
JMR-devandClaude Opus 5 a172cb137f Pin the fallback test's device profile so it is not machine-dependent
Running the suite across API 33, 34, 35 and 36 emulators failed the same test
on all four, while it passed on a Pixel 10 Pro XL. The assertion was "the
hardware path should have been attempted", and the routing was correct in both
cases: those emulators expose no hardware H.264 encoder, so the router sent the
job straight to FFmpeg and Media3 was never called.

That is the same mistake made earlier with routesAFastMp4JobToMedia3 -- baking
an assumption about the host's encoders into a test. A result that flips with
the machine says nothing about the code.

This test is about the fallback mechanism rather than about routing, so it now
pins DeviceCodecs.PERMISSIVE through the existing seam and the router's choice
becomes deterministic. Routing itself is covered separately, by tests that
derive their expectation from the device.

Verified on emulators for API 33, 34, 35 and 36: 40 instrumented tests each,
0 failures, 2 skipped, with the only skips being the opt-in benchmark. That
also exercises all three foreground-service regimes for the first time -- no
type below 34, dataSync at 34, mediaProcessing from 35 -- which had previously
only ever run at API 37.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:01:42 -05:00
JMR-devandClaude Opus 5 99bd961022 Force the last five failure branches
The previous commit called five branches unforceable. That was wrong on all
five, and the reasoning behind it was lazy rather than investigated.

Three were described as needing a SAF grant revoked mid-job. They do not: a
content URI whose authority does not exist reaches exactly the same null
branch as one whose permission was withdrawn, and constructing one is a single
line. That now covers the conversion input, the join inputs, and the publish
destination.

One was described as unreachable through ConcatWorker.request(). True, but
irrelevant -- a worker is just input Data, and WorkManager will run one built
without the input array. That is also what a version-skewed queue entry would
look like after an app update, so it is worth covering rather than dismissing.

One was described as Media3-internal. An output path whose parent directory
does not exist makes Transformer.start() fail synchronously, and the engine
has to surface that as a rejected suspension. The test asserts specifically
that it is not a timeout: hanging would be far worse than throwing, because
the worker would sit holding a foreground service indefinitely.

The assertions check outcomes rather than exception types. Whether a resolver
returns null or throws is a provider implementation detail; what matters is
that the job fails cleanly and carries a message, instead of taking down the
worker.

Every failure branch in the conversion and join paths is now forced.
40 instrumented tests on a Pixel 10 Pro XL and 66 unit tests, 0 failures, with
the only skips being the opt-in benchmark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 10:02:36 -05:00
JMR-devandClaude Opus 5 83fb55515b Add a seam for forcing failure paths, and force thirteen of them
Error handling was the least-tested code in the app. It only runs when
something goes wrong, which is exactly what a healthy test run avoids, so the
branches a user meets on a bad day were the ones that had never executed. An
audit put it at 4 of 18 fallback branches actually forced by a test.

There was also no mechanism to do better: ConversionWorker constructed
Media3Engine and FFmpegEngine directly, so no test could make either of them
fail. Media3Engine and FFmpegEngine now implement HardwareTranscoder and
SoftwareTranscoder, and ConversionDependencies holds the factories. Workers
are built by WorkManager and the app deliberately carries no DI framework, so
a small settable holder is the least machinery that does the job.

The foreground-service timeout needed different treatment. Its trigger is the
six-hour-per-day budget expiring, which no test can reach, so the decision
moved out of the worker into FailureOutcome. The rule is now verified on the
JVM across every WorkInfo stop reason, including that only the timeout earns a
retry -- retrying a user cancellation would ignore the user, and retrying a
constraint failure would spin.

Thirteen branches are now forced, including both free-space prechecks, both
engines failing, a missing input, and the router bypassing hardware entirely
for MP3. The dynamic fallback is covered twice over: once by injection, and
once by HardwareFallbackTest driving it with genuinely undecodable 4:4:4
footage. Injection alone would prove the plumbing without proving the
condition ever arises in reality.

Five remain unforced and are listed in ConversionDependencies' documentation.
They need a SAF grant to be revoked mid-job, which is not something a test can
arrange.

Media3EngineTest now asserts against the muxed file rather than the engine's
own ExportResult, which is a stronger check anyway: a result object can report
success for a file that will not play.

35 instrumented tests on a Pixel 10 Pro XL and 66 unit tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 09:54:06 -05:00
JMR-devandClaude Opus 5 356c04d137 Make the 4:4:4 fallback regression run without manual setup
The test covering the runtime fallback read its input from the app's internal
storage, which only ever contained a file because it had been piped in by hand
with run-as. On a fresh checkout it hit assumeTrue and skipped -- silently,
while still counting toward the suite total. A regression test that skips is
worse than no test, because the number reads as coverage.

It now ships its own fixture: three seconds of H.264 High 4:4:4 Predictive,
76 KB. Producing it needed x264, which the host toolchain cannot supply --
Fedora's ffmpeg carries openh264, which is Constrained Baseline only and
cannot even decode 4:4:4 -- so it was generated with ffmpeg-full inside the
existing FFmpeg build container. The command is recorded in the test's own
documentation so the fixture can be regenerated rather than trusted blindly.

Verified by deleting the hand-staged files first and running the suite clean:
the fallback test executes, Media3 fails to decode as expected, and the worker
completes the conversion through FFmpeg. It is no longer among the skips.

The benchmark stays opt-in and is now documented as such. It needs real
long-form media that does not belong in the repository, and its numbers should
not be mistaken for something the suite verifies.

29 instrumented tests on a Pixel 10 Pro XL: 0 failures, 2 skipped, and both
skips are the benchmark by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 09:45:48 -05:00
JMR-devandClaude Opus 5 ec6e37ad08 Stop selecting FFmpeg's MediaCodec encoders, and pin the pixel format
Testing on a Pixel 10 Pro XL with real footage found two defects that neither
the emulator nor any unit test could surface.

The sample is H.264 High 4:4:4 Predictive (avc1.F4001F). Media3 cannot decode
it on any device: hardware AVC decoders implement High 4:2:0, and Android's
software c2.google.avc.decoder does not cover 4:4:4 either, so the export dies
with a codec exception. The static routing rules cannot predict this -- the
container is MP4, the codec is "h264", and the device reports AVC decode and
encode -- so it is exactly the case the runtime fallback exists for. Until a
file like this was tried on hardware, that fallback had never actually fired.

Following it through exposed the two bugs behind it.

First, only one video encode path named a pixel format. FFmpeg decodes 4:4:4
to yuv444p and hands those frames to encoders that cannot accept them; naming
yuv420p makes it insert the conversion instead. Every path now does.

Second, and not fixed by that: hevc_mediacodec still failed with "Error
submitting video frame to the encoder". That is the second distinct failure
from FFmpeg's MediaCodec wrappers in one session -- the first being that on a
device with no hardware encoder they silently bind to the platform software
codec and crawl while presenting as the fast path. They are undocumented,
per-device flaky, and duplicate badly something Media3 already does properly.
A job only reaches FFmpeg because Media3 could not handle it, which is itself
evidence hardware encode is unlikely to work for that input.

So FFmpeg now always encodes in software, and Fast means a fast preset rather
than a different encoder: libx264/libx265 at veryfast against medium, both
with CRF. With that, the fallback completes and the file converts.

Measured on the Pixel: AV1 1080p through the hardware path runs at 7.9x
realtime, confirming the figure the two-engine design was based on; software
x264 CRF at 720p runs at 4.4x. Hardware encoders present are av01, avc and
hevc -- AV1 encode included, which is still rare.

29 instrumented tests pass on device, 63 unit tests on the JVM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 09:39:43 -05:00
JMR-devandClaude Opus 5 64e146ce5d Cover the join path with device tests
The join flow was implemented but had never run end to end: unit tests covered
the planner and the argument shapes, neither of which can tell you what FFmpeg
actually does with real files.

Three fixtures make the strategy decision testable. clip_a and clip_b match in
codec, resolution and frame rate; clip_c deliberately differs in both
resolution and frame rate. Without a genuinely mismatched input there is no way
to prove the re-encode branch is ever taken.

The tests assert which strategy ran, not merely that output appeared. That
distinction is the whole point here: the concat demuxer does not reliably
reject mismatched inputs, so a naive implementation produces a file whose later
segments are garbled while still exiting successfully. Each test also checks
the output is long enough to contain both inputs, since a truncated join is
exactly what a wrong stream copy looks like.

Also covered: the list file is cleaned up, fewer than two inputs is refused,
the probe distinguishes the clips the planner depends on, and the chosen
strategy reaches the UI through WorkManager -- it is what tells the user
whether their files were copied losslessly or re-encoded.

Measured on an API 37 emulator, the two paths differ by roughly thirty times
on the same pair of clips: 0.026s to stream copy against 0.829s to re-encode.
That gap is itself evidence the planner is not quietly re-encoding everything.

25 instrumented tests now pass, up from 17. 65 unit tests unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 09:12:12 -05:00
JMR-devandClaude Opus 5 e5f274183c Match the Join tab to the converter's empty state
Applies the same treatment to the Join tab: label and button centred on both
axes, button filling the width at 56dp tall, and the same asymmetric screen
padding. An empty state that looks different depending on which tab you are
on reads as a bug rather than as variety.

The two shared dimensions move into ui/Dimens.kt rather than being duplicated
per screen, so the tabs cannot drift apart later.

Verified on an API 37 emulator: both tabs now present an identical empty
state. 65 unit and 17 instrumented tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 09:09:33 -05:00
JMR-devandClaude Opus 5 a8fa3aecdf Centre the empty state and widen its primary button
Splits the converter screen into a fixed header and a body region that
behaves differently per state. The empty state centres its label and button
on both axes in whatever space is left; the working states keep scrolling,
since format pickers and progress can exceed the screen and centring content
that overflows would push it out of reach.

The primary button now fills the width and stands 56dp tall rather than the
Material default of 40dp, so it reads as the main affordance instead of a
small control adrift in an otherwise empty screen. Horizontal screen padding
drops to 16dp while vertical stays at 24dp, which is what lets a full-width
button sit close to both edges.

Both dimensions are named constants rather than inline numbers, because the
same treatment is applied to the primary action in every other state.

No theme changes were needed. Dark mode already tracked the device: the
Compose theme reads isSystemInDarkTheme() and the activity theme has a
values-night variant. Verified on an API 37 emulator by toggling
`cmd uimode night` and capturing both -- mean luminance 245/255 in light
against 18/255 in dark, with the button recolouring and status bar icons
inverting correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 09:06:09 -05:00
JMR-devandClaude Opus 5 2fcacd199c Use a real software encoder when the device has none in hardware
Running the device suite for the first time surfaced a defect the unit tests
could not: FFmpeg's *_mediacodec wrappers do not fail on a device without a
hardware encoder. They quietly bind to the platform's software codec
(c2.android.hevc.encoder on the test emulator) and encode far slower than
libx264 or libx265 would, while still presenting as the fast path. A three
second 320x240 clip did not finish inside a three minute timeout.

The Fast tier now checks whether a hardware encoder actually exists for the
target codec, and falls back to libx264/libx265 on -preset veryfast when one
does not. That is both quicker and honest about what it is doing, and the
distinction between Fast and Best survives the fallback: veryfast against
medium, rather than both collapsing to the same slow path.

The routing itself was already correct. The capability probe rightly rejects
c2.android.* as software-only, so the job was correctly sent to FFmpeg -- the
test asserting Media3 was encoding an assumption about the machine it ran on.
It now derives its expectation from the device, so it means the same thing on
hardware with a real encoder and on an emulator without one.

Verified on an API 37 emulator: 17 of 17 instrumented tests pass in 4.2s,
against six minutes of timeouts before. 65 unit tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 08:21:37 -05:00
JMR-devandClaude Opus 5 438f21783f Make the foreground-service test read the running API level
The three foreground-service-type regimes (none at 33, dataSync at 34,
mediaProcessing at 35+) are the reason ConversionForegroundType exists, so
the test derives its expectation from Build.VERSION rather than pinning one
value. The same test then means something on any device in the supported
range instead of only on the one it was written against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:47:37 -05:00
JMR-devandClaude Opus 5 bb969ece64 Enable R8 and add release, store and F-Droid infrastructure
Turning on R8 immediately surfaced a latent runtime bug: the ffmpeg-kit-next
wrapper references com.arthenica.smartexception.java.Exceptions from
AbstractSession.fail() in eighteen places, but a local .aar carries no
transitive dependencies, so nothing was pulling it in. Debug builds tolerate
this through lazy class loading -- the class is only touched on an error
path -- so it would have shipped as a crash the first time an FFmpeg
conversion failed. Declared explicitly now.

Keep rules cover the JNI boundary. The native library resolves classes and
methods by name, which R8 cannot see, so without them the FFmpeg calls fail
with NoSuchMethodError in release builds only. Workers are kept too, since
WorkManager reconstructs them reflectively from a class name persisted in its
database, and a rename breaks jobs enqueued before the update.

Verified on the produced artifacts rather than assumed: all 22 native
libraries survive minification and every one is still 16 KB aligned inside
the APK. Release is 82 MB against 115 MB for debug; the AAB is 40 MB and Play
splits it per ABI.

The privacy policy lists every permission, including the three WorkManager
adds automatically (WAKE_LOCK, RECEIVE_BOOT_COMPLETED, ACCESS_NETWORK_STATE).
Checking the merged manifest showed those, and a policy that omitted them
would look dishonest to anyone who inspected the app. INTERNET is genuinely
absent, so "files stay on the device" is enforced by the OS rather than a
promise.

CI runs unit tests on every push and builds the FFmpeg AAR only for release
tags, since that is a full cross-compile. Releases attach the FFmpeg
corresponding source next to the APK: GPL-3.0 requires it, and FFmpeg's
instruction to host it "on the same webserver" cannot be satisfied by a Play
listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:45:40 -05:00
JMR-devandClaude Opus 5 0c352cdcfb Route conversions between hardware and software engines
Adds the second engine and the rules that choose between them, turning a
video transcoder into a converter.

The router encodes capability boundaries, not preferences. Media3 handles
what it genuinely can and FFmpeg takes the rest:

  MKV, and any container Media3 cannot mux
  MP3, which Android cannot encode at any API level -- a platform gap
    rather than a Media3 limitation
  GIF and PNG frame sequences, which have no Media3 muxer
  VP9 and AV1 targets: Transformer.setVideoMimeType accepts only
    H.263/H.264/H.265/MP4V, so the WebM muxer has no encoder behind it
  inputs with no platform decoder, since Transformer ignores ExoPlayer's
    bundled software decoders and the dav1d extension does not rescue it
  the Best quality tier, because CRF and two-pass come from x264/x265 and
    no Android hardware encoder exposes either

Rule order matters and is deliberate: specific reasons are checked before
general ones because the reason is shown to the user. "Android has no
encoder for this format" is actionable for MP3; "this container needs
FFmpeg" is not. A test caught the original ordering getting this backwards.

Hardware support is vendor-declared and, per the platform's own docs,
"cannot be tested for correctness", so the static rules are backed by a
dynamic fallback: a Media3 export that fails is retried on FFmpeg rather
than surfaced as a failed conversion.

Joining files chooses between a stream copy and a re-encode by inspecting
the inputs. The concat demuxer requires matching codec, resolution and
timebase, and does not reliably fail when they differ -- it can emit a file
whose later segments are garbled. Unknown properties count as a mismatch,
because two nulls are not evidence of agreement.

The UI surfaces the routing decision rather than hiding it, so a slow job
explains itself, and offers a per-job engine override.

Navigation is adaptive: a bottom bar on phones, a side rail on wider
screens. Not cosmetic -- from targetSdk 37 Android ignores screenOrientation
and resizableActivity on displays at least 600dp wide, with no opt-out, so
the app is resized whether or not it is ready.

62 unit tests cover the routing matrix, the FFmpeg argument builder and the
concat planner on the JVM, against fabricated device profiles so branches
like "this device cannot encode HEVC" are reachable without that hardware.
Device-level tests for the FFmpeg formats are written but not yet run; the
emulator in this environment will not stay up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:39:19 -05:00
JMR-devandClaude Opus 5 e84ab6a89d Run conversions as durable foreground work
Conversions now go through WorkManager instead of a viewModelScope coroutine,
so a job outlives the ViewModel, survives process death, and keeps running
when the user leaves the app.

The foreground service type needs three branches across the supported range,
which is why ConversionForegroundType exists rather than a constant:

  API 33  no type is required at all
  API 34  a type is mandatory, but mediaProcessing does not exist yet, so
          dataSync is the only sensible fit
  API 35+ mediaProcessing, whose own documentation describes it as
          "converting media to different formats"

The manifest declares both types on WorkManager's SystemForegroundService
via tools:node="merge" -- setForeground runs *that* service, not one of
ours, so declaring the type on an app-owned service would have no effect.
The manifest cannot branch on API level, so the runtime picks which type is
actually passed.

Both types share a budget of six hours per twenty-four across the whole app.
When it runs out WorkManager reports STOP_REASON_FOREGROUND_SERVICE_TIMEOUT,
which the worker translates into Result.retry() rather than a failure: the
work is still valid, there is simply no budget right now. The UI surfaces
that as a distinct Waiting state that explains the pause instead of showing
an error.

Expedited work is deliberately not used. It maps to JobScheduler expedited
jobs with a short quota, which is the wrong shape for a multi-minute
transcode.

POST_NOTIFICATIONS is requested when the user taps Convert, not on first
launch, so the ask arrives with visible justification. The conversion starts
either way -- without the permission the foreground service still runs, but
its progress notification is confined to the Task Manager rather than the
shade. Notification updates are throttled to roughly one per second because
progress updates arrive far faster than the system UI can absorb.

Tests run against the real WorkManager rather than a test double,
specifically so setForeground and the declared service type are exercised on
a device that enforces them. Verified on an API 37 emulator: the service
starts, and logcat shows no type or permission exceptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:27:03 -05:00
JMR-devandClaude Opus 5 b082cea889 Add containerized FFmpeg build producing a 16 KB-aligned GPL AAR
There is no usable prebuilt FFmpeg for Android any more. arthenica/ffmpeg-kit
is archived and its binaries were deleted from Maven Central, so every
com.arthenica:ffmpeg-kit-* coordinate 404s and all of its release tags have
zero assets. Maven Central's search index still lists the old versions, which
misleads; the files behind those entries are gone. The successor,
ffmpeg-kit-next, is source-only by design. Building it ourselves is the only
remaining option, not a preference.

ffmpeg-kit-next is Nix-only -- there is no plain android.sh, only
nix-android.sh and a flake -- so the toolchain lives in a container rather
than on the developer's machine. The recipe doubles as the reproducibility
artifact F-Droid expects and as the GPL corresponding-source obligation.

Four problems this path hits, none of them documented upstream:

- The nixos/nix base image already ships bash, coreutils and git; installing
  them collides with the existing profile entries and fails the image build.
- Upstream scripts use #!/bin/bash but the image provides only /bin/sh, so
  start-android.sh dies with "cannot execute: required file not found" after
  the entire toolchain has been built.
- Gradle's AAPT2 comes from Maven as a prebuilt binary linked against FHS
  paths that do not exist under Nix, failing with "Daemon startup failed"
  after the whole native build succeeds. Nixpkgs' Android SDK ships an
  already-patched aapt2, so Gradle is pointed at that.
- A bare '*.aar' find also collects every AAR Gradle unpacked into its own
  caches, so the copy is scoped to the ffmpeg-kit outputs.

The NDK stays at r27d as the flake pins it. Do not "upgrade" to r28+:
android/jni/Android.mk applies -Wl,-z,max-page-size=16384 manually precisely
because r27 predates automatic alignment, and the result is verified 16 KB
compliant as-is.

Verified against the produced artifact: every .so on both ABIs reports LOAD
align 0x4000, libraries are separate rather than a static monolith as the
GPL relinking obligation requires, and the embedded configure line confirms
--enable-gpl --enable-version3 with x264, x265, SVT-AV1, LAME, libass and
the MediaCodec wrappers. Note that --enable-small and --enable-lto
internalize symbols, so absence from strings output proves nothing; check
the configure line instead.

The 35 MB AAR itself is gitignored. F-Droid strips checked-in prebuilt
native libraries, and the recipe is the artifact of record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:19:01 -05:00
JMR-devandClaude Opus 5 e4efa8a74a Add Media3 hardware conversion path with end-to-end tests
First working conversion: SAF input -> hardware transcode -> staged cache
file -> SAF export, driven from a Compose screen.

Media3 Transformer is the engine for this path rather than FFmpeg. It is
Apache-2.0, needs no native build, consumes content:// URIs directly, and
runs MediaCodec decode -> GL surface -> MediaCodec encode without frames
round-tripping through the CPU. FFmpeg remains necessary for the long tail
(MP3, GIF, MKV, exotic containers) but is not the right tool here.

Two hazards are designed against rather than discovered later:

Transformer must be driven from a single thread that has a Looper, and
start()/cancel() throw IllegalStateException from anywhere else. The Looper
it binds to is whichever the Builder saw, silently falling back to the main
one. A WorkManager Worker runs on a Looper-less executor thread, so the
naive arrangement builds against the main Looper and then throws on start.
Media3Engine owns a dedicated HandlerThread, passes its Looper explicitly,
and marshals every call onto it, so callers get a plain suspending function
and cannot reintroduce the bug. Media3EngineTest covers this directly by
driving a conversion from a Looper-less thread.

Output never goes through a SAF file descriptor. MP4 faststart rewrites the
moov atom at the end and needs to seek backwards, which a SAF fd does not
reliably support. OutputPublisher stages to app-private cache, a real POSIX
path, and copies out afterwards. That costs transient double disk usage, so
it checks free space before starting.

Input uses ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is
images and video only, offers no audio at all, and does not reliably
surface .mkv/.flac/.webm. SAF needs no runtime permission.

Tests run on an API 37 emulator and assert the output codec by reading the
muxed file with MediaExtractor, so a silent fallback to H.264 fails rather
than passing. Progress reporting is deliberately not asserted as non-empty:
a 3 s fixture can finish inside one 250 ms poll tick, which would be an
intermittent failure rather than a real defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:18:44 -05:00
JMR-devandClaude Opus 5 48f65f0941 Replace scaffold with Compose/Material 3 base and project identity
The generated scaffold was a Views-based Material 2 shell with no activity,
no Kotlin sources, and a placeholder package. Replace it with the real base
the conversion work builds on.

Build configuration, with three AGP 9 specifics that contradict most
tutorials still in circulation:

- AGP 9 has built-in Kotlin. Applying org.jetbrains.kotlin.android now fails
  the build, so the absence of that plugin is deliberate, not an oversight.
- The Compose compiler plugin is still separate and must be applied, pinned
  to 2.2.10 to match the kotlin-gradle-plugin AGP 9.3.1 brings transitively.
  Pinning it to the newest Kotlin release instead would mismatch.
- android.kotlinOptions {} was removed; jvm configuration moves to a
  top-level kotlin { compilerOptions {} }.

Java compatibility goes 11 -> 17 (AGP 9 requires JDK 17), abiFilters
restrict packaging to arm64-v8a and x86_64, and jniLibs packaging is set
uncompressed so the APK zip-aligns native libraries on 16 KB boundaries.

Every dependency version in the catalog was checked to resolve against
Google Maven rather than copied from documentation. Note that KSP has moved
to standalone versioning (2.3.11) and no longer uses the old
<kotlin>-<ksp> scheme; it is catalogued but left unapplied until Room lands.

Set applicationId to dev.jasonmross.mediaconverter. com.example.* is
rejected by the Play Console, and the application ID is permanent once
published, so it has to be right before the first upload. The display name
is just a string resource and stays changeable.

Document the split license posture: source is MIT, but the distributed
binary will be GPL-3.0 because it bundles FFmpeg built with x264/x265.
LICENSES/README.md records why, including that libass is ISC rather than
GPL, so subtitle burn-in is not what forces the GPL choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:18:28 -05:00
JMR-dev 18ae2cff80 initial commit 2026-08-19 17:29:15 -05:00