Compare commits

..
Author SHA1 Message Date
Jason Ross 4d21996735 Merge branch 'main' into feat/expedited-conversion-work 2026-09-07 11:11:53 -05:00
Jason Ross 264b8027e4 Merge pull request #260 from JMR-dev/fix/gate-cache-in-worktrees
Resolve the gate's cache dir with --git-common-dir, and say when it cannot (#258)
2026-09-06 18:02:22 -05:00
JMR-devandClaude Opus 5 7d1d3191a9 Resolve the gate's cache dir with --git-common-dir, and say when it cannot (#258)
CACHE_DIR was the literal ".git/lmc-verify". In a linked worktree `.git` is a
FILE containing `gitdir: ...`, so `mkdir -p .git/lmc-verify` fails with "Not a
directory" -- and because the write is the last thing the script does, it failed
while the gate still printed green and exited 0. Every commit and push from a
worktree then re-swept API 33-36 for nothing, silently. That is the worst shape a
cache can fail in: invisible and expensive, and it was found by an agent paying
for it four times over rather than by the tool saying anything.

Measured both ways: in a worktree the old expression gives
`mkdir: cannot create directory '.git': Not a directory`, exit 1; `git rev-parse
--git-common-dir` gives the real path and exit 0.

--git-common-dir rather than --git-dir so the cache is SHARED between worktrees.
The key is the app/src tree hash, and identical content is identical content
whichever worktree produced it -- a sweep run in one is evidence for all of them.

The write also stops being silent. record_sweep() prints when it cannot record,
because a cache that never fills looks exactly like one that is working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:54:12 -05:00
JMR-devandClaude Opus 5 6a8cc01862 Expedite user-initiated work, and give both getForegroundInfo overrides a caller (#252)
`ConversionWorker.request` and `ConcatWorker.request` now carry
`setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST)`. Conversions and joins are
started by a tap; the jobs that have to go back through JobScheduler because no
process is left to start them should not queue behind a background chore. The
class KDoc that said expedited was "deliberately not used" is replaced with what
was actually read out of work-runtime 2.11.2: retries are never expedited
(`SystemJobInfoConverter:135`), and a job the system stops mid-run is resolved as
`ResetWorkerStatus` and re-enqueued rather than answered by `FailureOutcome`.

#252's own premise does not survive measurement, and that is the second half of
this change. `getForegroundInfo()` is WorkManager's expedited-work hook, but
`WorkForeground.kt:38` opens the library's only caller with
`if (!spec.expedited || Build.VERSION.SDK_INT >= 31) return`, and minSdk is 33 --
so `setExpedited` alone leaves both overrides exactly as cold as the first
instrumented coverage read found them. Measured on API 34 rather than argued:
with the flag set and `doWork` still building its own notification, both methods
report `missed 1 / covered 0` and all ten lines `ci=0`, and the whole
instrumented suite is green anyway at 71/71.

What makes them live is that each worker held two definitions of one
notification. `doWork` now posts the override's instead of an identical copy, so
`ConcatWorker`'s countless "Joining files" -- which nothing executed and which
was therefore free to drift from the "Joining N files" that ran -- is gone.
After: both `getForegroundInfo` report `LINE 0 missed / 5 covered`.

Five mutations were run and all five went red: dropping `setExpedited` from
either request, hard-coding the conversion title, moving its `percent` off zero,
and dropping the join's input count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:06:00 -05:00
Jason Ross 68b863fbdb Merge pull request #257 from JMR-dev/chore/gate-runs-shellcheck
Run shellcheck in the local gate, at CI's exact pin
2026-09-06 16:28:29 -05:00
JMR-devandClaude Opus 5 f4174e5b06 Run actionlint in the gate too, at CI's exact pin
The other half of the hole the previous commit closed. `git ls-files '*.sh'` does
not match workflow `run:` blocks, and a good deal of this repo's bash lives
there -- so a workflow edit was still the case where the gate passed and CI's
Static analysis leg went red.

Pinned by digest, read out of status_check.yml rather than copied, for the reason
the shellcheck section gives and for actionlint's own: its documented install is
`curl | bash` off a moving branch, which does not belong in a repo that pins every
action by SHA.

The container runtime detection and the SELinux `:z` mount option are hoisted out
of the shellcheck branch so both checks share one answer rather than deciding it
twice and drifting.

Verified that it bites rather than assumed: status_check.yml was given a
`needs: [a-job-that-does-not-exist]`, and the real pre-commit hook blocked with
actionlint's own message -- `job "static-analysis" needs job
"a-job-that-does-not-exist" which does not exist in this workflow [job-needs]`.
Workflow restored; nothing but the hook is in this diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 16:19:49 -05:00
JMR-devandClaude Opus 5 dd01f9f27c Run shellcheck in the local gate, at CI's exact pin
The gate checked ktlint, detekt and Android lint but not shellcheck, so a new or
edited .sh file was precisely the case where the hook passed and CI's Static
analysis leg still went red. The first file it could not check was itself, and it
was caught by hand twice before it was caught here.

THE DIGEST IS READ OUT OF status_check.yml RATHER THAN COPIED. shellcheck 0.9.0
and 0.11.0 disagree about how to report a trap handler -- SC2317 on seven body
lines against SC2329 once on the declaration, same script, same directive, one
red and one green. That is why CI pins by digest, and it is also why a second
copy of the digest in this file would be worse than none: when it drifts, the
symptom is the gate passing and CI failing, which is the exact failure this
section prevents.

Runs over `git ls-files '*.sh'` -- all tracked files, not the diff -- because
that is what CI does, and the job here is to predict that leg rather than audit
the change. podman is preferred over docker for the mount's SELinux relabel;
neither present, or the digest unreadable, reports the check as NOT COVERED
rather than skipping it quietly.

Verified that it bites rather than assumed: a probe script whose only fault was
an unquoted `ls $foo` was staged, and the real pre-commit hook blocked on SC2086
before it reached the JVM gate. Probe removed; all tracked .sh are clean under
the pinned digest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 16:16:23 -05:00
Jason Ross dd76229e90 Merge pull request #256 from JMR-dev/test/publish-delete-arm-real-provider
Delete the document a failed save could not write (#250)
2026-09-06 16:11:13 -05:00
JMR-devandClaude Opus 5 8105291f6a Make the gate name the levels it ran instead of claiming all of them
The closing line was `green at every supported API level`, printed on both
paths -- including the one that had just said `NOT COVERED LOCALLY: API 37` two
lines above. A false claim, printed by the tool whose entire purpose is to stop
false claims reaching CI, on its first run.

It now names them: `green on API 33, 34, 35, 36` when the Pixel is absent, and
`green on API 33, 34, 35, 36, 37` when it is attached and passed.

Nothing else changes. The app/src subtree is untouched, so this exercises the
cache scoping from the previous commit: the sweep is skipped as already green and
only the JVM gate runs -- which is the whole reason that key was moved off the
repo tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 16:02:47 -05:00
JMR-devandClaude Opus 5 68bd24e54a Gate commits and pushes on a local sweep at every supported API level
New rule, and a hook rather than a habit. Source work needs the unit tests and
the instrumented tests green at every supported API level before it is committed
or pushed; test work needs the whole suite green at every level.

tools/git-hooks/local-gate.sh is wired in as pre-commit and pre-push (symlinks,
so shellcheck sees one file), enabled with
`git config core.hooksPath tools/git-hooks`.

WHAT "EVERY LEVEL" CAN MEAN HERE, measured rather than assumed. 33-36 run the
whole suite on emulators. API 37 CANNOT be run on an emulator on this host at
all -- not "is red", cannot run: the image logs `3 new surfaceflinger aborts in
45 s (want 0)` and the APK install then fails with `Can't find service: package`,
because the framework is gone before Gradle installs anything. Starting 0 tests.
So 37 runs on the attached Pixel 10 Pro XL when it is there, and the hook says
plainly that the level is uncovered when it is not, rather than claiming five
levels having run four.

The first cut passed a notAnnotation filter through E2E_EXTRA_GRADLE_ARGS, which
run-e2e.sh:587 overwrites with --rerun -- so that argument was discarded and
would have been discarded silently.

The sweep is cached under the app/src SUBTREE hash, not the whole repo tree. The
first cut used the whole tree and that was wrong in a way that would teach people
to resent this hook: editing a comment in CLAUDE.md discarded a sweep of
byte-identical application code and re-ran forty minutes of emulators to prove
nothing. Any change under app/src still invalidates it; the JVM gate always runs.

There is deliberately no skip variable -- that would be --no-verify wearing a
different hat.

Why it is worth the time: #256 spent several gating legs learning one leg at a
time what a sweep answers in one pass, and the failing leg MOVED between runs
(API 35 red then green, API 34 green then red). One leg at a time reads as
someone else's flake; as a sweep it is one signal.

Also here, and the reason the rule arrived now: awaitNode treated "the app has no
composition right now" as a failure rather than as not-yet. fetchSemanticsNodes
throws IllegalStateException when nothing is attached and waitUntil propagates it
on the first poll instead of waiting out the deadline. This class spends much of
its time behind the picker, the save dialog and the permission dialog, so there
is always a window where the app is coming back with no composition -- and on run
34057706195's API 34 leg both SAF tests died in it. Now it is not-yet, with the
last composition error carried into the timeout message so a genuinely dead app
stays diagnosable.

Verified: this commit's own hook swept API 33, 34, 35 and 36 at 71/71 failed=0,
API 34 included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 15:52:21 -05:00
JMR-devandClaude Opus 5 19e35394e1 Bound the conversion against API 35's encode, not API 34's
The API 35 leg of #256 went red on aSaveWritesToTheDocumentTheSystemPickerCreated
with a 120 s ComposeTimeoutException on action.saveFile. It was not a cancelled
job and not the new teardown: run 34056545386's logcat has

  20:05:26.897 FFmpegEngine: ffmpeg ... -c:v libx265 -crf 24 -preset veryfast
  20:07:41.693 ConversionWorker: Routing worker_sample.mp4 ...

134.8 s between the encode starting and the next job in the suite, with no cancel
between them. The conversion was healthy and still running when the bound fired.

CONVERSION_TIMEOUT_MS was 120_000, and its KDoc justified that with "the whole
test takes 11.8 s on the API 34 CI leg" -- a real measurement generalised to an
API level it was never taken on. Adding a second picker test made this class
encode twice, so the second one runs on a more contended emulator and crossed a
line that was already marginal. Now 300_000, justified against the 134.8 s, with
a note not to re-tighten it from a fast leg's timing.

cancelAllWork was SUSPECTED of causing this and did not. A local API 35 run with
it passed, which is what sent me to the logcat. pruneWork is kept because it is
the narrower call -- only finished records need to go, and cancelling live work
is a wider blast radius than teardown in a shared process needs -- and its KDoc
now says it fixed nothing rather than claiming a cause it does not have.

That correction is the point: the first version of that KDoc asserted a cause
from one red CI leg and one green local run on a different machine. A test
carrying a confident wrong explanation is the failure mode this whole read has
been about.

Verified: API 35 at 71/71 failed=0 with the raised bound, and the full gate green.
Production is untouched -- git diff origin/main -- app/src/main is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 15:20:51 -05:00
JMR-devandClaude Opus 5 cbbaf74285 Delete the document a failed save could not write (#250)
#226 proved D4's premise -- SAF hands back a document reporting exactly zero
bytes, so destinationIsKnownEmpty can answer true -- and then drove the success
path, where publish's catch is never entered. So deletePartialOutput had still
never run against a real DocumentsProvider; its only assertions were
OutputPublisherPublishTest's, against FakeSafProvider under Robolectric. That is
the same "asserted only against a fake built to match it" shape #226 was filed to
break, one layer down.

RecordingPublisher.failOpen makes openDestination return null, which publish
turns into error("Could not open destination for writing") AFTER its size probe
has run -- so the catch is reached with destinationWasEmpty true on a document
DocumentsUI created seconds earlier. Null rather than a throw because
openDestination's KDoc says a provider that is present and declines is the half
no fake can produce on demand, so that arm is also taken for the first time.

Mutation, measured: delete the deletePartialOutput call and this test fails with
"publish did not delete the document it could not write". Nothing anywhere went
red for that line before.

TWO DEAD ACCESSORS #226 LEFT, and the reason is the same one:

FixtureDocumentsProvider is declared by the test APK and runs in
org.libremediaconverter.test; instrumentation runs in the app's process. A static
in the provider is a different object from the one a test can see, so
deletedDocumentIds() would have read empty forever, and reset(File) deletes under
a filesDir that is not the provider's. Both are removed rather than worked
around. That is E7's process wall from a third side, after ACTION_OPEN_DOCUMENT
and ActivityScenario.

The oracle is the document instead, which crosses the boundary because the app
holds a URI grant for it. Still the path rather than the artefact: the size query
proves the document existed and was empty moments earlier, and one that no longer
answers a query is one something deleted.

CLEANUP IS IN TEARDOWN, and the mutation run is why. A failed save keeps its
staged file deliberately, so this test ends with a finished job for the next
launch to reattach to; its sibling then opened on Converted with no "Choose file"
to tap. The first fix tapped Start over at the end of the test body, which does
not run when the test fails -- so the mutation run turned one real failure into
two, the second looking like an unrelated flake. One cause must produce one red
test.

Baseline 6 -> 7, with the derived counts in CLAUDE.md, the marker KDoc and
status_check.yml moved in the same diff. 71 - 7 is 64, the same gating figure for
the third consecutive time, which is how that paragraph goes stale unnoticed.

Verified: three API 34 runs at 71/71 failed=0, the mutation red on the right
assertion, and the full gate plus pinned actionlint green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 14:58:40 -05:00
Jason Ross fac8e67db2 Merge pull request #255 from JMR-dev/docs/e8-instrumented-coverage
Record the first instrumented coverage measurement, and classify the 32 it found
2026-09-06 14:54:10 -05:00
JMR-devandClaude Opus 5 4de169c99b Record the first instrumented coverage measurement, and classify the 32 it found
E8. The instrumented suite had never been measured: enableAndroidTestCoverage was
unset, so a connected run emitted no .ec at all, and jacocoTestReport reads only
testDebugUnitTest. Measured on API 34 by setting the flag temporarily.

  JVM     2236/2374 line 94.2%   1171/1338 branch 87.5%
  E2E     1711/2374 line 72.1%    669/1354 branch 49.4%
  UNION   2342/2374 line 98.7%   1212/1354 branch 89.5%

The JVM row reproduced the committed figure exactly, which is the control that
says both exec sets match the current class files.

The device suite closes 106 lines the JVM suite misses, and the first four are
the 81 wave 4 wrote off as native or device edges -- FFmpegEngine 32,
Media3Engine 24, ConcatEngine 15, MainActivity 10. The union leaves one. That
confirms the read's own hypothesis rather than overturning it; nobody had
measured past the boundary it named.

All 32 lines reached by neither suite were read, and none is an e2e test gap:
nine are compiler-generated, ten are getForegroundInfo() for expedited work this
app never enqueues (#252), three are F5, three are uncalled members (#253), one
is the Vorbis encode arm (#254), and four are F4-shaped error guards.

MediaProbe:210-212 gained a measurement rather than an assumption. F7 ruled
probeWithExtractor's catch unreachable because Robolectric's MediaExtractor never
throws; probeWithFFprobe calls native ffmpeg-kit, so that reasoning does not
transfer. But probe() calls both, and RemuxTest drives it with garbage bytes on a
device -- so the ffprobe path has had malformed input on real hardware and did
not throw. Same conclusion as F7, different mechanism, now on record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 14:22:24 -05:00
Jason Ross e27d7601b1 Merge pull request #251 from JMR-dev/docs/api37-carrier-count-drift
Re-derive the API 37 carrier counts, and correct what #226 left behind
2026-09-06 14:13:56 -05:00
JMR-devandClaude Opus 5 e81403c5f3 Measure the fourth claim rather than asserting it, and fix three slips
Review of the previous commit found three things of exactly the kind it corrects.

"Both of the advisory runs that exist" asserted exhaustiveness that had not been
checked -- two jobs were read, and the #248 branch had three status_check runs.
All four advisory runs at baseline 6 are now read: 34041156680, 34041593697,
34042397320 and 34043502322 each report expected: 6, received: 4, failed: 4,
and the only SAF test reporting in any of them is the rotation one. The claim
was right; the wording claimed more than the evidence.

CONVERSION_TIMEOUT_MS's KDoc said the bound is "two orders of magnitude" clear
of the real cost. 120 s against a measured 11.8 s is one.

status_check.yml dated the save test's marker to 2026-09-05. fa10d94 is dated
2026-09-06.

Also reworded that comment's summary: "two abort the framework and two never
report" double-counts the picker test, so a reader summing gets seven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 11:20:05 -05:00
JMR-devandClaude Opus 5 54167c052f Re-derive the API 37 carrier counts, and correct what #226 left behind
The 2026-09-06 re-check of the instrumented suite. Every drifted line it found
came from #226, the last PR of the e2e read's own wave.

The suite is 70 tests in 14 classes, 6 carrying @FailsOnEmulatorApi37, gating
leg 64. The committed baseline says 6 and the advisory job agrees. Four places
still said five carriers of 69:

  - CLAUDE.md, three sites
  - FailsOnEmulatorApi37.kt's KDoc
  - two comments in status_check.yml

The gating figure is what hid it. 69 - 5 and 70 - 6 are both 64, so the one
number a reader checks against a run had not moved -- which is exactly why
CLAUDE.md says to derive these rather than remember them.

Two KDoc claims in SafPickerRoundTripTest described a draft rather than the
code. The save test says MP3 was chosen so the setup could not depend on device
codecs; the code converts at the default MP4_H265/FAST, which routes on
canEncode(H265). The negation of the stated reason was true. That is E1 and E3's
failure mode committed by the wave that found it, so it is written down as such
rather than quietly corrected.

Neither picker test has ever reported on the advisory leg. The marker's KDoc
said the picker test fails there behind the rotation test; with six carriers the
rotation test truncates the run first, and both advisory runs since #226 --
34042397320 and 34043502322 -- report expected: 6, received: 4, the four being
the three Media3 tests plus the rotation. The save test is therefore marked by
inheritance, not measurement, and both KDocs now say so.

FixtureDocumentsProvider.deletedDocumentIds() has no callers: #226 proved D4's
premise and drove only the success path, so deletePartialOutput against a real
DocumentsProvider is still asserted nowhere. Filed as #250 with the forcing
condition and the mutation; the accessor is kept with a KDoc naming that ticket
rather than removed and re-added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 11:16:15 -05:00
Jason Ross 1f21557b8d Merge pull request #249 from JMR-dev/docs/e7-second-constraint
Record E7's second constraint, and that the premise held
2026-09-06 10:57:35 -05:00
JMR-devandClaude Opus 5 3b0c262030 Record E7's second constraint, and that the premise held (#226)
Doing #226 turned up a second obstacle underneath E7's, with the same
cause. The obvious way to avoid driving the app was a host Activity in
androidTest owning its own CreateDocument launcher; it cannot be started at
all, because instrumentation runs in the target app's process and the
component is in the instrumentation one. That is the same fact as E7's
second bullet arriving from the other side, and it leaves the app's own
Save button as the only launcher available to drive.

And the answer #226 was filed for: on API 34, stock DocumentsUI hands back
a document URI reporting a size of exactly zero, so destinationIsKnownEmpty
can return true and D4's fix is live rather than inert.

A "no defect found", and not one that could have been reached by reading --
which is the argument for having done it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 10:49:09 -05:00
Jason Ross 18aff51c98 Merge pull request #248 from JMR-dev/test/publish-to-a-real-saf-destination
Save to a document stock DocumentsUI created
2026-09-06 10:48:21 -05:00
JMR-devandClaude Opus 5 b23ff0f082 Wait for the app to come back before asking Compose about it
The save test failed an API 35 leg with "No compose hierarchies found in
the app". Dismissing the POST_NOTIFICATIONS dialog presses back and waits
for the permission UI to be gone, but going away and the app being in front
again are not the same moment, and the next Compose query landed in the gap.

Asked of UiAutomator rather than through awaitAppFocus, which is the
opposite of what this class argues for elsewhere and is right here:
awaitAppFocus goes through composeRule.waitUntil, so it would raise the very
error it is being used to avoid.

Two more local API 34 runs at 70/0/0/3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 10:28:02 -05:00
JMR-devandClaude Opus 5 fa10d94192 Save to a document stock DocumentsUI created (#226)
publish deletes a destination it could not write to -- D4's fix, so a
failed save does not leave a truncated file at the name the user chose --
but only when that destination was positively zero bytes first.
destinationIsKnownEmpty is careful that "I could not tell" never authorises
a delete, which makes the precondition load-bearing.

Until now that precondition was asserted only against a fake built to match
it: OutputPublisherPublishTest writes ByteArray(0) into FakeSafProvider
before each case, under a comment stating this is how CreateDocument
behaves. If it were false in production, D4's fix would be inert and every
existing test would still pass.

It is not false. Measured on an API 34 emulator against the real dialog:
the document SAF hands back is a document URI and reports a size of exactly
zero before anything writes to it. RecordingPublisher reads both at the
moment publish sees them, through the ConversionDependencies seam, then
lets the real copy proceed so the bytes are checked too.

This has to go through the picker, and through the app, and both are
platform constraints rather than choices. E7 in docs/e2e-read-findings.md
records the first: a DocumentsProvider is reachable only through a
picker-issued grant. The second was measured here -- a host Activity in
this source set owning its own CreateDocument launcher cannot be started at
all, because instrumentation runs in the target app's process and
ActivityScenario refuses with "Intent in process org.libremediaconverter
resolved to different process org.libremediaconverter.test". So #226 has no
cheap half, which is what its comment now says.

Three things the flow needed, each measured rather than guessed:

Both taps scroll first. On Ready the screen carries a file card, five
pickers and then the button, so Convert is below the fold; performClick on
an off-screen node dispatches where nothing is and throws nothing, while
assertIsEnabled passes either way. The first version sat waiting for a
Converted that could never come.

The format stays at its default. FixtureDocumentsProvider advertises
video/mp4 so the picker's MIME filter has a mutation with a shape, and
DocumentsUI honours that on the save side too: choosing MP3 makes the
destination audio/mpeg and the fixture root is filtered out of the save
dialog entirely.

The notification dialog is dismissed rather than pre-granted. Convert
converts from the permission callback whichever way the answer goes, so
denying is a real user's path and enough. Granting programmatically did not
take -- GrantPermissionsActivity appeared anyway and swallowed the tap.

The provider gains create, write and delete support, which it needs to be a
save target at all. It carries @FailsOnEmulatorApi37 because anything that
puts DocumentsUI on screen aborts system_server on that image, as #245
established for the other two; baseline 5 -> 6.

Verified on a local API 34 emulator: three full-suite runs at 70/0/0/3, and
a publish that writes no bytes fails it with "array lengths differed,
expected.length=58677 actual.length=0".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 10:12:37 -05:00
Jason Ross 69d5392227 Merge pull request #247 from JMR-dev/fix/api37-report-match-line
Stop the advisory match line claiming a failure count nobody measured
2026-09-06 09:11:24 -05:00
JMR-devandClaude Opus 5 e7c3e5688f Stop the match line claiming a failure count nobody measured
This PR's own advisory leg caught it. With five markers and a truncated run it
printed

  failed:            4
  ...
  baseline: matches (5 expected, 5 failed)

three lines apart. The match line has always printed the baseline twice, which
was true while `failed` had to equal it to get there -- and the previous commit
removed that requirement for truncated runs without noticing this line depended
on it.

So the truncated spelling says what happened: `matches (5 expected; 4 of 5
failed, on a run the abort truncated -- not compared)`. Pinned by a third case
beside the two from that commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 09:03:21 -05:00
Jason Ross b3d4318273 Merge pull request #245 from JMR-dev/fix/api37-task-snapshot-crash
Take the picker test off the API 37 gating leg, and stop the SystemUI disable pretending
2026-09-06 09:01:56 -05:00
JMR-devandClaude Opus 5 07f7ed4259 Merge #221, and make the two counts derived rather than remembered
#221 landed while this was in review, adding one instrumented test: 68 -> 69, and
64 on the gating leg. Its own commit was "Move CLAUDE.md's instrumented counts
with the test that changes them", and it still arrived stale -- it says three
markers and 61 tests, both true of the main it was branched from and neither true
of the main it merged into.

That is the third time these two numbers have gone stale in a day, so the
paragraph now says where they come from: a grep for @Test over app/src/androidTest
minus the marker count, cross-checkable against any run's shape, since a leg below
37 reports the first as `expected` and the API 37 gating leg reports the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 08:53:59 -05:00
Jason Ross dc6ee3dc9b Merge pull request #221 from JMR-dev/test/join-failure-message-on-device
Assert the join failure message against a real FFmpeg session
2026-09-06 08:50:59 -05:00
JMR-devandClaude Opus 5 7fd95ddede Merge main, and re-derive every count it moved
main landed 25 commits while this branch was open, including a third
@FailsOnEmulatorApi37 on Media3EngineTest.cancellingARunningExportStopsIt and a
batch of new instrumented tests. Every number this branch touches moved with them.

Re-derived rather than adjusted, and cross-checked against run 34020234606: the
API 34 leg (no filter) reports 68 tests and the API 37 gating leg 64, which is
68 minus main's four markers. With the picker test marked that is five markers,
baseline 5, and 63 on the gating leg.

The conflict in FailsOnEmulatorApi37.kt is resolved main's way: it had replaced
the hardcoded "grows by two" with a reference to the constant, which is the same
drift this file exists to prevent and a better fix than the number I put there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 08:49:59 -05:00
Jason Ross 350b179c9e Merge branch 'main' into test/join-failure-message-on-device 2026-09-06 08:43:20 -05:00
Jason Ross 0cc4c4f3a3 Merge pull request #244 from JMR-dev/docs/e2e-read-findings-e7
Record what working the e2e tickets found
2026-09-06 03:32:19 -05:00
JMR-devandClaude Opus 5 c5b2dc0f55 Record what working the e2e tickets found (E7, and #238)
Two results from #223-#230 that belong with the read rather than only in
their own tickets.

E7 re-scoped its own ticket. #226 split into a cheap headless half and an
expensive picker-driven one, on the premise that a real DocumentsProvider
can be reached without DocumentsUI. It cannot: an unprotected one is
refused at install, instrumentation runs in the app's uid so the test APK's
own identity is no help, and adopting shell identity is denied too -- each
denial naming ACTION_OPEN_DOCUMENT as the only way in. Measured three ways.
So #226 is one item at the picker's cost, not two.

The useful half of that distinction is that the input bridge needs no
documents provider at all. getSafParameterForRead opens a descriptor
through the resolver, so any readable content:// URI exercises it, which is
what kept #225 headless.

And that is how the read's one production defect surfaced. #238: joining
files picked through the system picker failed outright on the stream-copy
path, because the concat demuxer whitelists protocols separately from
-safe 0 and ffkitsaf was not on the list. Only STREAM_COPY feeds the
demuxer a list file, and every existing join test passed Uri.fromFile, so
the one broken combination was the only one a user could reach.

Worth stating plainly next to the coverage entry: it was not a missed line
and not an unasserted value, but two covered things no test put together --
the gap shape a coverage number is worst at, and the reason the read
happened.

E4 is marked fixed; #243 made that KDoc name the constant rather than
restate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 02:50:37 -05:00
Jason Ross 8db9a6f52f Merge pull request #243 from JMR-dev/test/cancelling-a-running-export
Cancel a running Media3 export, completing the third engine
2026-09-06 02:48:22 -05:00
JMR-devandClaude Opus 5 31f249ae04 Cancel a running Media3 export, completing #224's third engine
The two FFmpeg engines were done in ad2a75d and d293646. This is
Media3Engine.transcode's invokeOnCancellation, which posts
transformer.cancel() onto the engine's own HandlerThread because cancel()
has the same single-thread requirement as start().

The assertion is the output file here, where it could not be for FFmpeg.
That side deletes the partial on cancellation, and on POSIX ffmpeg keeps
writing to the unlinked inode, so the path stays gone whether or not the
cancel landed -- it asserts the session's return code instead. Media3Engine
deletes nothing, the partial being ConversionWorker's to clean up, so the
file is the evidence.

A cancelled export reports itself two ways and both mean interrupted: no
video track, or MediaExtractor refusing the file outright with "Failed to
instantiate extractor" because there is no moov atom. The first version
treated only the null as success and the exception failed the test, which
is how that was measured. Only a playable file counts as a miss.

The wait before reading is several times the export's own length, so a
cancel that did not land has certainly finished by then: the failure
direction is "the file became playable", never "we did not wait long
enough". The attempt is retried for the reason the other two engines
measured -- a 3 s 320x240 export outruns a naive cancel on a loaded runner
-- and an export that never wrote a file at all is recorded as
inconclusive rather than allowed to pass as a cancellation.

It carries @FailsOnEmulatorApi37, so FAILS_ON_EMULATOR_API37_BASELINE moves
3 -> 4 in this diff. That file also said removing the marker would grow the
gating leg "by two", which has been wrong since the third marker landed; it
now names the constant instead of restating it.

Verified on a local API 34 emulator: 68 tests, 0 failures, 3 skipped; and
with transformer.cancel() removed all five attempts produce a playable
video/hevc and the test fails, naming each one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 02:28:18 -05:00
Jason Ross d6e1e3bf86 Merge pull request #242 from JMR-dev/test/reattach-to-a-running-job
Reattach to a conversion that is still running
2026-09-06 02:17:06 -05:00
JMR-devandClaude Opus 5 6992f0e783 Reattach to a conversion that is still running (#230)
Reattachment.rank gives RUNNING the highest rank of all -- "live work
outranks a finished result because a running job is holding a foreground
service" -- and no test on either source set had ever produced one.
ReattachOnLaunchTest covers a job that finished, one whose staged file is
gone, an ambiguous pair, one still queued, and one the user cancelled.
ReattachmentTest exercises the ranking as a pure function over fabricated
snapshots. What was missing is a ViewModel meeting a real running job,
which is also the likeliest reattachment there is: the user starts a
conversion, leaves, and comes back while it is still going.

The engine is a fake, deliberately. The job has to still be running when
the ViewModel is built, and every real conversion in this suite finishes in
about a second -- racing that is what made the cancellation tests flaky
enough to need retries. A SoftwareTranscoder that blocks until released
removes the race outright. Nothing about reattachment depends on which
engine is transcoding: the tag query, Reattachment.choose over live
WorkManager state, and observe's mapping to Converting all run identically
whatever is doing the work.

This is what #230 can actually deliver, and the ticket asked for the answer
either way. Process death itself stays device-manual. D3/D13 already record
that am kill refuses a process holding a foreground service, and there is a
more basic obstacle underneath it: instrumentation runs in the app's own
process, so any route that really killed it would take the test runner with
it and leave nothing to assert with. Observing a relaunch needs two
instrumentation runs, which the runner does not provide. So the closest
observable analogue is a fresh ViewModel, with no memory of the work,
meeting a job that is genuinely mid-flight.

The teardown now resets ConversionDependencies. The suite runs without
Android Test Orchestrator, so a BlockingTranscoder left in place would hang
the next class that converts anything.

Verified on a local API 34 emulator: 67 tests, 0 failures, 3 skipped; and
making RUNNING unreattachable in Reattachment.rank fails this test and
nothing else -- which is also the evidence that the JVM ranking test was
not already covering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:58:24 -05:00
Jason Ross bb920b5bd0 Merge pull request #239 from JMR-dev/test/content-uri-reaches-ffmpeg
Let a join read the files the user actually picked
2026-09-06 01:51:10 -05:00
JMR-devandClaude Opus 5 802997439d Let a join read the files the user actually picked (#238, #225)
Joining files picked through the system picker failed outright whenever
the strategy was stream copy -- the matched-files case the UI advertises
as "joined without re-encoding, no quality loss".

  [ffkitsaf @ ...] Protocol 'ffkitsaf' not on whitelist 'file,crypto,data'!
  Error opening input file .../joined_from_content.concat_list.txt

JoinScreen picks with OpenMultipleDocuments, so real inputs are always
content://. ConcatEngine maps each through getSafParameterForRead and
FFmpegConcatCommand writes the resulting ffkitsaf: paths into the concat
list file. The demuxer applies its own protocol whitelist, defaulting to
file,crypto,data, and -safe 0 does not touch it: that permits absolute
paths, this permits the scheme they carry. Two separate gates, and only
one was open.

Nothing caught it because the two halves of the bug never met. Only
STREAM_COPY feeds the demuxer a list file -- REENCODE passes each input
with its own -i, where the whitelist does not apply -- so joining over SAF
worked for mismatched clips. And every join test passed Uri.fromFile,
which takes ConcatEngine's uri.path arm instead of the bridge, so
matchingClipsAreJoinedByStreamCopy exercised stream copy with a file:
path and passed. The one broken combination was the one no test produced
and the only one a user can reach.

That is #225's gap: FFmpegKitConfig.getSafParameterForRead is on every
real conversion and join, and was on no passing test -- only on
UnopenableUriTest's failure side, which proves the error message rather
than the bridge. ContentUriInputTest now drives both the convert and join
paths from a real content:// URI.

It uses a plain ContentProvider, because the documents provider cannot be
reached. Measured three ways: a DOCUMENTS_PROVIDER without MANAGE_DOCUMENTS
is refused at install, instrumentation runs in the target app's process so
Instrumentation.getContext() still carries the app's uid and is denied, and
adoptShellPermissionIdentity(MANAGE_DOCUMENTS) is denied identically -- the
denial naming ACTION_OPEN_DOCUMENT as the only way in. The bridge needs no
documents provider: it opens a descriptor through the resolver, so any
readable content:// URI exercises it, and an ordinary provider may be
exported unprotected. The whole class stays headless. Recorded on #226,
which that also settles: its cheap half does not exist.

Verified on a local API 34 emulator: 66 tests, 0 failures, 3 skipped; and
removing the -protocol_whitelist pair reproduces the production failure
verbatim in the join test and nothing else. FFmpegConcatCommandTest pins
the flag on the JVM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:42:03 -05:00
Jason Ross 495eaa4ab8 Merge pull request #241 from JMR-dev/fix/launcher-wiring-waits-for-the-pick
Wait for the pick the launcher test is about
2026-09-06 01:41:55 -05:00
JMR-devandClaude Opus 5 bc8e67888e Wait for the pick the launcher test is about (#220)
onInputPicked does not reach Ready on the calling thread. It hops twice --
withContext(pickDispatcher) { InputQuery.describe(...) } and then the probe
-- and pickDispatcher defaults to Dispatchers.IO, a real background thread
Compose's idling knows nothing about. So deliver() returned with the state
still Idle, and asserting immediately was a race the test usually won.

It lost five times on CI in one day, on PRs whose diffs were instrumented
tests and documentation and could not reach it. Two of those failures came
alongside #125's deadlock and could be argued as fallout; three did not.

waitUntil polls through waitForIdle, draining the main looper each time, so
it sees the recomposition the IO hop eventually posts back.

Injecting the dispatcher would be better and is not available here.
pickDispatcher is a constructor parameter precisely so a test can pin it,
but this test composes the real ConverterScreen, which resolves its own
ViewModel through viewModel() -- the seam is one layer below the launcher
edge this class exists to cover, and reaching for it would mean not testing
that edge.

The evidence is the mutation rather than the repetition count, per the note
#218 left: transposing the two launcher callbacks at ConverterScreen.kt:70
and :83 -- the exact defect this test guards -- still fails it, so the wait
did not make it vacuous. A transposed callback leaves the screen in Idle
forever and it fails on the timeout with the meaning it had before.
Supporting evidence, eight consecutive green runs of the class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:34:17 -05:00
Jason Ross 706eea8709 Merge pull request #240 from JMR-dev/fix/cancel-tests-need-a-slower-encode
Stop the cancel tests losing their race on a loaded runner
2026-09-06 01:20:57 -05:00
JMR-devandClaude Opus 5 163ce54b77 Stop the cancel tests losing their race on a loaded runner
Both cancellation tests I added in ad2a75d and d293646 wait for
SessionState.RUNNING and then cancel. That is not enough. The conversion
one passed four consecutive local runs and all five CI legs, then failed
the API 34 and 35 legs of the next PR with state=COMPLETED rc=0, on a diff
that could not reach it. On a loaded runner the thread that observed
RUNNING can be descheduled long enough for a short encode to finish before
it calls cancel. A longer timeout does not help: the wait already
succeeded.

Two changes, because neither is sufficient alone.

A slower encode. The conversion test now targets WEBM_VP9 at BEST, the
slowest thing FFmpegCommandBuilder emits -- libvpx-vp9 -crf 31 -b:v 0,
with -deadline realtime added only on FAST. Probed on an API 34 emulator:
that session is still RUNNING at 1 s and finished by 2 s, against well
under a second for x265 -preset medium.

A bounded retry. An attempt whose session finished before the cancel
landed has not tested anything, so it is a miss rather than a failure and
is retried; only exhausting five attempts fails, and the message reports
every attempt's state and return code so a real breakage is distinguishable
from a slow machine.

The retry does not soften the test. With FFmpegKit.cancel removed from
both engines, every attempt ends COMPLETED, so both still fail -- verified,
each listing five [state=COMPLETED rc=0] outcomes. Two clean runs
beforehand at 64/0/0/3.

The join test gets the same treatment. It has not flaked yet, but it is
the same mechanism and the same fragility, and finding out on CI again is
not worth the round trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:04:03 -05:00
Jason Ross d293646f69 Merge pull request #237 from JMR-dev/test/cancelling-a-running-join
Cancel a running join session too
2026-09-06 00:11:33 -05:00
JMR-devandClaude Opus 5 5416788274 Cancel a running join session too (#224)
The FFmpegEngine half landed in ad2a75d; this is the same gap in
ConcatEngine. Between them, a real native session being asked to stop is
now covered on both FFmpeg paths.

The assertion is the session's return code again, and here that is not
merely the better choice but close to the only one: ConcatEngine does not
delete its output on cancellation at all. Its invokeOnCancellation is
FFmpegKit.cancel and nothing else, where FFmpegEngine's also deletes the
partial. Whether that asymmetry is deliberate is a separate question, so
this asserts what is true of both engines rather than depending on it.

The cancel triggers on SessionState.RUNNING rather than on progress.
ConcatWorker publishes no progress at all, so there is no callback to hang
it on even in principle -- and the conversion side already measured the
deeper reason, that the committed clips outrun a callback-triggered cancel.

The inputs are the mismatched pair on purpose, so ConcatPlanner chooses
REENCODE. A stream copy of two 2 s clips is close to instantaneous and
would leave nothing to interrupt; re-encoding is also the case where a user
would actually reach for Cancel.

Verified on a local API 34 emulator: three runs green at 64/0/0/3, and
replacing invokeOnCancellation's body with an empty block fails this test
and nothing else, with state=COMPLETED rc=0.

Media3Engine's transformer.cancel() is still uncovered and #224 stays open
for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 00:02:52 -05:00
Jason Ross ad2a75d9a0 Merge pull request #236 from JMR-dev/test/cancelling-a-running-session
Cancel a running FFmpeg session, which nothing had ever done
2026-09-05 23:53:13 -05:00
Jason Ross 2b921fafe4 Merge pull request #235 from JMR-dev/test/notification-cancel-action
Press the Cancel button in the notification
2026-09-05 23:45:53 -05:00
JMR-devandClaude Opus 5 98c0e4dba2 Cancel a running FFmpeg session, which nothing had ever done (#224)
Every cancel in app/src/androidTest is WorkManager.cancelWorkById against
work that is queued or already finished: ReattachOnLaunchTest cancels a job
carrying a one-hour initial delay, and another immediately after enqueue.
On the JVM, WorkerCancellationTest and HardwareFallbackTest's cancellation
case drive a SoftwareTranscoder double that records the call. No test on
any source set had asked a real native session to stop. That is
docs/defect-audit.md D10's forcing condition.

It is the one path where cancelling wrong is silently expensive rather than
loudly broken: a missed FFmpegKit.cancel leaves the native process encoding
to completion while the UI says the job is cancelled.

Two things were measured rather than assumed, and both changed the test.

The output file cannot be the assertion. invokeOnCancellation deletes the
path, and on POSIX unlinking a file ffmpeg still holds open leaves ffmpeg
writing to the unlinked inode -- so the path stays gone whether or not the
cancel reached the session, and removing FFmpegKit.cancel passes that check
every time. The session's own verdict is what separates them: a cancelled
session ends with the cancel return code, a completed one does not.

Cancelling from the first progress callback loses the race. It was tried
first and failed with state=COMPLETED rc=0: every committed fixture is
2-3 s at 320x240, and the encode finishes before the first statistics
callback is delivered and acted on. FFmpegKit.listSessions shows the
session RUNNING far earlier, so that is what the test waits for.
QualityTier.BEST is deliberate for the same reason -- preset medium leaves
more of the encode ahead of the cancel.

Verified on a local API 34 emulator. Four consecutive runs green at
62/0/0/3, and removing FFmpegKit.cancel while keeping output.delete fails
this test and nothing else, with state=COMPLETED rc=1.

ConcatEngine and Media3Engine carry the same shape and are not covered
here; #224 stays open for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 23:45:34 -05:00
JMR-devandClaude Opus 5 557b3edab4 Compare the advisory failure count only on a run that finished
The verification dispatch of the reworked harness (34011072884) came back
4 expected / 3 received / 3 failed, where the one before it (34008889182) had
been 4/4/4 on the identical configuration. Nothing about the test list changed
between them: the abort landed one test earlier and the picker test never
started.

The baseline check would have called that "one now passes", which is the wrong
reading and the kind of notice #120 is about -- a deviation that is wrong often
enough to teach everyone to skim past deviation notices. So `failed` is compared
only when `completed cleanly` is yes, and `expected` is compared always, because
`Starting N tests` is printed before anything can abort and is what actually
answers "is the marked set the size the baseline says".

Two cases in e2e-report-shape-test.sh, as a pair: a truncated run short by one is
not a deviation, and a CLEAN run short by one still is -- so the first cannot have
bought its quiet by disabling the check.

This is a consequence of adding the fourth marker rather than a pre-existing bug
worth its own ticket: with three, the advisory leg had been completing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 23:26:56 -05:00
Jason Ross cf540f1ecc Merge pull request #234 from JMR-dev/test/ffmpeg-progress-is-observed
Read the progress percentage FFmpeg has always been computing
2026-09-05 23:21:54 -05:00
JMR-devandClaude Opus 5 e0412329ff Press the Cancel button in the notification (#227)
ConversionNotifications.build attaches one action, wired to
WorkManager.createCancelPendingIntent(id). Until now
createCancelPendingIntent had no references anywhere outside its own
declaration -- no JVM test, no instrumented test.

That is worth more than an ordinary uncovered line. A conversion runs in a
foreground service and the user is invited to leave the app; once they do,
this action is the only way to stop it. If the PendingIntent carries the
wrong id the button does nothing, the notification stays, and the job runs
to completion, with no error, no log and no screen to look at.

The obvious version of this test reads NotificationManager's active
notifications for id 1001 and taps what it finds. Rejected: the
instrumented suite grants no runtime permissions, so POST_NOTIFICATIONS is
denied throughout, and whether a suppressed foreground-service notification
is returned there is a platform detail that varies. The test would be
asserting something about notification visibility rather than about
cancellation. The PendingIntent is the subject and where it is read from is
incidental, so this builds the notification for a real live work id and
fires its action -- a real dispatch reaching real WorkManager, the same way
on every API level.

The job carries an initial delay so it stays ENQUEUED. A conversion of the
3 s fixture finishes in well under a second on an emulator, so racing a
cancel against a running job would be flaky in the direction that fails,
and cancelWorkById acts on ENQUEUED identically. What is under test is
whether firing the action reaches WorkManager with the right id.

Verified both ways on a local API 34 emulator: 61 tests, 0 failures, 3
skipped as written; and building the PendingIntent from a random UUID
instead of the request's id fails the new test and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 23:20:49 -05:00
JMR-devandClaude Opus 5 97558c259f The root fix was wrong, and this is what it found: the disable does nothing
Three commits back I gave `disable_region_sampling` the `adb root` it needed, on
the strength of `Must be root` appearing in every API 37 leg's log. That part was
right and the conclusion drawn from it was not. api37-debug run 34010167885, with
the restart finally real:

  pm attempt 1: Package com.android.systemui new state: disabled-user
  restarting the framework
  adbd is running as root
  system_server down after 2 s
  NOT DISABLED after the restart -- the package state did not survive

three rounds of it, `final state: SystemUI STILL ENABLED`, and the leg reported
`expected: 0, received: 0`. Making the restart work cost the leg every test it had.

Bisected locally on android-37.0: a `stop` 2 s after `pm disable-user` kills
system_server before PackageManager flushes its delayed write, and a 15 s pause
makes the state survive. That repairs the wrong thing. With the package verified
disabled before AND after a clean restart, `com.android.systemui` comes up 3 s
after `system_server` regardless -- and CI's own logcat says the same with no
restart at all: run 34006456986 verifies the package disabled at 02:29:33 and has
SystemUI pid 4275 alive from 02:28:52 for the whole run.

So `pm disable-user` does not stop SystemUI starting on this image, with or without
a restart, and the restart is removed from all three copies rather than repaired.
What is kept is the 45-second window with no new aborts, which is what was always
doing the work: the boot aborts land at 02:28:18 and 02:28:43 and the wait is what
puts instrumentation at 02:32:42, after them rather than inside one. `pm
disable-user` is kept too, because every green leg and every number quoted about
this row was measured with it applied.

The prose the earlier commits got wrong is corrected in place, and one of the
corrections is good news: status_check.yml's caveat that this row runs a
configuration no other leg or Pixel run uses, so nothing depending on system UI
may trust it, describes a state that has never existed. The row is more comparable
to API 33-36 than it has been claiming, not less.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 23:15:57 -05:00
JMR-devandClaude Opus 5 948d53b67e Read the progress percentage FFmpeg has always been computing (#229)
FFmpegEngine derives progress as stats.time / durationMs * 100, and the
statistics callback runs on every conversion in FFmpegEngineTest -- they
all pass durationMs = 3_000. But every call site omits onProgress, so
nothing on any source set had ever looked at the number. Replacing percent
with a constant reddened nothing.

What already existed covers the plumbing downstream and not this: #196
covered the worker's progress lambda with a fake engine that reports
whatever the test tells it to, and ProgressNotificationTest covers the
throttling the same way. The arithmetic was the one part with no reader.

The new test passes 30 s as the duration for a fixture that is exactly
3.000 s, so the conversion still encodes the whole clip and the reported
percentage tops out around 10 rather than 100.

That is what makes it bite. A range check alone is worthless: a constant 0
satisfies both "every value is in 0..100" and "the values never go
backwards", and so does a list of [0, 100]. Pinning the band rejects every
constant, and because the band sits a tenth of the way up it also rejects
an implementation that ignores durationMs, which would report ~100 for the
same run. The bound is loose -- 5..25 for an expected 10 -- because the
last statistics callback can land slightly before the final frame.

Verified on a local API 34 emulator: 61 tests, 0 failures, 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 23:13:36 -05:00
Jason Ross 54932e97c6 Merge pull request #232 from JMR-dev/test/fallback-asserts-the-path
Make the fallback test say when it cannot test the fallback
2026-09-05 23:06:41 -05:00
JMR-devandClaude Opus 5 745c4f62ce The local runner had the same missing root, and hid it in /dev/null
Third copy of the same defect. tools/local-emulator/run-e2e.sh sends `adb shell
stop` and `start` to /dev/null, so its `Must be root` was never printed and the
framework restart it credits has never happened either.

That matters for what docs/api-37-emulator-crash.md's abort numbers are evidence
of, so the caveat goes next to them rather than in a commit message: on API 37
the image restarts its own framework every minute or so, and a restart landing
after a successful `pm disable-user` brings back a SystemUI-less zygote on its
own. That produces the recorded rate collapse by accident, and it is why the same
code bought nothing on CI's much quieter swiftshader legs, where the logcat shows
SystemUI alive for the whole run.

Also verified, because the previous commit asserted it: the advisory leg really
does run thePickedInputSurvivesARealRotation before the picker test -- run
34008889182 logs the four in the order Media3, Media3, rotation, picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 22:58:10 -05:00
JMR-devandClaude Opus 5 ba16f5a89b Make the fallback test say when it cannot test the fallback (#223)
HardwareFallbackTest is the only automated check of the hardware->software
fallback against a real codec failure, and it passed on every CI leg
without ever attempting the hardware path.

Measured on run 34004304566: the API 33, 34, 35 and 37 legs each log

  Routing sample_h264_444.mp4 -> ... via FFMPEG (NO_HARDWARE_ENCODER)

Emulators expose no hardware encoder, so the router never chooses Media3
and runMedia3OrFallBack's catch is never entered. The test's assertions --
succeeded, output non-empty -- are true of that conversion too. It
finished in 448 ms, which is not long enough to fail an export and then
software-encode a three-second clip. Deleting the catch reddened nothing.

The ticket offered two fixes and left the choice open. Trying the first
one answered it, and not the way the ticket expected. Pinning
deviceCodecs to PERMISSIVE, as ForcedFailureTest does, makes the router
choose Media3 -- and the export then SUCCEEDS. On a local API 34
emulator, MediaCodecInfo logs

  NoSupport [codec.profileLevel, avc1.F4000C, video/avc]

for both c2.goldfish.h264.decoder and c2.android.avc.decoder, and
ExoPlayer allocates the goldfish decoder anyway, which decodes the High
4:4:4 fixture regardless of the profile it declares. c2.android.hevc.encoder
then encodes it and the job reports MEDIA3.

So the class KDoc's "Media3 fails partway through the export on every
device" is not true of the emulator images, and no routing pressure makes
this fixture force a fallback there. Pinning would also swap in software
codecs, which is not the path a real device takes -- it is what made the
forced run succeed.

That leaves assumeTrue on the production premise as the honest answer, now
with a measurement behind it rather than a coin flip. The test skips where
it cannot mean anything and runs on the Pixel, where it always could.

When it does run the assertion is a pair, because KEY_ENGINE_USED is
FFMPEG whether the fallback fired or the router went straight there:
the router chose MEDIA3 for this request on this device, AND the worker
reported FFMPEG. Together, and only together, that is the fallback.

Verified on a local API 34 emulator: the test reports SKIPPED and the
level reports skipped=3. ForcedFailureTest still covers the fallback
wiring on every leg with a double; what needs a real encoder is two real
engines disagreeing about a real file.

The third permanent skip is recorded in docs/local-emulator.md and beside
SafPickerRoundTripTest's run-shape note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 22:56:18 -05:00
JMR-devandClaude Opus 5 e2f8ef2918 Cite the two measurements the last two commits asserted
The advisory-leg count (4 expected, 4 received, 4 failed with the new marker) is
api37-debug run 34008889182, dispatched with the annotation as its filter. The
force-stop recovery was made to go red before it was believed: on a local API 36
emulator, with the picker left open and the back presses removed, the test passes
with forceStopThePicker() and fails with exactly the API 37 message without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 22:55:42 -05:00
JMR-devandClaude Opus 5 393b931fff Give api37-debug's own SystemUI disable the same root, and say why there are two
The debug workflow's header says it does not fork e2e-run.sh, and it does not --
but it drives the SystemUI disable from its own probe step, so `disable_system_ui`
can be turned off for a dispatch. That is a second copy of the same logic, and run
34008889182 showed it carrying the same defect the real leg had: `Must be root`
twice, and `system_server down after 40 s` printed for a stop that did nothing.

Same fix, and a header note so the next person changing one knows to change both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 22:54:27 -05:00
Jason Ross bffcff92c7 Merge pull request #233 from JMR-dev/test/flac-and-opus-assert-their-format
Check that FLAC and Opus are FLAC and Opus
2026-09-05 22:45:47 -05:00
JMR-devandClaude Opus 5 9f06eb9988 Check that FLAC and Opus are FLAC and Opus (#228)
encodesFlacLosslessAudio and encodesOpus asserted only that a non-empty
file appeared. Five siblings in the same class check what is in it --
encodesWav reads RIFF two lines away, and GIF, Matroska, MP3, H.264 and
H.265 all assert a container marker or a track MIME.

convert() throws on a non-zero return code, so these two did prove the
command ran. What they could not distinguish is the command running and
producing the wrong thing, which is a failure mode this codebase has
already had once: F1 in docs/coverage-read-findings.md records a live
Vorbis arm in FFmpegCommandBuilder that ContainerCapabilities says cannot
exist. Pointing OutputFormat.FLAC's arm at pcm_s16le left both tests green.

Four bytes each, in the idiom the class already uses. OutputFormat.FLAC is
Container.FLAC, whose ffmpeg format is "flac", so the file opens with the
native stream marker fLaC. OutputFormat.OPUS is Container.OGG -- "ogg" --
so it is an Ogg stream and opens with OggS. Verified against both muxers
directly rather than assumed from the codec name; the marker belongs to the
container, so it does not vary with the ffmpeg build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 22:36:30 -05:00
JMR-devandClaude Opus 5 d759ef32f1 Take the picker test off the API 37 gating leg, and give the SystemUI disable root
The gating `E2E API 37` leg failed three of the last ten status_check runs. Four
runs were read logcat-first -- 34006456986, 34001744574, 34001377499 and the
green 34002313300 -- and each carries exactly two `hasReadColorBufferDma` aborts
before the suite (surfaceflinger, during boot and the SystemUI disable) and
exactly one during it: `system_server`, thread `TaskSnapshotPer`, always inside
`pickingAFileThroughTheSystemPickerFillsInTheFileCard`'s window. Nothing else in
the gating set reaches the mapper.

So that test kills the framework on this image whether it passes or not, and
whether the leg goes red is luck: 34001377499 passed it and lost the leg anyway
with `failed: 0`, 34002313300 passed it 0.6 s after the abort and went green.
That is #108. The test now carries `@FailsOnEmulatorApi37` and the baseline goes
3 -> 4; the marker's own wording widens from "does not pass on this image" to
"cannot be run on this image", because this carrier passes about half the time.

`docs/api-37-emulator-crash.md` had counted those aborts on 2026-08-24, put them
in its table, and then read the pass/fail column alone. The correction is
recorded beside the original rather than replacing it. Probed on the same image
and recorded there too: there is no shell knob for task snapshots -- not in
`getprop`, `settings`, `device_config` or `cmd window` -- so the marker is the
available answer rather than the lazy one.

Two separate defects came out of the same logcats.

`disable_region_sampling` has never restarted the framework on CI. `adb shell
stop` and `start` are root-only and every API 37 leg has printed `Must be root`
for both, so SystemUI stayed up for the whole run -- visible directly as
`WindowManagerShell ... app=com.android.systemui` minutes after "final state:
SystemUI disabled". Both waits also printed their own exhaustion as an elapsed
time, so "system_server down after ~40 s" is what a stop that did nothing looks
like. Measured on the local android-37.0 AVD, same fingerprint as CI: `adb root`
makes `stop` return 0 with `pidof system_server` empty. Root is dropped again
before Gradle runs, and both waits now say whether they observed anything.

And when the picker test does fail, the abort is the coda rather than the cause:
`InputDispatcher: No new touched window at (539.0, 525.0)` is in both reds and
absent from the green, so the tap on the root is discarded, the picker is never
navigated, and all four back presses land on an activity WindowManager says has
not added a window yet. `forceStopThePicker` goes around input entirely so
`pickTheFixture`'s whole-picker retry -- which exists for exactly this -- becomes
reachable. That one is a fix on every API level, not just 37.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 22:23:33 -05:00
Jason Ross 06ca167034 Merge pull request #231 from JMR-dev/docs/e2e-read-findings
Read the instrumented suite, and find the test that proves nothing
2026-09-05 22:13:07 -05:00
JMR-devandClaude Opus 5 c757565d64 Read the instrumented suite, and find the test that proves nothing
Four coverage waves have been steered by JaCoCo, which measures
testDebugUnitTest only and cannot see app/src/androidTest at all. So
nothing had ever asked what the 60 device tests pin, only that they were
green. This is that read: a triage, not a test push, in the shape of
docs/coverage-read-findings.md.

Six findings (E1-E6) are things a test would not fix, and five of those
six are prose rather than code — the suite itself is in good condition.
Eight tickets carry the rest (#223-#230), each naming the mutation that
has to go red rather than a coverage delta.

The one that matters is #223. HardwareFallbackTest is the only automated
check of the hardware->software fallback against a real codec failure,
and it has never attempted the hardware path. Measured on run
34004304566: the API 33, 34, 35 and 37 legs each log

  Routing sample_h264_444.mp4 -> ... via FFMPEG (NO_HARDWARE_ENCODER)

because emulators expose no hardware encoder, so the router sends the job
straight to FFmpeg and runMedia3OrFallBack's catch is never entered. Its
two assertions — succeeded, output non-empty — are true anyway. It
finishes in 448 ms, which is not long enough to fail a hardware export
and then software-encode a three-second clip. Deleting that catch reddens
nothing anywhere.

Two things generalise. A test can assert and still not reach, which
neither a coverage number nor a "does it assert something" review can
see; the filter that works is whether the test's premise holds on the
machine running it. And the codebase already knew — ForcedFailureTest
pins DeviceCodecs.PERMISSIVE against this exact hazard and says why, as
does ConversionWorkerTest. Their assertions are about the path, so
without the pin they fail loudly; HardwareFallbackTest's are about the
output, so it passes quietly. That asymmetry is why nobody noticed.

E5 records the structural reason this document is separate: F7 in the
coverage findings calls probeWithExtractor's catch uncovered when
RemuxTest drives it on a device every leg. A JaCoCo-derived document
cannot see androidTest, so it will keep re-deriving that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 21:54:56 -05:00
JMR-devandClaude Opus 5 4d090d9a81 Move CLAUDE.md's instrumented counts with the test that changes them
The suite goes 60 -> 61 and the gating leg 57 -> 58, because the new join-failure
test carries no @FailsOnEmulatorApi37 and so runs on every level including 37.

FAILS_ON_EMULATOR_API37_BASELINE stays at 3, checked rather than assumed:
e2e-report-shape.sh counts lines matching '^[[:space:]]*@FailsOnEmulatorApi37',
which is three annotations -- the fourth occurrence in the tree is the KDoc at
Media3EngineTest.kt:195 saying a test is deliberately NOT marked, and the anchor
excludes it. Nothing here adds a marker.

Carried by this PR rather than the docs one so the file never states a count main
does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 21:25:44 -05:00
JMR-devandClaude Opus 5 39327beea7 Assert the join failure message against a real FFmpeg session
#217 closed by noting the join legs had not been run locally. Running them would
not have answered it: nothing on either source set drove a real join *failure*,
so the message unified in #203 was asserted only against values a JVM test hands
to sessionOutcome directly. CI had already run those legs green on the merged
commit, so the outstanding item was a gap in coverage rather than a gap in
execution.

The new case forces a failure with an input that does not exist -- rejected the
same way by every FFmpeg build, unlike malformed media -- and asserts the message
names the operation, carries the return code, and has detail after it.

That last clause is the device-only half. getReturnCode, getFailStackTrace and
getAllLogsAsString are native reads; if the log tail came back empty on a device
the user would see "Joining failed (1): " with nothing after the colon, and every
JVM test would still pass.

It deliberately does not pin which detail source wins. On an ordinary non-zero
return code FFmpegKit reports no fail stack trace, so stack-trace-first and
log-tail-first produce identical text and no assertion here could tell them
apart. SessionOutcomeTest pins that, where both sources can be non-blank at once.
Claiming it here would be a KDoc asserting more than the test checks.

Measured on the local API 33 emulator: 61/61 green with the test, and with both
detail sources nulled in ConcatEngine it fails on the intended assertion --
"the message stopped at the return code and told the user nothing, was:
'Joining failed (1): '" -- so the prefix and return code survive the mutation and
only the device-only claim goes red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 21:25:05 -05:00
Jason Ross 4b02294cfb Merge pull request #222 from JMR-dev/docs/wave4-coverage-numbers
Record wave 4's landed coverage, and that the hang watchdog has fired
2026-09-05 21:24:43 -05:00
JMR-devandClaude Opus 5 79097a0256 Record wave 4's landed coverage, and that the hang watchdog has fired
Numbers re-measured on 6004398 rather than inherited: 94.2% line (2234/2372),
87.5% branch (1171/1338), 628 tests in 96 classes, missed 138 lines and 167
branches.

The entry explains its own branch move, because this file has a history of not
doing that. Wave 4's rise is almost entirely numerator -- up 80, with the
denominator moving -4 -- unlike the 2026-08-29 seam work, where the numerator
rose 37 while the denominator fell 70 and much of the gain was scaffolding
leaving the measurement. The line denominator grew 20, which is the seams the
wave cut rather than untested code.

Also records a methodological result worth more than the percentages: #218 fixed
an intermittent flake whose six-runs-per-arm comparison caught nothing either way
and proved nothing, and was settled by a deterministic mutation instead -- then
confirmed when the race reproduced on #217's leg, below the fix.

The JUnit-timeout trap gains the outcome it was waiting for: the jstack watchdog
caught #125's Room/WorkManager deadlock on CI in 10m57s with the hung test named,
against that ticket's prediction of a 60-minute cap and no cause. #125 is closed
as bounded; the inversion is internal to the two libraries and still live at
work-runtime 2.11.2 / room 2.7.0.

Instrumented counts are untouched: #221 is what changes them, so it carries them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 21:24:33 -05:00
Jason Ross 6004398a83 Merge pull request #219 from JMR-dev/fix/rotation-waits-for-recreation
Wait for the rotation to rebuild the Activity, not for the composition to idle
2026-09-05 20:43:47 -05:00
Jason Ross a354620bf5 Merge pull request #218 from JMR-dev/fix/injectable-startup-sweep-scope
Finish the startup sweep before onCreate returns, in the JVM suite
2026-09-05 20:43:24 -05:00
Jason Ross 17c91081cd Merge branch 'main' into fix/injectable-startup-sweep-scope 2026-09-05 20:35:39 -05:00
Jason Ross 20f718842d Merge pull request #217 from JMR-dev/test/session-outcome-seam
Give both engines one rc-to-outcome function, and unify the failure message
2026-09-05 20:29:45 -05:00
Jason Ross dec7089b59 Merge branch 'main' into test/session-outcome-seam 2026-09-05 20:21:57 -05:00
Jason Ross b677a9ad02 Merge pull request #216 from JMR-dev/fix/convert-guards-on-ready
Stop a permission answer starting a second conversion
2026-09-05 20:19:19 -05:00
Jason Ross 34e4ab52a4 Merge pull request #215 from JMR-dev/test/retry-save-mime
Open the save dialog with the type the job produced
2026-09-05 20:18:57 -05:00
Jason Ross 1437157a8f Merge pull request #214 from JMR-dev/test/launcher-callback-identity
Pin the launcher layer, where two callbacks share a signature
2026-09-05 20:18:36 -05:00
Jason Ross 1041faf920 Merge branch 'main' into test/launcher-callback-identity 2026-09-05 20:11:15 -05:00
Jason Ross fe68f839c1 Merge pull request #213 from JMR-dev/test/theme-follows-system-dark
Call the theme the way MainActivity calls it
2026-09-05 20:10:01 -05:00
Jason Ross f65578b1f7 Merge branch 'main' into test/theme-follows-system-dark 2026-09-05 20:01:47 -05:00
Jason Ross b38ad6a683 Merge pull request #212 from JMR-dev/test/hardware-progress-reaches-workmanager
Report hardware progress to WorkManager, which nothing had checked
2026-09-05 20:00:46 -05:00
Jason Ross 9a0f494e26 Merge pull request #211 from JMR-dev/test/ffprobe-mapping-seam
Read FFprobe's answer without spawning FFprobe
2026-09-05 20:00:22 -05:00
Jason Ross f3478706b3 Merge pull request #210 from JMR-dev/test/device-codec-enumeration-seam
Cut a seam through the codec enumeration, and say what a failed one actually does
2026-09-05 19:59:58 -05:00
Jason Ross 61c400d2c6 Merge pull request #209 from JMR-dev/test/unknown-container-row
Render the container row for a video nothing could name
2026-09-05 19:59:36 -05:00
Jason Ross d83775d5c6 Merge pull request #208 from JMR-dev/test/audio-drop-arm
Build a command for the audio the user turned off
2026-09-05 19:59:14 -05:00
Jason Ross e90f5a801c Merge branch 'main' into test/audio-drop-arm 2026-09-05 19:49:49 -05:00
Jason Ross 68015b3374 Merge pull request #207 from JMR-dev/test/null-message-fallbacks
Make a failure that says nothing still say something
2026-09-05 19:48:11 -05:00
JMR-devandClaude Opus 5 32ab54da3c Wait for the rotation to rebuild the Activity, not for the composition to idle
thePickedInputSurvivesARealRotation synchronised a rotation with waitForIdle(),
which waits for the compose hierarchy to settle. Right after a rotation the
window manager has accepted but not yet delivered as a configuration change, the
old Activity's composition is already idle -- so it returns, composeRule.activity
still resolves to the old instance, and the guard reads an unchanged identity
hash. Nothing waited for MainActivity to be rebuilt.

That is the clean AssertionError on #217's API 33 gating leg, run 33698846104:
it failed the SECOND guard, so the first had passed and the display really had
rotated. The wedges this ticket opened with are the same race taken the other
way -- land while the composition is being torn down and waitForIdle has nothing
coherent to settle on.

awaitRecreation() waits on a counter fed by the runner's lifecycle monitor,
bounded at 15 s. Deliberately not polling composeRule.activity: that resolves
through scenario.onActivity, which blocks on the main thread, so polling it
across a recreation is a plausible reading of the very wedge being fixed.

Both guards stay. The identity-hash one is now a backstop rather than the
primary detector -- a configChanges attribute trips the barrier's timeout first,
with a message saying what was waited for.

Measured on the local API 33 emulator. With the fix, 60/60 green. With the
watcher mutated so the counter never increments, the test fails in 15 s naming
itself and its condition, and the run still reports received 60/60 completed
cleanly -- where the same missing recreation used to cost the leg 20 minutes and
name nothing. The pre-fix flake does not reproduce on this host, so that is a
demonstration of the timeout path, not a before/after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:42 -05:00
JMR-devandClaude Opus 5 e7caeeac43 Finish the startup sweep before onCreate returns, in the JVM suite
Robolectric builds an Application per test class that asks for one, and each
onCreate launched a staging sweep on Dispatchers.IO over the shared
<cacheDir>/conversions/. Nothing joined them, so a test asserting about a staged
file was racing every sweep the classes before it had left in flight (#159).
It was CI-only until wave 4 added ten Robolectric classes, at which point
OutputPublisherStagingTest started failing locally too.

LibreMediaConverterApp gains a protected open sweepScope and publishes the Job
onCreate started; the JVM suite substitutes TestLibreMediaConverterApp, whose
scope is Dispatchers.Unconfined so the sweep -- a plain function that never
suspends -- runs to completion inline. The SupervisorJob is kept so this differs
from production in the dispatcher alone.

Suite-wide rather than per-test: 27 of the 58 Robolectric classes touch that
directory, so opt-in was not a real option.

It costs one assertion, knowingly. AppStartSweepTest opened by asserting that
the manifest's android:name is what Robolectric instantiated. An application=
override replaces the manifest rather than being checked against it, and
applicationInfo.className reports the override too, so that claim is now
unobservable from this source set and a rewritten version would assert the
override against itself. The manifest link is device-only; the cast in setUp
still catches the test app ceasing to extend the real one.

AppStartSweepTest also joins the published Job instead of polling for ten
seconds -- a poll cannot tell "swept" from "not started yet" -- and gains a test
pinning that the sweep is complete when onCreate returns, which is the property
the substitution exists for and the only place it is checked.

Verified by mutation rather than by repetition. Putting the test app back on
Dispatchers.IO reddens that test 5 times out of 5, while running the whole suite
six times per arm caught nothing either way: at the rate #159 was observed at, a
clean six-run arm is roughly a coin flip, so the comparison was underpowered and
is not offered as evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:40 -05:00
JMR-devandClaude Opus 5 ec2cae256f Give both engines one rc-to-outcome function, and unify the failure message (#203)
FFmpegEngine and ConcatEngine each carried their own copy of the same `when`, and the copies
had drifted: one preferred the fail stack trace and fell back to the log tail, the other only
ever read the log tail. Neither was tested -- both live inside a callback handed to FFmpegKit,
which does not run on the JVM -- so nothing could see that the two disagreed about what a
failed session says.

sessionOutcome() now holds the rule and each engine maps Success/Cancelled/Failed onto its
continuation. Verified JVM-safe rather than assumed: javap over the committed AAR shows
ReturnCode(int) as a plain public constructor with pure static isSuccess/isCancel and a
<clinit> that loads no native library.

Per #203's decision this unifies on the stack trace, so a join failure now carries the
diagnostics a conversion failure always did. The PREFIX stays per-engine: unifying the
strategy must not unify the sentence, since a join reporting "FFmpeg failed" would be a worse
message than the one it replaces. There is a test for exactly that.

The two message sources are lambdas rather than values, and that is load-bearing.
getFailStackTrace and getAllLogsAsString are calls onto a native session, and only the
failure arm needs either; taking them by value would put both on the happy path of every
successful conversion, which the shape this replaces did not -- it read them inside the else
branch. Same reasoning as capabilitiesFrom taking a Sequence in #194: a seam should not change
what runs when. There is a test that counts the reads, and the eager mutation reddens it.

A null return code is a real input rather than a defensive one -- getReturnCode() is nullable
and a session killed before reporting has none -- so it fails, with "null" where the number
would be.

Nothing asserted the old join text: `grep -rn 'Joining failed|FFmpeg failed' app/src/` returns
only main, plus ConcatWorker.GENERIC_FAILURE_MESSAGE, which is a different constant this does
not touch. Re-run immediately before committing, as the ticket asked.

Mutations, all run and restored:

  swap the ifBlank operands            1 red
  treat cancellation as a failure      2 red
  read both message sources eagerly    1 red
  hardcode the prefix                  1 red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:39 -05:00
JMR-devandClaude Opus 5 4e88de3045 Stop a permission answer starting a second conversion (#202)
currentInput() answered for Converting, Waiting and Converted as well as Ready. Those three
arms were unreachable by tapping Convert -- the button renders only in the Ready branch --
but they were reachable through the POST_NOTIFICATIONS *result*, which ConverterScreen.kt:91
wires to convert() rather than to the button.

Reaching one enqueued a SECOND job over a live one: activeWorkId was overwritten, and the
first job kept running with its foreground notification orphaned and nothing left holding
its id to cancel it.

#202 decided to narrow rather than to test it as it stood, because a test written against
the old shape would have frozen the double-enqueue as intended behaviour -- the F1/F5 failure
mode docs/coverage-read-findings.md names. currentInput() is now
(_state.value as? ConversionState.Ready)?.input, which is what JoinViewModel.join() has been
all along; the two screens are the same shape and only one of them was over-general.

Four cold refusal arms come with it, all reached the same way -- a system callback arriving
after the screen has moved on, which is what a result redelivered after process death does:

  ConversionViewModel.kt:513   currentInput() ?: return
  ConversionViewModel.kt:600   pendingSave() ?: return
  JoinViewModel.kt:316         (as? Ready)?.inputs ?: return
  JoinViewModel.kt:390         pendingSave() ?: return

One fixture note worth keeping: the second case needs a real staged file in the worker's
output Data. A SUCCEEDED job with no output path maps to Failed rather than Converted, so
Data.EMPTY never reaches the state the case is about -- which cost a timed-out awaitState
before it was spotted.

Mutations, all run and restored:

  restore the over-general four-arm when   1 red  <- the defect this change fixes
  currentInput()!! at :513                 2 red
  pendingSave()!! in save()                1 red
  drop both join guards                    1 red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:38 -05:00
JMR-devandClaude Opus 5 2db0dc65d3 Open the save dialog with the type the job produced (#201)
ConverterScreen.kt:80 -- state.pendingSave()?.mimeType ?: settings.spec.mimeType -- had
never taken its left-hand side. Its comment records what the line is for: a retry after a
failed save must open with the type its FIRST attempt used, because the fallback beside it
is the current picker, which a reattached job never set. So the untested half is the fix and
the tested half is the fallback it was added to stop being used.

This withdraws a named exemption rather than working around it. FailedSaveRetryTest's KDoc
listed the line as not asserted because "it lives in the entry point, above the ScreenContent
seam, and reaching it needs a real ViewModel inside a composition". True when written;
AdaptiveShellTest (#173) then established composing the real screens with real ViewModels,
and #200 added the ShadowActivity mechanics for reading what a launcher launched. The reason
the exemption gave no longer holds, so it is withdrawn in the same change rather than left
to be taken at face value -- the shape of #141 revising #84's boundary.

The job is reattached rather than run because the screen composes its own ViewModel through
viewModel() and nothing can be injected into it. That is also the case the line exists for:
a reattached job's spec was never in these settings at all.

The test asserts the two mime types differ as well as which one is used. Without that, the
assertion would pass just as well against the fallback if the fixture ever drifted onto MP4.

Mutation: collapse :80 to settings.spec.mimeType -- red. Run and restored.

Unrelated, and recorded because it turned up here: #159 now reproduces on this host. The
full suite failed once in six runs on OutputPublisherStagingTest:184, and the isolating
experiment says it is not this change -- three runs WITH the new test all passed, and the
failure occurred on a run with the file removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:37 -05:00
JMR-devandClaude Opus 5 6334dcba34 Pin the launcher layer, where two callbacks share a signature (#200)
ConversionViewModel.onInputPicked(uri: Uri) and .save(destination: Uri) are both
(Uri) -> Unit, so swapping the two launcher callbacks at ConverterScreen.kt:70 and :83
compiles, renders, and passed the entire suite. Picking a file would attempt a save to it;
choosing a destination would load it as input.

That is the defect class ScreenWiringTest exists for, on the one pair it declines to cover:
it drives converterActions directly and says the launcher-backed actions stay parameters.
Right about the actions seam, and it leaves the edge above that seam unpinned. Join's
equivalents are List<Uri> and Uri, so they are not transposable and get no such test.

Two mechanics, neither used anywhere else in the suite, so both were spiked before any
assertion was written:

  shadowOf(activity).nextStartedActivityForResult   reads the launched Intent, EXTRA_MIME_TYPES intact
  shadowOf(activity).receiveResult(...)             reaches ComponentActivity's ActivityResultRegistry
                                                    and fires the rememberLauncherForActivityResult callback

createAndroidComposeRule for AdaptiveShellTest's reason: the screens compose real ViewModels
through viewModel(), and the plain rule supplies no ViewModelStoreOwner.

The picker filter rides along, since the harness is the same. ConverterScreen.kt:65-67
records why the all-types wildcard is load-bearing -- the photo picker offers no audio and
misses mkv/flac/webm -- and narrowing it would have made every audio conversion unreachable
from the picker with nothing going red.

One incidental: a KDoc cannot contain the all-types wildcard, because its second half closes
the block comment. The literal is spelled only in the assertion, and the KDoc says why.

Mutations, all run and restored:

  transpose onInputPicked and save          1 red
  narrow the converter picker to video only 1 red
  widen the join picker to every type       1 red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:36 -05:00
JMR-devandClaude Opus 5 7e09f010c7 Call the theme the way MainActivity calls it (#197)
ThemeColorSchemeTest resolves every branch of the `when` and always passes darkTheme
explicitly, so the $default bridge is never entered and isSystemInDarkTheme() is never
called. MainActivity.kt:79 is its only default-argument caller and does not execute on the
JVM, which left the app's actual call shape -- no arguments at all -- the one nothing
exercised. LibreMediaConverterTheme reported mi=21, mb=6, cb=12 at method level.

Not #68. That issue is the two unreachable arms, DarkColorScheme and LightColorScheme,
which cannot run because dynamicColor is always true and nothing can flip it; it is an open
product decision and stays open. This is the reachable half.

The assertion compares schemes rather than reading a luminance threshold, which would be a
guess about the device palette. What is asserted is that the no-argument call resolves the
SAME scheme an explicit darkTheme of the matching value does, and a different one from its
opposite -- true whatever palette the platform hands back, and exactly the claim being made:
the default reads the system rather than picking a side. The two assertions are also what
stops the pair passing vacuously if all three resolutions were identical.

Two @Config(qualifiers = ...) cases rather than two classes: qualifiers are settable per
method, unlike the sdk pinning ForegroundTypeRegimeTest needed nested classes for.

Mutations, all run and restored, and each reddening a different half -- which is also what
shows the qualifiers take effect rather than both cases running in one mode:

  darkTheme defaulted to false            night case red
  darkTheme defaulted to true             light case red
  isSystemInDarkTheme() inverted          both red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:34 -05:00
JMR-devandClaude Opus 5 49249be280 Report hardware progress to WorkManager, which nothing had checked (#196)
ConversionWorker.kt:208-210 is a second onProgress lambda at a second call site -- the one
handed to engine.transcode -- and it reported ci == 0. Every test in this file drives the
FFmpeg path; HardwareFallbackTest reaches runMedia3OrFallBack but its recording transcoder
records the call and never invokes the callback it was handed.

So the two engines' progress wiring was one tested and one not, and the untested one is the
default: ConversionRouter sends everything it can to Media3, which makes this the lambda
most conversions actually use. Same asymmetry argument CLAUDE.md records for
ContainerCapabilities:94.

It goes in this file rather than beside HardwareFallbackTest because this is where progress
plumbing lives and where RecordingForegroundUpdater already is -- and because the software
and hardware cases now sit side by side, which is what makes the asymmetry visible rather
than merely fixed. workerReporting gains an engine-preference parameter defaulted to
FORCE_SOFTWARE, so no existing case changes.

AUTO with a real H.264 probe, because FORCE_SOFTWARE is exactly what keeps the other tests
out of this branch, and because InputProbe() reports UNPARSEABLE -- which PERMISSIVE.canDecode
refuses, sending every job to FFmpeg with no test saying why.

The percentage is asserted, not merely that an update happened: publishProgress takes a
display name and a percent, and replacing the percent with a constant compiles fine.

Mutations, both run and restored:

  empty the hardware onProgress lambda            1 red
  report a constant percent instead of the engine's   1 red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:33 -05:00
JMR-devandClaude Opus 5 2125763ebf Read FFprobe's answer without spawning FFprobe (#195)
readMediaInformation was 114 missed instructions and 24 missed branches -- the second
largest block on the wave-4 report -- and exactly one line of it needed a device:

    FFprobeKit.getMediaInformation(path).getMediaInformation()

Everything after it reads an ordinary object, so it moves into ffprobeInfoFrom and the edge
keeps the call and the null check. Verified JVM-safe rather than assumed: javap over the
committed AAR's runtime jar shows MediaInformation(JSONObject, List<StreamInformation>,
List<Chapter>) and StreamInformation(JSONObject) as plain public constructors whose <clinit>
does not load the native library, so a test builds its own without libffmpegkit present.

The decision worth reaching is containerFrom's SECOND argument. FFprobe reports
"matroska,webm" for both MKV and WebM because they share a demuxer, so the video codec is
the only thing separating them. containerFrom has thirty-three covered branches and not one
can notice that argument being dropped -- the mistake is at the call, not in the callee, so
every existing containerFrom test stays green while every VP9 WebM quietly becomes an MKV.

Two things the tests found rather than confirmed.

The format properties are NESTED under "format": getFormat() resolves through
getStringFormatProperty, not off the top-level object. The first fixture put the keys at the
top level and four cases failed with a null container. The helper says so now.

And one mutation SURVIVED on the first pass -- reading dimensions with
streams.firstNotNullOfOrNull { it.getWidth() } instead of video?.getWidth(). The fixture put
the dimensions on the chosen video stream, which is also the first stream carrying any, so
the two readings agreed and the test could not tell them apart. Separating them needs a
chosen video stream with NO dimensions and a later one that has them, which is a real shape:
FFprobe omits width/height for a stream it could not measure. That case is now its own test
and the mutation reddens it.

Mutations, each run and restored:

  drop the video codec argument to containerFrom        1 red
  take the LAST video stream instead of the first       1 red
  read dimensions from any stream, not the chosen one   0 red -> 1 red after the new case
  let an unparseable duration throw instead of zero     1 red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:32 -05:00
JMR-devandClaude Opus 5 6d700f0014 Cut a seam through the codec enumeration, and say what a failed one actually does (#194)
probe() was 20 never-executed lines, the biggest single block on the report. It has been
looked at twice and left out twice, and both closes were right about what they closed:
#86 ruled it device-bound, and #133 re-checked that with ShadowMediaCodecList in hand and
still declined, because MediaCodecInfoBuilder has no setIsAlias and no setCanonicalName --
"so the alias skip and the canonical-name dedup, the two things the class's KDoc calls out
as easy to get wrong, are not reachable through it."

That objection is about the shadow. It does not apply to a function taking its own entry
type, which #133 did not evaluate. capabilitiesFrom(enumerate: () -> Sequence<CodecEntry>)
holds every rule; the edge keeps only the mapping from MediaCodecList onto CodecEntry.

The parameter is a Sequence rather than a List on purpose. runCatching has always wrapped
the *iteration*, so a MediaCodecInfo whose properties throw partway leaves the codecs
already read in place. A List parameter would move that throw outside the loop and turn a
partial answer into an empty one -- a behaviour change smuggled in as a refactor. There is
now a test for the partial case, and swapping the Sequence for an eager toList() reddens it.

The behaviour change this DOES make is one line of log, and it is the reason the seam was
worth cutting at all. The fallback said "Codec enumeration failed; assuming permissive" and
returned empty sets -- but "video/avc" in emptySet() is false, so canEncode and canDecode
answer no to everything and every job routes to FFmpeg. That is the restrictive answer, and
it is the right one: FFmpeg does whatever Media3 does, only slower. The code stays; the
message and the class KDoc now describe it.

Seven tests. One of them was wrong first and the mutation is what said so: the alias case
originally listed the alias *after* the codec it aliases and passed with the skip deleted,
because canonicalName is shared and the dedup catches the second entry either way. The two
rules overlap, so a fixture that does not separate them tests neither. Order separates
them -- an alias arriving first claims the canonical name in `seen` and gets its own types
credited, and the real codec is then dropped by the dedup. That is now the test, and it
also says what the rule is worth: with a Set accumulator, an alias declaring the same types
as its codec changes nothing, so the skip earns its place only when the two disagree.

Mutations, each run and restored, each reddening the test that owns it:

  drop !isSoftwareOnly from the encoder predicate     1 red
  remove the alias skip                               1 red  (0 before the fixture was fixed)
  remove the canonical-name dedup                     1 red
  remove the video/ prefix filter                     1 red
  apply the hardware predicate to decoders too        1 red
  make the failure fallback permissive                2 red
  eager toList() instead of the lazy Sequence         1 red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:31 -05:00
JMR-devandClaude Opus 5 da8d53851b Render the container row for a video nothing could name (#199)
ConverterScreen.kt:668's null arm -- DetailRow("Container", probe.container?.label ?:
"Unknown") in the VIDEO branch -- had never rendered. Every video case in FileCardTest uses
VIDEO_PROBE, which carries container = MP4.

The argument for adding it is the asymmetry, not the coverage. FileCard renders that exact
expression twice, once in AUDIO_ONLY (:660) and once in VIDEO (:668), and "an audio-only
file nothing else could describe degrades one row at a time" drives only the first. Same
expression, same fallback, one kind covered and one not -- which is the same argument
CLAUDE.md records for including ContainerCapabilities:94.

Nor is null an edge case here. InputProbe.container's own KDoc says MediaExtractor cannot
report a container at all, so it comes from FFprobe alone: any run where FFprobe did not
answer produces exactly this shape -- real codec, real dimensions, real duration, no
container. An empty value in its place would read as a rendering bug rather than as a
probe that got half its sources.

The other three rows are asserted alongside, which is what keeps this from being a copy of
the audio-only case. There, everything is unknown at once; here one field is missing from
a probe that is otherwise complete, and the rest have to be unaffected by it.

Mutation: `?: "Unknown"` -> `?: ""` at :668 only, run and restored. The AUDIO_ONLY twin at
:660 is a separate expression, and mutating that one would redden the existing test instead
-- which would prove nothing about this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:30 -05:00
JMR-devandClaude Opus 5 cc215195ee Build a command for the audio the user turned off (#198)
audioArgs' Drop arm -- `AudioPlan.Drop -> listOf("-an")` -- was ci == 0. The suite's only
-an assertion lives in "gif generates a palette to avoid banding and drops audio", and that
one comes from the image path at FFmpegCommandBuilder.kt:79/:90, which emits -an directly
and never reaches audioArgs. Two sites, one string, one tested.

It is a live path rather than defensive code. AdvancedPicker renders all of
AudioCodec.entries including NONE, ContainerCapabilities.validate permits audio-off whenever
the input has video, and MKV routes the job to FFmpeg -- so "convert this and drop the
soundtrack" is something a user can do today and nothing had built the command for.

Both halves are asserted, and the second is not padding: -an alone still passes if the arm
falls through to the else and emits an AAC encoder beside the flag, which is a file that is
silent because the flag won while carrying an encoder nobody asked for.

Three mutations, all run and restored. The third is the one that justifies the second
assertion, since the first two break -an as a side effect and so cannot show it:

  Drop -> emptyList()                     red
  Drop -> the else arm's aac encoder      red (loses -an as well)
  Drop -> listOf("-an", "-c:a", "aac")    red -- -an intact, caught by assertFalse

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:28 -05:00
JMR-devandClaude Opus 5 92bcff8656 Make a failure that says nothing still say something (#193)
Three sites, all ci == 0 before this, and all the same rule:

  work/ConversionWorker.kt:316        cause.message ?: GENERIC_FAILURE_MESSAGE
  convert/ConversionViewModel.kt:631  e.message ?: SAVE_FAILED_MESSAGE
  join/JoinViewModel.kt:416           e.message ?: SAVE_FAILED_MESSAGE

Every existing test throws WITH a message, so the right-hand side had never been evaluated
anywhere in the suite. A Throwable carrying none is not exotic: RuntimeException(),
IOException() and most platform exceptions raised without an argument all have a null
message, and a native engine that dies is exactly where one comes from.

The worker case needed care, and the care is the reason it survived three waves.
ConversionStateMappingTest's "a failure with nothing said still says something" looks like
it covers that site and does not -- it drives the READ side, map(FAILED, Data.EMPTY), and
that side has a fallback of its own at ConversionViewModel.kt:147-149 which turns a blank
KEY_ERROR back into the same constant. So a test asserting on the resulting Failed state
stays green while the worker's fallback is broken. Measured rather than reasoned: with
:316 mutated to .orEmpty(), exactly ONE of 587 tests went red, and it was the new one.
Everything else, including the test that appears to cover it, stayed green.

So the worker case reads KEY_ERROR off the worker's own Result, before anything downstream
can repair it. The two save cases have no such second line -- both write _state.value
directly -- so the state is the right thing to assert there, and both also assert that
`pending` still travels: a fallback that dropped the handle would leave the file
unreachable from the very screen that just said the save failed.

Held in one class against the ticket's suggestion of three. They are one rule at three
layers, and the masking above has to be explained once rather than three times.
FailedSaveRetryTest already sets the precedent for both ViewModels in one file; this adds
one worker to that shape.

FORCE_SOFTWARE in the worker fixture so the failure comes straight out of runFFmpeg. AUTO
would enter runMedia3OrFallBack, whose catch runs the job a second time in software: the
same exception arrives, but by a path this is not about and which HardwareFallbackTest owns.

Mutations, all run and restored -- each site to .orEmpty(), never to a different constant,
which would only prove the test reads a constant:

  ConversionWorker:316      1 of 587 red (this file)
  ConversionViewModel:631   red
  JoinViewModel:416         red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:27 -05:00
Jason Ross a847d3a81d Merge pull request #206 from JMR-dev/test/cancel-reaches-workmanager
Connect the Cancel button to WorkManager, which nothing did
2026-09-05 19:31:00 -05:00
JMR-devandClaude Opus 5 e4867ff956 Connect the Cancel button to WorkManager, which nothing did (#192)
Both ViewModels' cancel() is one line -- activeWorkId?.let(workManager::cancelWorkById) --
and JaCoCo reports every line of both as covered. The only test of either was
SettingsEditsTest's "cancelling with no active job does nothing rather than throwing",
whose own comment names the half it drives: "the null side". The other side had never been
entered, and JoinViewModel.cancel() had no test at all.

So nothing in 584 tests connected the Cancel button to WorkManager. The affordance tests
click TestTags.CANCEL and assert the action fires into a stub; ScreenWiringTest asserts the
action calls viewModel.cancel(). Both halves were pinned and the join between them was not.

No line-level filter could have found this. It takes a method-level read -- mi=11, ci=7,
mb=1, cb=1 on both -- a covered method with an arm nothing takes, which is the second of
the two filters #194 records and the gap that argued for adding it.

The fixture is why this stayed uncovered rather than why it is hard. The test WorkManager
runs on a SynchronousExecutor, so an ordinary request finishes inline: by the time a test
could call cancel(), convert()'s job was already terminal, leaving only the null arm
reachable. setInitialDelay is what TestScheduler honours, so the job sits in ENQUEUED and
the test never releases it. Production never sets a delay, so the request is built by hand
rather than through ConversionWorker.request -- but ENQUEUED at runAttemptCount 0 is a real
state every job passes through, Reattachment.choose ranks it QUEUED, and conversionStateFrom
maps it to Converting(input, 0). The delay changes how long the job stays in a real state,
not which state it is in.

Three tests, and the third is not padding: without it, cancelAllWork() in place of
cancelWorkById(activeWorkId) passes the other two. It asserts the shape rather than the
identity -- exactly one of two queued jobs is cancelled -- because which one the ViewModel
reattached to is the query's business, and Reattachment's ordering notes say queued jobs
are left tied deliberately.

WorkManager's own record is asserted before the screen. The screen alone would be weaker
than it looks: CANCELLED maps to Idle for a reattached job, and Idle is also where a
ViewModel that did nothing whatsoever would sit.

Mutations, both run and both restored:

  cancel() -> no-op                          all three red
  cancelWorkById(id) -> cancelAllWork()      only the third red

The second is what shows the third test does independent work rather than restating the
first two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 17:58:03 -05:00
Jason Ross 4d5fba515a Merge pull request #205 from JMR-dev/docs/wave4-coverage-findings
Record the wave-4 coverage read, and correct the filter that missed the biggest gap
2026-09-02 16:41:49 -05:00
JMR-devandClaude Opus 5 223fe6deea Record what the wave-4 coverage read found, and correct the filter that missed the biggest gap
Five findings (F6-F10) and a methodology correction. The twelve test tickets the same
read produced are #192-#203, with #204 for four candidates whose cost was not obviously
worth paying; nothing here is work, by this document's standing rule.

The correction is the part worth carrying forward. Wave 3 filtered candidates on `mi > 0`
and CLAUDE.md recommended it. That filter fails in both directions. It over-reports on
Compose: JoinScreen.kt:222 reads mi=10 and also ci=38, and JoinStateAffordancesTest
already clicks that Save button and asserts save:joined.mp4 -- the missed instructions are
the synthesized $changed/$dirty recomposition-skip path, the same codegen this repo
already knew inflated the branch count, showing up in the instruction count too. Every
onClick lambda flagged that way turned out to be covered at method level.

It under-reports on the case that mattered more. ConversionViewModel.cancel() and
JoinViewModel.cancel() miss no line at all, so no line-level filter can see them -- yet
only the null arm of activeWorkId?.let(workManager::cancelWorkById) had ever been entered,
and nothing in 584 tests connected the Cancel button to WorkManager. That is #192, and it
needs `ci > 0 && mb > 0` at method level to surface. Use both filters; `ci == 0` alone is
JaCoCo's own missed-line definition and needs no judgement, which is why it is the first.

The five findings are what a test would not fix. F6: four more unreachable arms, each
traced to the upstream guard that makes it so, one of which (ConversionRouter:214-217)
carries a KDoc describing a hazard :117 already removed. F7: probeWithExtractor's catch is
unreachable for the same reason probeForConcat's is -- the measurement was on record for
one site and not the other, three lines apart in the same file. F8: three more dead
members and six unused defaults. F9: both getForegroundInfo overrides are dead because
getForegroundInfoAsync is only called for expedited work and nothing sets it -- which
sharpens #88's close rather than reopening it. F10: three arms that ARE reachable and
still cannot be made to bite, recorded because all three were picked up as candidates and
put down again.

Six of the ten findings are now "no action" or "not a test gap", and that shape is the
honest summary of what is left: arms nothing can reach, members nothing calls, and arms a
test can reach but not pin. A coverage number tells none of them apart.

One close is qualified rather than overturned. #86 and #133 ruled AndroidDeviceCodecs.probe()
out through ShadowMediaCodecList, on the grounds that MediaCodecInfoBuilder cannot set
isAlias or canonicalName. A pure seam does not have that constraint and #133 did not
evaluate one, so #194 is a different mechanism, not a third run of the same spike -- and
its argument is not coverage but that the runCatching fallback logs "assuming permissive"
while returning empty sets, which makes canEncode and canDecode answer no for everything.

Documentation only: no Kotlin, Gradle or shell file is touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:54:57 -05:00
Jason Ross 54ca2dda5a Merge pull request #191 from JMR-dev/docs/coverage-wave3-recovery
Re-measure after wave 3, and write down the two kinds of gap it had to separate
2026-09-02 00:06:33 -05:00
JMR-dev 5a5a680b4c Re-measure after wave 3, and write down the two kinds of gap it had to separate
92.8% line (2183/2352), 81.3% branch (1091/1342), 584 JVM tests in 87 classes, measured
2026-09-02 on the tree this branch creates rather than quoted from a PR body.

The shape of the wave is worth more than the number, and it is different from the two
before it. Waves 1 and 2 were finding uncovered code; by wave 3 there was little of that
left, so the gaps had to be sorted before any test was written. Coverage gaps -- filtered
to sites where JaCoCo reports mi > 0, which is what separates a real gap from a partial
branch on a compound condition, and which cut the candidate list roughly in half. And
assertion gaps, where JaCoCo is green and nothing checks the answer: MainActivity's rail
and bottom bar were both executed and transposing them passed the entire suite, as did
swapping the two progress-notification strings and swapping Content's two destinations.
No coverage number would have found any of the three.

Naming the required mutation per ticket earned its keep three times, each recorded with
what the weak assertion actually was. Also recorded: a green mutation is only evidence
when the mutation is a real change -- one classify reordering was semantically equivalent
for every reachable input, and a bad mutation and a weak test look identical in the output.

Two entries came back as not gaps, which is a result rather than a shortfall:
ContainerCapabilities:282's exclude filter cannot drop anything, and probeForConcat's
catch arm is unreachable on this runtime -- Robolectric's MediaExtractor never throws from
setDataSource, measured across four input shapes.

Both denominators moved, in opposite directions and for different reasons, so they are
stated rather than folded into the percentage: 1340 -> 1342 branches from MediaProbe.merge,
2348 -> 2352 lines from the ConcatJoiner interface. Neither is new untested code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Recovered onto main after hitting #160's trap for real. #189 was opened against
test/concat-engine-seam and, unlike #184-#188, never retargeted to main before merging --
so it merged into a branch that had already been merged and left behind. GitHub reported
`merged`, the PR shows MERGED, and none of it was on main: `git merge-base --is-ancestor`
is what said so, one line, immediately.

That check is the entire reason this was a five-minute recovery rather than a coverage
entry that silently stayed three points stale. The failure mode is exactly what CLAUDE.md
warns about; what it did not say, and now would, is that the auto-retarget it describes
belongs to GitHub's stacking feature, so a stack opened with plain `gh pr create --base`
has to be retargeted by hand for every single PR -- and missing one is invisible until you
check ancestry.

Numbers re-measured on this tree with --rerun-tasks rather than inherited from the branch
they were taken on: 584 tests, 0 failures, 2183/2352 line, 1091/1342 branch. Same figures,
earned again.
2026-09-01 23:46:51 -05:00
Jason Ross 7f23ea8fd7 Merge pull request #188 from JMR-dev/test/concat-engine-seam
C1 (#176): give ConcatWorker the seam ConversionWorker always had, and test what was behind it
2026-09-01 23:44:27 -05:00
Jason Ross 05422a6896 Merge pull request #187 from JMR-dev/test/mediaprobe-merge-seam
C2 (#177): cut MediaProbe's two-probe merge into a seam, and ask which probe wins
2026-09-01 23:44:21 -05:00
Jason Ross fa3a32e5c0 Merge pull request #186 from JMR-dev/test/adaptive-shell-wiring
B1 (#173): tell the rail from the bottom bar, and the Convert tab from the Join tab
2026-09-01 23:13:01 -05:00
Jason Ross e2c9981bbe Merge pull request #185 from JMR-dev/test/aac-audio-args
B3 (#175): pin the AAC arm every ordinary conversion takes
2026-09-01 23:12:54 -05:00
Jason Ross 7a5622c896 Merge pull request #184 from JMR-dev/test/notification-progress-text
B2 (#174): read what the progress notification actually says
2026-09-01 23:12:35 -05:00
Jason Ross d4ca6b7b0f Merge pull request #183 from JMR-dev/test/media3-muxer-guard
A5 (#171): fire the muxer guard that repairs "MP4 for everything", which had never fired
2026-09-01 23:02:03 -05:00
Jason Ross bbe40cf8f7 Merge pull request #182 from JMR-dev/test/hardware-fallback-and-cancellation
A2 + A3 (#168, #169): the hardware fallback, the cancellation that must not take it, and the name a job may not have
2026-09-01 22:36:13 -05:00
Jason Ross f81d76e730 Merge pull request #181 from JMR-dev/test/unprobeable-join-clip
A4 (#170): join the two halves of an unreadable join clip, and record why the catch arm stays device-only
2026-09-01 22:34:25 -05:00
Jason Ross 8849ae96eb Merge pull request #180 from JMR-dev/test/one-branch-outcomes
A6 (#172): six one-branch outcomes nothing produced, and one that cannot be produced
2026-09-01 22:26:27 -05:00
Jason Ross 25e14b26d9 Merge pull request #179 from JMR-dev/test/foreground-type-regimes
A1 (#167): pin all three foreground-service regimes, and the boundary between two of them
2026-09-01 22:13:13 -05:00
JMR-devandClaude Opus 5 aa7e1d8b01 C1 (#176): give ConcatWorker the seam ConversionWorker always had, and test what was behind it
ConcatWorker constructed ConcatEngine in place while ConversionWorker reached its engines
through ConversionDependencies. That asymmetry is the whole reason one worker had a tested
failure path and the other had none: everything past setForeground was untested on *every*
source set, JVM and device alike.

The repo had already measured the cost and written it down. PerJobStagingTest's KDoc
records that **reverting ConcatWorker to a constant staging name left all 257 tests
green**, because nothing could reach the line that names the file. RefusedJobTest says it
from the other side -- "the next thing past the count guard is ConcatEngine, which is
native". That mutation is red now.

The seam is `ConversionDependencies.concat: (Context) -> ConcatJoiner`, beside .hardware
and .software. ConcatEngine implements the interface; its Result type stays nested in the
implementation, because moving it would touch every call site to buy nothing -- what a
test needs is the ability to not run FFmpeg, and that is the method, not the type.

Five tests, and the mutations that hold them:

  the engine's own reason reaches the user   replace e.message with the generic string
  a failure with no message still says one   drop the ?: GENERIC_FAILURE_MESSAGE fallback
  a failed join deletes its partial          drop staged.delete() from the catch
  no input array at all is refused           swap in TOO_FEW_INPUTS_MESSAGE
  a join reports its own staged file         revert to the constant staging name

The delete test was vacuous on its first draft and the mutation caught it: it scanned the
staging directory for a "join-" prefix that StagingNames.forJob does not produce -- it
names files <jobId>.<ext> -- so the assertion was trivially true. Rewritten to assert
against the handle the joiner was actually given.

Two small fixes ride along, both the repo's own conventions rather than new opinions.
"No input files." becomes NO_INPUTS_MESSAGE, per #158: a message the user can see is
named once, so a test asserts the string the worker writes rather than a copy that can
drift. And the JVM now covers that arm, which ran before staging and before any native
code and had no business being a device test.

579 -> 584 JVM tests, 0 failures.
ConcatWorker: 14 -> 5 missed lines, 2 -> 0 missed branches.
Line 2173/2348 -> 2183/2352; branch 1091/1342 unchanged in the numerator.

The line denominator moved 2348 -> 2352: that is the ConcatJoiner interface, not new
untested code. Said plainly because CLAUDE.md's coverage entry has a history of explaining
its own numbers wrongly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:06:52 -05:00
JMR-devandClaude Opus 5 794cef7b34 C2 (#177): cut MediaProbe's two-probe merge into a seam, and ask which probe wins
probe() runs MediaExtractor and FFprobe independently and merges the two, and every rule
in that merge is a decision nothing held. The reason is structural rather than an
oversight: RemuxTest drives the whole thing on a device against committed fixtures, but
only ever with one probe answering and the other agreeing or also failing. Nothing on any
source set can arrange for a real extractor and a real FFprobe to *disagree*, so every
elvis in the merge was taken in one direction and never the other.

The seam is `internal fun merge(Extracted?, FFprobeInfo?): InputProbe`, pulled out of
probe() whole -- probe() now reads the two probes, merges, and keeps the log. FFprobeInfo
becomes internal alongside it; Extracted already was, with a KDoc giving this exact reason,
and FFprobeInfo simply never got the same treatment. Half a signature being private is
what made the function unnameable from a test.

Eleven tests, and the mutations that hold them:

  image beats a real video codec      demote the isImage arm below the video arm
  the extractor wins on codecs        flip the elvis to FFprobe-first
  duration is the larger reading      replace maxOf with extractor-first
  dimensions prefer the extractor     flip the width elvis
  no recognised stream is unreadable  narrow the guard to `extracted == null && info == null`

All five red, then restored. One mutation I tried first was *semantically equivalent* --
moving the image arm above the both-null arm changes nothing for any reachable input -- so
it stayed green and is recorded here rather than counted: a green mutation is only evidence
when the mutation is a real change.

The last row is the arm the ticket was filed for: parsed, and carrying no stream either
probe recognised, which is what a container holding only subtitles looks like. Its input
was already being constructed elsewhere in the suite -- MediaProbeTrackWalkTest calls
extractedFrom(emptyList()) and gets exactly it -- and had never been handed to the merge.

568 -> 579 JVM tests, 0 failures.
MediaProbe: 35 -> 24 missed lines, 72 -> 40 missed branches.
Line 2103/2348 -> 2173/2348; branch 1029/1340 -> 1091/1342.

The branch denominator moved by two, and it is the seam that moved it -- worth stating
separately from the numerator, because CLAUDE.md's coverage entry has a documented history
of explaining its own numbers wrongly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:59:00 -05:00
JMR-devandClaude Opus 5 eded47d666 B1 (#173): tell the rail from the bottom bar, and the Convert tab from the Join tab
Two assertion gaps, not coverage gaps, which is why they lasted. AppRootRestorationTest
already drives AppRoot at Compact and Expanded, so JaCoCo is green on useRail -- but it
asserts only that the selected tab survives recreation, through a stub `content`
composable. Nothing anywhere queried for a rail or a bar, and nothing composed the real
screens. Measured before this file existed:

  - transposing the NavigationRail and NavigationBar bodies passed the entire suite
  - transposing Content's two arms passed it too

A tablet showing phone chrome, or the Convert tab opening the Join screen, and 546 tests
with nothing to say about either. AppRoot's own KDoc is why that matters more than it
looks: from targetSdk 37 the app is resized and rotated whether or not it is ready, so the
width class is not a preference.

WindowWidthSizeClass.Medium appears in no test in either source set today. useRail is
`!= Compact`, so Medium takes the rail; narrowing it to `== Expanded` is one character and
breaks every tablet and unfolded foldable. That mutation is red now, and it is red only
because of the Medium test -- the Compact and Expanded ones both survive it.

Two things this needed:

**createAndroidComposeRule rather than createComposeRule.** Rendering AppRoot with its
default content reaches ConverterScreen's `viewModel = viewModel()`, which needs a
ViewModelStoreOwner. It works because both ViewModels are
`@JvmOverloads constructor(app: Application, ...)` so AndroidViewModelFactory can build
them, and because ui-test-manifest's debugImplementation entry already puts a
ComponentActivity in the merged manifest the unit tests build against -- which
app/build.gradle.kts says in terms. Checked with a throwaway spike before the ticket was
filed, rather than discovered here.

**Two tags, applied inside main.** The only production change: TestTags.Shell, set on the
rail and the bar. There is no other way to tell the two apart -- both render the same two
destinations with the same labels and the same selection state, so any assertion writable
without them is satisfied by either layout. In TestTags and applied by the shell rather
than handed down by the test, for the reason that file's KDoc gives: a tag the test
supplies proves only that the test set it. TagTableUniquenessTest covers the new group.

564 -> 568 JVM tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:54:50 -05:00
JMR-devandClaude Opus 5 b41341a1cb B3 (#175): pin the AAC arm every ordinary conversion takes
audioArgs has six arms. Five are named codecs with tests; AAC arrives through the `else`,
so nothing named it -- neither "aac" nor "192k" appeared anywhere in
FFmpegCommandBuilderTest. It is the audio MP4 and M4A get, which is to say the audio the
picker offers first and most conversions produce.

Both halves are asserted, and the bitrate is the half worth arguing for: an -b:a that
quietly changed would fail nothing, look wrong in no command line, and surface only as
files that sound different from the ones the app produced last month. Both mutations
confirmed red -- 192k -> 128k and aac -> libfdk_aac.

Asserted through MP4_H264 and M4A_AAC rather than one of them, so an AAC arm added above
the `else` later has to keep answering the same way for both.

**Deliberately not added here: an ENCODABLE_AUDIO-vs-audioArgs agreement test**, the
obvious companion to VideoCodecMimeAgreementTest. It would freeze the answer to F1, which
is open: ContainerCapabilities.kt:84 says "nothing here emits a Vorbis encoder" and
FFmpegCommandBuilder.kt:188 does. docs/coverage-read-findings.md says in terms that the
tempting fix there locks in the wrong answer and that the decision comes first. This is
the AAC arm only.

563 -> 564 JVM tests, 0 failures. No production code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:50:31 -05:00
JMR-devandClaude Opus 5 0f842243b5 B2 (#174): read what the progress notification actually says
An assertion gap rather than a coverage one, which is the reason it survived. JaCoCo is
green on build()'s `if (indeterminate)` because ProgressNotificationTest drives it through
a real worker -- but that test reads the notification id and EXTRA_PROGRESS and nothing
else. Nothing had ever read the text. Swapping the two branches passed the whole suite;
so did replacing the caller's title with a constant. Both are red now.

What it costs to get wrong is small and permanent: a conversion four minutes in still
saying "Preparing", or one that has not started reporting yet claiming 0%. Neither is a
crash, and nothing else here would have found it.

Nothing in the suite had constructed ConversionNotifications directly, and the reason
turned out to be mechanical rather than an oversight: build() reaches
WorkManager.getInstance for the Cancel action's PendingIntent, so the notification cannot
be built without one. installTestWorkManager in setUp is the whole fixture, and the KDoc
records the coupling so the next person does not rediscover it.

areEnabled() in the same file is deliberately still untested. It has no caller anywhere in
app/src/main, so a test would assert that a function nobody calls returns what the platform
told it -- and would imply the app handles the disabled-notification case, which it does
not. That is F5 in docs/coverage-read-findings.md, and it asks for a decision rather than a
test.

561 -> 563 JVM tests, 0 failures. No production code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:48:36 -05:00
JMR-devandClaude Opus 5 2fbc957119 A5 (#171): fire the muxer guard that repairs "MP4 for everything", which had never fired
Media3Muxers' KDoc names the defect this guards -- "the router claimed five containers
while the engine silently wrote MP4 for all of them" -- and the repair itself was
untested: Media3Engine$buildTransformer$3, the requireNotNull message lambda, was four
lines and four branches at 0%. Nothing had ever driven a plan whose container Media3
cannot mux, and factoryFor answers null for fourteen of them.

Weakening it does not crash. The wrong output is a playable file with the wrong
container, which is why a test rather than a bug report is what would catch it.

Same harness and the same two disciplines as Media3EngineEmptyCompositionTest, which is
the sibling this joins: assert the plan really is the one the test needs before driving
the engine, and rule out CancellationException so an unresumed continuation cannot read
as a pass. Three premises are asserted here rather than assumed -- that the plan is still
WebM by the time the engine sees it, that Media3 really has no muxer for WebM, and that
neither track was dropped, since the empty-composition refusal fires earlier and is a
different test's subject.

The assertion is on the exception type *and* its message, and the ticket predicted why:
replacing requireNotNull with `?: DefaultMuxer.Factory()` does not make the export
succeed, it lets it run on and fail some other way. Measured -- that mutation fails the
type assertion, so the guard is genuinely what this test is holding, and the message
assertion stands behind it.

560 -> 561 JVM tests, 0 failures. No production code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:45:43 -05:00
JMR-devandClaude Opus 5 a645442acc A2 + A3 (#168, #169): the hardware fallback, the cancellation that must not take it, and the name a job may not have
runMedia3OrFallBack was eleven lines at 0% and isCancellation had never been called by any
JVM test -- ci=0, not merely a missed branch. The seam to reach it has existed the whole
time: ConversionDependencies.hardware, which no unit test had ever set. What kept the path
cold is that every worker test uses EnginePreference.FORCE_SOFTWARE, which never enters
the function, and the probe defaults to UNPARSEABLE, which PERMISSIVE.canDecode refuses --
so even AUTO would have routed straight to FFmpeg for a reason no assertion mentioned.
Both are now stated in setUp rather than inherited.

Four behaviours, each with the mutation that proves it:

  hardware failure falls back to software    delete the fallback call
  ... on a *clean* staging file              delete staged.delete() before it
  cancellation is rethrown, not fallen back  delete `if (isCancellation(e)) throw e`
  engine.close() runs either way             empty the finally block
  the display-name fallback (#169)           change "input" to anything else

All five confirmed red, then restored.

The cancellation one is the reason this ticket was first in the group. runMedia3OrFallBack
catches Throwable, so without that re-throw a user cancelling a hardware transcode has the
app quietly start a *second* conversion in software -- the one thing cancelling is for.
ForcedFailureTest covers the failure half on a device and does not cover this half at all.

The clean-staging assertion is made where it is observable rather than by reading the
file: the software fake records whether the output existed when it was entered, so a
missing delete shows up as FFmpeg finding a half-written hardware output at the path it is
about to write.

556 -> 560 JVM tests, 0 failures. No production code changed.
ConversionWorker: 22 -> 9 missed lines, 14 -> 7 missed branches.
Line 2090/2348 -> 2103/2348; branch 1022/1340 -> 1029/1340.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:42:27 -05:00
JMR-devandClaude Opus 5 5761faced6 A4 (#170): join the two halves of an unreadable join clip, and record why the catch arm stays device-only
The ticket asked for two things. One of them is not reachable from the JVM, and saying
so is most of the value here.

**probeForConcat's catch arm cannot be provoked on this runtime.** Robolectric's
MediaExtractor never throws from setDataSource -- measured across four input shapes: an
unregistered content:// authority, a missing file://, a file of garbage bytes, and an
http:// URL. All four returned normally with trackCount = 0. So a failed read arrives as
an empty track list rather than as an exception and reaches the same
ConcatInput(null, null, 0, 0, 0) by the other road. The catch stays covered only by
ConcatEngineTest on a device. The test file says this rather than implying the arm is
handled.

**What is reachable, and was genuinely missing, is the span.** Both halves were already
covered and neither reached the other: MediaProbeTrackWalkTest pins what concatInputFrom
makes of a track list, ConcatPlannerTest's `an unknown codec is not treated as a match`
pins what the planner does with a hand-built ConcatInput(video = null). The planner's
safety rests on the probe really producing that shape, and the hand-built fixture would
go on passing if it stopped.

Measured rather than claimed: mutating concatInputFrom's initial `video` to a non-null
placeholder leaves ConcatPlannerTest green and turns this red. Dropping the planner's
video null guard turns both red -- so that half was already held, and this file does not
claim credit for it.

The coupling itself is worth writing down: ConcatPlanner guards video against a null codec
and audio not at all, and that asymmetry is correct rather than an oversight --
MediaProbe.shortName returns a non-null String, so a null audioCodec means the track is
absent and two clips with no audio really do match, while a null videoCodec means absent
*or* unreadable. The audio check is safe because the video guard fires first on a clip
nothing could read. Nothing held that.

555 -> 556 JVM tests, 0 failures. No production code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:37:45 -05:00
JMR-devandClaude Opus 5 c2c0bfa848 A6 (#172): six one-branch outcomes nothing produced, and one that cannot be produced
Each of these is a site where JaCoCo reported mi > 0 -- a concrete instruction no test
runs -- rather than a partial branch on a compound condition, which is how the group was
filtered in the first place. Six closed, one moved to the exclusions.

  AndroidDeviceCodecs:35   the UNPARSEABLE sentinel, refused where an unknown name is not
  ContainerCapabilities:94 accepts(container, VideoCodec.NONE, mode) -- the audio twin has
                           had a test since #136; the asymmetry is the argument
  ContainerCapabilities:323 repairVideo's keep-the-requested-codec arm
  ContainerCapabilities:348 firstContainerHolding's fallback container
  OutputPublisher:230      the resolver call that throws -- the third case the KDoc names
                           and the one the shape list was missing
  JobSnapshots:31          a job that recorded no output path at all

Every one was mutated and confirmed red, then restored. Two are worth stating because
they did not go red first time or would not have:

**The firstContainerHolding test was vacuous on its first draft.** It asserted the
refusal still offered *something*, and deleting the fallback left it green: the source
container is a candidate in its own right, so the list stays non-empty and only its
contents change. Rewritten around AVI, which has no mapping for H.265, and asserting the
codec survives -- without the fallback the app silently offers H.264 instead, which is
the actual loss. This is the failure mode CLAUDE.md records from the mutation review, met
head on rather than in the abstract.

**OutputPublisher:230 needed one line.** `a destination whose size cannot be determined is
never deleted` already walked three RowShapes; QUERY_THROWS was the fourth case its own
KDoc names -- "a resolver call that throws" -- and the only one that reaches `?: false`
through runCatching rather than through a cursor answer.

ContainerCapabilities:282's `.filter { it != exclude }` is **not** closed here and is not
a gap: nothing can make it drop anything. On the shared container `repair` always changes
at least one codec, because a codec it left alone is one validate would not have refused;
every other candidate differs by container; and the single call site passing a non-default
exclude (validateVideo:186) excludes a spec carrying VideoCodec.COPY while every repaired
candidate carries NONE. F4-shaped -- recorded rather than covered, and 550 tests agree.

550 -> 555 JVM tests, 0 failures. Five new tests rather than six: OutputPublisher:230
is one line inside a test that already existed. No production code changed.
Line 2087/2348 -> 2090/2348; branch 1011/1340 -> 1022/1340.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:31:21 -05:00
JMR-devandClaude Opus 5 016030f3e4 A1 (#167): pin all three foreground-service regimes, and the boundary between two of them
`ConversionForegroundType.current()` has three arms and the JVM suite executed one.
`robolectric.properties` pins everything to `sdk=36`, and `@Config` appears nowhere in
`app/src/test`, so 3 lines and 3 of 4 branches were cold.

The instrumented test is not a substitute, and the reason is specific rather than
general. `ConversionWorkerTest.foregroundTypeMatchesTheRunningApiLevel` asserts against
whichever API the leg is, so it covers one arm per leg and never the other two -- and the
legs that would cover 33 and 34 are the ones #122 wedges. From
docs/coverage-read-findings.md, an API 33 run reported `received: 60` with
`failed: unknown`: the regime was exercised and that leg could not have said so if it had
broken. This runs all three deterministically in the same ./gradlew invocation.

Four classes, not three. 35 shares its answer with 36 and looks redundant; it is the
whole point. Relaxing `>= VANILLA_ICE_CREAM` to `>` is invisible at every level except
exactly 35 -- measured, not assumed: that mutation failed ForegroundTypeApi35Test alone,
while swapping DATA_SYNC and MEDIA_PROCESSING failed 34, 35 and 36. Without the 35 class
the first mutation survives the suite.

546 -> 550 JVM tests, 0 failures. No production code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:19:06 -05:00
Jason Ross d354f6470c Merge pull request #166 from JMR-dev/docs/coverage-wave2
Re-measure coverage after wave 2, and write down how a stacked PR merges
2026-08-29 11:33:20 -05:00
JMR-devandClaude Opus 5 dbedfb4708 Re-measure coverage after wave 2, and write down how a stacked PR merges
COVERAGE. 87.1% line / 69.1% branch, 502 tests -> 88.9% line (2087/2348), 75.4%
branch (1011/1340), 546 tests in 76 classes, as #153's five children land.

The branch figure moved for two reasons and the entry now says so, because only
one of them is new tests. The numerator rose 974 -> 1011; the denominator *fell*
1410 -> 1340. Both are the seam work: pulling a `when` out of a lambda inside a
`collect` deletes the coroutine state machine's synthesized branches around it,
and leaves a plain function whose branches a test can choose.
`ConversionViewModel$observe$1$1` went from carrying the whole mapping to six
branches, while the extracted `ConversionViewModelKt` covers 41 of 42 and
`JoinViewModelKt` 38 of 39.

That is worth stating rather than quoting the percentage alone. A number that
rises because the denominator shrank is a different claim from one that rises
because more branches are tested, and this entry has a documented history of
explaining its own movements wrongly.

STACKED PRS. A new Conventions entry, from two traps measured on 2026-08-27
while landing #144-#151.

`gh pr merge` refuses a stacked PR outright -- "must be merged using the
asynchronous merge REST API" -- and so does the plain `/merge` endpoint. The one
that works is `PUT .../pulls/N/merge-async`, which returns a uuid to poll.

The second is worse because nothing looks wrong. GitHub retargets a stacked PR's
base to main when the one below it merges, but asynchronously. Merging five about
thirty seconds apart outran it, so each merged into its own already-merged base
branch. Every call returned `status: merged`, every PR read MERGED, `gh pr list
--state open` was empty, and none of the content was on main.

What caught it was a coverage re-measure two points below what the same tree had
produced an hour earlier -- a fresh `git pull` changed nothing, which is what
made it a question rather than a stale checkout. `git merge-base --is-ancestor`
answers it in one line. #160 is what the recovery cost.

Also recorded: the auto-retarget belongs to the stacking feature. A PR opened
with a plain `--base some-branch` does not retarget when that branch merges, and
has to be moved by hand -- which is what #163 needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:16:20 -05:00
Jason Ross c6e9a480e1 Merge pull request #165 from JMR-dev/test/screen-wiring
W3: the screen wiring, and a narrower hazard than the ticket claimed
2026-08-29 11:13:34 -05:00
Jason Ross d8110d7a62 Merge pull request #164 from JMR-dev/test/viewmodel-setters
W4: the seven settings edits, and three tests that did not bite until they did
2026-08-29 10:57:14 -05:00
JMR-devandClaude Opus 5 6f3966cc69 W3 (#156): the screen wiring, and a narrower hazard than the ticket claimed
The stateful outer composables hand `ConverterScreenContent` and
`JoinScreenContent` a list of `viewModel::` references. No test in the suite had
ever seen that list: the content tests build their own `ConverterActions`, so
they drive the stateless inner and never touch the wiring.

THE TICKET'S PREMISE WAS HALF WRONG, AND CHECKING BEAT ASSUMING. #156 was filed
claiming a transposition of any two of seventeen bindings would survive the
suite. Measured instead of trusted:

  onVideoCodec <-> onAudioCodec   -> REJECTED: "Inapplicable candidate(s):
                                     fun setAudioCodec(codec: AudioCodec)"
  onCancel     <-> onReset        -> COMPILES

Every typed binding -- container, both codecs, preset, suggestion, quality,
engine preference -- takes a distinct parameter type, so the compiler is already
the test. Writing assertions against those transpositions would have been
theatre, and this file says so rather than quietly including them.

WHAT IS ACTUALLY AT RISK is the `() -> Unit` bindings, which are interchangeable
to the compiler: two on the converter screen (onCancel, onReset) and *three* on
the join screen (onJoin, onCancel, onReset). A Cancel button that discards the
finished file, a Start-over that leaves it on screen, or a Join button that
cancels -- each is one wrong word and each ships.

I got that wrong in the first check too: an early run reported the onCancel/
onReset swap as rejected, from a grep-and-exit-code test that misread a stale
build. Re-running it properly printed BUILD SUCCESSFUL with the swap in place.

THE SEAM. `converterActions(viewModel, onPickInput, onConvert, onSave)` and
`joinActions(viewModel, onPickInputs, onSave)`. The launcher-backed actions stay
parameters -- they need an ActivityResultLauncher, which is the part that
genuinely needs a composition, and keeping them out means the rest needs none.

Told apart by effect rather than by a recording double: `reset()` sets the state
to Idle, `cancel()` with no active job leaves it alone (`activeWorkId?.let`,
which SettingsEditsTest pins).

Mutations -- every transposition caught, each by two tests:

  converter onCancel <-> onReset  | 2 tests
  join      onJoin   <-> onCancel | 2 tests
  join      onReset  <-> onCancel | 2 tests
  a typed binding dropped to {}   | 1 test
  a launcher action rerouted      | 1 test

The two-test symmetry is deliberate: one direction alone passes against a wiring
with BOTH actions bound to the same method, which is what a copy-pasted line
produces.

Three guard assertions earned their place during writing -- the picks land
through an injected dispatcher, and without `ParkedPickDispatcher.runAll()` all
three state-based tests sat on Idle and would have asserted nothing. They failed
loudly instead of passing quietly.

`@UnstableApi` on both builders, per CLAUDE.md; lint caught their absence, as it
did in W1.

525 -> 537 tests, 88.0% -> 88.9% line, 70.5% -> 75.4% branch. Gate green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:56:44 -05:00
Jason Ross 7595177e81 Merge pull request #163 from JMR-dev/test/join-state-mapping
W2: the join state mapping, and a crash the seam exposed
2026-08-29 10:49:03 -05:00
JMR-devandClaude Opus 5 a507736d3d W4 (#157): the seven settings edits, and three tests that did not bite until they did
`setPreset` was covered; the six beside it and `cancel()` had no coverage at all.
That asymmetry is the tell -- they are reachable from the JVM suite by exactly
the route `setPreset` already takes, and nothing had asked.

WHAT IS ASSERTED. Not "the setter sets something". Each of these copies into a
nested `OutputSpec`, so the failure worth catching is a setter that writes the
right value into the wrong field, or that rebuilds the spec and quietly discards
the other two. Every test asserts the field it changed AND that the rest survived.

THREE OF THEM DID NOT BITE, AND THE REASON IS WORTH KEEPING. The first run of the
mutations came back with two green:

  setContainer rebuilding from OutputFormat.MP4_H265.spec   -> GREEN
  setQuality also resetting enginePreference to AUTO        -> GREEN

Both for one mistake of mine: I asserted "the rest survived" against values that
were still at their defaults. `ConversionSettings` starts at `MP4_H265.spec`,
`QualityTier.FAST` and `EnginePreference.AUTO` -- so a mutation that RESET a
neighbouring field to its default was indistinguishable from one that left it
alone. The tests were checking a value, not a behaviour.

Fixed by moving each neighbour off its default before the call under test. A
third test had the same latent hazard -- it asserted `quality == FAST` -- and was
corrected with the others rather than left to fail later.

That is precisely the shape CLAUDE.md warns about ("five of them passing the
whole suite over a completely unguarded code path"), and it is the second time in
this wave the mutation pass has earned its place: green was not evidence.

Eight mutations, all red after the fix:

  setContainer rebuilds from a preset            | 1 test
  setQuality resets the engine preference        | 1 test
  setEnginePreference resets the quality         | 2 tests
  applySuggestion resets quality                 | 1 test
  setVideoCodec writes nothing                   | 1 test
  setAudioCodec writes nothing                   | 2 tests
  setEnginePreference writes nothing             | 2 tests
  cancel() dereferences a null activeWorkId      | 1 test

516 -> 525 tests, 87.7% -> 88.0% line, 70.4% -> 70.5% branch. Gate green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:46:48 -05:00
JMR-dev 1ff5c4463c Merge remote-tracking branch 'origin/main' into test/join-state-mapping 2026-08-29 10:39:55 -05:00
Jason Ross 70337b2c12 Merge pull request #162 from JMR-dev/test/conversion-state-mapping
W1: cut the conversion state mapping into a seam, and choose all six arms
2026-08-29 10:38:52 -05:00
JMR-devandClaude Opus 5 fea480b000 W2 (#155): the join state mapping, and a crash the seam exposed
The join-side twin of W1, deliberately the same shape -- one refactor done twice,
and letting the two diverge would cost more than the duplication. Five arms had
never been chosen by any test, for the same reason: a real ConcatWorker only ever
reaches a terminal state with well-formed output.

WHAT THE SEAM TURNED UP. This line was in the SUCCEEDED arm:

    info.outputData.getString(ConcatWorker.KEY_STRATEGY)
        ?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE

`valueOf` throws IllegalArgumentException on a name this build does not define,
and this runs inside a `viewModelScope` collect with no handler -- so it is not a
Failed state, it takes the process down.

Not theoretical. WorkManager keeps finished work about a week, so a downgrade or
rollback hands this build a job enqueued by another one -- the premise
`WorkerEnumFallbackTest` and `JobTags` are both written on. `ConcatWorker` writes
`result.strategy.name`, so a build that added a third strategy would leave this
one crashing on its own completed joins.

The codebase had already made this exact fix one file over, and said why:

    // Looked up rather than `valueOf` -- see the same three reads in
    // ConversionWorker. This one is above the try as well, so a format name this
    // build does not define used to throw past the catch

The matching read on the ViewModel side had not been changed with it. It is now
`ConcatStrategy.entries.firstOrNull { it.name == name } ?: REENCODE`.

PROVEN RATHER THAN ASSERTED. Restoring `valueOf` and running the new test:

    RED: an unknown strategy name is read as a re-encode rather than thrown
      java.lang.IllegalArgumentException: No enum constant
      org.libremediaconverter.model.ConcatStrategy.SMART_CONCAT_V2

REENCODE is the conservative default rather than an arbitrary one: it is the
answer for inputs that do not match, so a job whose strategy cannot be read is
described as the more cautious of the two rather than claimed as a lossless
stream copy. The mutation to STREAM_COPY reddens two tests.

Eight mutations, all red:

  unknown strategy -> STREAM_COPY            | 2 tests
  runAttemptCount ignored, both directions   | 2 tests
  success with no path -> empty Joined       | 1 test
  BLOCKED unfolded from RUNNING              | 1 test
  blank error no longer falls back           | 1 test
  cancellation ignores the caller's state    | 1 test

516 -> 530 tests, 87.7% -> 88.0% line, 70.4% -> 71.3% branch. Gate green:
assembleDebug, testDebugUnitTest, compileDebugAndroidTestKotlin, ktlintCheck,
detekt, lintDebug.

Stacked on W1 (#162), which this mirrors and should not land before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:37:13 -05:00
JMR-devandClaude Opus 5 ddfb1dd78e W1 (#154): cut the conversion state mapping into a seam, and choose all six arms
`ConversionViewModel.observe` maps a `WorkInfo` onto a `ConversionState`. That is
the app's main UI state machine, and no test had ever chosen which arm it took.

NOT COLD CODE, WHICH IS THE POINT. `ConversionViewModel$observe$1$1` already
reported 28 covered lines and 24 covered branches: every test that drives a real
worker runs this. But a real worker only ever reaches a terminal state with
well-formed output, so `SUCCEEDED`-with-a-path and `FAILED`-with-a-message were
the only arms any test had produced. The other six ran never -- the progress
read, both sides of the retry check, a success naming no file, a failure with
nothing to say, `CANCELLED`, and `BLOCKED`.

A grep makes that look untrue: all six `WorkInfo.State` constants appear in the
JVM suite. They are in `ReattachmentTest`, driven into `Reattachment.choose` --
a *different* function encoding the same enqueued-means-retry rule. So the rule
had a test in one of its two homes, and the copy the user's screen reads had
none.

THE SEAM. `workManager` comes from `WorkManager.getInstance` in the constructor
and `observe` is private, so nothing could hand this a chosen `WorkInfo`. The
`when` is now `conversionStateFrom`, a pure function over a `ConversionUpdate`
carrying only the fields it reads -- the same shape as `JobSnapshot` beside
`Reattachment.choose`, and its KDoc gives the same reason. `outputData` stays a
`Data`, which this suite already builds with `workDataOf` everywhere; unpacking
it into five nullable strings would move the same reads without helping.

TWO THINGS DELIBERATELY LEFT OUTSIDE IT:

  - The ownership check stays at the call site. Its comment says it guards the
    file ownership the SUCCEEDED arm takes, not merely the assignment, so moving
    it inside would change what it protects.
  - The mapping takes no responsibility for the staged file. It returns the
    state; the caller reads the file off the result. That is strictly better
    than the original, where `pendingStaged = staged` happened inside one arm:
    "the state and `pendingStaged` refer to the same file or to no file" is now
    the shape of the code rather than a rule two branches have to keep.

Mutations, each killing exactly the test it should:

  | mutation                                    | red test                    |
  |---------------------------------------------|-----------------------------|
  | progress read ignored                       | reports the progress        |
  | runAttemptCount ignored -> always Waiting   | never run is simply starting|
  | runAttemptCount ignored -> never Waiting    | already run is waiting      |
  | success with no path -> empty Converted     | named no file is a failure  |
  | blank name/type no longer falls back        | blank falls back like missing|
  | blank error no longer falls back            | blank message falls back    |
  | cancellation ignores the caller's state     | lands where caller said     |
  | BLOCKED remapped                            | blocked looks like starting |

`ENQUEUED` needs both mutations and both tests: either one alone passes against a
mapping that ignores `runAttemptCount` entirely.

The extracted functions carry `@UnstableApi` rather than swallowing the marker
with `@OptIn`, per CLAUDE.md -- lint's UnsafeOptInUsageError caught their absence.
An early `@Suppress("ReturnCount")` turned out to be unnecessary and was removed
rather than left: detekt is clean without it, and the file now carries none.

502 -> 516 tests, 87.1% -> 87.7% line, 69.1% -> 70.4% branch. Gate green:
assembleDebug, testDebugUnitTest, compileDebugAndroidTestKotlin, ktlintCheck,
detekt, lintDebug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:23:52 -05:00
Jason Ross 348eaa2f22 Merge pull request #161 from JMR-dev/test/dedupe-user-messages
W5: one sentence per user-facing condition, not two
2026-08-29 10:14:08 -05:00
JMR-devandClaude Opus 5 104d02de03 W5 (#158): one sentence per user-facing condition, not two
Four messages were written out in two places each, in a codebase that already
had the convention for this and states it in `OutputPublisher.kt`:

    Kept next to [STAGED_FILE_GONE_MESSAGE] for the same reason it is: both
    ViewModels need it and staging is what it is about.

The ticket named three. A wider scan -- `"[A-Z][^"]{8,90}[.!]"` rather than the
{15,70} that produced the original list -- found a fourth, `"Joining failed."`,
which is the exact join-side twin of `"Conversion failed."` and had been missed
because it is fifteen characters long.

  "Pick at least two files to join."  ->  ConcatWorker.TOO_FEW_INPUTS_MESSAGE
  "Joining failed."                   ->  ConcatWorker.GENERIC_FAILURE_MESSAGE
  "Conversion failed."                ->  ConversionWorker.GENERIC_FAILURE_MESSAGE
  "Could not save the file."          ->  SAVE_FAILED_MESSAGE, beside
                                          STAGED_FILE_GONE_MESSAGE

Each constant sits with the layer that owns the condition, which is what the two
existing constants do. The arity rule is the worker's -- `request(...)` takes a
`List<Uri>` and checks nothing about its length -- so `TOO_FEW_INPUTS_MESSAGE`
lives there and the ViewModel reads it, not the other way round.

WHY THE TWO `Log.e` LITERALS STAY. `"Conversion failed."` and `"Joining failed."`
each also appear in a log line beside the failure they describe. Those keep their
own copies: a log has a different audience and carries the exception with it, and
coupling it to the user-facing wording would mean rewording the screen to change
a log. Stated in the KDoc so the next scan does not read them as a miss.

THE TEST IS A CROSS-LAYER ONE, DELIBERATELY. #158's done-when is explicit that "a
test asserting the constant equals its own value is worth nothing". Sharing a
constant makes the two sites agree by construction; what it cannot show is that
both layers still *reach* it. So `SharedFailureMessagesTest` drives each for real
-- the ViewModel through `onInputsPicked`, the worker through `doWork` -- and
asserts the two answers are the same string, taken from two running layers rather
than from one declaration.

That the sharing was worth doing at all is visible in what was pinned before:
`RefusedJobTest` (#139) pinned the worker's copy of the arity message and nothing
pinned the ViewModel's, so the screen's wording could drift with no test saying
anything.

Mutations:

| mutation | result |
|---|---|
| ViewModel keeps its own drifted literal | red |
| ViewModel's arity guard removed entirely | red |

Gate green: assembleDebug, testDebugUnitTest, compileDebugAndroidTestKotlin,
ktlintCheck, detekt, lintDebug.

Not done here: `"Saved ${s.displayName}."` appears in both screens. It is left
alone, and the reason is a real distinction rather than an oversight -- the four
above are cases where one layer's message is another layer's *fallback*, so drift
means the user sees different words for one condition. Two screens each wording
their own success text is ordinary UI, and drift there is cosmetic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:05:27 -05:00
Jason Ross 90814222b7 Merge pull request #160 from JMR-dev/fix/restore-stack-merges
Restore the five stacked PRs that merged into their bases instead of main
2026-08-29 09:09:46 -05:00
JMR-devandClaude Opus 5 2d4898ad44 Re-measure the coverage entry against the tree this branch creates
84.9% line / 63.8% branch, 454 tests -> 87.1% line (2025/2324), 69.1% branch
(974/1410), 502 tests in 71 classes, as #132 and #133's ten children land.

The entry already instructs re-measuring before quoting, and that is why this is
here rather than in the batch: quoting these numbers before the work merged would
have described a tree that did not exist. It nearly went wrong the other way too
-- the first measurement for this commit was taken against a main that was three
merges stale and read 85.1%.

Also says something the bare numbers do not. Branch moved 5.3 points against
line's 2.2, and that asymmetry is the expected shape of this kind of work rather
than a curiosity: those children targeted decision code -- enum fallbacks,
refusal arms, cursor shapes, a `when` over container rules -- where one test
chooses a branch the suite had never taken. Line coverage barely notices that.
Branch coverage is the whole point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:01:40 -05:00
JMR-dev 83ac7eff2c Merge commit '79cca0e' into fix/restore-stack-merges 2026-08-27 08:59:45 -05:00
Jason Ross b2c11bbfe0 Merge pull request #151 from JMR-dev/test/outputpublisher-seams
S2 + S3: the two OutputPublisher seams, and where the second one actually goes
2026-08-27 08:54:30 -05:00
JMR-devandClaude Opus 5 3a5210ec5d S2 + S3 (#142, #143): the two OutputPublisher seams, and where the second one goes
#142 -- openOutputStream refuses two ways and only one was reachable. A
provider that has gone away throws from inside the call, which
`a destination the provider will not open...` already drives. A provider
that is present and declines returns null, and nothing could produce that
on demand. openDestination is the seam; the test asserts the failure names
the destination, which is what separates the `?: error(...)` from an NPE
inside `use`.

#143 -- the sweep's re-read. **The seam the ticket proposed does not reach
it.** Overriding the listing fires before the entries are snapshotted, so
StagingSweep.collectable is handed the new timestamp, the file is never
proposed for deletion, and the guard is never exercised. Measured: with an
entriesIn seam, deleting the guard outright left the test green.

The race is a file that *was* collectable when the snapshot was taken and
is not by the time the delete comes round, so the seam has to sit at the
snapshot. `snapshot(listing)` does, and deleting the guard now reddens the
test.

Three mutations after the move, three red:

  null stream returns silently   null-return test
  null stream via !! instead     null-return test
  sweep deletes unconditionally  race test

OutputPublisher.kt now has no never-executed lines at all. Two partial
branches are left and both are named exemptions rather than gaps:
L216's `getOrNull() ?: false` and L304's `getOrDefault(absoluteFile)` are
the failure arms of a runCatching whose body cannot be made to throw
through any public entry point -- the same shape as the `size >= 0`
exemption recorded in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:22:38 -05:00
Jason Ross 5f3eda9c40 Merge pull request #149 from JMR-dev/test/outputpublisher-partial-branches
C6: OutputPublisher's guarded branches, three of them guarding a delete
2026-08-27 07:22:35 -05:00
JMR-dev 79cca0eb47 Merge branch 'test/refused-jobs' into test/mediaprobe-track-seam 2026-08-27 07:19:42 -05:00
JMR-dev 2c0bc4a583 Merge branch 'test/concatworker-failure-arms' into test/refused-jobs 2026-08-27 07:19:41 -05:00
JMR-dev 7a47285f37 Merge branch 'test/container-capabilities-audio' into test/concatworker-failure-arms 2026-08-27 07:19:39 -05:00
JMR-dev 8a2bc86cac Merge branch 'test/readspec-enum-fallbacks' into test/container-capabilities-audio 2026-08-27 07:19:38 -05:00
JMR-dev 9b3b9f952b Merge remote-tracking branch 'origin/test/outputpublisher-seams' into test/readspec-enum-fallbacks 2026-08-27 07:19:37 -05:00
JMR-dev a84b24ba27 Merge branch 'test/refused-jobs' into test/mediaprobe-track-seam 2026-08-27 07:18:29 -05:00
JMR-dev 0e2525195b Merge branch 'test/concatworker-failure-arms' into test/refused-jobs 2026-08-27 07:18:28 -05:00
JMR-dev 713d813a65 Merge branch 'test/container-capabilities-audio' into test/concatworker-failure-arms 2026-08-27 07:18:27 -05:00
JMR-dev c360e82a10 Merge branch 'test/readspec-enum-fallbacks' into test/container-capabilities-audio 2026-08-27 07:18:25 -05:00
JMR-dev 699d608b47 Merge remote-tracking branch 'origin/main' into test/readspec-enum-fallbacks 2026-08-27 07:18:24 -05:00
JMR-devandClaude Opus 5 ad47ce6c96 S2 + S3 (#142, #143): the two OutputPublisher seams, and where the second one goes
#142 -- openOutputStream refuses two ways and only one was reachable. A
provider that has gone away throws from inside the call, which
`a destination the provider will not open...` already drives. A provider
that is present and declines returns null, and nothing could produce that
on demand. openDestination is the seam; the test asserts the failure names
the destination, which is what separates the `?: error(...)` from an NPE
inside `use`.

#143 -- the sweep's re-read. **The seam the ticket proposed does not reach
it.** Overriding the listing fires before the entries are snapshotted, so
StagingSweep.collectable is handed the new timestamp, the file is never
proposed for deletion, and the guard is never exercised. Measured: with an
entriesIn seam, deleting the guard outright left the test green.

The race is a file that *was* collectable when the snapshot was taken and
is not by the time the delete comes round, so the seam has to sit at the
snapshot. `snapshot(listing)` does, and deleting the guard now reddens the
test.

Three mutations after the move, three red:

  null stream returns silently   null-return test
  null stream via !! instead     null-return test
  sweep deletes unconditionally  race test

OutputPublisher.kt now has no never-executed lines at all. Two partial
branches are left and both are named exemptions rather than gaps:
L216's `getOrNull() ?: false` and L304's `getOrDefault(absoluteFile)` are
the failure arms of a runCatching whose body cannot be made to throw
through any public entry point -- the same shape as the `size >= 0`
exemption recorded in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:14:19 -05:00
JMR-devandClaude Opus 5 c60d5d54c6 Stop the staging fixture losing a race with the app-start sweep (#159)
`the sweep tolerates a staging path that is not a directory` failed once on run
33069641674, against 468 tests that pass on this machine including under
`--rerun-tasks`:

    java.io.FileNotFoundException at OutputPublisherStagingTest.kt:112
    468 tests completed, 1 failed

Line 112 was `writeBytes` immediately after `deleteRecursively()`.
`FileOutputStream` answers `FileNotFoundException` for an existing directory, so
something had recreated the path inside that window. That something is
`LibreMediaConverterApp.onCreate`, which ends with

    appScope.launch { OutputPublisher(...).sweepStaging() }

on `Dispatchers.IO`, and `sweepStaging` reads `stagingDir`, whose getter calls
`mkdirs()`. Robolectric builds the application for every test that asks for one,
so that background `mkdirs()` is in flight across the whole suite on a thread the
paused main looper does not control and no test awaits.

Retrying closes the window rather than narrowing it, because the race is not
symmetric: `mkdirs()` fails on an existing regular file, so the invariant only has
to survive being *established*. Once a write lands, nothing in the suite can turn
this path back into a directory -- which is also why the new assertion that the
sweep left a file behind is worth making.

The `check()` matters as much as the loop. The next failure here should say
"something recreated conversions/ as a directory", not `FileNotFoundException at
line 112` -- that is the difference between a flake someone reads and a flake
someone re-runs.

The wider problem is #159 and is deliberately not fixed here: `AppStartSweepTest`,
`JobSnapshotsTest` and `SpaceArithmeticTest` all name the same path, and the real
answer is an injectable scope rather than a retry loop in every staging test.
#159's done-when is that this loop can be deleted.

Mutation: `listFiles() ?: return` -> `listFiles()!!` reddens exactly this test.
Gate green: assembleDebug, testDebugUnitTest, compileDebugAndroidTestKotlin,
ktlintCheck, detekt, lintDebug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:14:18 -05:00
JMR-devandClaude Opus 5 d59e9acce5 C6 (#140): OutputPublisher's guarded branches, three of them guarding a delete
destinationIsKnownEmpty's three short-circuits -- no SIZE column, no row,
a null cell -- each had to answer false and none was tested. Its KDoc is
unambiguous about why: "this decides whether a delete is allowed and 'I
could not tell' must never authorise one." The existing tests only ever
drove a provider that answers properly, where the answer is zero and the
delete is correct. Getting the uncertain cases backwards costs the user a
file they already had, on a save that failed.

Also discardStaged's null parentFile, and sweepStaging's null listing --
which is not the case the existing `tolerates a staging directory that
does not exist yet` covers, because stagingDir's own mkdirs() recreates a
missing directory and it then lists as empty. Only a path that cannot be
a directory makes listFiles() answer null.

Five mutations, three bite:

  drop !row.isNull(size)                 short-circuit test red
  drop row.moveToFirst()                 five tests red
  parentFile!! instead of ?: return false parentless test red

  size >= 0  ->  size >= -1              GREEN, does not bite
  parentless treated as staged           GREEN -- bad mutation, see below

The first green one is recorded in the test as a named exemption.
Measured: getColumnIndex returns -1 for an absent column and isNull(-1)
throws CursorIndexOutOfBoundsException, which the surrounding runCatching
already turns into `?: false`. Same answer, reached by the exception path,
so no behavioural test can pin that conjunct. It stays anyway -- control
flow through an exception is worse than a comparison, and another Cursor
implementation need not throw.

The second was my mistake rather than a finding: substituting stagingDir
for the null parent reaches `return false` by a different route, so it
proves nothing. parentFile!! is the honest mutation and it goes red.

:197, :235 and :258 are now covered. What is left in this file is exactly
what the ticket scoped out: :173-174 (#142) and :267 (#143).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:14:18 -05:00
Jason Ross 324c9a4555 Merge pull request #144 from JMR-dev/test/fake-provider-scaffolding
C0 + C3: fake-provider scaffolding, and what InputQuery makes of a metadata row
2026-08-27 07:14:14 -05:00
JMR-devandClaude Opus 5 8a88fc4ae7 C3 (#137): pin what InputQuery makes of a metadata row
Nothing had ever handed InputQuery a cursor row. UnknownInputSizeTest
drives the no-provider case thoroughly -- query returns null, measure()
answers -- so firstRow's body, displayNameOrNull and sizeOrNull had never
executed at all.

Nine tests over FakeSafProvider's RowShape states. What they pin is not
"reads a cursor" but the rule the class exists for: a size nobody could
determine must arrive as null, never 0. Four separate ways a provider
fails to give one -- a null cell, a missing column, a negative value, an
empty cursor -- plus a provider that throws outright, which is the guard
firstRow's KDoc is written for.

Mutations run, all four bite:

  drop `takeIf { it >= 0 }` from sizeOrNull -> negative-size test red
  drop `!isNull(it)` from sizeOrNull        -> null-size test red
  drop the runCatching in firstRow          -> throwing-provider test red
  drop `!isNull(it)` from displayNameOrNull -> GREEN, does not bite

That last one is recorded in the test's KDoc as a named exemption rather
than papered over. Measured: MatrixCursor.getString on a null cell returns
null while getLong returns 0. So the guard is load-bearing on the size path
-- it is what stops a null becoming a real number -- and unfalsifiable on
the name path, where getString already yields null. It stays regardless:
Cursor.getString's contract makes throwing on null implementation-defined,
and a real provider may do what MatrixCursor does not.

InputQuery.kt now has no never-executed lines. Suite 456 -> 465 tests,
branch coverage 63.8% -> 65.6%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 06:57:15 -05:00
JMR-devandClaude Opus 5 44d4c61738 C0 (#134): move the fake providers to scaffolding, let them answer wrongly
Two of #132's items are cursor-shaped -- InputQuery's row reads (#137) and
OutputPublisher.destinationIsKnownEmpty's short-circuits (#140) -- and the
provider that could drive them lived inside OutputPublisherPublishTest and
could only answer correctly. Its row was always (file.name, file.length()).

Moved FakeSafProvider, FakePlainProvider and the registration helper to
FakeProviders.kt, same package, following StagingCleanupSupport.kt and
ParkedPickDispatcher.kt. UnreliableOutputStream stays behind: it serves one
test, which is the line WorkerStubs.kt draws.

Added RowShape, seven ways a provider can answer a metadata query. Column
granularity is deliberate -- OutputPublisher reads only SIZE, InputQuery
reads both and reaches different answers depending on which is bad -- and so
is keeping null, missing, negative and no-row distinct rather than folding
them into one "bad" case. That distinction is the whole reason InputQuery
exists: hasSpaceFor(0) is only "is there 128 MB free", so a size nobody
could determine must not arrive as 0.

No production change. OutputPublisherPublishTest, OutputPublisherStagingTest
and UnknownInputSizeTest pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 06:57:15 -05:00
JMR-devandClaude Opus 5 44493d9943 C5 (#139): the join side's count refusal, found by the residual-gap audit
A gap audit over the eight branches merged together looked for lines still
never executed and asked, for each, whether something already accounts for it.
Everything mapped except one: `ConcatWorker.kt:42`, the refusal of a join with
fewer than two inputs.

Its neighbour maps. `ConcatWorker.kt:40` -- the missing-URI-array arm, two lines
above -- is covered on the device by
`UnopenableUriTest.aJoinWithNoInputArrayFailsWithAMessage`. That is invisible to
JaCoCo, which measures `testDebugUnitTest` only, so the report shows both arms
cold and cannot distinguish the one that is e2e-covered from the one nothing
touches. Only reading the androidTest source separates them.

`grep` says nothing in either source set mentions "Pick at least two files to
join." Two tests here now do:

- `a join of a single file is refused with a message rather than joined` pins
  the verdict and the message together, via `Failure.equals`, for the reason the
  file's header already gives.
- `a join of two files is not refused for its count` is the control that puts
  the assertion on the boundary rather than on the string. It refuses the
  *space* rather than letting the job run: the next thing past the count guard
  is `ConcatEngine`, which is native, and `NamingPublisher`'s KDoc already
  records that no JVM test gets past it. A failure carrying the space message is
  proof execution reached line 57, which is proof it cleared line 42, at no
  engine cost.

Reachability is the header's argument plus one of its own: `request(...)` takes
a `List<Uri>` and checks nothing about its length, so a one-item join is a
well-formed call rather than a corrupted queue entry.

Mutations, each killing exactly the test it should:

| mutation | red |
|---|---|
| guard deleted outright | `a join of a single file is refused...` |
| `uris.size < 2` -> `< 3` | `a join of two files is not refused for its count` |

Restored, both green. Gate green: assembleDebug, testDebugUnitTest,
compileDebugAndroidTestKotlin, ktlintCheck, detekt, lintDebug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:14:46 -05:00
JMR-devandClaude Opus 5 b2790e13d9 S1 (#141): cut the track walk into a pure seam, and test the matrix
#84 closed by classifying probeWithExtractor and probeForConcat as
device-bound and explicitly not a gap. That was right about FFprobe and
right about the measurement boundary, and wrong that these are only
orchestration. The track walk is a branch matrix, and androidTest reaches
it only through whatever the committed fixtures happen to contain -- so
none of its rules is *chosen* by any test there.

#133 offered two ways to reach it: drive ShadowMediaExtractor, or cut the
loop into a pure function. Taking the second, which is the pattern
CLAUDE.md names and work/FailureOutcome.kt documents. extractedFrom and
concatInputFrom take List<MediaFormat>; what is left needing a device --
setDataSource, getTrackFormat, release -- is one three-line extension
function, which is the thin edge androidTest should be covering.

The two are deliberately not merged despite the overlap. One reads
duration and not frame rate; the other reads frame rate and not duration.
A merged version would compute both for every caller, and ConcatPlanner
treats an unknown frame rate as "cannot prove a match" -- so a field the
join flow does not need must not start arriving as a number.

Eleven tests over cases no fixture provides: two video tracks, two audio
tracks, audio outlasting video, a track with no KEY_DURATION, audio
declared before video, a subtitle track, and no tracks at all.

Six mutations, six red:

  last video track wins            first-video test
  last audio track wins            first-audio test
  duration = last rather than max  longest-track test
  drop the containsKey guard       six tests (getLong throws on a missing key)
  guess a frame rate of 30         no-frame-rate test
  join takes the last video track  join frame-rate test

MediaProbe's missed branches drop 91 -> 70; what is left is the FFprobe
half and the two catch arms, which are native and device-bound exactly as
#84 said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:51:10 -05:00
JMR-devandClaude Opus 5 de6d9526ba C5 (#139): the two jobs ConversionWorker refuses before converting
Both exits were cold, and both are reachable for the same reason: a job
does not have to come from the picker. WorkManager keeps work for about a
week, so a downgrade or rollback hands this build a job enqueued by
another one, and request(...) is callable directly.

:62 -- a missing KEY_INPUT_URI -- was untested everywhere, JVM and device.
The nearest e2e test, ForcedFailureTest.aMissingInputFailsRatherThanCrashing,
passes a URI pointing at a file that does not exist, which reaches the
engine and fails much later with a different message.

:124-126 -- the Validation.Invalid refusal -- had no test at all, though
its comment names both arrival paths it exists for.

Five tests, in a new file because both are about the *message*. A refusal
that fails with empty output Data renders the UI's generic "Conversion
failed." with nothing else to say, which is the defect shape
DeniedForegroundStartTest records from the device pass; asserting the
verdict alone would pass against exactly that.

Two of the five are there to stop the others passing for the wrong reason:
`a refused spec never reaches an engine` says it failed *before*
converting rather than during, and `a valid spec is not refused` is the
control -- without it every assertion here would still pass against a
worker that refused everything.

Three mutations, three red:

  change the no-input message         no-input message test
  drop the validation refusal         both refusal tests
  validate but keep converting        both refusal tests

L61-62 and L123-126 are now fully covered, branches included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:37:23 -05:00
JMR-devandClaude Opus 5 bb3358f209 C4 (#138): ConcatWorker's cancellation and give-up arms
ConversionWorker has WorkerCancellationTest and DeniedForegroundStartTest.
Its twin had the retry case only -- `a join whose foreground start is
denied` already existed -- so two of ConcatWorker's three failure exits
were cold: the CancellationException arm, and FOREGROUND_DENIED.

Four tests, added to the files that own each rule rather than to a new
ConcatWorker file, which is how this suite is organised: a file per rule,
tested across both workers.

The cancellation seam is worth a look in review. The conversion twin
cancels inside the engine, which is honest there because
ConversionDependencies has a seam for it. ConcatWorker calls ConcatEngine
directly and has none -- it is native and nothing here gets past it -- so
the cancellation is injected at the only other point inside the try,
setForeground. That is a real shape rather than a contrivance: a job
cancelled while WorkManager is promoting it is exactly when that window is
open, and the catch arm cannot tell where in the try it came from.

FailedFuture moved to WorkerStubs.kt on the way. Two tests now inject two
different failures through it, and Kotlin will not take two file-private
top-level classes of one name in one package.

Four mutations, four red, each isolated:

  cancellation arm -> Result.failure     propagation test only
  drop delete on cancellation            cancellation-partial test only
  FOREGROUND_DENIED -> Result.retry      past-the-bound test only
  drop delete on the Throwable path      give-up-partial test only

ConcatWorker's :92, :95-96 and :105-106 are covered; missed branches 4 -> 3.
What is left is what the ticket scoped out: the two input guards (e2e), the
ConcatEngine success path (native), and getForegroundInfo (#88's named
exemption).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:33:04 -05:00
JMR-devandClaude Opus 5 04850a0415 C2 (#136): test the audio half of validate, and the one video refusal missing
The two halves of ContainerCapabilities.validate were written together
and only one of them was ever checked. Six audio outcomes had no test --
every one a string the user reads -- while the video twin of each was
already covered.

Seven tests, deliberately shaped like their twins rather than as a fresh
idea about what to assert:

  unidentifiable source audio on a COPY   twin of `an unidentifiable
                                          source codec cannot be copied`
  container cannot hold the copied source twin of `a codec the container
                                          cannot hold is refused...`
  container cannot carry it on encode     twin of `H265 in AVI is refused`
  this app cannot encode it               twin of `copying is offered as
                                          the fix when...`
  accepts(_, AudioCodec.NONE, _) -> true  twin of the VideoCodec.NONE arm
  accepts(_, AudioCodec.COPY, _) throws   twin of `resolving COPY before
                                          asking the matrix is required`

The seventh is not the audio axis: validateVideo's copy-into-a-container-
that-cannot-hold-it refusal was the one video outcome with no test, and it
is the same shape and the same file.

Each asserts the message verbatim and re-validates every suggestion the
refusal offers. Validation.Invalid promises its suggestions are themselves
valid and names this class as the proof; the existing property test walks
the presets, and no preset reaches suggestions() through validateAudio.

Seven mutations run, seven red, each isolated to exactly one test:

  CARRIES_AUDIO check -> false     encode-path test only
  drop the COPY error arm          resolve-first test only
  AudioCodec.NONE -> false         no-audio-track test only
  drop ENCODABLE_AUDIO check       unencodable test only
  drop audio copy container check  audio-copy test only
  drop video copy container check  video-copy test only
  drop unidentified-audio guard    unidentifiable test only

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:27:27 -05:00
JMR-devandClaude Opus 5 8ab433b647 C1 (#135): pin readSpec's three enum fallbacks
WorkerEnumFallbackTest already existed for this defect class -- a name
this build does not define, read above the try, throwing out of doWork
entirely: FAILED with reschedule=false, empty output Data so the screen
said "Conversion failed." with nothing else, and the staged file never
deleted. It covered 2 of the 5 above-the-try reads. readSpec's three
were the ones left, and all three were cold.

The baseline is the part worth reviewing. readSpec returns the *entire*
fallback spec the moment any one axis fails to resolve, so a test
starting from MP4_H265 -- which is itself the fallback -- cannot tell a
worker that read the spec correctly from one that gave up on it. These
start from MKV/H.264, which differs on container and video codec at
once, and assert the spec that actually reached the transcoder rather
than only that a Result came back.

Mutations run, four for three tests:

  KEY_CONTAINER    `?: return fallback` -> `?: error(...)`  -> container test red
  KEY_VIDEO_CODEC  same                                     -> video test red
  KEY_AUDIO_CODEC  same                                     -> audio test red
  fallback = MP4_H264 instead of MP4_H265                   -> all three red

The first three confirm the tests are isolated to their own axis; the
fourth confirms they pin *which* spec ran, which is what "a Result at
all" would have missed.

readSpec is now fully covered, branches included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:24:10 -05:00
Jason Ross 5e58334230 Merge pull request #131 from JMR-dev/docs/coverage-read-findings
Record the code findings from the 2026-08-26 coverage read
2026-08-26 22:11:09 -05:00
JMR-devandClaude Opus 5 d9c32c6ce5 Add F5: areEnabled() is never called, so it is not a test gap
Found while decomposing #132 into children. It was item 6 there, and it
looked like the cheapest item on the list: three cold lines, a KDoc with
real user-visible stakes, and a permission Robolectric can flip in one
line.

grep -rn 'areEnabled' app/src returns the declaration and nothing else.
Both workers construct ConversionNotifications and only ever call
build(). So the behaviour the KDoc describes -- warning when progress
will be invisible -- does not happen, and a test would assert that a
function nobody calls returns what the platform told it. Green, vacuous,
and worse than nothing, because it would imply the disabled-notification
case is handled.

Recorded rather than tested, and the summary now names what F1 and F5
have in common: a comment describing behaviour the code lacks, where the
tempting fix freezes the wrong answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:03:11 -05:00
Jason Ross c43d865651 Merge branch 'main' into docs/coverage-read-findings 2026-08-26 22:01:55 -05:00
JMR-devandClaude Opus 5 8a23f2a0b8 Correct the #122 claim this document got wrong from one green run
The ConversionForegroundType note asserted that #122's wedge no longer
kills the API 33 leg, on the evidence of a single run. The PR carrying
this document then wedged that exact leg: 23m08s, "wedged: yes --
gradle was killed after 1200s and never returned", failed: unknown.

Corrected to what the runs actually show: intermittent, not resolved --
five of the last six completed legs passed in ~7 minutes. And the
distinction the wedge row exists to draw is now stated, because it is
what keeps #88's reasoning intact: received: 60 means all sixty tests
still reported, so the API 33 regime was exercised; it is the failed
count that reads "unknown", so the leg could not have reported a break.

Also names what that changes -- a @Config(sdk = 33/34) JVM test is
worth three lines as insurance against a leg that cannot be trusted to
go red, which is a different and much smaller claim than the uncovered
behaviour this first looked like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:47:17 -05:00
JMR-devandClaude Opus 5 25992863e6 Name the ticket numbers the findings doc defers to
#132 holds the seven JVM test gaps from the same read, #133 the three
seam questions. The doc drew the line between them in prose already;
this makes it followable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:21:34 -05:00
JMR-devandClaude Opus 5 232cbd1949 Record the code findings from the 2026-08-26 coverage read
Four things came out of re-measuring coverage that a test would document
rather than repair, so they go in a doc rather than a ticket:

- F1 FFmpegCommandBuilder emits a Vorbis encoder ContainerCapabilities'
  own comment says nothing emits. Traced unreachable through four call
  sites, but the interesting reading is the other one: FFmpeg can encode
  Vorbis, WebM and OGG carry it, and the picker never offers it.
- F2 ConversionRequest.hardwareEncodeAvailable is written once and read
  by nothing; its KDoc describes a Fast-tier preset choice that was
  removed, and the router computes the same answer itself.
- F3 ConversionRequest.videoCodec/.audioCodec have no callers anywhere.
  Named as NOT a test gap: asserting a delegation restates it.
- F4 Two private guards reachable only by direct call. No action, per
  the judgement #88 reached about getForegroundInfo.

Also records two things the read makes look like gaps and are not: the
Compose screens' branch numbers (inflated by compiler-synthesised
recomposition checks; the line figures are 34/383 and 20/143), and
ConversionForegroundType, where #88's premise was re-checked against
#122's wedge and holds -- the API 33 leg completes 60/60 cleanly.

Entry ids are F1-F4 so they cannot be confused with defect-audit.md's
D1-D16, and the confidence vocabulary is deliberately that document's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:18:30 -05:00
Jason Ross 099b7fd7c4 Merge pull request #129 from JMR-dev/chore/gitignore-kotlin
Ignore Gradle's .kotlin/ build-state directory
2026-08-26 00:41:44 -05:00
JMR-dev 7f2a6e1376 Merge remote-tracking branch 'origin/main' into m-129-tmp 2026-08-26 00:34:02 -05:00
Jason Ross dc8b7c3944 Merge pull request #127 from JMR-dev/test/bound-the-hangs
Bound the JVM suite's hangs so a deadlock ends in minutes with a stack
2026-08-26 00:21:48 -05:00
JMR-devandClaude Opus 5 1d80e88f9b Ignore Gradle's .kotlin/, which every local build leaves in the repo root
It has never been committed, so nothing is wrong today -- but nothing stops it
either, and `git add -A` would stage Kotlin build-session state into history.

It belongs beside /build, .gradle and .cxx, which are the same category and are
already here. Placed with them rather than in a section of its own, and left
without a comment: unlike tools/ffmpeg/out/ and .claude/, there is no non-obvious
choice here to explain.

Verified rather than assumed:

  $ git check-ignore -v .kotlin
  .gitignore:16:.kotlin	.kotlin

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 00:14:31 -05:00
JMR-dev 27d7cc0a86 Merge remote-tracking branch 'origin/main' into m-127b-tmp 2026-08-26 00:14:07 -05:00
Jason Ross 6df1bdf31a Merge pull request #115 from JMR-dev/docs/coverage-remeasure
Re-measure coverage, because the figure here predates the test push
2026-08-26 00:04:40 -05:00
JMR-dev c6b581ab5a Merge remote-tracking branch 'origin/main' into m-115b-tmp 2026-08-25 23:57:17 -05:00
JMR-dev c27881ab64 Merge remote-tracking branch 'origin/main' into m-127-tmp 2026-08-25 23:57:00 -05:00
Jason Ross 34641df19d Merge pull request #126 from JMR-dev/ci/baseline-counter-precision
Count the annotation, not the comment saying a test does not carry it
2026-08-25 23:49:01 -05:00
JMR-devandClaude Opus 5 0f39964193 Say which misfire the hang watchdog actually has
The comment described adopting a later build's worker as an edge case. It is the
ordinary CI shape: the worker is found by scanning this daemon's descendants for
GradleWorkerMain, which cannot tell one invocation from the next, and the Unit
tests job runs testDebugUnitTest and jacocoTestReport back to back against one
daemon. Still harmless -- the watchdog only reads and writes -- but a reader
should not have to rediscover that.

Refs #125.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:47:16 -05:00
JMR-dev 71141b5734 Merge remote-tracking branch 'origin/main' into m-126-tmp 2026-08-25 23:40:37 -05:00
JMR-devandClaude Opus 5 81ad102f2a Stop a deadlocked unit-test run, and make it say what it deadlocked on
The JVM suite had no timeout of any kind, so #125's Room/WorkManager lock-order
inversion ran until something outside it gave up: 47 minutes locally, and on CI
it would burn the Unit tests job's 30-minute cap and report as a job timeout
with no cause. The deadlock is monitor contention, which no interrupt breaks, so
nothing inside the JVM could have ended it either.

The obvious fix does not work here. A JUnit `Timeout` -- as a rule or as
`@Test(timeout = ...)` -- runs the test body on a separate thread, and every Compose test in this
source set goes through Robolectric's paused main looper. Both forms fail with
"main looper can only be controlled from main thread"; the same tests with the
timeout removed pass, so it is the mechanism and not the probe.

So the bound comes from outside the test JVM, where it moves no threads:
`timeout` on the Test tasks kills the forked worker, and a watchdog jstacks that
worker two minutes earlier. The jstack is the point. Gradle's timeout on its own
kills silently, a timed-out run writes no XML for the class that hung, and the
JVM's own "Found one Java-level deadlock" section naming both monitors is the
only reason #125 could be described at all -- so it goes to stdout as well as to
a file, because the Unit tests job uploads only reports/tests/.

Ten minutes is against the slowest observed passing run, not the typical one:
eight CI samples of the whole invocation ranged 62-90s, so this is ~6.7x that
and a third of the job cap. A timeout that fires on a healthy slow runner turns
a real signal into noise.

Both numbers live in a build script that nothing compiles, so HangBoundTest
reads them back and the build script joins build.yml as a declared input --
without that the guard would go stale on exactly the edit it exists to catch.

Refs #125.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:35:13 -05:00
JMR-devandClaude Opus 5 0f41bc3f6b Count the annotation, not the comment saying a test does not carry it
The advisory baseline check has announced a deviation on every PR since #113:
the tree "carries 4 tests marked @FailsOnEmulatorApi37" where it carries three
and FAILS_ON_EMULATOR_API37_BASELINE says three. The fourth is a KDoc in
Media3EngineTest saying the opposite -- "Deliberately not
`@FailsOnEmulatorApi37`: nothing here decodes or encodes" -- which the old
matcher counted because it looked for the string anywhere on any line.

Neither ingredient was wrong on its own, and the number is not the real damage.
#83 added this check so that a new failure joining the known ones could not be
invisible; a notice that is wrong every single time teaches everyone to skim
past deviation notices, which is precisely the signal it was built to create.
Editing the baseline to 4 would have silenced it by breaking it -- the check
would then have been wrong the moment someone added or removed a real marker.

Anchor the pattern at line start and require whitespace or end-of-line after the
name. The second half is the part that is easy to get wrong: "only the
annotation on a line of its own" also stops counting `@FailsOnEmulatorApi37
@Test`, which is legal Kotlin, and undercounting is the dangerous direction --
it hides a genuine new marker, the one thing this exists to catch. Measured
against a fixture carrying every shape at once: the old matcher 5, own-line-only
2, this one 3; on the real tree 4 / 3 / 3, so the baseline is untouched.
`grep -v import` goes too, since `^[[:space:]]*@` cannot match an import.

The check is a pure function of the working tree, so the fixture is committed
and e2e-report-shape-test.sh runs the real report against it -- inside a
throwaway repo root, which the script finds from BASH_SOURCE, so no knob had to
be added that could point the live count somewhere else. The fixture sits under
.github/, where Gradle does not compile it and :app's ktlint and detekt do not
see it; running the report against the real root with it committed still
reports 3.

Every other path through the report is byte-identical to the previous version on
both stdout and the job summary -- passing, failing, wedged, no-run, and
advisory-with-an-unreadable-baseline all diff empty -- and the two advisory legs
differ only by the false line disappearing. No job's status or pass/fail rules
change; the advisory leg stays continue-on-error and stays red by design.

The test is deliberately not wired into CI: adding a step to Static analysis
would add a new way for a gating job to go red, which #120 ruled out. shellcheck
still covers the file, since that step reads `git ls-files '*.sh'`.

Closes #120

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:30:55 -05:00
JMR-devandClaude Opus 5 4f1a007b58 Quote the settled figure, now that the work it was waiting on has landed
This PR was opened quoting 81.4%, measured on b53f326. Holding it until #116,
#117, #119, #121 and #124 merged was the point: by the time it was ready the
number had moved three points, which is the same staleness the entry is about.

Measured on 93ebfa6 with ./gradlew :app:jacocoTestReport:

  LINE    1971/2321   84.9%   (81.4% four hours earlier, 69.2% on 2026-08-24)
  BRANCH   900/1410   63.8%   (60.2%, then 53.2%)

454 JVM tests in 67 classes, all green.

The note now says the entry went stale while it was open, because that is a
better argument for the rule than the rule restating itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:24:54 -05:00
JMR-dev bf78e06969 Merge remote-tracking branch 'origin/main' into m-115-tmp 2026-08-25 23:24:29 -05:00
Jason Ross 93ebfa6b4a Merge pull request #117 from JMR-dev/fix/invalid-suggestion-chip
Offer a fix that works when the file has no video to copy
2026-08-25 23:21:09 -05:00
JMR-dev d94906ef42 Merge remote-tracking branch 'origin/main' into m-117b-tmp 2026-08-25 23:13:02 -05:00
Jason Ross 959bd8be13 Merge pull request #121 from JMR-dev/ci/wedged-leg-report
Say in the run-shape table when the wedge timeout was what killed the leg
2026-08-25 23:11:39 -05:00
JMR-dev b93ef79931 Merge remote-tracking branch 'origin/main' into m-117-tmp 2026-08-25 23:04:11 -05:00
JMR-dev d739b425c0 Merge remote-tracking branch 'origin/main' into m-121-tmp 2026-08-25 23:04:09 -05:00
Jason Ross cd77aceff4 Merge pull request #124 from JMR-dev/fix/reattachment-overwrites-pick
Let the user's pick keep the screen a reattachment was about to take
2026-08-25 22:58:57 -05:00
JMR-dev febd141bea Merge remote-tracking branch 'origin/main' into merge-124-tmp 2026-08-25 22:51:06 -05:00
JMR-dev 3d8b89bfab Merge remote-tracking branch 'origin/main' into merge-121-tmp 2026-08-25 22:47:44 -05:00
JMR-devandClaude Opus 5 c4bb7d4d2d Quote the rate the ticket settled on, and point the save gap at its ticket
Two accuracy fixes to notes the earlier commits left behind.

The test KDocs carried "roughly 1-in-130" and a 400-leg-attempt denominator.
Both come from earlier comments on #49 that its own census later replaced --
that ticket has three recorded corrections to its rate claims, and a
superseded figure in a permanent comment is the exact thing its author kept
having to fix. What survives the corrections is the count and the spread:
four occurrences, API 33, 35 and 36, every one on attempt 1 and green on
re-run.

The save exemption described a real defect with nowhere to look it up. It is
#123 now, so the KDoc names a number instead of trailing off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 22:38:49 -05:00
JMR-devandClaude Opus 5 3599307040 Say what the save exemption does not cover, rather than implying it is total
The note claimed `save` is left unguarded because nothing can overwrite what
it writes. That half is true -- the only observation that could belongs to a
job already in a terminal state. The other half was missing: a save whose
copy is still in flight when the user taps Start over lands `Saved` on a
screen they have just cleared.

Guarding it would drop that write instead, which reports nothing for a file
that may genuinely have reached the destination. That is a question about
what the screen should offer during a save, and answering it in a race fix
would be deciding it by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 22:36:23 -05:00
JMR-dev 3f731d8ea7 Merge remote-tracking branch 'origin/main' into merge-117-tmp 2026-08-25 22:35:01 -05:00
JMR-devandClaude Opus 5 cc424dd08f Let the user's pick keep the screen a reattachment was about to take
`reattach()` read `_state.value`, found it `Idle`, and then handed the job
to `observe()` -- which launches a *separate* coroutine that cannot write
until its `collect` has resumed with a `WorkInfo`. So the check happened at
one moment and the write landed at another, with a whole pick able to fit
in between: the user tapped, their metadata query suspended, the guard saw
an empty screen, and the finished job from an earlier session wrote over
`Ready(picked)` a moment later.

The comment above that guard said "no suspension point between this check
and the assignment below, so nothing can interleave". There is no
assignment below, and the two lines are in different coroutines. That
sentence is why this sat as flaky CI for two days rather than being read as
the product race it is.

`ScreenOwnership` makes the answer the test already encodes -- the user's
pick wins -- true rather than probable. A claim is taken synchronously when
the user acts; every write that lands after a suspension point checks the
claim it was made under and drops itself if that claim has been superseded.
Dropped, not reordered: a write that is dropped cannot come back later.

Cancelling the superseded observer was never enough on its own. `Job.cancel`
is honoured at the next suspension point, and a collector that has already
resumed and is on its way to `_state.value = ...` has none left; the write
lands anyway. It also cannot help at all in the case reported, where nothing
supersedes the observation until after it has been launched.

`JoinViewModel` had the identical shape and nothing watching it, so it gets
the same fix and the counterpart test that was missing. Its pick dispatcher
becomes injectable for the same reason `ConversionViewModel`'s already was:
without that seam there is no way to ask what happens while a pick is still
in flight.

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 22:15:17 -05:00
Jason Ross b49295d2bf Merge pull request #119 from JMR-dev/test/theme-live-branches
Correct the theme KDoc's switch claim and cover the branches that actually run
2026-08-25 22:05:28 -05:00
JMR-devandClaude Opus 5 25aac95db9 Say in the run-shape table when the wedge timeout was what killed the leg
The report added by #111 runs on every path out of e2e-run.sh, including the
wedge, and until now it answered a question it had not been asked. On job
98035980326 -- API 34, a docs-only PR -- it printed `received: 59` and
`completed cleanly: yes` six seconds before `##[warning] ... WEDGED`, for a leg
the WEDGE_TIMEOUT had killed 22 minutes in. `completed cleanly` means only
"instrumentation was not aborted", which was true; a reader scanning the table
had to notice a separate warning line to learn the leg had died.

The wedge cannot be read out of the log, which is why it is passed in: a wedge
is gradle never returning, so gradle printed no verdict, no truncation line and
no INSTRUMENTATION_ABORTED, and the log it leaves is the log of a run that just
stops. Only e2e-run.sh saw `timeout` exit 124. It now derives that fact once and
tells the report as E2E_WEDGED_AFTER, and reuses the same variable for
capture_wedge so the two cannot drift.

The table gains a `wedged:` row above `completed cleanly`, and `completed
cleanly` flips to no -- but only where it would have said yes. An abort already
says no and names the abort, which the wedge row does not, and a run that left
no evidence still says unknown; a wedge on top of either prints both facts.

`received`'s source line told the same lie in the same table -- "the run was not
truncated, so every expected test reported" is only "gradle never got as far as
saying so" when the leg was killed -- so it is qualified on that path. The
number itself is unchanged, and so is `failed: unknown`: gradle printed no
summary line, so that count genuinely is not knowable.

Nothing here decides anything. No exit status, no pass/fail rule, no baseline
comparison and no `::notice::` behaviour changes; the leg already failed
correctly and still does.

Verified against captured CI output rather than a live emulator, as #111 was and
for the same reason -- this host cannot run API 37 and cannot wedge on demand.
Four real logs (the wedged leg, a green API 34 leg, a failing gating leg, and an
advisory leg with its baseline deviation) through both versions of the script,
in both env states, comparing stdout and the job summary: only the wedged run
with the signal set differs, byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:59:54 -05:00
JMR-devandClaude Opus 5 dce516224c Offer a fix that works when the file has no video to copy
Refusing "copy the video" for a file that has none built its one suggestion by
hand — drop the video track and leave everything else alone. That is valid only
when the audio axis already happened to be fine. For any audio the target cannot
carry (Vorbis or PCM into MP4, MP3 into WebM) the offer is refused in the next
breath, so the Advanced picker showed a one-tap fix leading straight to a second
error. Nothing unsafe shipped — ConversionWorker re-validates — but it is a dead
end, and it contradicted the promise Validation.Invalid makes in its own KDoc.

Route it through the shared repair-and-filter path instead, as every other branch
does. Excluding what the *user* asked for rather than the already-repaired spec
is what keeps the case that worked working: an MP3 into MP4 still gets its copy
offered, because the repair of a copyable track is that same copy.

Only a branch that builds its own list can break that promise at all, since
suggestions() ends by filtering on validate().isValid. The property test now
covers both of them — this one and the image output — rather than reaching them
by luck, which is how a dead-end chip survived two earlier widenings of it. Its
failures name the probe too: three rows share a spec and differ only in the input.

Closes #114

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:59:37 -05:00
JMR-dev 2b7520061b Merge remote-tracking branch 'origin/main' into merge-119-tmp 2026-08-25 21:57:49 -05:00
JMR-devandClaude Opus 5 4e46eb99f6 Say what the theme's dynamicColor parameter does, and test the branches that run
The KDoc claimed dynamic colour "stays switchable so users can opt back to the
brand palette". Nothing switches it: MainActivity is the only caller and passes
no arguments, so dynamicColor is always true and the two brand-palette branches
are dead. A reader who trusted that sentence would go looking for a setting that
has never existed.

Replace the claim with what is true today and point at #68, which holds the
decision -- add a switch, delete the dead branches along with the template
palette, or replace that palette first. None of the three is taken here.

ThemeKt had no test, so nothing would have caught the branches being swapped
either. Assert what the theme resolves by reading MaterialTheme.colorScheme
inside the content lambda: the two live branches on background luminance, which
is the one thing two schemes off the same device palette do not share, and the
dead pair by passing dynamicColor explicitly. Both KDocs say plainly that the
test is the only thing that passes it, so the coverage is not misread as
evidence a switch exists -- which is the misreading #68 exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:46:18 -05:00
Jason Ross 62040b2161 Merge pull request #116 from JMR-dev/fix/failed-save-retry
Offer the file again after a failed save, rather than only offering to delete it
2026-08-25 21:44:05 -05:00
JMR-dev 83b557409e Merge remote-tracking branch 'origin/main' into merge-116-tmp 2026-08-25 21:36:57 -05:00
Jason Ross 0eb00d2003 Merge pull request #113 from JMR-dev/fix/empty-composition-crash
Refuse a spec that would produce an empty file, and guard the Media3 export that used to die building one
2026-08-25 21:17:10 -05:00
JMR-devandClaude Opus 5 c887af0d83 Offer the file again after a failed save, rather than only offering to delete it
save()'s onFailure keeps the staged file on purpose -- it can be the only copy
of an hour of transcoding, and the destination did not receive it -- and then
handed the screen a Failed carrying a message and nothing else. That branch
rendered exactly one control: "Start over", wired to reset(), which discards
precisely the file the comment above it goes out of its way to keep. The intent
was already written down in main; the UI did not honour it, and the only rescue
was process death followed by reattach -- unadvertised, and bounded by a sweep
that collects anything a day old.

Failed now carries a PendingSave, and only where the failure came from save().
A transcode that died staged nothing and must not sprout a save button, so the
handle is nullable and the observe() arm leaves it null; so does a save that
found the file already gone. The branch renders "Try saving again" above "Start
over", opening the same CreateDocument flow with the same name and type the
first attempt used. A retry that fails again lands back on a carrying Failed
rather than a bare one, so the second failure cannot eat what the first kept.

Start over still deletes from there, and that is a decision rather than an
inheritance: deletion is the user's choice only once the alternative has been
offered. pendingStaged remains the single owner of the delete, so the carried
handle is a view of it rather than a second owner and no path out of the state
can drop a file the old shape could not.

pendingSave() exists so save() and each screen's CreateDocument registration
answer "what would a save target" once instead of twice -- the entry points
cast to Converted/Joined, which answered null for a Failed and fell back to the
current pickers, wrong for any spec edited since the job ran and for every
reattached job.

Both tabs, since JoinViewModel and JoinScreen have the same shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:16:02 -05:00
JMR-dev 2e0c6737d6 Merge remote-tracking branch 'origin/main' into merge-113-tmp 2026-08-25 21:09:13 -05:00
JMR-devandClaude Opus 5 68f841e84f Re-measure coverage, because the figure here was quoted from before the test push
CLAUDE.md's own rule is "re-measure before quoting", and the figure it carried
was measured on 2026-08-24 -- before the #52 children, the MediaProbe and codec
tests, and the guards from #100/#107 landed. Quoting it now would understate the
suite by twelve points, which is the same failure the bullet directly below it
was written to describe.

Measured on b53f326 with ./gradlew :app:jacocoTestReport:

  LINE    1847/2268   81.4%   (was 1519/2194, 69.2%)
  BRANCH   837/1390   60.2%   (was 53.2%)

against 417 JVM tests in 60 classes, all green.

The denominator moved too, 2194 -> 2268: the same push added production code of
its own, so this is not a pure numerator gain and the note now says so. The
Robolectric/isIncludeNoLocationClasses history is left exactly as it was -- it
explains why every pre-2026-08-24 figure was an artifact, and that is still the
most useful thing in the entry. "That date" is now spelled out, since the
headline date above it has moved and the phrase no longer points at itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:07:28 -05:00
Jason Ross b53f326f9e Merge pull request #111 from JMR-dev/ci/advisory-failure-report
Say what the advisory API 37 job actually found, so a new failure is not invisible
2026-08-25 21:00:58 -05:00
JMR-devandClaude Opus 5 85461943d6 Keep the instrumented test counts in step with the suite
The API 37 entry names how many instrumented tests there are and how
many the gating leg runs, and this PR adds one. Nothing asserts those
figures, which is exactly why they rot quietly: 59/56 becomes 60/57.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:59:20 -05:00
JMR-devandClaude Opus 5 238142d9cc Guard the whole Media3 export instead of only its two ends
transcode() posts its work to a HandlerThread, and everything on that
thread has no caller to throw back to: an escaping exception reaches the
thread's uncaught handler and takes the process down, while the
continuation is never resumed. Both halves of that are bad, and the
second is arguably worse — a worker left suspended forever holds a
foreground service.

The guarding was two narrow runCatching blocks, one around
buildTransformer and one around transformer.start, with the two Media3
builders sitting unguarded between them. That gap was not theoretical.
EditedMediaItem.Builder rejects a composition with both tracks removed,
which is exactly what a plan of (Drop, Drop) asks for, and it does so
with a plain IllegalStateException from the constructor.

Validation now refuses the spec that produces such a plan, so neither
the picker nor ConversionWorker will start one. Routing is a separate
question and still answers Media3 for it — a dropped track makes nothing
un-hardware-able — so a request that skips validation still arrives
here: a job queued before the settings changed, or one made through
ConversionWorker.request directly. CopyPlanner's own KDoc already names
that path as the reason it re-checks what validation has checked; this
is the same belt for the same braces.

One guard around the whole body costs nothing on success and turns any
such refusal into a failed job with a reason attached. The export body
moves into startExport, whose contract is the thing that makes one guard
enough: returning normally means the export is running and the listener
owns the continuation, throwing means it never started and the caller
does. Cancellation is still registered before start.

Covered twice on purpose. Robolectric runs the real HandlerThread and
the real Media3 builders, so the JVM test exercises the whole sequence
and can be run anywhere; the instrumented one repeats it against the
real framework. Neither asserts only that the failure is an
IllegalStateException, because withTimeout raises
TimeoutCancellationException and java.util.concurrent.CancellationException
extends IllegalStateException — so that assertion alone calls an
unresumed continuation a pass. Both were written that way first, and
reverting the guard is what exposed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:57:41 -05:00
JMR-devandClaude Opus 5 9c809d4e16 Refuse a spec that would leave the output with no tracks at all
Validation already refused two ways of asking for an empty file: None on
both codec axes, and Copy for a video track the input does not have. It
missed the third, because it read only the spec. Name H.265 with the
audio off, hand it an MP3, and the spec looks fine — it names a video
codec — while CopyPlanner drops that track anyway, because the *input*
has no video to encode. The plan is (Drop, Drop), the router still says
Media3, and EditedMediaItem.Builder refuses to build a composition with
both tracks removed. It refuses it on Transformer's own HandlerThread,
where the user sees the app die rather than a reason.

Asking the probe as well as the spec catches all three faces with one
guard, and the equivalence is exact rather than approximate: CopyPlanner
drops video for None or for an input with none, and audio for None, so
"(Drop, Drop)" and this condition are the same set. A sweep over every
non-image container by codec by codec against both probes asserts that,
so a new container or codec cannot reopen the gap on an axis nobody
wrote a case for.

This newly refuses a combination the Advanced picker accepts today, and
that is the point: today it crashes. What it must not do is refuse
without a way out. The Copy face had one only nominally — its single
hand-built suggestion was None + None, which validation rejects in the
next breath, so the one-tap fix fixed nothing. All three faces now go
through the shared repair-and-filter path, which for an MP3 into MP4
offers "copy the audio across" and nothing that has to be re-refused.

Repair is also stopped from naming a video codec for a file with no
video track. It used to fall through to the first codec the container
could encode, so the fix offered for an MP3 was "H.264" — a codec
CopyPlanner then drops, making the offer a fiction that happened to
validate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:49:16 -05:00
JMR-dev bf2214a549 Merge remote-tracking branch 'origin/main' into merge-111-tmp 2026-08-25 20:41:26 -05:00
Jason Ross 989069207e Merge pull request #110 from JMR-dev/docs/seven-run-counts
Stop counting the run this page calls inconclusive
2026-08-25 20:32:37 -05:00
JMR-dev 72ff7adfcc Merge branch 'main' into docs/seven-run-counts 2026-08-25 20:25:06 -05:00
JMR-dev 994ea8a3dd Say in the step log that the summary was written, since nothing else can
The job summary is the deliverable #83 asked for -- "readable without opening a
log" -- and GitHub exposes no API that reads a job summary back: the check-run
output for the advisory job returns summary: null, so a write that silently did
not happen would be invisible to everything except a human on the run page. The
step log can be read, so it now carries one line saying which of the two
happened, including the case where GITHUB_STEP_SUMMARY is unset entirely, which
is what running the script by hand looks like.
2026-08-25 20:23:05 -05:00
JMR-dev 3e9528454c Announce a baseline it cannot read, rather than falling quiet
"A comparison was asked for" and "a number was found to compare against" were one
variable, and collapsing them put the report one refactor away from being the
thing #83 filed. The sed that reads FAILS_ON_EMULATOR_API37_BASELINE is anchored
at the line start, so indenting the const into an object -- or renaming it, or
moving it -- empties it, and the old code then skipped the whole comparison while
the table kept printing exactly as before. Silent, and indistinguishable from a
run that matched.

Now an unreadable baseline is itself a deviation, with the notice naming the
const so the fix is obvious. Verified against the real captured log of run
32865281555 three ways: baseline file absent, const indented into an object, and
the committed file unchanged -- the first two announce, the third stays silent.
2026-08-25 20:20:59 -05:00
JMR-dev 0702916229 Say what the advisory API 37 job actually found, so a new failure is not invisible
That job is continue-on-error and red on every PR by design, which CLAUDE.md
states plainly -- and that instruction is exactly why nobody reads it. Nothing in
a red X separates "the known three" from "the known three plus yours".

A bare failure count would not have fixed it, and this is measured rather than
assumed. The run is usually truncated: seven of eight advisory runs read on
2026-08-25 ended in `Test run failed to complete. Expected 3 tests, received 2.`
with INSTRUMENTATION_ABORTED, and one did not. A count taken from a truncated run
misleads in both directions -- a fourth marked test can still yield the same
number if the abort lands earlier, and the known set getting worse can lower it.

The test XML does not rescue it either, which was the thing worth checking before
building on it: it IS written for an aborted run, and it reports a tidy
tests="3" failures="3" for a run the runner had just described as truncated. So
the XML is the authority on how many results landed, the runner's own output is
the only authority on whether the run finished, and the report reads both and
says which number came from where.

The baseline is one number beside the marker, because the marker means "cannot
pass on this image": the count is both how many tests the advisory leg runs and
how many should fail. A smaller failure count is the interesting direction -- it
means one now passes, which is the documented trigger for deleting the
annotation.

Nothing about the job's status changes. It stays continue-on-error, stays red,
stays out of the required contexts; a deviation is a ::notice::, never an
::error::. The report is a separate script so it can be run against a real log
saved from a real CI run, which is how the comparison was shown to fire.

The gating legs get the shape without the comparison: they run the whole suite,
so comparing there would announce a deviation five times a run -- but a truncated
run reporting fewer results than it ran is what #108 looks like, and "completed
cleanly" is the field that would show it.

Closes #83
2026-08-25 16:04:13 -05:00
Jason Ross 3fd34c24a6 Merge pull request #109 from JMR-dev/test/release-permission-guard
Notice if the release job loses the permission that lets it publish
2026-08-25 15:59:04 -05:00
JMR-dev d01a46a708 Stop counting the run this page calls inconclusive
R29 found the discriminator claimed "exact across all seven" while r07 is recorded lower
down as "inconclusive rather than ruled out, because no evidence came back from it". A row
this page calls inconclusive cannot also be counted as evidence for the conclusion.

Checking it turned up a second instance of the same over-count, which R29 did not name. The
abort-cadence section said "Measured across the seven runs above" -- but the table records
r07's aborts as **not readable**, because adb wedged before a crash buffer could be taken.
Six runs contributed gaps, not seven.

Both now say six, and both say why. The discriminator paragraph also says what excluding r07
costs, which is nothing: it is a `host` row, so the discriminator predicts it would not boot,
and confirming a prediction with the one run whose evidence did not come back adds no
information in either direction. That is the point R29 made -- claiming six does not weaken
the conclusion -- and it is worth stating in the document rather than only in the ticket,
because the next reader will otherwise wonder whether a run was quietly dropped.

Deliberately left: "four of the seven runs show the directory creation itself is broken
during the loop". That is a count of how many runs showed something, not a claim that all
seven were readable for it, so it survives. Checked rather than assumed, and named here so
the next pass does not re-audit it.

R29's other half -- "state how r07's boot outcome was read" -- is not taken, because I do
not know and inventing a source would be worse than narrowing the claim. Narrowing is the
option R29 offered and the one that can be honest.

Closes #38.
2026-08-25 15:54:48 -05:00
JMR-dev 3806641cb2 Merge branch 'main' into test/release-permission-guard 2026-08-25 15:50:20 -05:00
Jason Ross 5f9498150c Merge pull request #106 from JMR-dev/ci/build-workflow-permissions
Declare build.yml's token reach in build.yml
2026-08-25 15:49:19 -05:00
JMR-dev 8d8703ab49 Merge branch 'main' into ci/build-workflow-permissions 2026-08-25 15:41:39 -05:00
Jason Ross 27b7654418 Merge pull request #105 from JMR-dev/docs/api37-point-release
Say 37.0 is the choice, not the only api-level that exists
2026-08-25 15:41:11 -05:00
JMR-dev 4a8e30099e Notice if the release job loses the permission that lets it publish
build.yml's `release` job declares `contents: write`, and nothing checked it. Deleting those
lines leaves actionlint clean and CodeQL silent -- a narrower permission is not an alert --
and the job is `if: startsWith(github.ref, 'refs/tags/v')`, so no pull request and no merge
can exercise it. Measured with the declaration removed: every gating check still passed. The
first thing that would notice is a release failing to publish, at the moment someone is
trying to cut one.

The deletion also looks like tidying. #106 has just put a top-level `permissions: contents:
read` directly above it, so a reader could reasonably take the job-level block for a
duplicate. It is an override, and a comment saying so is not a check.

BackupExclusionsTest is the precedent: configuration rather than code, load bearing, and
unguarded because nothing compiles it.

The part worth reading twice is the second commit-worth of work in here. The test passed,
and then the mutation that is supposed to redden it did not:

  BUILD SUCCESSFUL in 614ms

Gradle cannot infer that a test depends on a file outside the source set, so the task stayed
UP-TO-DATE and the test never ran. Under --rerun-tasks the same mutation failed it properly,
which is the tell: the assertion was right and the wiring was not. A guard that does not
re-run when its subject changes is not a guard -- it is a test that will be green on the day
it matters, which is worse than no test because it reads as cover.

Fixed by declaring the workflow as a task input. Verified the whole way round afterwards,
without --rerun-tasks: mutate the file and the task re-runs and fails; restore it and the
task re-runs and passes.

What this pins and what it does not: it asserts the declaration exists in the release job's
block. It cannot assert a release actually publishes -- that needs a tag push, which is the
thing no PR can do. A tripwire against silent removal, not proof the path works, and the
KDoc says so.

Closes #107.
2026-08-25 15:38:14 -05:00
JMR-dev e7d84cc69f Merge branch 'main' into docs/api37-point-release 2026-08-25 15:33:27 -05:00
Jason Ross a8b494b846 Merge pull request #104 from JMR-dev/docs/benchmark-populate-path
Stop telling people to stage the benchmark the one way it cannot be staged
2026-08-25 15:33:02 -05:00
JMR-dev 865a4a7c8e Merge branch 'main' into docs/benchmark-populate-path 2026-08-25 10:21:23 -05:00
Jason Ross e856679395 Merge pull request #103 from JMR-dev/fix/dead-assertion-probe-test
Delete an assertion that could never fail, and say what guards instead
2026-08-25 10:21:18 -05:00
JMR-dev 49c483d877 Declare build.yml's token reach in build.yml
CodeQL alert #1, the only open one on this repository:

  actions/missing-workflow-permissions, warning / medium, build.yml:23
  Actions job or workflow does not limit the permissions of the GITHUB_TOKEN.

Alerts 2, 3 and 4 were the same rule against status_check.yml and are fixed -- that file
has a top-level block. build.yml declares permissions in exactly one place, the release
job's `contents: write`, and has no top-level default, so the `test` job inherits the
repository setting.

**Nothing is over-privileged today.** The repository default is already `read`
(default_workflow_permissions: read, can_approve_pull_request_reviews: false, read from the
API rather than assumed), so the test job holds a read token now. Saying so matters: this
is hygiene, and a commit that implied it was closing a live hole would be overstating it.

What it buys is that the default CANNOT widen these jobs later without someone editing this
file. That is not invented for the occasion -- it is the argument status_check.yml already
makes, which even names this file:

  the token's reach should be readable here, and a default that widens later should not
  silently widen these jobs with it. build.yml's release job makes the opposite
  declaration for the same reason.

So the principle was decided, applied in two workflows and in one job of this one, and the
top level of build.yml was the gap.

Verified the thing that would actually break: the release job's `contents: write` still
wins. Top level is a default, not a ceiling -- parsed and printed both, test inherits
`contents: read`, release keeps `contents: write`.

Also ran the ticket's mutation, and it found something. Deleting the release job's
`contents: write` leaves actionlint green and CodeQL quiet -- a narrower permission is not
an alert -- so nothing would catch it until a tagged release failed to publish. That is a
separate gap and is filed rather than fixed here.

actionlint clean at the pinned digest. Comment and permissions only; no step, job or
trigger changes.

Closes #100.
2026-08-25 10:16:08 -05:00
JMR-dev 1b220856ab Say 37.0 is the choice, not the only api-level that exists
R19 raised two things about this comment. One resolved itself: it used to explain why the
matrix had no API 37 row at all, and #56 added the gating row, so that half is gone.

The other survived, and this is it. The comment read

  api-level must be "37.0". A bare 37 is not an SDK package and fails during setup

The second sentence is true and was measured -- it cost a run to find. The first overstates
it. What must be true is that the api-level is a POINT release; 37.0 is one of several.
api37-debug.yml's own input descriptions already say so:

  API level, as the SDK spells it. 37.0, 37.1, 37.2-beta3, 36 ...
  System image target. android-37.1 and 37.2-beta* ship ONLY as google_apis_ps16k

and docs/api-37-emulator-crash.md measures android-37.0 rev 6 and android-37.1 rev 8 side
by side, both aborting. So the repo already knows 37.1 exists and behaves the same; only
this comment implied otherwise.

That matters for the reader it is written for. Someone debugging this row and wondering
whether a newer image helps reads "must be 37.0" as a constraint and stops. The measured
answer is that it does not help, which is a better thing to learn than a rule that is not
one -- and the ps16k-only wrinkle above 37.0 is the detail that would actually bite them.

Comment only. No job, matrix, filter or gating behaviour changes. actionlint clean at the
pinned digest.

Closes #28.
2026-08-25 10:14:34 -05:00
JMR-dev 40ae524388 Merge branch 'main' into fix/dead-assertion-probe-test 2026-08-25 10:13:12 -05:00
Jason Ross 58a29ab093 Merge pull request #99 from JMR-dev/ci/actionlint
Lint the bash inside the workflows, not only the bash in files
2026-08-25 10:13:00 -05:00
JMR-dev d37c391c60 Stop telling people to stage the benchmark the one way it cannot be staged
RealMediaBenchmark's class KDoc said:

  Populate with:
    adb push <file>.mp4 /sdcard/Android/data/org.libremediaconverter/files/

Twelve lines below, the `samples` property KDoc -- on `get() = context.filesDir` -- says:

  Internal storage, not the external files dir. Files placed in the external dir by
  `adb push` or `adb shell cp` stay owned by the shell user, and the app then gets
  EACCES trying to read them -- which presents as an unparseable input rather than a
  permission problem.

Different directories, and the second exists specifically to explain why the first fails.
Anyone following the class KDoc stages files the benchmark cannot read, gets a skip, and
reads the skip as "not staged yet" -- the failure mode the property KDoc warns about, walked
into by the instruction in the same file.

The fix is not a corrected command. Restating the mechanism in a second place is what let
these drift, and a replacement command I have not executed would be the same defect with a
fresher date. The class KDoc now names [samples] as the single place that answers it.

Two things added that are checkable rather than remembered: the exact filenames the tests
look for, via [H264_SAMPLE] and [AV1_SAMPLE] -- the old text said `<file>.mp4`, so even the
right directory left you guessing -- and a note that the two skips every green E2E leg
reports are these.

Not claimed: that the benchmark misbehaves on CI. An earlier version of the ticket said so;
it was wrong, and measuring settled it -- both tests report SKIPPED on the gating legs, the
guards work, and "harmless in CI" is accurate. The failure that prompted the look is
Media3EngineTest, tracked as #102.

Closes #101.
2026-08-25 09:45:00 -05:00
JMR-dev 95902a7889 Delete an assertion that could never fail, and say what guards instead
ConversionViewModelProbeFailureTest's pickedProbe() helper held:

  val ready = awaitState(viewModel.state, "Ready with a probe") {
      it is ConversionState.Ready && it.input.probe != null
  }
  assertNull("nothing here should reach a terminal failure", (ready as? ConversionState.Failed))

The predicate requires `Ready`. `Ready` and `Failed` are sibling subtypes of one sealed
interface, so `ready as? Failed` is always null and the assertNull could never fire. R26
filed this PLAUSIBLE on types read; it is measured now.

Flipping the line to assertNotNull failed 3 of the 4 tests in the class -- three, because
pickedProbe() has three callers, which is also why a dead line here was worth removing
rather than shrugging at: it read as coverage in a helper the whole class depends on.

Deleted rather than replaced. There is nothing for a live assertion to add: a pick that
ended in Failed never satisfies the predicate, so awaitState fails on its timeout naming
what it was waiting for -- "Ready with a probe" -- which is a better failure message than
the assertion would have produced. The comment now says that, so the next reader does not
re-add the guard the predicate already is.

This is the ninth vacuous assertion this line of work has turned up, and the pattern is
consistent: they hide in helpers, they pass, and they look like care. The suite is green
before and after, which is exactly the point -- deleting a dead assertion cannot change a
result, and if it had, the line was not dead.

Closes #35.
2026-08-25 09:36:36 -05:00
114 changed files with 13785 additions and 755 deletions
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env bash
#
# Exercises e2e-report-shape.sh's baseline counter against fixture source, with no emulator and
# no CI run. Run it directly:
#
# .github/scripts/e2e-report-shape-test.sh
#
# WHY THIS CAN EXIST AT ALL: the counter is a pure function of the working tree. It greps
# `app/src/androidTest` for `@FailsOnEmulatorApi37` and compares the total against the number
# committed in FailsOnEmulatorApi37.kt. Nothing about that needs a device, which is the whole
# reason #120 could be measured rather than argued about.
#
# WHY A THROWAWAY REPO ROOT rather than a knob on the script. The report finds its own root from
# `BASH_SOURCE`, so a copy of it placed at `<root>/.github/scripts/` reads `<root>/app/src/...`.
# Building that root is three mkdirs and costs the shipped script nothing:
#
# - the REAL script is what runs, byte for byte, so reverting the matcher reddens this test
# rather than a testing-only code path beside it;
# - no environment variable exists that could point the LIVE count somewhere else, which is
# the failure mode #83 built the baseline check to prevent in the first place;
# - XML_DIR resolves inside the throwaway root, so a stale app/build/outputs left by a real
# run on a developer machine cannot leak into the numbers here.
#
# WHAT IT GUARDS (#120). The old matcher looked for the string anywhere on any line, so a KDoc
# saying `Deliberately not @FailsOnEmulatorApi37` counted as a marked test and every PR got a
# deviation notice that was wrong. The obvious repair -- count only lines that are nothing but
# the annotation -- silently stops counting `@FailsOnEmulatorApi37 @Test`, which is legal Kotlin,
# and undercounting is the direction that hides a genuine new marker. The fixture carries every
# shape at once -- three that count and three that must not, enumerated in its own header -- so
# both mistakes fail here instead of on a PR: against testdata/marker-shapes the old matcher says
# 5, own-line-only says 2, and the shipped one 3.
#
# NOT WIRED INTO CI, deliberately and as a known gap. Adding a step to the Static analysis job
# would add a new way for a gating job to go red, and #120 was explicit that nothing about it may
# change any job's status or the pass/fail rules. shellcheck still covers this file, since that
# step reads `git ls-files '*.sh'` rather than a fixed list.
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPORT="$SCRIPT_DIR/e2e-report-shape.sh"
FIXTURE_DIR="$SCRIPT_DIR/testdata/marker-shapes"
FIXTURE="$FIXTURE_DIR/MarkerShapes.kt"
TMP="$(mktemp -d)"
# Single quotes: the path is expanded when the trap fires, not when it is set.
trap 'rm -rf -- "$TMP"' EXIT
failures=0
pass() { printf 'ok %s\n' "$1"; }
fail() {
failures=$((failures + 1))
printf 'FAIL %s\n' "$1"
shift
printf ' %s\n' "$@"
}
# assert_contains <name> <haystack> <needle>
# `case` rather than grep: the strings being matched carry backticks and an em dash, and this
# way neither the shell nor a regex engine gets an opinion about them.
assert_contains() {
case "$2" in
*"$3"*) pass "$1" ;;
*) fail "$1" "wanted to find: $3" "in:" "$2" ;;
esac
}
assert_absent() {
case "$2" in
*"$3"*) fail "$1" "did NOT want to find: $3" "in:" "$2" ;;
*) pass "$1" ;;
esac
}
# make_root <marked-tree-dir> <baseline-number>
# Assembles a throwaway repo root around the given tree and prints its path.
make_root() {
local tree="$1" baseline="$2" root testdir
root="$(mktemp -d "$TMP/root.XXXXXX")"
testdir="$root/app/src/androidTest/java/org/libremediaconverter"
mkdir -p "$root/.github/scripts" "$testdir"
cp -- "$REPORT" "$root/.github/scripts/"
cp -- "$tree"/*.kt "$testdir/"
# The synthetic stand-in for the committed baseline. Its KDoc names the marker the way the real
# file does -- in brackets, never with an `@` -- because the real file lives inside the tree
# being counted, so an `@` spelling here would add a phantom to every number below.
cat > "$testdir/FailsOnEmulatorApi37.kt" <<EOF
package org.libremediaconverter
/** Stand-in for the real marker file. Only [FAILS_ON_EMULATOR_API37_BASELINE] is read. */
const val FAILS_ON_EMULATOR_API37_BASELINE = $baseline
EOF
# A clean, untruncated run of exactly <baseline> tests, all failing -- which is what the
# advisory leg looks like when nothing has drifted. It leaves the marked count as the only
# field that can deviate, so every assertion below is about the thing under test.
cat > "$root/gradle.log" <<EOF
> Task :app:connectedDebugAndroidTest
Starting $baseline tests on test(AVD) - 16
There was $baseline failure(s).
EOF
printf '%s\n' "$root"
}
# run_report <root> -- stdout of the real script; its summary lands in <root>/summary.md.
run_report() {
E2E_WEDGED_AFTER='' GITHUB_STEP_SUMMARY="$1/summary.md" \
bash "$1/.github/scripts/e2e-report-shape.sh" 37 "$1/gradle.log" \
"$1/app/src/androidTest/java/org/libremediaconverter/FailsOnEmulatorApi37.kt"
}
# ---------------------------------------------------------------------------
# The fixture still carries every shape.
#
# Three of the checks below are covered twice over -- deleting a real annotation moves the count
# and fails a case further down. The two decoys are not: drop the KDoc mention and the count
# stays 3, so the precision this whole ticket is about would stop being tested and nothing would
# say so. That asymmetry is why the shapes are asserted by name rather than only by their effect
# on the total.
# ---------------------------------------------------------------------------
fixture_text="$(cat -- "$FIXTURE")"
assert_contains "fixture: the import" "$fixture_text" 'import org.libremediaconverter.FailsOnEmulatorApi37'
assert_contains "fixture: annotation own line" "$fixture_text" '
@FailsOnEmulatorApi37
@Test'
assert_contains "fixture: annotation with @Test on one line" "$fixture_text" '@FailsOnEmulatorApi37 @Test'
assert_contains "fixture: annotation nested and indented" "$fixture_text" '
@FailsOnEmulatorApi37'
assert_contains "fixture: KDoc mention (this is #120)" "$fixture_text" "* Deliberately not \`@FailsOnEmulatorApi37\`"
assert_contains "fixture: commented-out annotation" "$fixture_text" '// @FailsOnEmulatorApi37'
# ---------------------------------------------------------------------------
# 1. The fixture's three real annotations against a baseline of 3: no deviation.
#
# This one case fails under both wrong matchers -- the old one counts 5, own-line-only counts 2 --
# which is why it is first.
# ---------------------------------------------------------------------------
root="$(make_root "$FIXTURE_DIR" 3)"
out="$(run_report "$root")"
assert_contains "3 real markers, baseline 3: reports a match" "$out" ' baseline: matches (3 expected, 3 failed)'
assert_absent "3 real markers, baseline 3: says nothing about the tree" "$out" 'the tree carries'
assert_contains "3 real markers, baseline 3: summary agrees" \
"$(cat -- "$root/summary.md")" '**Matches the committed baseline of 3**'
# ---------------------------------------------------------------------------
# 2. A fourth REAL annotation. The count has to move and the deviation has to fire.
#
# The important half of #120: precision was the bug, but a matcher that stopped noticing a new
# marker would have been a worse one, silently.
# ---------------------------------------------------------------------------
plus_one="$(mktemp -d "$TMP/plusone.XXXXXX")"
cp -- "$FIXTURE" "$plus_one/"
cat > "$plus_one/FourthMarker.kt" <<'EOF'
package org.libremediaconverter.fixture
class FourthMarker {
@FailsOnEmulatorApi37
@Test
fun addedToday() = Unit
}
EOF
root="$(make_root "$plus_one" 3)"
out="$(run_report "$root")"
assert_contains "a 4th real marker: the deviation fires, and counts 4" "$out" \
" baseline DEVIATION: the tree carries 4 tests marked \`@FailsOnEmulatorApi37\` but the baseline says 3 — update FAILS_ON_EMULATOR_API37_BASELINE"
assert_contains "a 4th real marker: the summary carries it too" "$(cat -- "$root/summary.md")" \
"- the tree carries 4 tests marked \`@FailsOnEmulatorApi37\` but the baseline says 3"
# ---------------------------------------------------------------------------
# 3. Delete the same-line annotation and the count must drop to 2.
#
# This is the trap, pinned down. `@FailsOnEmulatorApi37 @Test` on one line is what separates the
# shipped matcher from `^[[:space:]]*@NAME[[:space:]]*$`, and without this case the fixture entry
# guarding it could be deleted as decoration -- case 1 would then pass under the wrong matcher.
# Here the same-line entry is worth exactly one, and it is asserted to be.
# ---------------------------------------------------------------------------
minus_same_line="$(mktemp -d "$TMP/minus.XXXXXX")"
sed -e '/@FailsOnEmulatorApi37 @Test/d' -- "$FIXTURE" > "$minus_same_line/MarkerShapes.kt"
root="$(make_root "$minus_same_line" 3)"
out="$(run_report "$root")"
assert_contains "same-line annotation removed: counts 2, so it was worth 1" "$out" \
" baseline DEVIATION: the tree carries 2 tests marked \`@FailsOnEmulatorApi37\` but the baseline says 3 — update FAILS_ON_EMULATOR_API37_BASELINE"
# ---------------------------------------------------------------------------
# 4. A run the abort truncated, with fewer failures than the baseline: NOT a deviation.
#
# `expected` comes from `Starting N tests`, printed before anything can abort, so it still
# answers "is the marked set the size the baseline says". `failed` is a tally of what actually
# ran, and on a truncated run the tests after the abort never start. Measured on 2026-09-05, two
# api37-debug dispatches of the same four marked tests: 4/4/4 and then 4/3/3. Announcing the
# second as "one now passes" is the wrong reading, and #120 is the standing lesson about a notice
# that is wrong often enough to be skimmed past.
# ---------------------------------------------------------------------------
root="$(make_root "$FIXTURE_DIR" 3)"
cat > "$root/gradle.log" <<'TRUNCATED'
> Task :app:connectedDebugAndroidTest
Starting 3 tests on test(AVD) - 16
There was 2 failure(s).
Test run failed to complete. Expected 3 tests, received 2. onError: commandError=false message=INSTRUMENTATION_ABORTED: System has crashed.
TRUNCATED
out="$(run_report "$root")"
assert_contains "truncated run: the truncation is reported" "$out" ' completed cleanly: no'
assert_absent "truncated run: the short failure count is not a deviation" "$out" 'tests failed, the baseline is'
# And the match line has to say what actually happened rather than repeat the baseline: PR #245's
# advisory leg printed `failed: 4` three lines above `matches (5 expected, 5 failed)`.
assert_contains "truncated run: the match line does not claim the baseline's failure count" "$out" \
' baseline: matches (3 expected; 2 of 3 failed, on a run the abort truncated — not compared)'
# ---------------------------------------------------------------------------
# 5. The same short failure count on a run that finished IS a deviation.
#
# The pair is the point: case 4 must not have bought its quiet by disabling the check outright.
# ---------------------------------------------------------------------------
root="$(make_root "$FIXTURE_DIR" 3)"
cat > "$root/gradle.log" <<'CLEAN'
> Task :app:connectedDebugAndroidTest
Starting 3 tests on test(AVD) - 16
There was 2 failure(s).
CLEAN
out="$(run_report "$root")"
assert_contains "clean run, short by one: the deviation fires" "$out" \
'2 tests failed, the baseline is 3'
echo
if [ "$failures" -eq 0 ]; then
echo "e2e-report-shape-test.sh: all checks passed"
exit 0
fi
echo "e2e-report-shape-test.sh: $failures check(s) failed"
exit 1
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env bash
#
# Reports the SHAPE of an instrumented run -- how many tests were expected, how many
# reported, how many failed, and whether the run completed at all -- to the step log and to
# the job summary. In advisory mode it also compares that shape against a committed baseline
# and says plainly whether it matches.
#
# WHY THIS EXISTS (#83): the advisory API 37 leg is red on every PR by design, so a NEW failure
# joining the known ones is invisible -- nothing in a red X distinguishes "the known ones" from
# "the known ones plus yours". CLAUDE.md tells everyone not to read that job's red as their
# change breaking something, which is correct, and which also means nobody looks.
#
# WHY NOT A BARE FAILURE COUNT, measured rather than assumed. On this image the run is usually
# truncated: `Test run failed to complete. Expected 3 tests, received 2.` with
# `INSTRUMENTATION_ABORTED: System has crashed.` A count taken from a truncated run misleads in
# both directions -- a fourth marked test can still yield the same number if the abort lands
# earlier, and the known set getting worse can LOWER it. So all four fields are recorded, and
# the one saying the run was truncated is recorded with them.
#
# WHY IT IS A SEPARATE SCRIPT rather than a function inside e2e-run.sh: it is a pure seam. It
# reads a captured log plus the test XML and writes a report, so it can be run against a REAL
# log saved from a REAL CI run -- which is how the baseline comparison was shown to fire
# without waiting on an emulator. `git ls-files '*.sh'` also picks it up for shellcheck for
# free.
#
# THIS SCRIPT NEVER FAILS A RUN. It is a diagnostic, and e2e-run.sh's header explains why that
# rule is absolute here. Every field defaults to `unknown` and every comparison is guarded,
# because an unset variable under `set -u`, or a `[ "" -eq 3 ]`, is exactly how a diagnostic
# becomes the thing that turns a leg red. It exits 0 unconditionally.
#
# Usage:
# e2e-report-shape.sh <label> <gradle-log> [<baseline-file>]
# E2E_WEDGED_AFTER=<seconds> the wrapper timeout killed gradle after that many seconds
#
# With a third argument the run is compared against the baseline in that file (advisory mode)
# and a `::notice::` is emitted per deviation. NEVER `::error::`: the advisory job is
# `continue-on-error: true` and stays that way, and an error annotation would be a new way for
# a diagnostic to change a conclusion.
#
# WHY THE WEDGE ARRIVES AS AN ENV VAR (#118) rather than being read out of the log like every
# other field: there is nothing in the log to read. A wedge is gradle never returning, so gradle
# never printed a verdict, never printed a truncation line, and never aborted instrumentation --
# the log of a wedged leg is the log of a run that simply stops. Measured on job 98035980326:
# `expected: 59`, `received: 59`, `completed cleanly: yes`, six seconds before the wedge warning,
# for a leg that the timeout had killed 22 minutes in. Only e2e-run.sh knows, because only it
# saw `timeout` exit 124, so it says so. Guessing it from a log that ends abruptly would call
# every cancelled run a wedge.
#
# It is read as a STRING and only ever interpolated into one. `[ -n ... ]`, never `-gt`: it
# crosses a process boundary from a shell that deliberately sets it EMPTY on every non-wedge
# path, and an arithmetic test on an empty string is the header's rule four paragraphs up.
set -uo pipefail
LABEL="${1:-unknown}"
LOG="${2:-}"
BASELINE_FILE="${3:-}"
WEDGED_AFTER="${E2E_WEDGED_AFTER:-}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
XML_DIR="$REPO_ROOT/app/build/outputs/androidTest-results/connected/debug"
# Gradle colours its output even when it is piped, so `FAILED` arrives wrapped in escape codes.
# The numeric lines parsed below are not coloured, but stripping is cheap insurance against a
# pattern that would otherwise silently match nothing.
ESC="$(printf '\033')"
scan() { [ -s "$LOG" ] && sed -e "s/${ESC}\[[0-9;]*[a-zA-Z]//g" -- "$LOG"; }
first_number() { grep -oE '[0-9]+' | head -1; }
# ---------------------------------------------------------------------------
# Source 1: the runner's own output. This is the ONLY place a truncation is visible. The test
# XML read below is written even for an aborted run and says nothing whatever about the abort
# -- measured on run 32865281555, where the XML reports a tidy tests="3" failures="3" for a run
# the runner had just described as truncated. That is the reason this parses stdout at all.
# ---------------------------------------------------------------------------
starting_line="$(scan | grep -aoE 'Starting [0-9]+ tests on .*' | tail -1)"
abort_line="$(scan | grep -aoE 'Test run failed to complete\. Expected [0-9]+ tests, received [0-9]+\.' | tail -1)"
aborted_hits="$(scan | grep -ac 'INSTRUMENTATION_ABORTED' || true)"
failure_line="$(scan | grep -aoE 'There was [0-9]+ failure\(s\)\.' | tail -1)"
failed_names="$(scan | grep -aoE 'Execute [A-Za-z0-9_.$]+: FAILED' | sed -e 's/^Execute //' -e 's/: FAILED$//' | sort -u)"
expected="$(printf '%s' "$starting_line" | first_number)"
expected_src="\`$starting_line\`"
abort_expected="$(printf '%s' "$abort_line" | grep -oE 'Expected [0-9]+' | first_number)"
abort_received="$(printf '%s' "$abort_line" | grep -oE 'received [0-9]+' | first_number)"
log_failed="$(printf '%s' "$failure_line" | first_number)"
# `Starting N tests` is missing when the framework restarted under the run and Gradle never got
# a test list. The truncation line still carries the number it was told to expect.
if [ -z "$expected" ] && [ -n "$abort_expected" ]; then
expected="$abort_expected"
expected_src="\`$abort_line\`"
fi
# ---------------------------------------------------------------------------
# Source 2: the JUnit XML. Measured on both a truncated advisory run and a green gating leg:
# `<testsuites tests="N" failures="M">` is present in both, and aggregates every suite. It is
# the authority on how many results landed and how many were failures. It is NOT an authority
# on whether the run finished, which is what source 1 is for.
# ---------------------------------------------------------------------------
#
# Read ONLY when the runner said a test run happened. `app/build` survives between runs on a
# developer machine -- tools/local-emulator/run-e2e.sh drives several API levels against one
# checkout -- so a leg that never got as far as starting tests would otherwise be reported from
# the previous leg's XML, which is a wrong answer rather than a missing one.
xml_head=""
xml_count=0
if [ -n "$starting_line$abort_line" ] && [ -d "$XML_DIR" ]; then
while IFS= read -r f; do
xml_count=$((xml_count + 1))
[ -z "$xml_head" ] && xml_head="$(grep -ao '<testsuites[^>]*>' "$f" | head -1)"
done < <(find "$XML_DIR" -maxdepth 1 -name 'TEST-*.xml' -print 2> /dev/null | sort)
fi
xml_tests="$(printf '%s' "$xml_head" | grep -oE ' tests="[0-9]+"' | first_number)"
xml_failed="$(printf '%s' "$xml_head" | grep -oE ' failures="[0-9]+"' | first_number)"
# ---------------------------------------------------------------------------
# Derive the four fields, each with where its number came from. Everything stays a string, so a
# missing source reads `unknown` rather than becoming 0 -- a report claiming 0 tests when it
# merely could not see them would announce a deviation on every cancelled run.
# ---------------------------------------------------------------------------
received="unknown"
received_src="no source"
if [ -n "$xml_tests" ]; then
received="$xml_tests"
received_src="test XML \`<testsuites tests=\"$xml_tests\">\`"
elif [ -n "$abort_received" ]; then
received="$abort_received"
received_src="\`$abort_line\`"
elif [ -n "$expected" ] && [ -z "$abort_line" ]; then
received="$expected"
received_src="the run was not truncated, so every expected test reported"
# ... unless it was killed, in which case "not truncated" is only "gradle never got as far as
# saying so". This is the branch the wedged leg in #118 took -- with no XML written yet, the
# number is what the runner was TOLD to run, and the source line said the opposite in the same
# table that called the leg clean. The number is deliberately left alone: it is still the best
# available answer, and only the claim about where it came from was wrong.
[ -n "$WEDGED_AFTER" ] \
&& received_src="no test XML was written and gradle never printed a truncation line — but the leg was killed mid-run, so this is what it was told to run, not what reported"
fi
failed="unknown"
failed_src="no source"
if [ -n "$xml_failed" ]; then
failed="$xml_failed"
failed_src="test XML \`<testsuites failures=\"$xml_failed\">\`"
elif [ -n "$log_failed" ]; then
failed="$log_failed"
failed_src="\`$failure_line\`"
fi
if [ -z "$expected" ]; then
expected="unknown"
expected_src="no \`Starting N tests\` line"
fi
# A run whose start nobody can see is not a run of zero tests. Cancellation (this workflow sets
# cancel-in-progress) and the `Starting 0 tests` shape a framework restart produces both land
# here, and both have to say so rather than compare a number that does not exist.
no_run="none"
if [ "$expected" = "unknown" ] && [ "$received" = "unknown" ]; then
no_run="nothing"
elif [ "$expected" = "0" ]; then
no_run="zero"
fi
if [ -n "$abort_line" ]; then
completed="**no**"
completed_src="\`$abort_line\` with \`INSTRUMENTATION_ABORTED\`"
elif [ "${aborted_hits:-0}" -gt 0 ]; then
completed="**no**"
completed_src="\`INSTRUMENTATION_ABORTED\` in the runner output"
elif [ "$no_run" = "nothing" ]; then
# "cleanly" would be a lie about a run that left no evidence it happened.
completed="unknown"
completed_src="no runner output to read"
elif [ -n "$WEDGED_AFTER" ]; then
# The wedge is checked LAST of the four, so it only ever overrides the `yes`. The two "no"s
# above are already right and name the abort, which the wedge row does not; `unknown` is
# already right too. A wedge on top of an abort is both facts, and both get printed.
completed="**no**"
completed_src="the wrapper timeout killed gradle after ${WEDGED_AFTER}s — instrumentation itself was never aborted, which is why nothing in the log says so"
else
completed="yes"
completed_src="no truncation line and no \`INSTRUMENTATION_ABORTED\`"
fi
# ---------------------------------------------------------------------------
# Advisory mode: compare against the committed baseline.
#
# ONE number covers both compared fields, and that is deliberate rather than a shortcut. The
# marker means "cannot pass on this image", so the number of tests carrying it is both how many
# the advisory leg should run and how many should fail. A smaller `failed` means one now passes
# -- which is the trigger to delete the annotation, written down in FailsOnEmulatorApi37.kt.
# ---------------------------------------------------------------------------
#
# `advisory` and `baseline` are two variables on purpose. "A comparison was asked for" and "a
# number was found to compare against" are different facts, and collapsing them is how this
# report would go quietly back to being the thing #83 filed: the `sed` below is anchored, so
# indenting the const into an object -- or renaming it, or moving it to another file -- empties
# `baseline`, and a single flag would take the whole comparison down with it while the table
# kept printing. An unreadable baseline is itself a deviation, and is announced as one.
advisory="no"
baseline=""
marked=""
deviations=()
if [ -n "$BASELINE_FILE" ]; then
advisory="yes"
[ -f "$BASELINE_FILE" ] \
&& baseline="$(sed -nE 's/^const val FAILS_ON_EMULATOR_API37_BASELINE = ([0-9]+).*/\1/p' "$BASELINE_FILE" | head -1)"
# What the tree actually carries. Reported next to the baseline so a stale baseline shows up
# here rather than only once the emulator disagrees with it.
#
# ANCHORED AT LINE START, AND WHITESPACE-OR-END-OF-LINE AFTER THE NAME (#120). The #81 check
# this replaces matched the string anywhere on any line, so #113's KDoc reading `Deliberately
# not @FailsOnEmulatorApi37` counted as a fourth marked test and the report announced a
# deviation on every PR. That is worse than a wrong number: #83 built this so a new failure
# could not be invisible, and a notice that is wrong every time teaches everyone to skim past
# deviation notices.
#
# THE OBVIOUS REPAIR IS A TRAP, and the reason for the second half of the pattern.
# `^[[:space:]]*@NAME[[:space:]]*$` -- "the annotation on a line of its own" -- also stops
# counting `@FailsOnEmulatorApi37 @Test`, which is legal Kotlin, and UNDERcounting is the
# dangerous direction: it hides a genuine new marker, which is the one thing this exists to
# catch. Measured against `testdata/marker-shapes`, a fixture carrying every shape at once:
# the old matcher says 5, own-line-only says 2, this one says 3. On the real tree, 4 / 3 / 3.
# e2e-report-shape-test.sh runs that fixture through this whole script.
#
# `^[[:space:]]*@` cannot match an `import` line, so the old `grep -v import` goes with it
# rather than staying to imply a filter is still doing work.
#
# This is a regex over source text and not a parser. An annotation inside a multi-line string,
# or inside a `/* */` block that opened mid-line, would still be counted. Neither exists here;
# if one ever does, this check wants a different tool rather than a longer regex.
if [ -d "$REPO_ROOT/app/src/androidTest" ]; then
marked="$(grep -rhcE '^[[:space:]]*@FailsOnEmulatorApi37([[:space:]]|$)' \
"$REPO_ROOT/app/src/androidTest" --include='*.kt' \
| awk '{ total += $1 } END { print total + 0 }' || true)"
fi
fi
if [ "$advisory" = "yes" ] && [ -z "$baseline" ]; then
deviations+=("the committed baseline could not be read from \`$(basename -- "$BASELINE_FILE")\` — has \`FAILS_ON_EMULATOR_API37_BASELINE\` been renamed, indented into a class, or moved? Nothing was compared")
fi
if [ -n "$baseline" ]; then
if [ "$no_run" = "nothing" ]; then
deviations+=("no test run observed — the runner never reported starting one, where the baseline expects $baseline tests carrying \`@FailsOnEmulatorApi37\`")
elif [ "$no_run" = "zero" ]; then
deviations+=("the runner started 0 tests, where the baseline expects $baseline — on this image that is the framework having restarted under the run, not an empty test list")
else
if [ "$expected" != "unknown" ] && [ "$expected" != "$baseline" ]; then
deviations+=("the runner started $expected tests, the baseline is $baseline")
fi
# `expected` is compared on every run and `failed` only on a run that finished, and the
# difference is the truncation this file already records rather than compares. `expected`
# comes from `Starting N tests`, which is printed before anything can abort, so it answers
# "is the marked set the size the baseline says" whatever happens afterwards. `failed` is a
# tally of what actually ran: on a truncated run the tests after the abort never start, so
# comparing it to the baseline announces a deviation about the framework dying rather than
# about the test list. Measured on 2026-09-05, two api37-debug dispatches of the same four
# marked tests: 4/4/4 and then 4/3/3, the second having lost the last test to the abort.
# Announcing that as "one now passes" is exactly the wrong reading, and #120 is the standing
# lesson about a notice that is wrong often enough to be skimmed past.
if [ "$failed" != "unknown" ] && [ "$failed" != "$baseline" ]; then
if [ "$completed" = "**no**" ]; then
echo "::debug::$failed of $baseline marked tests failed, on a run the abort truncated — not compared"
else
deviations+=("$failed tests failed, the baseline is $baseline — every test carrying the marker is expected to fail on this image, so fewer means one now passes and more means a new one joined")
fi
fi
fi
if [ -n "$marked" ] && [ "$marked" != "$baseline" ]; then
deviations+=("the tree carries $marked tests marked \`@FailsOnEmulatorApi37\` but the baseline says $baseline — update FAILS_ON_EMULATOR_API37_BASELINE")
fi
fi
# ---------------------------------------------------------------------------
# Emit. Step log first, so the common case needs neither the summary page nor an artifact.
# ---------------------------------------------------------------------------
echo "----- RUN SHAPE (api${LABEL}) -----"
echo " expected: $expected"
echo " received: $received"
echo " failed: $failed"
# Above `completed cleanly`, because it is the line that says what happened to the leg and the
# other one only qualifies it. A reader who stops after three rows still sees it.
if [ -n "$WEDGED_AFTER" ]; then
echo " wedged: yes -- gradle was killed after ${WEDGED_AFTER}s and never returned"
fi
echo " completed cleanly: ${completed//\*/}"
if [ -n "$abort_received" ]; then
echo " received before the abort: $abort_received"
fi
if [ -n "$failed_names" ]; then
echo " failed tests:"
printf '%s\n' "$failed_names" | sed -e 's/^/ /'
fi
if [ "$advisory" = "yes" ]; then
if [ "${#deviations[@]}" -eq 0 ]; then
# Two spellings, because one of them would be a lie half the time. `$baseline expected,
# $baseline failed` is only true of a run that finished; on a truncated one `failed` is a
# tally of the tests that got to run before the framework died, and printing the baseline in
# its place claims a number nobody measured. Seen on PR #245's advisory leg, which reported
# `failed: 4` three lines above `matches (5 expected, 5 failed)`.
if [ "$failed" != "unknown" ] && [ "$failed" != "$baseline" ]; then
echo " baseline: matches ($baseline expected; $failed of $baseline failed, on a run the abort truncated — not compared)"
else
echo " baseline: matches ($baseline expected, $baseline failed)"
fi
else
printf ' baseline DEVIATION: %s\n' "${deviations[@]}"
fi
fi
# A notice, never an error. See the header.
if [ "${#deviations[@]}" -gt 0 ]; then
for d in "${deviations[@]}"; do
echo "::notice::E2E api${LABEL}: $d"
done
fi
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{
echo "### E2E api${LABEL} — run shape"
echo
echo "| field | value | where it came from |"
echo "| --- | --- | --- |"
echo "| expected | $expected | $expected_src |"
echo "| received | $received | $received_src |"
echo "| failed | $failed | $failed_src |"
if [ -n "$WEDGED_AFTER" ]; then
echo "| wedged | **yes** | \`timeout\` fired after ${WEDGED_AFTER}s and killed gradle (exit 124), which is what e2e-run.sh then captured the wedge diagnostics for |"
fi
echo "| completed cleanly | $completed | $completed_src |"
if [ -n "$abort_received" ]; then
echo "| received before the abort | $abort_received | the same line — the XML above counts the truncated test as a failure, this number does not |"
fi
echo
if [ -n "$failed_names" ]; then
echo "Failed:"
echo
printf '%s\n' "$failed_names" | sed -e 's/^/- `/' -e 's/$/`/'
echo
fi
if [ "$xml_count" -gt 1 ]; then
echo "> $xml_count test XML files were present; the counts above come from the first."
echo
fi
if [ "$advisory" = "yes" ]; then
if [ "${#deviations[@]}" -eq 0 ]; then
echo "**Matches the committed baseline of $baseline** — $baseline tests carry \`@FailsOnEmulatorApi37\` and all $baseline failed, which is what this job is for."
elif [ -z "$baseline" ]; then
echo "**The committed baseline could not be read, so nothing was compared.** Announced as a notice, not an error: this job is advisory and its conclusion is unchanged by anything here."
echo
printf -- '- %s\n' "${deviations[@]}"
else
echo "**DEVIATION from the committed baseline of $baseline.** Announced as a notice, not an error: this job is advisory and its conclusion is unchanged by anything here."
echo
printf -- '- %s\n' "${deviations[@]}"
fi
echo
echo "<sub>The baseline lives beside the marker, in \`FailsOnEmulatorApi37.kt\`. \`completed cleanly\` is recorded rather than compared: the truncation is intermittent — of eight advisory runs read on 2026-08-25, seven aborted and one did not — so comparing it would announce a deviation on a run that is fine.</sub>"
else
echo "<sub>No baseline comparison: that is the advisory API 37 leg only. The shape is recorded here anyway because a truncated run reports fewer results than it ran, which is what issue #108 looks like on a gating leg.</sub>"
fi
echo
} >> "$GITHUB_STEP_SUMMARY"
# The summary page is the deliverable -- "readable without opening a log" is what #83 asked
# for -- and GitHub exposes no API for reading a job summary back, so a write that silently
# did not happen would be invisible. This line is in the step log, which can be read.
echo " (the table above is also on the job summary page)"
else
echo " (GITHUB_STEP_SUMMARY is unset -- step log only)"
fi
exit 0
+117 -52
View File
@@ -34,6 +34,13 @@ TMP="${RUNNER_TEMP:-/tmp}"
LOGCAT_LOG="$TMP/logcat-api${LABEL}.txt"
DIAG_LOG="$TMP/diagnostics-api${LABEL}.txt"
WEDGE_LOG="$TMP/wedge-diagnostics-api${LABEL}.txt"
# Gradle's own output, captured to a file as well as the step log, because the run-shape report
# below has to parse it. Uploaded with the diagnostics, so a report that reads wrong can be
# checked against what it read.
GRADLE_LOG="$TMP/gradle-api${LABEL}.txt"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
# ~5 min is a healthy leg (measured across API 33-36), and this wraps only the gradle client,
# a subset of that. 20 min is generous enough never to trip on a slow-but-working run, and far
@@ -45,40 +52,72 @@ WEDGE_TIMEOUT=1200
# the same shape as E2E_EXTRA_GRADLE_ARGS below. The other four E2E legs run byte-identical
# commands with it unset.
#
# WHY IT RUNS HERE, BEFORE THE LOGCAT STREAM: `adb shell stop` ends the `adb logcat` started
# below, and nothing restarts it, so a disable performed after that point would cost this leg
# its whole diagnostic story for the part of the run that matters. Everything this function
# counts comes from `adb logcat -d -b crash`, which is a fresh read each time and independent
# of the stream.
# WHY IT RUNS HERE, BEFORE THE LOGCAT STREAM: it is a 45-second wait, and the stream below is
# meant to cover the suite rather than the wait. Everything this function counts comes from
# `adb logcat -d -b crash`, a fresh read each time and independent of the stream. (The original
# reason was stronger and no longer applies: `adb shell stop` would have ended the streamed
# `adb logcat` and nothing restarts it. There is no `stop` here any more -- see below.)
#
# WHAT IT IS FOR: the android-37.x images abort surfaceflinger from RegionSamplingThread inside
# their own gralloc mapper (docs/api-37-emulator-crash.md). surfaceflinger is a critical service,
# so init SIGKILLs zygote with it and the framework restarts under the run -- Gradle then reports
# WHAT IT IS FOR -- AND THE NAME IS NOW WRONG, WHICH IS WHY THIS PARAGRAPH IS LONG.
# The android-37.x images abort surfaceflinger from RegionSamplingThread inside their own gralloc
# mapper (docs/api-37-emulator-crash.md). surfaceflinger is a critical service, so init SIGKILLs
# zygote with it and the framework restarts under the run -- Gradle then reports
# `cmd: Can't find service: package` and `Starting 0 tests`. RegionSamplingThread exists only
# because SystemUI registers a nav-bar luma-sampling listener, so removing the package removes
# the whole chain. Measured cadence of those kills: 20-90 s apart, median 60-70 s, three to five
# in a four-minute window -- fast enough that install and instrumentation start-up do not fit
# inside one gap.
# because SystemUI registers a nav-bar luma-sampling listener, so this was written to remove the
# package and with it the whole chain. Measured cadence of those kills on `-gpu host`: 20-90 s
# apart, median 60-70 s, three to five in a four-minute window.
#
# **THE DISABLE HALF OF THAT HAS NEVER WORKED, AND THE QUIET WINDOW IS WHAT THE LEG ACTUALLY
# GETS.** Measured 2026-09-05, two ways that agree:
#
# - On CI, in the gating leg of run 34006456986: `pm disable-user` is accepted at 02:28:37.9 and
# `com.android.systemui` really is in `pm list packages -d` at 02:29:33 -- and SystemUI is
# started anyway at 02:28:39.5 and again at 02:28:52.3, the second of which (pid 4275) is
# alive for the whole instrumentation run, logging `WindowManagerShell ...
# app=com.android.systemui` minutes after this function prints its final line.
# - Locally on android-37.0, with the package verified disabled before AND after a deliberate
# `stop; start`: `com.android.systemui` comes up 3 s after `system_server` regardless.
#
# So `pm disable-user --user 0 com.android.systemui` does not stop SystemUI starting on this
# image, whatever else happens. The name `E2E_DISABLE_SYSTEM_UI` and the name of this function are
# kept because the matrix row, both workflows and two documents refer to them, and a rename would
# touch all of that to no benefit -- read this comment, not the name.
#
# WHAT IS LEFT IS LOAD-BEARING, so do not delete the function as dead weight. It is the 45-second
# window with zero new `hasReadColorBufferDma` aborts. The boot-time aborts land close together --
# 02:28:18 and 02:28:43 in that same run -- and the wait is what puts instrumentation (02:32:42)
# after them rather than inside one. That is what stops a leg reporting `Starting 0 tests`, and it
# is why the three-round retry stays.
#
# THE `pm disable-user` CALL STAYS TOO, for a narrower reason than it was written for: every green
# leg and every measurement quoted anywhere about this row was taken with it applied and SystemUI
# running. Removing it would change the configuration the numbers came from, which is not a change
# to make while fixing a flake.
#
# AND THE FRAMEWORK RESTART IS GONE, having been measured to be worse than nothing. It was written
# as `adb shell stop; adb shell start`, which are root-only; adbd is not root, so every leg printed
# `Must be root` twice and restarted nothing. Adding `adb root` made it real, and api37-debug run
# 34010167885 is what that looks like: `pm disable-user` reports success, the stop lands ~2 s later
# and kills system_server before PackageManager has flushed its delayed write of package
# restrictions, so the state is gone on the way back up -- `NOT DISABLED after the restart`, three
# rounds, `final state: SystemUI STILL ENABLED`, and the leg then reported `expected: 0,
# received: 0`. A 15 s pause before the stop does make the state survive (bisected locally), and it
# still does not help, because of the two measurements above. So the restart is removed rather than
# repaired: it cost the leg every test it had, and there is nothing for it to buy.
#
# NOTHING HERE TRUSTS A COMMAND'S OWN REPORT, and that is not paranoia: of four runs of an
# earlier one-shot version, one (32646029143) reported `new state: disabled-user` and then
# started SystemUI eight more times, with ten more aborts. `pm disable-user` can be accepted by
# a system_server that is SIGKILLed before the state is written, and `pm disable-user` does not
# retract SystemUI's existing region-sampling registration either -- by the time boot completes
# it has already registered, so only a framework restart brings back a SystemUI-less
# surfaceflinger. Hence: disable, take the framework DOWN and confirm system_server is really
# gone (an earlier probe asked `service check` 0.3 s after `stop` and got `found` from the
# system_server that was still exiting, so its wait was not a wait), bring it back, verify the
# package against `pm list packages -d`, and require a 45 s window with zero new aborts.
# Three rounds, because one is not reliable and the failure is silent.
# started SystemUI eight more times. So this reports what `pm list packages -d` says AND what
# `pidof` says, side by side, rather than one line implying both.
# ---------------------------------------------------------------------------
count_aborts() { adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma'; }
systemui_disabled() { adb shell pm list packages -d 2> /dev/null | grep -q 'com.android.systemui'; }
systemui_pid() { adb shell pidof com.android.systemui 2> /dev/null | tr -d '\r\n'; }
disable_region_sampling() {
local round=1 i out before after
local round=1 i out pid before after
while [ "$round" -le 3 ]; do
echo "--- SystemUI disable, round $round ---"
echo "--- round $round ---"
for i in $(seq 1 10); do
out="$(adb shell pm disable-user --user 0 com.android.systemui 2>&1 | tr -d '\r')"
echo " pm attempt $i: $out"
@@ -86,36 +125,20 @@ disable_region_sampling() {
sleep 5
done
echo " restarting the framework"
adb shell stop
for i in $(seq 1 20); do
[ -z "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ] && break
sleep 2
done
echo " system_server down after ~$((i * 2)) s"
adb shell start
for i in $(seq 1 30); do
if adb shell service check package 2> /dev/null | grep -q ': found' \
&& adb shell service check activity 2> /dev/null | grep -q ': found' \
&& [ -n "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ]; then
echo " services back after ~$((i * 5)) s"
break
fi
sleep 5
done
if systemui_disabled; then
echo " verified: com.android.systemui is in pm list packages -d"
echo " pm list packages -d: com.android.systemui is in it"
else
echo " NOT DISABLED after the restart -- the package state did not survive"
round=$((round + 1))
continue
echo " pm list packages -d: com.android.systemui is NOT in it"
fi
# Printed next to the line above precisely because the two disagree on this image, and a
# reader who sees only the first will believe something that is not true.
pid="$(systemui_pid)"
echo " com.android.systemui pid: ${pid:-none} (expected: a pid -- see the header)"
before="$(count_aborts)"
sleep 45
after="$(count_aborts)"
echo " abort rate, SystemUI disabled: $((after - before)) new in 45 s (total ${after:-0})"
echo " aborts: $((after - before)) new in 45 s (total ${after:-0})"
[ "$((after - before))" -eq 0 ] && break
echo " still aborting after round $round"
round=$((round + 1))
@@ -124,16 +147,16 @@ disable_region_sampling() {
# A warning rather than an exit. If the disable did not take, the run is about to report
# `Starting 0 tests` and fail on its own -- and it will do so with the logcat, the crash
# buffer and the diagnostics attached, which is more useful than dying here with none of it.
if systemui_disabled; then
echo " final state: SystemUI disabled"
if [ "$((after - before))" -eq 0 ]; then
echo " final state: 45 s with no new aborts -- the suite starts here"
else
echo "::warning::E2E api${LABEL}: SystemUI is still enabled -- expect INSTRUMENTATION_ABORTED"
echo "::warning::E2E api${LABEL}: still aborting after three rounds -- expect INSTRUMENTATION_ABORTED"
fi
return 0
}
if [ "${E2E_DISABLE_SYSTEM_UI:-}" = "1" ]; then
echo "::group::E2E api${LABEL} -- removing the region-sampling listener"
echo "::group::E2E api${LABEL} -- waiting out the boot-time gralloc aborts"
disable_region_sampling
echo "::endgroup::"
fi
@@ -214,18 +237,60 @@ status=0
# script rather than forking it: that runs several API levels back to back against one checkout
# and passes `--rerun`, so a level cannot be skipped as up-to-date and report the previous
# level's results as its own. CI gets a fresh runner per level and does not need it.
#
# `2>&1 | tee`, and the `2>&1` is the load-bearing half. The step log merges both streams, so
# reading one cannot tell you which stream a line came from -- and the single line the report
# below needs most, `Test run failed to complete. ... INSTRUMENTATION_ABORTED`, is not on
# stdout. Capturing stdout alone would leave the report saying "completed cleanly: yes" forever,
# which is precisely the comparison that cannot fire. pipefail is already set and tee exits 0,
# so the pipeline's status is still gradle's -- including the 124 that means the wrapper fired.
#
# `tee` and not `tee -a`, unlike the logcat above: CI gets a fresh runner per leg, but
# tools/local-emulator/run-e2e.sh reuses one machine, and an appended log would have the report
# reading the PREVIOUS run of the same API level. The console goes plain rather than showing
# gradle's live progress bar, which is what it already did in CI.
# shellcheck disable=SC2086
timeout -k 30s "$WEDGE_TIMEOUT" \
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64 --stacktrace \
${E2E_EXTRA_GRADLE_ARGS:-} || status=$?
${E2E_EXTRA_GRADLE_ARGS:-} 2>&1 | tee "$GRADLE_LOG" || status=$?
echo "::endgroup::"
# Whether the wrapper timeout fired, decided ONCE. 124 is `timeout` saying it killed the
# command, and two places downstream need that fact: capture_wedge below, and the report, which
# otherwise calls a killed leg `completed cleanly: yes` (#118). Deriving it twice is how those
# two would drift apart -- the report would keep printing after someone changed what a wedge
# means here. It stays a string: empty on every other path, so those legs pass an empty
# E2E_WEDGED_AFTER and the report behaves exactly as before.
wedged=""
[ "$status" -eq 124 ] && wedged="$WEDGE_TIMEOUT"
# The run-shape report: expected/received/failed and whether the run finished, every time,
# green or red. It never changes `status` -- it is a diagnostic, and the header's rule about
# diagnostics applies to it as much as to every probe below.
#
# E2E_WEDGED_AFTER is the wedge, told to the report rather than left for it to infer. It cannot
# be inferred: a wedge is gradle never returning, so gradle printed no verdict at all, and the
# log the report reads looks like a run that simply stopped. Only this script knows the
# difference, because only this script saw the exit status.
#
# The baseline argument, and only it, turns on the comparison, and only the advisory API 37 job
# passes E2E_ADVISORY=1. Comparing on the gating legs would announce a deviation on all five of
# them every run, since they run the whole suite rather than the marked three. They still get
# the report: a truncated run reporting fewer results than it ran is what #108 looks like, and
# `completed cleanly` is the field that shows it.
if [ "${E2E_ADVISORY:-}" = "1" ]; then
E2E_WEDGED_AFTER="$wedged" bash "$SCRIPT_DIR/e2e-report-shape.sh" "$LABEL" "$GRADLE_LOG" \
"$REPO_ROOT/app/src/androidTest/java/org/libremediaconverter/FailsOnEmulatorApi37.kt" || true
else
E2E_WEDGED_AFTER="$wedged" bash "$SCRIPT_DIR/e2e-report-shape.sh" "$LABEL" "$GRADLE_LOG" || true
fi
if [ "$status" -eq 0 ]; then
kill "$LOGCAT_PID" 2>/dev/null || true
exit 0
fi
if [ "$status" -eq 124 ]; then
if [ -n "$wedged" ]; then
capture_wedge "api${LABEL}"
else
echo "::error::E2E api${LABEL} failed (exit $status)"
@@ -0,0 +1,54 @@
// NOT A TEST, AND NEVER COMPILED. This is fixture data for e2e-report-shape-test.sh, which
// copies it into a throwaway repo root and runs the real report script against that. It lives
// under .github/ deliberately: Gradle only compiles app/src/**, ktlint and detekt are applied to
// :app only, and the report's own count reads app/src/androidTest -- so nothing here can reach
// the build, the linters, or the number the advisory job compares against. Verified by running
// the report against the real repo root with this file committed: still 3.
//
// It carries every shape the counter has to tell apart, in one file, because the bug in #120 was
// exactly that two of them look alike to a substring match. Three count and three must not:
//
// COUNTS the annotation on its own line
// COUNTS the annotation sharing a line with @Test -- legal Kotlin, and the case the
// obvious "own line only" repair silently drops
// COUNTS the annotation indented inside a nested class
// must NOT a KDoc mentioning it -- this is #120 itself, copied from Media3EngineTest
// must NOT a commented-out annotation
// must NOT the import
//
// Three count. That is what the synthetic baseline in the test is set to, so the fixture and the
// baseline agree exactly the way the real tree and FAILS_ON_EMULATOR_API37_BASELINE do.
//
// The `@Test` here is spelled the way a real test spells it so the fixture reads like source
// rather than like a regex exercise. Nothing runs it.
package org.libremediaconverter.fixture
import org.junit.Test
import org.libremediaconverter.FailsOnEmulatorApi37
class MarkerShapes {
@FailsOnEmulatorApi37
@Test
fun ownLine() = Unit
@FailsOnEmulatorApi37 @Test
fun sameLineAsTest() = Unit
/**
* Deliberately not `@FailsOnEmulatorApi37`: nothing here decodes or encodes, so no emulator
* codec is involved and the API 37 image has no quarrel with it.
*/
@Test
fun mentionedInKdoc() = Unit
// @FailsOnEmulatorApi37 -- taken off on 2026-01-01, kept as a note rather than deleted
@Test
fun commentedOut() = Unit
class Nested {
@FailsOnEmulatorApi37
@Test
fun indentedDeeper() = Unit
}
}
+22 -28
View File
@@ -28,6 +28,15 @@ name: API 37 debug
# - It does not fork .github/scripts/e2e-run.sh. That script owns the FAILED-vs-WEDGED
# split, the SIGQUIT thread dump and the streamed logcat, and it is the copy CI
# exercises every day. This calls it, exactly as status_check.yml does.
#
# The SystemUI disable below is the exception, and it is a real one: this workflow
# drives it from its own probe step so `disable_system_ui` can be turned off for a
# dispatch, where the real leg gets it through `E2E_DISABLE_SYSTEM_UI`. Two copies of
# that logic therefore exist and must be changed together. **This instrument is also
# what established that the disable half of it does nothing** -- run 34010167885, in
# which making its framework restart real cost the leg every test it had. Read
# .github/scripts/e2e-run.sh's header for the measurements; the restart is gone from
# both copies and what remains is the 45-second quiet window.
# - It does not change status_check.yml. If a configuration here turns out to work,
# the change to the real matrix is proposed separately.
#
@@ -291,45 +300,30 @@ jobs:
sleep 5
done
# pm disable-user does not retract SystemUI's existing region-sampling
# registration -- by the time boot completes it has already registered. Only a
# framework restart brings back a SystemUI-less SurfaceFlinger. See
# disable_region_sampling in tools/local-emulator/run-e2e.sh.
echo " restarting the framework"
adb shell stop
for i in $(seq 1 20); do
[ -z "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ] && break
sleep 2
done
echo " system_server down after $((i * 2)) s"
adb shell start
for i in $(seq 1 30); do
if adb shell service check package 2> /dev/null | grep -q ': found' \
&& adb shell service check activity 2> /dev/null | grep -q ': found' \
&& [ -n "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ]; then
echo " services back after $((i * 5)) s"
break
fi
sleep 5
done
# NO FRAMEWORK RESTART. There was one here, and making it work (it needed
# `adb root`) is what proved the whole disable is ineffective on this image:
# SystemUI starts anyway, measured on CI and locally, and the restart itself
# loses the package state to PackageManager's delayed write and leaves the leg
# reporting `Starting 0 tests`. e2e-run.sh's header carries the measurements.
# What is left, and what is load-bearing, is the quiet window below.
if systemui_disabled; then
echo " verified: com.android.systemui is in pm list packages -d"
echo " pm list packages -d: com.android.systemui is in it"
else
echo " NOT DISABLED after the restart -- the package state did not survive"
round=$((round + 1))
continue
echo " pm list packages -d: com.android.systemui is NOT in it"
fi
# Beside it, because the two disagree on this image and the first line alone
# reads as a claim about the process that is not true.
echo " com.android.systemui pid: $(adb shell pidof com.android.systemui 2> /dev/null | tr -d '\r\n')"
before="$(count_aborts)"
sleep 45
after="$(count_aborts)"
echo "--- abort rate, SystemUI disabled: $((after - before)) new in 45 s (total ${after:-0}) ---"
echo "--- aborts: $((after - before)) new in 45 s (total ${after:-0}) ---"
[ "$((after - before))" -eq 0 ] && break
echo " still aborting after round $round"
round=$((round + 1))
done
systemui_disabled && echo "final state: SystemUI disabled" || echo "final state: SystemUI STILL ENABLED -- expect Starting 0 tests"
systemui_disabled && echo "final state: com.android.systemui is disabled in pm (it still runs)" || echo "final state: com.android.systemui is not even disabled in pm"
fi
echo "--- crash buffer (tail 60) ---"
+11
View File
@@ -13,6 +13,17 @@ on:
# reference amounts to running whatever that repository contains tomorrow. This matters
# more here than on pull requests: these jobs sign nothing today, but they do publish
# the artifacts people install.
# Declared here rather than inherited, for the reason status_check.yml gives for its own
# block: the token's reach should be readable in the file that uses it, and a repository
# default that widens later should not silently widen these jobs with it. The repository
# default is `read` today, so this changes nothing about what runs -- it fixes what a
# reader can know without leaving the file, and it is what CodeQL alert #1 asked for.
#
# The `release` job below overrides this with `contents: write`, which is how job-level
# permissions work: this is a default, not a ceiling.
permissions:
contents: read
env:
GRADLE_CACHE_PATHS: |
~/.gradle/caches
+45 -27
View File
@@ -254,38 +254,48 @@ jobs:
api-level: "36"
# API 37, and it is NOT the same device as the four rows above it.
#
# CAVEAT, read this before trusting a green here: this leg runs with
# SystemUI disabled and the framework restarted under it. No other leg
# and no Pixel run uses that configuration. It is defensible only because
# nothing THIS LEG RUNS touches system UI -- Media3, FFmpeg and
# WorkManager tests -- and because the alternative is no CI coverage of
# the level this app targets. **Anything that ever does depend on system
# UI must not trust this row.** E2E_DISABLE_SYSTEM_UI is what does it;
# .github/scripts/e2e-run.sh explains the mechanism and why every step of
# it is verified rather than assumed.
# THE CAVEAT THAT USED TO BE HERE IS WITHDRAWN, 2026-09-05, and the
# withdrawal is good news. It said this leg "runs with SystemUI disabled
# and the framework restarted under it", that no other leg or Pixel run
# uses that configuration, and that anything depending on system UI must
# not trust this row. **None of that was ever true.** Measured: the
# framework restart is two root-only adb commands that answered `Must be
# root` on every leg ever run, and `pm disable-user` does not stop SystemUI
# starting on this image anyway -- in run 34006456986 the package is
# verified disabled at 02:29:33 and SystemUI (pid 4275) is up from 02:28:52
# for the whole run. So this row's device configuration is the same as the
# other four's, and a green here means what a green on 33-36 means.
#
# "this leg" and not "this suite", since 2026-08-24, and the difference is
# now load-bearing: SafPickerRoundTripTest DOES touch system UI. It drives
# DocumentsUI and rotates the display, and both reach the gralloc mapper
# this image aborts in -- disabling SystemUI removes the IDLE trigger, not
# those. Measured per method on android-37.0: the ROTATION test takes the
# framework down (INSTRUMENTATION_ABORTED) and carries
# @FailsOnEmulatorApi37, so notAnnotation below keeps it off this row; the
# PICKER test passes and runs here like anything else. A rotation rebuilds
# every surface at once, and starting another app's activity does not.
# E2E_DISABLE_SYSTEM_UI still exists and still runs, because what it
# actually buys is a 45-second window with no new gralloc aborts before the
# suite starts -- the boot-time ones land close together and instrumentation
# has to begin after them, not between them. The name is stale and kept:
# read .github/scripts/e2e-run.sh's header, which carries the measurements.
#
# So this row does now run one test that depends on system UI, and the
# caveat above still applies to it: a green here is not evidence the picker
# works on a device with SystemUI running -- the Pixel release check is.
# docs/api-37-emulator-crash.md has the per-method measurements, and the
# correction that produced them.
# notAnnotation below keeps seven tests off this row. SafPickerRoundTripTest's
# PICKER test was measured on 2026-08-24 as passing here and was left on the
# leg; four gating logcats read on 2026-09-05 show it aborting system_server
# from the task-snapshot path on every single run, pass or fail, which is what
# had been failing unrelated PRs (#108). All FOUR of that class's tests now
# carry the marker -- the two saves through the picker (#226, #250) joined on
# 2026-09-06 by inheritance rather than measurement, since they open the same picker.
# docs/api-37-emulator-crash.md has the timings and the correction, and
# FailsOnEmulatorApi37.kt has why the third one cannot be measured here.
#
# api-level must be "37.0". A bare 37 is not an SDK package and fails
# during setup, which cost a run to discover.
# api-level must be a POINT release. A bare 37 is not an SDK package and
# fails during setup, which cost a run to discover. `37.0` is the choice
# here rather than the only option: `37.1` and `37.2-beta*` exist and
# abort the same way, and api37-debug.yml's inputs document both, with
# the wrinkle that above 37.0 they ship only as google_apis_ps16k.
# docs/api-37-emulator-crash.md measures 37.0 rev 6 and 37.1 rev 8 side
# by side, so pinning 37.0 is a decision, not a constraint.
#
# notAnnotation removes the three tests that do not pass on this image; they
# notAnnotation removes the seven tests that cannot be RUN on this image; they
# run in the advisory job below, off the same marker so they cannot end up
# in both or neither. docs/api-37-emulator-crash.md has the measurements.
# in both or neither. "Cannot be run" rather than "do not pass" is deliberate:
# four fail outright, one of those aborts the framework on its way down, and on
# the advisory leg the three picker tests behind it never report at all.
# docs/api-37-emulator-crash.md has the measurements.
- label: "37"
api-level: "37.0"
disable-system-ui: "1"
@@ -372,6 +382,7 @@ jobs:
path: |
${{ runner.temp }}/logcat-api${{ matrix.label }}.txt
${{ runner.temp }}/diagnostics-api${{ matrix.label }}.txt
${{ runner.temp }}/gradle-api${{ matrix.label }}.txt
if-no-files-found: warn
# Only exists when the wrapper timeout tripped, so `ignore` keeps healthy runs quiet
@@ -462,6 +473,12 @@ jobs:
# The complement of the gating row's notAnnotation, off the same marker,
# so a test can never be excluded from both jobs or run in both.
E2E_EXTRA_GRADLE_ARGS: "-Pandroid.testInstrumentationRunnerArguments.annotation=org.libremediaconverter.FailsOnEmulatorApi37"
# Turns on the baseline comparison in the run-shape report, and only here. Every leg
# prints the shape; this is the one that also says whether it matches
# FAILS_ON_EMULATOR_API37_BASELINE, because this is the one whose test list is the
# marker. A deviation is a `::notice::` -- this job stays continue-on-error and stays
# out of the required contexts, so nothing the report finds can change a conclusion.
E2E_ADVISORY: "1"
with:
# Every device pin below matches the gating row exactly, so a difference
# between the two jobs is the test selection and nothing else.
@@ -494,6 +511,7 @@ jobs:
path: |
${{ runner.temp }}/logcat-api${{ env.E2E_LABEL }}.txt
${{ runner.temp }}/diagnostics-api${{ env.E2E_LABEL }}.txt
${{ runner.temp }}/gradle-api${{ env.E2E_LABEL }}.txt
if-no-files-found: warn
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+1
View File
@@ -13,6 +13,7 @@
.externalNativeBuild
.cxx
local.properties
.kotlin
# The FFmpeg AAR is committed under bin/ so test runs do not depend on a rebuild.
# Build outputs from tools/ffmpeg are not.
+375 -13
View File
@@ -76,21 +76,74 @@ days. Read it as the current answer, and see the git history if you need the old
`angle_indirect` and `swangle_indirect` all boot, while `auto`, `off`, `guest` and
`swiftshader_indirect` do not. `docs/local-emulator.md` has the evidence and the per-API renderer
table.
- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Three** of the 59 instrumented
tests cannot pass on that image, for two unrelated reasons: two Media3 hardware transcodes fail
inside the emulator's own `c2.goldfish.h264.decoder`, and one SAF test takes the framework down
when it rotates the display. All three carry `@FailsOnEmulatorApi37` and run in a separate
`continue-on-error` job; the gating leg runs the other 56.
- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Seven** of the 71 instrumented
tests cannot be *run* on that image, for three measured reasons and two inherited: three Media3
tests fail inside the emulator's own `c2.goldfish.h264.decoder`, one SAF test takes the framework
down when it rotates the display, and its sibling — the SAF picker round trip — aborts
`system_server` from the task-snapshot path whether it passes or not. The sixth, that class's
two saves through the picker (#226 and #250), carry the marker because they open the same picker
and a second DocumentsUI dialog on top of it — **not** because either has ever been observed here. It cannot be:
the rotation test runs first and takes the framework down, so all five advisory runs at the
previous baseline reported `expected: 6, received: 4, failed: 4`, and the four were the three
Media3 tests plus the rotation — runs 34041156680, 34041593697, 34042397320, 34043502322 and
34045105857. **No picker test has ever reported on the advisory leg**, which is a correction to
what the marker's own KDoc used to say. All seven carry `@FailsOnEmulatorApi37` and run in a
separate `continue-on-error` job; the gating leg runs the other 64 — **the same 64 for the third
time running**, which is exactly how this paragraph goes stale unnoticed: 69−5, 70−6 and 71−7
are all 64.
**These two numbers move with the suite and are derived, not remembered.** `grep -cE
'^\s*@Test' ` over `app/src/androidTest` is the first; the second is that minus the marker
count `.github/scripts/e2e-report-shape.sh` greps. Cross-check against any run's shape rather
than trusting the sentence: a leg below 37 reports the first as `expected`, and the API 37
gating leg reports the second.
**That third reason is why "cannot pass" became "cannot be run" on 2026-09-05.** Four gating
runs were read logcat-first — 34006456986, 34001744574, 34001377499 and the green 34002313300 —
and each carries exactly two `hasReadColorBufferDma` aborts before the suite (surfaceflinger,
during boot and the SystemUI disable) and exactly **one** during it: `system_server`, thread
`TaskSnapshotPer`, always inside the picker test's window, and nothing else in the gating set
reached the mapper at all. Whether the leg went red was luck — one run passed the test and lost
the leg anyway with `failed: 0`, another passed it 0.6 s after the abort and went green. That is
#108, it cost roughly a third of the gating legs over the wave-4 landings (#190), and a marker
is what it needed. `docs/api-37-emulator-crash.md` has the timings.
**A second thing came out of those logcats, and it withdraws a caveat rather than adding one.**
The API 37 row was documented as the one leg running "with SystemUI disabled and the framework
restarted under it", which nothing else does. Neither half was ever happening: `adb shell stop`
and `start` are root-only and answered `Must be root` on every leg ever run, and `pm
disable-user` does not stop SystemUI starting on this image anyway — measured on CI and locally,
with and without a real restart. **So this row's device configuration is the same as the other
four's, and a green here means what a green at 33–36 means.** `E2E_DISABLE_SYSTEM_UI` is kept
under its now-stale name because what it really buys is a 45-second window with no new gralloc
aborts before the suite starts, which is load-bearing; `.github/scripts/e2e-run.sh`'s header is
where that is written down.
That job is still called `E2E API 37 Media3 hardware transcode (advisory)`, which no longer
describes everything in it. The name is kept deliberately — it is not a required context and
people have learned to look for it — so **read the marker, not the name**, for what it holds.
**It is red on every PR, by design**: do not read it as your change breaking something, and do
not read a green run as evidence those three tests pass.
not read a green run as evidence those seven tests pass.
`docs/api-37-emulator-crash.md` has the measurements.
**That instruction is also why nobody looks, so the job now reports its own shape** — expected,
received, failed, and whether the run completed — to the job summary, and compares it against
`FAILS_ON_EMULATOR_API37_BASELINE`, committed beside the marker. A deviation is a `::notice::`;
the job stays advisory and its conclusion is untouched. **Add or remove a `@FailsOnEmulatorApi37`
and that number changes in the same diff**, or the next run says so. A bare failure count would
not have worked: the run is usually truncated by an `INSTRUMENTATION_ABORTED`, and the test XML
is written anyway and says nothing about it — `.github/scripts/e2e-report-shape.sh` is where that
is measured and explained.
Every leg prints that table, advisory or not, and **on the wedge path it also carries a `wedged:`
row** (#118). `completed cleanly` only ever meant "instrumentation was not aborted", which stays
true of a leg the `WEDGE_TIMEOUT` killed 22 minutes in — so without that row the table read
`received: 59, completed cleanly: yes` for a leg that had just died. The wedge is passed to the
report as `E2E_WEDGED_AFTER` by `e2e-run.sh`, which is the only thing that can know it: a wedge
is gradle never returning, so the log it left says nothing about it.
Still true, and the reason the advisory job is not simply deleted: **API 37 needs a manual check on
the Pixel 10 Pro XL before each release.** Those three tests are the one thing CI cannot answer
the Pixel 10 Pro XL before each release.** Those seven tests are the one thing CI cannot answer
for.
On a device or emulator, build only the ABI it can execute:
@@ -114,10 +167,11 @@ install for code that can never run — and on API 37 the full APK does not fit
- The `model` package is excluded from `ReturnCount` and `CyclomaticComplexMethod` only. It is the
decision layer, where one branch is one documented user-visible outcome and the metric counts
answers rather than complexity. Every other rule still applies there.
- **Coverage is reported, not gated** — **69.2% of lines (1519/2194), 53.2% of branches**,
measured 2026-08-24 with `./gradlew :app:jacocoTestReport`.
- **Coverage is reported, not gated** — **94.2% of lines (2234/2372), 87.5% of branches
(1171/1338)**, measured 2026-09-05 with `./gradlew :app:jacocoTestReport`, against 628 JVM tests
in 96 classes.
**Every figure this file carried before that date was an artifact, roughly half the real one.**
**Every figure this file carried before 2026-08-24 was an artifact, roughly half the real one.**
Robolectric loads classes through its own sandbox classloader with no source location, JaCoCo
skips no-location classes by default, and nothing told it otherwise — so **not one Robolectric
test counted**, and Robolectric is what exercises the framework edge here. The
@@ -131,9 +185,257 @@ install for code that can never run — and on API 37 the full APK does not fit
disproportionately Robolectric, so each one added denominator and no numerator — the measurement
was punishing exactly the tests that were hardest to write.
Two things still hold. A floor needs a baseline that has settled, and this one has now moved by
39 points in a single build change, so it has not. And **re-measure before quoting** — that
instruction is the only reason this was caught.
Two things still hold. A floor needs a baseline that has settled, and this one has not. It moved
39 points in a single build change on 2026-08-24; then another 16 as the #52 test push and the
fixes it turned up landed — 69.2% -> 84.9% line, 53.2% -> 63.8% branch — while the denominator
grew 2194 -> 2321, because that work added production code of its own; then again on 2026-08-27
as #132 and #133's ten children landed — 84.9% -> 87.1% line, 63.8% -> **69.1%** branch, 454 ->
502 tests. **Branch moved four times as far as line that last time**, and that is the shape to
expect from this kind of work rather than a surprise: those children targeted decision code —
enum fallbacks, refusal arms, cursor shapes, a `when` over container rules — where one test
chooses a branch the suite had never taken. Line coverage barely notices; branch coverage is the
whole point.
Then #153's five children on 2026-08-29 — 87.1% -> 88.9% line, 69.1% -> **75.4%** branch, 502 ->
546 tests.
**That last branch figure moved for two reasons, and only one of them is new tests.** The
numerator rose 974 -> 1011; the denominator *fell* 1410 -> 1340. Both are the seam work. Pulling
a `when` out of a lambda inside a `collect` deletes the coroutine state machine's synthesized
branches around it, and what is left is a plain function whose branches a test can choose:
`ConversionViewModel$observe$1$1` went from carrying the whole mapping to 6 branches, while the
extracted `ConversionViewModelKt` covers 41 of 42 and `JoinViewModelKt` 38 of 39. So a seam is
worth more than the tests it enables — it also stops the measurement counting scaffolding.
Be careful quoting a branch move on its own for that reason. A percentage that rises because the
denominator shrank is not the same claim as one that rises because more branches are tested, and
this entry has a history of explaining its own numbers wrongly.
Then wave 3 (#167-#178) on 2026-09-02 — 88.9% -> 92.8% line, 75.4% -> **81.3%** branch, 546 ->
584 tests, in ten PRs from #179 to #188.
**Its shape is different from the two before it, and the difference is the thing to carry
forward.** Waves 1 and 2 were finding uncovered code. By wave 3 there was not much of that left,
so the gaps were sorted into two kinds before any test was written:
- **coverage gaps** — the line never executes. Filtered to sites where JaCoCo reports `mi > 0`, a
concrete instruction no test runs, which is what separates a real gap from a partial branch on
a compound condition. That filter cut the candidate list roughly in half and was right to.
**Wave 4 found it wrong in both directions, though — use the two filters below instead.**
- **assertion gaps** — JaCoCo is green and nothing checks the answer. `MainActivity`'s rail and
bottom bar were both *executed* by `AppRootRestorationTest` and **transposing them passed the
entire suite**; so did swapping the two progress-notification strings, and swapping `Content`'s
two destinations. No coverage number would ever have found any of the three.
**Wave 4 (2026-09-02) corrected that first filter, and the correction is the reusable part.**
`mi > 0` fails in both directions. It *over-reports* on Compose: `JoinScreen.kt:222` reads
`mi=10` and also `ci=38`, and `JoinStateAffordancesTest` already clicks that Save button and
asserts `save:joined.mp4` — the missed instructions are the synthesized `$changed`/`$dirty`
recomposition-skip path, the same codegen this file already warns about for *branch* counts,
showing up in the instruction count too. And it *under-reports* on warm methods with cold arms:
`ConversionViewModel.cancel()` misses no line, yet `activeWorkId?.let(...)` had only ever been
entered on the null side in 584 tests. Use two filters together instead:
- **`ci == 0`** — the line never executed. This is JaCoCo's own missed-line definition, so it
totals exactly the reported missed-line count and needs no judgement.
- **`ci > 0 && mb > 0` at method level** — a covered method with an arm nothing takes. This is
the only one that finds the `cancel()` shape.
Of wave 4's 251 missed branches, just **18** sat on lines that do execute, so the branch gap and
the line gap are largely the same gap; the second filter is about which of them are reachable.
So **every ticket named the mutation that had to go red, and that was its acceptance criterion
rather than a coverage delta**. It caught **two vacuous tests written in the same session**,
before either shipped:
- a `firstContainerHolding` test asserting a refusal still offered *something*. True, and
useless: the source container is a candidate in its own right, so the list stays non-empty
whatever the fallback does. What it actually buys is the codec the user asked for.
- a staged-delete test scanning for a `"join-"` prefix `StagingNames.forJob` does not produce —
it names files `<jobId>.<ext>`, so the assertion was true of everything.
It also corrected a *third* test that was not vacuous: `probeForConcat`'s KDoc claimed to drive
the `catch` arm, and rethrowing from that catch left it green. That is how the arm turned out to
be unreachable — see the next paragraph. A passing test with a wrong explanation is its own
failure mode.
**A green mutation is only evidence when the mutation is a real change**, which is the mirror
trap: one `classify` mutation stayed green because reordering two arms was semantically
equivalent for every reachable input. A bad mutation and a weak test look identical in the output.
Three things came back **not as the ticket described them**, which is a result rather than a
shortfall:
- `ContainerCapabilities:282`'s `exclude` filter **cannot drop anything**. `repair` always
changes a codec on the shared container — a codec it left alone is one `validate` would not
have refused — and the one non-default `exclude` carries `COPY` while every candidate carries
`NONE`. F4-shaped.
- `probeForConcat`'s catch arm is **unreachable on this runtime**. Robolectric's `MediaExtractor`
never throws from `setDataSource`, measured across an unregistered `content://` authority, a
missing `file://`, a file of garbage bytes and an `http://` URL — all four returned with
`trackCount = 0`. It stays device-only.
- `JobSnapshots:31`'s missed arm was **not** the `!isFile` one the ticket named — that is already
covered by the `reclaimed` fixture. It was `path == null`: a job carrying no output path at
all. Read the report, not the ticket, when the two disagree.
One item was **included against** the F4 rule rather than exempted by it, and the distinction is
worth having written down since both live in the same function: `ContainerCapabilities:94`
(`accepts(container, VideoCodec.NONE, mode)`) is dead in production today — every caller guards
`NONE` first — and was tested anyway, because its audio twin at `:101` has had a test since #136
and the asymmetry was the argument. The `COPY -> error(...)` arms beside it stay exempt, because
a second line of defence that can be provoked is not one.
Denominators moved here too, in both directions and for two different reasons: 1340 -> 1342
branches from `MediaProbe.merge`, 2348 -> 2352 lines from the `ConcatJoiner` interface. Neither
is new untested code.
And **re-measure before quoting**: this entry was once written quoting 81.4%, measured four hours
earlier, and was already three points stale by the time it was ready to merge.
**Wave 4's read (2026-09-02) moved no number at all, and that is its result.** It was a triage
rather than a test push: twelve tickets (**#192-#203**), four deferred candidates (**#204**), and
five findings (**F6-F10** in `docs/coverage-read-findings.md`). What it establishes is the shape
of what is left, which is different again from wave 3's:
- Of 169 never-executed lines, **81 are native or device edges and stay that way** —
`FFmpegEngine` 33, `Media3Engine` 24, `ConcatEngine` 14, `MainActivity.onCreate` 10 — their
zeroes being the `testDebugUnitTest`-only measurement boundary that #84, #85, #86 and #88 each
recorded before. A further **34 are device-bound only until a seam moves them**:
`AndroidDeviceCodecs` 20 (#194) and the 14 of `MediaProbe`'s 26 that are `readMediaInformation`
(#195). Do not read that second group as exempt — the two tickets exist because it is not.
- Most of the rest is **already closed with a reason on record**, or compiler-generated: default-arg
bridges, DI factory lambdas, synthetic `NoWhenBranchMatchedException` arms, coroutine completion.
- Six of the ten findings in that document are now "no action" or "not a test gap". By this point
the report's remaining red is mostly arms nothing can reach, members nothing calls, and arms a
test *can* reach but cannot pin — and a coverage number tells none of them apart.
**The biggest single gap it found was not a missed line.** `ConversionViewModel.cancel()` and
`JoinViewModel.cancel()` report every line covered; only the null arm of
`activeWorkId?.let(workManager::cancelWorkById)` had ever been entered, so nothing in 584 tests
connected the Cancel button to WorkManager (#192). That is what the second filter above is for.
It also re-opened a mechanism, not a close: #86 and #133 ruled `AndroidDeviceCodecs.probe()` out
**through `ShadowMediaCodecList`**, on the grounds that the builder cannot set `isAlias` or
`canonicalName`. A pure seam does not have that constraint, and #133 did not evaluate one. Read
#194 before re-arguing either way — and note the reason it is worth cutting is not coverage but
that the `runCatching` fallback logs "assuming permissive" while returning empty sets, which makes
`canEncode` and `canDecode` answer *no* for everything.
**Wave 4's tests then landed on 2026-09-05**, as #206-#217 for the twelve tickets plus #218
(#159) and #219 (#122): 92.8% -> **94.2%** line, 81.3% -> **87.5%** branch, 584 -> 628 tests in 87
-> 96 classes. Missed lines 169 -> 138, missed branches 251 -> 167.
**Its branch move is a different animal from the 2026-08-29 seam work's, and the difference is the
point.** That one gained 6.3 branch points with the numerator up 37 (974 -> 1011) while the
denominator *fell* 70 (1410 -> 1340) — much of the rise was scaffolding leaving the measurement
rather than arms being covered. Here the numerator is up **80** (1091 -> 1171) and
the denominator moved **-4** (1342 -> 1338). So this one is almost entirely tests choosing arms
nothing had chosen, which is what the entry above warns to check before quoting a branch figure.
The line denominator rose the other way, 2352 -> 2372, and that is new production code rather than
untested code: the seams the wave cut — `capabilitiesFrom`, `ffprobeInfoFrom`, `sessionOutcome`,
and `sweepScope`/`startupSweep`.
**The two-filter method above is what found the work**, and its second filter earned its place:
the largest single gap of the wave (#192, the Cancel button never shown to reach WorkManager) sits
on lines that were already green and no line-level filter could see it.
One result worth carrying forward about *evidence* rather than coverage. #218 fixed a flake whose
reproduction is statistical, and running the whole suite six times per arm caught nothing either
way — at the observed rate a clean six-run arm is roughly a coin flip, so the comparison was
underpowered and proved nothing. What settled it was a deterministic mutation, and then the merge
train confirmed it by accident: the race reproduced on #217's Unit tests leg, which sits below
#218 and carries the unfixed scope. **Prefer a mutation that must go red to a repetition count**
when a fix is for something intermittent.
**Every number above is `testDebugUnitTest` only, and on 2026-09-05 the instrumented suite got its
first read for that reason** — `docs/e2e-read-findings.md`, entries **E1-E7**, tickets
**#223-#230**. Four waves had been steered by a figure that **cannot see `app/src/androidTest` at
all**, so nothing had ever asked what those 60 device tests pin, only that they were green.
**It found one test that passes while testing nothing, and it is the one that matters most.**
`HardwareFallbackTest` is the only automated check of the hardware→software fallback against a
*real* codec failure, and on run `34004304566` the API 33, 34, 35 and 37 legs each log
`Routing sample_h264_444.mp4 -> ... via FFMPEG (NO_HARDWARE_ENCODER)` (API 36's logcat artifact on
that run is truncated, so it is unread rather than different): emulators expose no
hardware encoder, so the job never reaches Media3 and the `catch` it exists to prove is never
entered. Its two assertions — succeeded, output non-empty — are true anyway, and it finishes in
448 ms. **Deleting that `catch` reddens nothing on any leg** (#223).
Two things generalise from it. **A test can assert and still not reach**, which no coverage
number and no "does it assert something" review would catch — the filter that works is *does this
test's premise hold on the machine that runs it?*. And the codebase **already knew**: the sibling
`ForcedFailureTest` pins `DeviceCodecs.PERMISSIVE` against exactly this hazard and writes out why,
as does `ConversionWorkerTest`. The difference is that their assertions are about the *path*, so
without the pin they would fail loudly; `HardwareFallbackTest`'s are about the *output*, so it
passes quietly. **Prefer asserting the path over asserting the artefact** where the two differ.
The read was a triage, not a test push, and six of its seven findings are prose rather than code —
the suite itself is in good shape. What had drifted is its self-description.
**Working the tickets then found the thing the read could not: one production defect.** #238 —
joining files picked through the system picker failed outright on the stream-copy path. The
concat demuxer whitelists protocols separately from `-safe 0`, and `ffkitsaf` was not on the
list; only `STREAM_COPY` feeds it a list file, and every existing join test passed
`Uri.fromFile`, so **the one broken combination was the only one a user could reach**. Not a
missed line and not an unasserted value — two covered things no test put together, which is the
gap shape a coverage number is worst at.
**E7 is the other reusable result**, because it re-scoped its own ticket. A real
`DocumentsProvider` cannot be reached without the picker: an unprotected one is refused at
install, instrumentation runs in the app's uid so the test APK's identity is no help, and shell
identity is denied too — each denial naming `ACTION_OPEN_DOCUMENT`. So #226 has no cheap headless
half. But the *input* bridge needs no documents provider at all, which is what kept #225 headless
and is how #238 surfaced.
**The 2026-09-06 re-check found that the read's own last PR had re-introduced the drift the read
was about**, and that is the entry worth carrying forward. #226 moved the suite 69 -> 70 and the
markers 5 -> 6 and changed neither the count in this file, the marker's KDoc, nor the two
comments in `status_check.yml`. **The gating figure is what hid it**: 69 - 5 and 70 - 6 are both
64, so the one number a reader checks against a run had not moved — which is precisely why the
paragraph above says to derive these rather than remember them. Worse, two KDoc claims in the new
test described a draft rather than the code: it says MP3 was chosen so the setup could not depend
on the device's codecs, while the code converts at the default `MP4_H265`/`FAST` and therefore
routes on `canEncode(H265)` — the *negation* of the stated reason. **That is E1 and E3's failure
mode, committed by the wave that found it.** All of it is fixed; the standing item is **#250**,
because #226 proved D4's premise and never drove its delete arm.
- **Nothing is committed or pushed until the local gate is green, at every supported API level.**
Source work (`app/src/main`) needs the unit tests **and** the instrumented tests passing on every
level; test work (`app/src/test`, `app/src/androidTest`) needs the whole suite passing on every
level. `tools/git-hooks/local-gate.sh` enforces it as `pre-commit` and `pre-push`; wire it up once
with `git config core.hooksPath tools/git-hooks`.
33-36 run the whole suite on emulators. **API 37 cannot be run on an emulator on this host at
all** — not "is red", *cannot run*: measured 2026-09-06, the image logs `3 new surfaceflinger
aborts in 45 s (want 0)` and then the APK install itself fails with `Can't find service:
package`, because the framework is gone before Gradle installs anything. `Starting 0 tests`. So
the hook runs API 37 on the **attached Pixel 10 Pro XL** when it is there, and says plainly that
the level is uncovered when it is not — CI's gating leg being what answers for it then. It never
claims five levels having run four.
**It runs shellcheck and actionlint too, at CI's exact pins** — shellcheck over
`git ls-files '*.sh'`, actionlint over the workflows, the same digests and the same file sets
that leg uses. actionlint is not an afterthought to shellcheck but the other half of the same
hole: much of this repo's bash lives in workflow `run:` blocks, which `'*.sh'` does not match at
all. That gap was found the hard way: the gate checked ktlint,
detekt and Android lint, so a new `.sh` file was precisely the case where it passed and CI still
went red, and the first file it could not check was itself. **The digest is read out of
`status_check.yml` rather than copied** — two copies drift, and the symptom of that drift is the
gate passing while CI fails, which is the one thing this check exists to prevent.
The sweep is cached under the hash of the **`app/src` subtree**, not the whole repo tree. Keying
it on the whole tree was the first cut and it was wrong: editing a comment in `CLAUDE.md` threw
away a sweep of byte-identical application code and re-ran forty minutes of emulators to prove
nothing, which is how a gate teaches people to resent it. Any change under `app/src` still
invalidates it, and the JVM gate runs unconditionally. **There is deliberately no skip
variable**, and `--no-verify` needs the repo owner's say-so each time rather than being reached
for when the gate is inconvenient.
Why it is worth tens of minutes a commit: the alternative was measured on 2026-09-06, when one PR
spent several gating legs learning one leg at a time what a sweep answers in one pass — and the
failing leg **moved** between runs (API 35 red then green, API 34 green then red). One leg at a
time that reads as someone else's flake; as a sweep it is one signal.
- **Testable code is not done until it is tested.** If a piece is unit testable, it gets unit
tests before it counts as done. If it is e2e testable, it gets e2e tests. Both clauses apply —
a change that is both needs both.
@@ -155,6 +457,32 @@ install for code that can never run — and on API 37 the full APK does not fit
- `kotlin.code.style=official`. Gradle stays Kotlin DSL.
- **A stacked PR does not merge with `gh pr merge`, and `MERGED` is not proof it reached `main`.**
Two separate traps, both measured on 2026-08-27 while landing #144-#151.
`gh pr merge` uses the GraphQL mutation, which refuses a stacked PR outright: *"This pull request
is part of a stack and must be merged using the asynchronous merge REST API."* So does
`PUT .../pulls/{n}/merge`. The one that works is
`gh api -X PUT repos/OWNER/REPO/pulls/N/merge-async -f merge_method=merge`, which returns a uuid
to poll at `.../merge-async/{uuid}` until `status` is `merged` or `failed`.
**The second trap is worse, because nothing looks wrong.** GitHub retargets a stacked PR's base
to `main` when the PR below it merges, but *asynchronously*. Merge a stack faster than that
settles — five PRs about thirty seconds apart, in the case that found this — and each one merges
into its own base branch, which has itself already been merged and left behind. Every call
returns `status: merged` and every one is true. `gh pr list --state open` comes back empty, every
PR shows `MERGED`, and **none of the content is on `main`**.
What caught it was a coverage re-measure reading two points lower than the same tree had measured
an hour earlier; a fresh `git pull` changed nothing, which is what turned it into a question.
`git merge-base --is-ancestor <merge-sha> origin/main` answers it in one line. Do that after
merging a stack, or merge one at a time and re-read `baseRefName` between. #160 is what the
recovery cost.
The auto-retarget belongs to the stacking feature specifically. A PR opened with a plain
`gh pr create --base some-branch` does **not** retarget when that branch merges — it is left
pointing at a dead base and has to be moved by hand.
- **File one-off issues with `tools/github/file-issue.sh`, not `gh issue create`.** `gh issue
create` does not touch the project board, so the issue exists, carries its labels, and is
invisible in the Kanban — indistinguishable from never having been filed. Measured 2026-08-24:
@@ -227,3 +555,37 @@ Because versions float, a build can change without a commit. `./gradlew :app:dep
`@OptIn`. Android lint's `UnsafeOptInUsageError` catches a missed one.
- **Release builds ship both ABIs.** `-PabiFilters` is a test-run override only; `build.yml`
verifies the released APK carries every ABI and that all native libraries are 16 KB aligned.
- **A JUnit `Timeout` — rule or `@Test(timeout=)` — cannot be used in the JVM suite.** Both run the
test body on a separate thread, and every Compose test here goes through Robolectric's paused
main looper: `UnsupportedOperationException: main looper can only be controlled from main
thread`, from `ShadowPausedLooper` under `RobolectricIdlingStrategy.runUntilIdle`. The identical
tests pass with the timeout removed, so it is the mechanism, not the test. What bounds a hung
run instead is `timeout` on the `Test` tasks plus the jstack watchdog beside it in
`app/build.gradle.kts`, neither of which moves a thread. `HangBoundTest` guards both numbers,
and **a timed-out run writes no XML for the class that hung** — the dump is its only
attribution, so do not delete the watchdog as stray config. It has since been exercised in anger:
on 2026-09-05 it caught #125's Room/WorkManager deadlock on CI, failing in 10m57s with the hung
test named, where that ticket had predicted a 60-minute cap and no cause. #125 is closed as
bounded on the strength of it — the inversion itself is internal to the two libraries and still
live at `work-runtime` 2.11.2 / `room` 2.7.0.
- **The JVM suite does not run `LibreMediaConverterApp`.** `app/src/test/resources/robolectric.properties`
names `TestLibreMediaConverterApp` for every test, and it differs from the real class in exactly
one thing: `sweepScope` is `Dispatchers.Unconfined`, so the startup staging sweep finishes before
`onCreate()` returns instead of running on `Dispatchers.IO`.
**That line is load-bearing — do not delete it as stray config.** Robolectric builds an
`Application` per test class that asks for one, and each `onCreate` launched a sweep over the
shared `<cacheDir>/conversions/` that nothing joined. So a test asserting about a staged file was
racing every sweep the classes before it had left in flight (#159). It was CI-only until wave 4
added ten Robolectric classes, at which point `OutputPublisherStagingTest` failed on roughly one
local run in six. Per-test opt-in was measured and rejected: **27 of the 58 Robolectric classes
touch that directory**. The `SupervisorJob` is kept in the test scope so a throwing sweep is
swallowed there exactly as in production — the dispatcher is the only intended difference.
**It cost one assertion, knowingly.** `AppStartSweepTest` used to open by asserting that the
manifest's `android:name` is what Robolectric instantiated, so the sweep is code that actually
runs. An `application=` override *replaces* the manifest rather than being checked against it, and
`applicationInfo.className` reports the override too — measured — so that claim is not merely
unasserted on the JVM now, it is unobservable, and a rewritten version would assert the override
against itself. **The manifest link is device-only.** What remains is the `as LibreMediaConverterApp`
cast in that class's `setUp`, which catches only the test app ceasing to extend the real one.
+122
View File
@@ -1,4 +1,7 @@
import org.gradle.api.tasks.PathSensitivity
import org.gradle.testing.jacoco.tasks.JacocoReport
import java.io.File
import java.time.Duration
plugins {
// Applied by id: these two come from the root buildscript classpath, which is what
@@ -206,6 +209,125 @@ detekt {
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
// no location for either, and the test JVM dies rather than reporting a number.
tasks.withType<Test>().configureEach {
// ReleasePermissionTest reads .github/workflows/build.yml, and Gradle cannot infer that a
// test depends on a file outside the source set. Without this the task stays UP-TO-DATE
// when the workflow changes, so the guard goes stale exactly when it matters. Measured:
// deleting the release job's `contents: write` and re-running gave "BUILD SUCCESSFUL in
// 614ms" with the test never executing; the same mutation under --rerun-tasks failed it.
// A guard that does not re-run when its subject changes is not a guard.
inputs.file(rootProject.file(".github/workflows/build.yml"))
.withPropertyName("releaseWorkflow")
.withPathSensitivity(PathSensitivity.RELATIVE)
// Same reasoning, same trap: HangBoundTest reads the two numbers below out of this file, and
// they are the one part of the change that does not compile. Without this the task stays
// UP-TO-DATE when the build script changes, so the guard would go stale on exactly the edit
// it exists to catch.
inputs.file(project.file("build.gradle.kts"))
.withPropertyName("moduleBuildScript")
.withPathSensitivity(PathSensitivity.RELATIVE)
// --- Bounding a hung run (#125) -----------------------------------------------------------
//
// This suite had no timeout of any kind, so a hang ran until something outside it gave up.
// #125 is a real Java-level deadlock -- a lock-order inversion between Room's
// TransactionExecutor and WorkManager's SerialExecutorImpl, reached through the WorkInfo flow
// -- and one local run sat in it for 47 minutes. On CI it would burn the Unit tests job's
// 30-minute cap and report as a job timeout with no cause at all.
//
// WHY NOT A JUnit `Timeout` RULE, which is the obvious answer: it runs the test body on a
// separate thread, and this suite is thread-affine. Measured here, `@Rule Timeout` and
// `@Test(timeout = ...)` against a `createComposeRule()` Robolectric test both give:
//
// java.lang.UnsupportedOperationException: main looper can only be controlled from main
// at org.robolectric.shadows.ShadowPausedLooper.executeOnLooper
// at androidx.compose.ui.test.RobolectricIdlingStrategy.runUntilIdle
//
// The same two tests with the timeout removed pass, so that is the mechanism and not the
// probe. Nothing that moves a test off its own thread can be used here.
//
// `Task.timeout` moves nothing -- it stops the forked test JVM from outside. Its weakness is
// that it kills without a thread dump, and the jstack is the only reason #125 could be named
// at all; the watchdog below is what answers that, and it only dumps.
//
// THE NUMBER, against the slowest observed *pass* rather than the typical one. Eight CI runs
// sampled 2026-08-26, whole `./gradlew :app:testDebugUnitTest` invocation with compilation in
// it and this task a subset: 62, 76, 77, 79, 81, 84, 86 and 90 seconds. Locally the task
// itself is ~11 s over 454 tests. Ten minutes is ~6.7x the slowest of those and a third of
// the job's 30-minute cap, so a fired timeout still has room to be reported and uploaded. It
// is deliberately nowhere near the observed duration: a timeout that fires on a healthy slow
// runner turns a real signal into noise and teaches people to re-run reflexively.
timeout.set(Duration.ofMinutes(10))
// The dump, two minutes before the kill. jstack is what turned #125 from "CI timed out" into
// a named lock-order inversion, and `Task.timeout` on its own would have thrown it away.
//
// It is deliberately incapable of failing a build: it reads a live process and writes a file.
// Nothing here kills, interrupts or signals anything, so the worst a misfire can do is leave a
// stack trace nobody needed. It has one, and it is the ordinary CI shape rather than an exotic
// case: the worker is found by scanning this daemon's descendants for GradleWorkerMain, which
// cannot tell one invocation's worker from the next, and the Unit tests job runs
// testDebugUnitTest and jacocoTestReport back to back against the same daemon. If this task's
// own worker lived and died inside a single poll, the watchdog can adopt the following one.
//
// Everything it needs is read here, at configuration time, and captured by value. Reaching
// back through the task or the project from inside the action would not survive the
// configuration cache, which `gradle.properties` turns on for every build.
val threadDump = layout.buildDirectory.file("reports/hang/$name-threads.txt").get().asFile
val taskPath = path
val dumpAfterNanos = Duration.ofMinutes(8).toNanos()
val captureWindowNanos = Duration.ofMinutes(1).toNanos()
val pollMillis = 1_000L
doFirst {
val watchdog = Thread {
val startedAt = System.nanoTime()
var worker: ProcessHandle? = null
while (true) {
Thread.sleep(pollMillis)
val elapsed = System.nanoTime() - startedAt
val watched = worker
if (watched == null) {
// Gradle forks the worker moments after this task starts. If none has shown
// up by the end of the capture window there is nothing to watch, and going on
// polling would only risk adopting some other build's.
if (elapsed > captureWindowNanos) return@Thread
worker = ProcessHandle.current().descendants()
.filter { it.info().commandLine().orElse("").contains("GradleWorkerMain") }
.findFirst().orElse(null)
} else if (!watched.isAlive) {
return@Thread // the run finished; this is the healthy exit
} else if (elapsed >= dumpAfterNanos) {
val jstack = File(File(System.getProperty("java.home"), "bin"), "jstack")
threadDump.parentFile.mkdirs()
if (jstack.canExecute()) {
ProcessBuilder(jstack.absolutePath, "-l", watched.pid().toString())
.redirectErrorStream(true)
.redirectOutput(threadDump)
.start()
.waitFor()
} else {
threadDump.writeText("no jstack at ${jstack.absolutePath}\n")
}
// To stdout as well as to the file, and that is the half that matters on CI:
// the Unit tests job uploads app/build/reports/tests/ and nothing else, so a
// dump that only ever existed under reports/hang/ would be unreachable from a
// red run -- which is the "timed out with no cause" this exists to end. The
// step log always survives, and needs no workflow edit to say so.
println(
"$taskPath is still running after ${Duration.ofNanos(elapsed).toMinutes()} " +
"minutes and is about to be timed out. Thread dump of pid " +
"${watched.pid()}, also written to $threadDump -- look for 'Found one " +
"Java-level deadlock' (that is #125).\n" + threadDump.readText(),
)
return@Thread
}
}
}
watchdog.isDaemon = true
watchdog.name = "hang-watchdog"
watchdog.start()
}
extensions.configure<JacocoTaskExtension> {
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
+22
View File
@@ -42,6 +42,28 @@
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<!--
A PLAIN provider, for the ffkitsaf bridge on the success path.
FFmpegKitConfig.getSafParameterForRead is on every real user conversion and was on no
passing test: they all pass Uri.fromFile, which takes the other arm. Only its failure
side was covered, by UnopenableUriTest naming an authority that does not exist.
The documents provider above cannot serve this. Any DOCUMENTS_PROVIDER must hold
MANAGE_DOCUMENTS or the platform refuses to install it, instrumentation runs in the
target app's process and so carries the app's uid, and the resulting denial says what
is actually required: access obtained through ACTION_OPEN_DOCUMENT. That means a picker,
and the flake it brings. See issue #226.
The bridge does not need a documents provider. It opens a descriptor through the
resolver and hands FFmpeg a saf: path, so any readable content:// URI exercises it, and
an ordinary provider is allowed to be exported without a permission.
-->
<provider
android:name="org.libremediaconverter.saf.FixtureContentProvider"
android:authorities="org.libremediaconverter.test.content"
android:exported="true" />
</application>
</manifest>
@@ -1,7 +1,7 @@
package org.libremediaconverter
/**
* Marks an instrumented test that does not pass on the `android-37.x` **emulator** system images.
* Marks an instrumented test that cannot be run on the `android-37.x` **emulator** system images.
*
* This is a marker, not a skip. Nothing reads it except CI, and CI reads it twice — once with
* `notAnnotation` to build the gating API 37 leg, and once with `annotation` to build the advisory
@@ -9,6 +9,24 @@ package org.libremediaconverter
* That is the whole reason there is one annotation rather than a pair of test lists: two lists
* drift, and the drift is silent in both directions (a test that runs nowhere reads as green).
*
* **"Cannot be run" covers three things now, and it covered only the first until 2026-09-05.**
* Four of the seven carriers simply fail: three Media3 tests die in the image's own
* `c2.goldfish.h264.decoder`, and the SAF rotation test takes the framework down with it. The
* fifth — `SafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard` —
* **passes about half the time and aborts `system_server` every time**, which is worse for a
* gating leg than an honest failure: it fails the leg from the teardown, with no failing test to
* point at (#108). The wording was widened rather than the test excused; that test's own KDoc has
* the four-run measurement.
*
* **The sixth and seventh are the new third thing: they are marked by inheritance, not by
* measurement.** `SafPickerRoundTripTest.aSaveWritesToTheDocumentTheSystemPickerCreated` (#226)
* and `.aFailedSaveDeletesTheDocumentItCouldNotWrite` (#250) each open the same picker and then a
* second DocumentsUI dialog on top of it, so they sit on the same task-snapshot path their sibling
* was marked for. Neither has ever been observed at API 37 either way — see the measurement under
* [FAILS_ON_EMULATOR_API37_BASELINE], which is why they cannot be. Marking them was the
* conservative choice, and **the trigger for revisiting it is the rotation test, not themselves**:
* while that one truncates the advisory run, nothing downstream of it can report.
*
* It says only what has been measured: **on the emulator, at API 37.** The same tests pass on a
* physical Pixel 10 Pro XL at API 37 and at API 33–36 on the same runner under the same renderer,
* so this must never be read as "this test is allowed to fail at API 37" — only as "the API 37
@@ -17,8 +35,67 @@ package org.libremediaconverter
*
* Removing it is the goal, and the trigger is written down: a new API 37.x system image, or an
* ATD image for 37. Delete the annotation from the tests, and the advisory job goes empty and
* the gating one grows by two.
* the gating one grows by [FAILS_ON_EMULATOR_API37_BASELINE].
*
* **How many tests carry it is committed below**, as [FAILS_ON_EMULATOR_API37_BASELINE], and the
* advisory job checks the run against it. Adding or removing a marker means changing that number
* in the same diff.
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class FailsOnEmulatorApi37
/**
* How many tests carry [FailsOnEmulatorApi37] — the advisory API 37 job's committed baseline.
*
* **No Kotlin reads this, and it is not stray config.** `.github/scripts/e2e-report-shape.sh`
* parses it out of this file by name, with a line-anchored pattern, and the advisory job compares
* the run it just did against it: this many tests should start, and all of them should fail.
* Deleting it, renaming it, or indenting it into a class stops the comparison — the report would
* keep printing with nothing to compare to, so it announces that it could not read the baseline
* rather than falling quiet. If you see that notice, this line is what it means.
*
* **One number, both checks, and that is what the marker was meant to mean.** A test carrying it
* cannot be run on this image, so the count is meant to be simultaneously how many the advisory
* leg runs and how many fail. A *smaller* failure count is the interesting direction: it means one
* of them now passes, which is the trigger the KDoc above names for deleting the annotation.
* **Since 2026-09-06 the second half no longer holds in practice** — the run truncates before
* three of the seven start, which the last paragraph below measures. `expected` still holds, and it is the
* field that catches a marker added without changing this number.
*
* **The picker tests are the ones to read that sentence carefully for, and the reason changed
* on 2026-09-06.** `pickingAFileThroughTheSystemPickerFillsInTheFileCard` was marked on
* 2026-09-05 for aborting `system_server` rather than for failing (#108), and on the gating leg
* it passed two runs of four. It was recorded here as *failing* on the advisory leg, behind the
* rotation test — measured, `api37-debug.yml` run 34008889182, `expected: 4, received: 4,
* failed: 4`, in the order Media3, Media3, rotation, picker. (Those dispatches predate the third
* Media3 marker, so their totals are four rather than six.)
*
* **But a second dispatch of the identical configuration reported 4/3/3**, having lost the last
* test to the abort rather than to anything about the test list, and that is why
* `e2e-report-shape.sh` compares `failed` only on a run that finished. `expected` is compared
* always — it comes from `Starting N tests`, which is printed before anything can abort, so it is
* the field that answers "is the marked set the size this number says". Read a *clean* run
* reporting fewer failures than this as one of them now passing; read a truncated one as the
* framework having died, which is this job's normal.
*
* **That is no longer what happens, and the difference is that neither picker test reports at
* all.** The rotation test truncates the run before them: **all five** advisory runs at the
* previous baseline of six — 34041156680, 34041593697, 34042397320, 34043502322 and 34045105857 —
* report `expected: 6, received: 4, failed: 4`, and the four are the three Media3 tests plus the
* rotation. #250 adds a third picker test behind the same wall, so expect `expected: 7,
* received: 4`. So the advisory leg currently answers for
* four of its six, and the comparison below is unaffected only because `failed` is not compared
* on a truncated run. Read it as **unmeasured**, not as passing or failing.
*
* So: adding or removing a [FailsOnEmulatorApi37] means changing this number, in this file, in
* the same diff. The report says so on the run itself if you forget — it prints the tree's own
* `grep` count beside this one.
*
* Why a baseline at all (#83): that job is `continue-on-error` and red on every PR by design, so
* a red X cannot distinguish the known failures from the known failures plus a new one. Counting
* failures alone does not fix it either — the run is usually truncated by an
* `INSTRUMENTATION_ABORTED`, so the count is a number taken from a partial run. The report
* records the truncation next to the counts for that reason.
*/
const val FAILS_ON_EMULATOR_API37_BASELINE = 7
@@ -36,8 +36,20 @@ import java.io.File
* 1. that the hardware path is worth having a second engine for at all, and
* 2. that x264's CRF is worth the GPL licence the app carries for it.
*
* Skips itself when the sample files are absent, so it is harmless in CI. Populate with:
* adb push <file>.mp4 /sdcard/Android/data/org.libremediaconverter/files/
* Skips itself when the sample files are absent, so it is harmless in CI — every green E2E
* leg reports two skips, and these are they.
*
* The two files it looks for, by exact name:
*
* - [H264_SAMPLE] for [hardwareVersusSoftwareOnRealVideo]
* - [AV1_SAMPLE] for [av1InputRoutesAccordingToDeviceDecodeSupport]
*
* **Where they go, and how, is on [samples] — read it before staging anything.** This used to
* carry an `adb push` line naming the external files dir, which [samples] then explains cannot
* work: a pushed file stays owned by the shell user and the app reads EACCES, surfacing as an
* unparseable input rather than a permission error. The instruction and its own refutation sat
* twelve lines apart. It is named in one place now rather than restated here, because restating
* it is what let the two drift.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
@@ -7,19 +7,35 @@ import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.FailsOnEmulatorApi37
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.AudioPlan
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.CopyPlanner
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.VideoPlan
import java.io.File
import java.util.concurrent.CancellationException
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@@ -170,6 +186,63 @@ class Media3EngineTest {
)
}
/**
* The builders that used to throw where nothing could catch them.
*
* `EditedMediaItem.Builder` rejects a composition with both tracks removed —
* checkState("Audio and video cannot both be removed") — and the engine builds it on its own
* HandlerThread. That build sat *between* two narrow `runCatching` blocks, one around
* `buildTransformer` and one around `start`, so the exception reached the thread's uncaught
* handler and took the process with it while the continuation was never resumed.
*
* `ContainerCapabilities.validate` now refuses the spec that gets here from the picker; this
* is the other half — the engine surviving a request that arrives without being validated.
* Deliberately not `@FailsOnEmulatorApi37`: nothing here decodes or encodes, so no emulator
* codec is involved. The builder refuses the input before any media is touched.
*/
@Test
fun aPlanThatRemovesBothTracksFailsInsteadOfKillingTheProcess() {
val request = ConversionRequest(
spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
probe = InputProbe(
videoCodec = null,
audioCodec = "mp3",
hasVideo = false,
container = Container.MP3,
kind = InputKind.AUDIO_ONLY,
),
)
// Asserted rather than assumed: ConversionRequest's default probe says hasVideo = true,
// and with it this same spec plans to (Encode, Drop) and nothing throws at all — which
// would make the whole test vacuous without a word of warning.
val plan = CopyPlanner.plan(request.spec, request.probe)
assertEquals(VideoPlan.Drop, plan.video)
assertEquals(AudioPlan.Drop, plan.audio)
val failure = runCatching {
runBlocking {
withTimeout(BUILDER_TIMEOUT_MS) {
engine.transcode(Uri.fromFile(input), output, request) {}
}
}
}.exceptionOrNull()
// Two assertions, and the second is not pedantry. withTimeout raises
// TimeoutCancellationException, and `java.util.concurrent.CancellationException` *extends*
// IllegalStateException — so testing only the type below would call an unresumed
// continuation a pass. A hang is the other half of this defect and every bit as bad as the
// crash: the worker would sit holding a foreground service forever.
assertFalse(
"the continuation was never resumed — the failure escaped instead of being reported: " +
"$failure",
failure is CancellationException,
)
assertTrue(
"the builder's refusal must surface as a failed job, not a dead process; got $failure",
failure is IllegalStateException,
)
}
private fun durationMsOf(file: File): Long {
val extractor = MediaExtractor()
return try {
@@ -194,6 +267,92 @@ class Media3EngineTest {
}
}
/**
* Cancelling a *running* export stops it, completing #224's third engine.
*
* The two FFmpeg engines were done first (`ad2a75d`, `d293646`); this is
* `Media3Engine.transcode`'s `invokeOnCancellation`, which posts `transformer.cancel()` onto the
* engine's own `HandlerThread` because `cancel()` has the same single-thread requirement as
* `start()`.
*
* ## Why the assertion is the output file here, and was not for FFmpeg
*
* The FFmpeg side could not use the file: `invokeOnCancellation` unlinks it, and on POSIX ffmpeg
* keeps writing to the unlinked inode, so the path stays gone whether or not the cancel landed.
* It asserted the session's return code instead.
*
* `Media3Engine` deletes nothing — the partial is `ConversionWorker`'s to clean up — so the file
* *is* the evidence. An export that was cancelled leaves no moov atom, so `MediaExtractor`
* either finds no video track or refuses the file outright with
* `IOException: Failed to instantiate extractor` — measured, and both mean interrupted. One
* that ran to completion leaves a playable HEVC file, which is the only outcome treated as a
* miss. The wait before
* reading it is deliberately several times the length of the export, so a *non*-cancelled export
* has certainly finished by then: the failure direction is "the file became valid", never "we
* did not wait long enough".
*
* ## Why it retries
*
* Same reason as the other two, measured there: the committed fixture is 3 s at 320x240 and the
* export outruns a naive cancel on a loaded runner. An attempt whose export finished before the
* cancel landed has tested nothing, so it is a miss and is retried; only exhausting
* [CANCEL_ATTEMPTS] fails. With `transformer.cancel()` removed every attempt produces a playable
* file, so the mutation still bites — it just takes five tries to say so.
*
* Progress having been reported is what proves the export really started, so a miss is
* distinguishable from an export that never ran at all — which matters on the API 37 image,
* where the decoder is what fails.
*/
@Test
@FailsOnEmulatorApi37
fun cancellingARunningExportStopsIt(): Unit = runBlocking {
val outcomes = mutableListOf<String>()
repeat(CANCEL_ATTEMPTS) { attempt ->
val partial = File(context.cacheDir, "cancelled_export_$attempt.mp4").apply { delete() }
val job = launch(Dispatchers.IO) {
engine.transcode(
input = Uri.fromFile(input),
output = partial,
request = ConversionRequest(OutputFormat.MP4_H265.spec),
)
}
// The muxer creating the file is proof the export really started, and it is the
// earliest such proof available -- earlier than the first progress tick.
withTimeout(TIMEOUT_MS) {
while (!partial.exists() && job.isActive) delay(POLL_MS)
}
val started = partial.exists()
job.cancelAndJoin()
if (!started) {
// The export failed before writing anything. That is not a cancellation result
// either way, so it is not allowed to pass as one.
outcomes += "attempt $attempt never produced an output file to cancel"
return@repeat
}
// Several times the export's own length, so a cancel that did not land has certainly
// finished. The failure direction is "the file became playable", never "too soon".
delay(SETTLE_MS)
// A cancelled export reports itself two ways and both mean the same thing: no video
// track, or MediaExtractor refusing the file outright with "Failed to instantiate
// extractor" because there is no moov atom to read. Only a *playable* file is a miss.
val video = runCatching { videoMimeTypeOf(partial) }.getOrNull()
partial.delete()
if (video == null) return@runBlocking
outcomes += "attempt $attempt produced a playable $video"
}
fail(
"never interrupted a running export in $CANCEL_ATTEMPTS attempts, so either every " +
"export finished first or cancellation does not reach the transformer: $outcomes",
)
}
private fun videoMimeTypeOf(file: File): String? {
val extractor = MediaExtractor()
try {
@@ -211,5 +370,25 @@ class Media3EngineTest {
private companion object {
const val TIMEOUT_SECONDS = 120L
/** Bounds the wait for the muxer to create the file; a hang here is a defect. */
const val TIMEOUT_MS = 30_000L
const val POLL_MS = 25L
/**
* How long to let a *failed* cancel finish. Several times the export's own length, so
* "the file is not playable" cannot mean "not yet".
*/
const val SETTLE_MS = 10_000L
/** See the KDoc: a miss is the loaded-runner case, not a defect. */
const val CANCEL_ATTEMPTS = 5
/**
* Short on purpose. Nothing is decoded or encoded on this path — the builder refuses the
* input outright — so anything approaching this is a hang, which is what the test is
* looking for.
*/
const val BUILDER_TIMEOUT_MS = 30_000L
}
}
@@ -13,6 +13,7 @@ import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -27,7 +28,10 @@ import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.libremediaconverter.work.JobTags
@@ -64,6 +68,26 @@ class EchoWorker(context: Context, params: WorkerParameters) : Worker(context, p
* path, foreground service included — into a synchronous test double, depending on class order.
*/
@UnstableApi
/**
* A [SoftwareTranscoder] that holds the worker in [WorkInfo.State.RUNNING] until released.
*
* Declared here rather than in `FakeFailures` because it is the only test that needs a job to stay
* live on demand, and the shape is specific to that: the others fake a *failure*, this fakes
* *duration*.
*/
private class BlockingTranscoder(private val released: CompletableDeferred<Unit>) : SoftwareTranscoder {
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
) {
released.await()
output.writeBytes(ByteArray(1_024))
}
}
@RunWith(AndroidJUnit4::class)
class ReattachOnLaunchTest {
@@ -75,7 +99,13 @@ class ReattachOnLaunchTest {
fun clearTheQueue() = emptyQueueAndStaging()
@After
fun leaveNothingBehind() = emptyQueueAndStaging()
fun leaveNothingBehind() {
// The suite runs without Android Test Orchestrator, so every class shares one process and
// a swapped seam outlives the class that set it. Only one test here swaps one, but a
// BlockingTranscoder left in place would hang the next class that converts anything.
ConversionDependencies.reset()
emptyQueueAndStaging()
}
/**
* The claim the whole fix rests on, checked against the production request builder rather
@@ -261,6 +291,69 @@ class ReattachOnLaunchTest {
return request.id
}
/**
* Reattaching to a conversion that is **running right now**, which nothing had ever driven.
*
* This class covers a job that finished, one whose staged file is gone, an ambiguous pair, one
* still queued, and one the user cancelled. [Reattachment.rank] gives
* [WorkInfo.State.RUNNING] the **highest** rank of all — "live work outranks a finished result
* because a running job is holding a foreground service" — and no test on either source set
* ever produced one. `ReattachmentTest` exercises the ranking as a pure function over
* fabricated snapshots; what was missing is a ViewModel meeting a real running job.
*
* It is also the likeliest reattachment there is: the user starts a conversion, leaves, and
* comes back while it is still going.
*
* ## Why the engine is a fake here, and why that is not a weakening
*
* The job has to still be running when the ViewModel is built, and every real conversion in
* this suite finishes in about a second — racing that is what made the cancellation tests flaky
* enough to need retries (#224). A [SoftwareTranscoder] that blocks until released removes the
* race outright: the job is `RUNNING` for exactly as long as the test wants.
*
* Nothing about reattachment depends on which engine is transcoding. What is under test is the
* tag query, [Reattachment.choose] over live WorkManager state, and `observe` mapping it to
* [ConversionState.Converting] — all of which run identically whatever is doing the work.
*
* ## What this does not do, and cannot (#230)
*
* It does not kill the process. `docs/defect-audit.md` D3/D13 record that `am kill` refuses a
* process holding a foreground service, and there is a more basic obstacle: **instrumentation
* runs in the app's own process**, so any route that really killed it would take the test
* runner with it and there would be nothing left to assert with. A relaunch-and-observe test
* needs two instrumentation runs, which the runner does not provide.
*
* So process death stays device-manual, and this is the closest observable analogue: a fresh
* ViewModel, with no memory of the work, meeting a job that is genuinely mid-flight.
*/
@Test
fun reattachesToAConversionThatIsStillRunning(): Unit = runBlocking {
val released = CompletableDeferred<Unit>()
ConversionDependencies.software = { BlockingTranscoder(released) }
val request = ConversionWorker.request(
inputUri = Uri.fromFile(stage("running_input.mp3")),
displayName = RUNNING_NAME,
sizeBytes = RUNNING_SIZE,
spec = OutputFormat.MP3.spec,
quality = QualityTier.FAST,
)
workManager.enqueue(request).result.get()
// Deterministic: the worker cannot finish until this test lets it.
withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it?.state == WorkInfo.State.RUNNING }
}
val reattached = awaitConversion<ConversionState.Converting>()
assertEquals(RUNNING_NAME, reattached.input.displayName)
assertEquals(RUNNING_SIZE, reattached.input.sizeBytes)
released.complete(Unit)
workManager.cancelWorkById(request.id).result.get()
}
/**
* Enqueues a job that stays [WorkInfo.State.ENQUEUED]. The delay is what holds it there: it
* is long enough that nothing can run it during a test, and it is cancelled either way.
@@ -326,5 +419,9 @@ class ReattachOnLaunchTest {
* against WorkManager's database, so this is generous rather than tuned.
*/
const val SETTLE_MS = 5_000L
/** Read back off the job's tags by the reattaching ViewModel, so both have to survive. */
const val RUNNING_NAME = "still_running.mp3"
const val RUNNING_SIZE = 4_242L
}
}
@@ -12,11 +12,17 @@ import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.ConversionRouter
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.work.ConversionWorker
import java.io.File
@@ -34,6 +40,47 @@ import java.io.File
* hand — a regression test that silently skips is worse than no test, because the count
* still reads as coverage.
*
* ## Why this skips on emulators, and why that is the honest answer (#223)
*
* **This test used to pass everywhere while proving nothing.** Two independent facts stop the
* fallback happening on an emulator, and both were measured rather than reasoned:
*
* 1. **The router never sends the job to Media3.** A Fast MP4/H.265 job goes to the hardware path
* only when `device.canEncode(H265)`, and emulators expose no hardware encoder — every leg of
* run `34004304566` logged
* `Routing sample_h264_444.mp4 -> ... via FFMPEG (NO_HARDWARE_ENCODER)`. The whole test
* finished in 448 ms, which is not long enough to fail an export and then re-encode.
* 2. **Forcing it to Media3 does not help either, which is the part that settles it.** Pinning
* `ConversionDependencies.deviceCodecs` to [DeviceCodecs.PERMISSIVE] — the trick
* [ForcedFailureTest] uses — makes the router choose Media3, and the export then *succeeds*.
* Measured on a local API 34 emulator: `MediaCodecInfo` logs
* `NoSupport [codec.profileLevel, avc1.F4000C, video/avc]` for **both**
* `c2.goldfish.h264.decoder` and `c2.android.avc.decoder`, and ExoPlayer allocates the
* goldfish decoder anyway, which decodes the file regardless of the profile it declares.
* `c2.android.hevc.encoder` then encodes the result and the job reports `MEDIA3`.
*
* So the class KDoc above — "Media3 fails partway through the export on every device" — **is not
* true of the emulator images**, and no amount of routing pressure makes this fixture force a
* fallback there. The emulator cannot answer this question, so the test says so out loud instead
* of passing.
*
* That is why the gate is [assumeTrue] on the *production* premise (`canEncode(H265)`) rather than
* a pinned profile: pinning would also swap in software codecs, which is not the path a real
* device takes and is what made the forced run succeed. **This is now the third permanent skip**;
* the other two are [org.libremediaconverter.bench.RealMediaBenchmark]'s.
*
* `ForcedFailureTest.hardwareFailureFallsBackToSoftware` still covers the fallback *wiring* on
* every leg, with an `ExplodingHardware` double. What only a device with a real hardware encoder
* can show is two real engines disagreeing about a real file, and that is what this is for.
*
* ## Why the assertion is a pair
*
* `KEY_ENGINE_USED` is `FFMPEG` whether the fallback fired **or** the router went straight there,
* so asserting it alone would not have caught any of the above. The premise is asserted
* separately: [ConversionRouter.route] chooses `MEDIA3` for this request on this device. Static
* routing wanted hardware, the runtime result was software — together, and only together, that is
* the fallback.
*
* The fixture was produced with x264, which the host toolchain cannot do (Fedora's
* ffmpeg ships openh264, which is Constrained Baseline only):
*
@@ -66,6 +113,15 @@ class HardwareFallbackTest {
@Test
fun aFileMedia3CannotDecodeStillConvertsViaFfmpeg(): Unit = runBlocking {
// See "Why this skips on emulators" on the class. Without a real hardware encoder the
// router never chooses Media3, and forcing it makes the export succeed instead of fail --
// so there is no fallback to observe and a green run would mean nothing.
assumeTrue(
"no hardware HEVC encoder, so the router cannot choose Media3 and there is no " +
"fallback to exercise",
AndroidDeviceCodecs.get().canEncode(VideoCodec.H265),
)
val request = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = SAMPLE,
@@ -75,6 +131,19 @@ class HardwareFallbackTest {
// the tier where the fallback has to rescue the conversion.
quality = QualityTier.FAST,
)
// The premise, asserted rather than assumed: this request is one the router wants to send
// to hardware on this device. Without it the test is green whether the fallback fired or
// the job never went near Media3, which is exactly how #223 stayed invisible.
val decision = ConversionRouter.route(
ConversionRequest(OutputFormat.MP4_H265.spec, quality = QualityTier.FAST),
AndroidDeviceCodecs.get(),
)
assertEquals(
"this test only means something if the router sends this job to Media3",
Engine.MEDIA3,
decision.engine,
)
workManager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
@@ -88,6 +157,14 @@ class HardwareFallbackTest {
terminal?.state,
)
// The outcome. Paired with the routing assertion above this is the fallback and nothing
// else: hardware was chosen, software is what ran.
assertEquals(
"the router chose Media3, so a successful job must have fallen back to FFmpeg",
Engine.FFMPEG.name,
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
)
val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
assertTrue("no output produced", out.exists() && out.length() > 0)
out.delete()
@@ -4,10 +4,20 @@ import android.media.MediaExtractor
import android.media.MediaFormat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegSession
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.SessionState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -113,6 +123,11 @@ class FFmpegEngineTest {
fun encodesFlacLosslessAudio() {
val out = convert(OutputFormat.FLAC)
assertTrue("no FLAC produced", out.exists() && out.length() > 0)
// "fLaC", the native FLAC stream marker. Without this the test passed on any non-empty
// file, so a builder arm emitting the wrong encoder into a .flac name shipped green
// (#228) -- the same shape the five assertions above already guard against.
val magic = out.inputStream().use { String(it.readNBytes(4), Charsets.US_ASCII) }
assertEquals("fLaC", magic)
}
@Test
@@ -127,6 +142,165 @@ class FFmpegEngineTest {
fun encodesOpus() {
val out = convert(OutputFormat.OPUS)
assertTrue("no Opus produced", out.exists() && out.length() > 0)
// OutputFormat.OPUS is Container.OGG, so the file is an Ogg stream: "OggS" (#228).
// Deliberately the container marker rather than the codec -- it is what the other
// container-level assertions in this class check, and it is four bytes at offset 0.
val magic = out.inputStream().use { String(it.readNBytes(4), Charsets.US_ASCII) }
assertEquals("OggS", magic)
}
/**
* The percentage itself, which every other test in this class computes and none of them reads.
*
* `FFmpegEngine` derives progress as `stats.time / durationMs * 100`, and the statistics
* callback runs on every conversion here — but every call site omits `onProgress`, so until
* this test nothing on any source set had ever looked at the number (#229). #196 covered the
* *worker's* progress lambda, and did it with a fake engine that reports whatever the test
* tells it to; `ProgressNotificationTest` covers throttling the same way. The arithmetic was
* the one part with no reader.
*
* ## Why the duration is deliberately wrong
*
* `sample_h264.mp4` is exactly 3.000 s, and this passes **30 s** as the duration. So the
* conversion still encodes the whole clip, `stats.time` still climbs to about 3000 ms, and the
* reported percentage tops out around **10** rather than 100.
*
* That is what makes the assertion bite. A range check alone is worthless here: replacing
* `percent` with a constant `0` satisfies "every value is in 0..100" and "the values never go
* backwards", and so does a list of `[0, 100]`. Pinning the *band* rejects every constant, and
* — because the band is a tenth of the way up — it also rejects an implementation that ignores
* `durationMs`, which would report ~100 for the same run.
*
* The bound is deliberately loose (5..25 for an expected 10). The last statistics callback can
* land slightly before the final frame, so the peak is "about 3000 ms of a claimed 30 000",
* not exactly it.
*/
@Test
fun progressIsReportedAsAFractionOfTheDurationItWasGiven() {
val seen = mutableListOf<Int>()
val out = outputFor("out_progress.mp4")
runBlocking {
engine.run(
request = ConversionRequest(spec = OutputFormat.MP4_H264.spec, quality = QualityTier.BEST),
inputPath = input.absolutePath,
output = out,
// Ten times the fixture's real 3 s. See the KDoc.
durationMs = 30_000,
onProgress = { percent -> seen += percent },
)
}
assertTrue("the statistics callback never reported progress", seen.isNotEmpty())
assertTrue("progress out of range: $seen", seen.all { it in 0..100 })
assertEquals("progress went backwards: $seen", seen.sorted(), seen)
// The band. Rejects any constant, and rejects ignoring durationMs (which would read ~100).
val peak = seen.max()
assertTrue(
"3 s of media against a claimed 30 s should peak near 10%, got $peak from $seen",
peak in 5..25,
)
}
/**
* Cancelling a *running* conversion actually stops the native session.
*
* Nothing on any source set did this before (#224). Every `cancel` in `app/src/androidTest` is
* `WorkManager.cancelWorkById` against work that is **queued or already finished** — the two in
* `ReattachOnLaunchTest` cancel a job carrying a one-hour initial delay, and one immediately
* after enqueue. On the JVM, `WorkerCancellationTest` and `HardwareFallbackTest`'s cancellation
* case drive a `SoftwareTranscoder` double that records the call. No test had ever asked a real
* native session to stop. This is `docs/defect-audit.md` **D10**'s forcing condition.
*
* It is the one path where cancelling wrong is silently expensive rather than loudly broken: a
* missed `FFmpegKit.cancel` leaves the native process encoding to completion while the UI says
* the job is cancelled, and nothing reports the battery and thermal cost.
*
* ## Why the assertion is the session's return code, not the output file
*
* The obvious assertion — the partial output is gone — **cannot fail**, so it would have been a
* vacuous test. `invokeOnCancellation` deletes the path, and on POSIX unlinking a file ffmpeg
* still holds open leaves ffmpeg writing to the unlinked inode; the path stays gone whether or
* not the cancel ever reached the session. Deleting `FFmpegKit.cancel` and keeping
* `output.delete()` passes that check every time.
*
* What distinguishes them is the session's own verdict: a cancelled session ends with the
* cancel return code, a completed one ends successfully. That is a fact about the session
* rather than about timing, so it is read *after* waiting for the session to leave
* [SessionState.RUNNING] rather than at a fixed delay.
*
* ## Why it cancels on RUNNING rather than on the first progress callback
*
* Measured. Cancelling from the first `onProgress` was tried first and **failed on a local API
* 34 emulator with `state=COMPLETED rc=0`** — every committed fixture is 2-3 s at 320x240, and
* the encode finishes before the first statistics callback has been delivered and acted on. The
* progress callback proves the session is running, but arrives too late to interrupt anything.
* `FFmpegKit.listSessions` shows the session [SessionState.RUNNING] far earlier.
*
* ## Why it retries, which is the part that took two attempts to get right
*
* Waiting for `RUNNING` is not on its own enough. With `MP4_H265` at [QualityTier.BEST] this
* passed four consecutive local runs and all five CI legs, then failed on the API 34 and 35 legs
* of the next PR with `state=COMPLETED rc=0`. Nothing had changed: on a loaded runner the thread
* that observed `RUNNING` can be descheduled long enough for a short encode to finish before it
* calls `cancel`. A longer timeout does not help — the wait already succeeded.
*
* Two changes together, because neither is sufficient:
*
* - **A slower encode.** `WEBM_VP9` at `BEST` is the slowest thing this builder emits:
* `libvpx-vp9 -crf 31 -b:v 0`, with `-deadline realtime` added **only** on
* [QualityTier.FAST]. Probed on an API 34 emulator, that session is still `RUNNING` at 1 s
* and finished by 2 s, against well under a second for x265 `-preset medium`.
* - **Retrying the attempt.** An attempt whose session finished before the cancel landed has
* not tested anything, so it is not a failure — it is a miss, and it is retried. Only
* exhausting [CANCEL_ATTEMPTS] is a failure, and its message says which case it hit.
*
* That keeps the mutation honest: with `FFmpegKit.cancel` removed **every** attempt ends
* `COMPLETED`, so the test still fails — it just takes [CANCEL_ATTEMPTS] tries to say so.
*
* The session is identified by diffing against the ids present before each attempt, because
* this class has already produced eight of them by the time this executes.
*/
@Test
fun cancellingARunningConversionCancelsTheNativeSession(): Unit = runBlocking {
val outcomes = mutableListOf<String>()
repeat(CANCEL_ATTEMPTS) { attempt ->
val before = FFmpegKit.listSessions().map { it.getSessionId() }.toSet()
val out = outputFor("out_cancelled_$attempt.webm")
val job = launch(Dispatchers.IO) {
engine.run(
// The slowest target this builder emits -- see the KDoc. Not decoration:
// with a faster one this loses the race on a loaded CI runner.
request = ConversionRequest(spec = OutputFormat.WEBM_VP9.spec, quality = QualityTier.BEST),
inputPath = input.absolutePath,
output = out,
durationMs = 3_000,
)
}
val ours = withTimeout(TIMEOUT_MS) {
var found: FFmpegSession? = null
while (found == null) {
found = FFmpegKit.listSessions().firstOrNull { it.getSessionId() !in before }
if (found == null) delay(POLL_MS)
}
found
}
job.cancelAndJoin()
withTimeout(TIMEOUT_MS) {
while (ours.getState() == SessionState.RUNNING) delay(POLL_MS)
}
if (ReturnCode.isCancel(ours.getReturnCode())) return@runBlocking
// The encode beat us to it. That attempt proved nothing either way, so try again.
outcomes += "state=${ours.getState()} rc=${ours.getReturnCode()}"
}
fail(
"never interrupted a running session in $CANCEL_ATTEMPTS attempts, so either every " +
"encode finished first or cancellation does not reach it: $outcomes",
)
}
// --- the quality tier the GPL licence was taken for --------------------
@@ -166,4 +340,19 @@ class FFmpegEngineTest {
}.exceptionOrNull()
assertTrue("expected an FFmpegException, got $failure", failure is FFmpegEngine.FFmpegException)
}
private companion object {
/** Generous: it bounds a hang, and every wait here normally settles in well under a second. */
const val TIMEOUT_MS = 30_000L
const val POLL_MS = 50L
/**
* How many times to try to catch the session mid-encode.
*
* Each miss costs about the length of one VP9 encode -- a second or two -- and a miss is
* the loaded-runner case rather than a defect. Five is enough that exhausting them means
* cancellation is not reaching the session, which is what the failure message says.
*/
const val CANCEL_ATTEMPTS = 5
}
}
@@ -5,17 +5,29 @@ import android.media.MediaFormat
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegSession
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.SessionState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.convert.StagingNames
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.ffmpeg.FFmpegEngine
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import java.io.File
/**
@@ -50,6 +62,82 @@ class ConcatEngineTest {
(staged + listOf(clipA, clipB, clipMismatched)).forEach { it.delete() }
}
/**
* Cancelling a *running* join actually stops the native session.
*
* The `FFmpegEngine` half of #224 landed first (PR #236); this is the same gap in
* [ConcatEngine]. Before these two, no test on any source set had ever asked a real native
* session to stop — every `cancel` in `app/src/androidTest` targets WorkManager entries that
* are queued or already finished.
*
* ## Two things carried over from the conversion side, both measured there
*
* **The assertion is the session's return code.** A cancelled session ends with the cancel
* code, a completed one does not. The alternative — checking the output file — is even less
* available here than it was for conversions: [ConcatEngine] does not delete its output on
* cancellation at all. Its `invokeOnCancellation` is `FFmpegKit.cancel(...)` and nothing else,
* where [org.libremediaconverter.ffmpeg.FFmpegEngine]'s also deletes the partial. Whether that
* asymmetry is deliberate is a separate question from this test, which is why this asserts the
* thing that is true of both.
*
* **The cancel is triggered on [SessionState.RUNNING], not on progress.** `ConcatWorker`
* publishes no progress at all, so there is no callback to hang it on even in principle — but
* the conversion side established the deeper reason: the committed clips are 2 s at 320x240 and
* the encode outruns a callback-triggered cancel.
*
* **And the attempt is retried**, for the reason the conversion side measured the hard way: on
* a loaded runner the thread that observed `RUNNING` can be descheduled long enough for a short
* encode to finish before it calls `cancel`, which failed two CI legs there. An attempt whose
* session finished first has tested nothing, so it is a miss rather than a failure; only
* exhausting [CANCEL_ATTEMPTS] fails, and with `FFmpegKit.cancel` removed every attempt misses,
* so the mutation still bites.
*
* The inputs are deliberately the **mismatched** pair, so [ConcatStrategy.REENCODE] is chosen.
* A stream copy of two short clips is close to instantaneous and would leave nothing to
* interrupt; re-encoding is the case where a user would actually reach for Cancel.
*
* *Mutation:* drop `FFmpegKit.cancel(session.getSessionId())` from `ConcatEngine`'s
* `invokeOnCancellation` — the session runs to completion and this fails.
*/
@Test
fun cancellingARunningJoinCancelsTheNativeSession(): Unit = runBlocking {
val outcomes = mutableListOf<String>()
repeat(CANCEL_ATTEMPTS) { attempt ->
val before = FFmpegKit.listSessions().map { it.getSessionId() }.toSet()
val out = output("cancelled_join_$attempt.mp4")
val job = launch(Dispatchers.IO) {
engine.join(
listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)),
out,
ConcatWorker.DEFAULT_FORMAT,
)
}
val ours = withTimeout(TIMEOUT_MS) {
var found: FFmpegSession? = null
while (found == null) {
found = FFmpegKit.listSessions().firstOrNull { it.getSessionId() !in before }
if (found == null) delay(POLL_MS)
}
found
}
job.cancelAndJoin()
withTimeout(TIMEOUT_MS) {
while (ours.getState() == SessionState.RUNNING) delay(POLL_MS)
}
if (ReturnCode.isCancel(ours.getReturnCode())) return@runBlocking
outcomes += "state=${ours.getState()} rc=${ours.getReturnCode()}"
}
fail(
"never interrupted a running join in $CANCEL_ATTEMPTS attempts, so either every " +
"encode finished first or cancellation does not reach it: $outcomes",
)
}
private fun copyAsset(name: String): File {
val out = File(context.cacheDir, name)
InstrumentationRegistry.getInstrumentation().context.assets
@@ -149,6 +237,55 @@ class ConcatEngineTest {
)
}
/**
* A failed join tells the user the return code and what FFmpeg said.
*
* **This is the device half of #203/#217**, whose PR closed by noting the join legs had not
* been run. Running them would not have answered it: nothing on either source set drove a real
* join *failure*, so the unified message was asserted only against values a JVM test hands to
* `sessionOutcome` directly.
*
* What is device-only here is that the three reads behind that message work against a real
* native session at all — `getReturnCode`, `getFailStackTrace` and `getAllLogsAsString`. If
* the log tail came back null or empty on a device, the user would get `Joining failed (1): `
* with nothing after the colon and every JVM test would still pass.
*
* **What this deliberately does not pin is the preference between the two detail sources.** On
* an ordinary non-zero return code FFmpegKit reports no fail stack trace, so the stack-trace-
* first rule and the log-tail-first rule produce the same text and no assertion here can tell
* them apart. That ordering is [SessionOutcomeTest][org.libremediaconverter.ffmpeg.SessionOutcomeTest]'s
* job, where both sources can be non-blank at once. Asserting it here would be a test whose
* KDoc claims more than it checks — the `probeForConcat` mistake wave 3 caught.
*
* The failure is forced with an input that does not exist, which the concat demuxer rejects
* the same way on every FFmpeg build, rather than with malformed media whose handling varies.
*/
@Test
fun aFailedJoinReportsTheReturnCodeAndWhatFFmpegSaid(): Unit = runBlocking {
val missing = File(context.cacheDir, "no_such_clip.mp4").also { it.delete() }
val out = output("joined_failure.mp4")
val failure = runCatching {
engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(missing)), out)
}.exceptionOrNull()
assertTrue(
"a join over a missing input must fail, got $failure",
failure is FFmpegEngine.FFmpegException,
)
val message = failure?.message.orEmpty()
assertTrue(
"the message must name the operation and carry the return code, was: '$message'",
message.startsWith("Joining failed ("),
)
// The half a JVM test cannot reach: a real session actually produced detail to show.
val detail = message.substringAfter("): ", "")
assertTrue(
"the message stopped at the return code and told the user nothing, was: '$message'",
detail.isNotBlank(),
)
}
@Test
fun theListFileIsCleanedUpAfterJoining(): Unit = runBlocking {
val out = output("joined_cleanup.mp4")
@@ -180,4 +317,13 @@ class ConcatEngineTest {
a.width != mismatched.width || a.height != mismatched.height,
)
}
private companion object {
/** Generous: it bounds a hang, and both waits here normally settle in well under a second. */
const val TIMEOUT_MS = 30_000L
const val POLL_MS = 50L
/** See the conversion side: a miss is the loaded-runner case, not a defect. */
const val CANCEL_ATTEMPTS = 5
}
}
@@ -0,0 +1,123 @@
package org.libremediaconverter.saf
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConversionWorker
import java.io.File
/**
* A `content://` input reaching FFmpeg successfully, which nothing had ever driven (#225).
*
* `FFmpegKitConfig.getSafParameterForRead` stands between a SAF grant and the native process, and
* it is on **every real user conversion**. Every passing convert and join test in this suite hands
* the worker a `Uri.fromFile(...)`, which takes the `uri.path` arm instead — so the bridge was
* exercised only on its failure side, by `UnopenableUriTest` naming an authority that does not
* exist. That proves the error message, not the bridge.
*
* ## Why a plain provider rather than the documents one
*
* [FixtureDocumentsProvider] cannot be reached from the app, measured three ways on an API 34
* emulator (#226): a `DOCUMENTS_PROVIDER` declared without `MANAGE_DOCUMENTS` is refused at install
* — *"Provider must be protected by MANAGE_DOCUMENTS"*; instrumentation runs in the **target app's
* process**, so `Instrumentation.getContext()` still carries the app's uid and is denied; and
* `adoptShellPermissionIdentity(MANAGE_DOCUMENTS)` is denied identically. The denial names the only
* way in: *"you obtain access using ACTION_OPEN_DOCUMENT or related APIs"*.
*
* The bridge does not need one. It opens a descriptor through the resolver and hands FFmpeg a
* `saf:` path, so any readable `content://` URI exercises it — and [FixtureContentProvider] is an
* ordinary provider, which may be exported without a permission. The whole class is headless: no
* DocumentsUI, and none of the flake #190 records.
*
* ## Why MP3
*
* The bridge lives on the FFmpeg arm, and MP3 is the format the router sends there unconditionally
* — no platform encoder exists at any API level, so `ConversionWorkerTest.routesAnMp3JobToFfmpeg…`
* relies on the same fact. Choosing a video target would make the engine depend on the device's
* codecs, and #223 is what that costs.
*
* *Mutation:* make `getSafParameterForRead` return `uri.toString()`. FFmpeg cannot open it and both
* tests fail; nothing else in either suite notices.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class ContentUriInputTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val workManager = WorkManager.getInstance(context)
@After
fun tearDown() {
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
@Test
fun aContentUriInputConvertsThroughTheSafBridge(): Unit = runBlocking {
val input = FixtureContentProvider.uriFor(SAMPLE)
val request = ConversionWorker.request(
inputUri = input,
displayName = SAMPLE,
sizeBytes = 0L,
spec = OutputFormat.MP3.spec,
quality = QualityTier.FAST,
)
workManager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
val error = terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)
assertEquals(
"a content:// input must convert, but failed with: $error",
WorkInfo.State.SUCCEEDED,
terminal?.state,
)
// The bridge is on the FFmpeg arm only, so this is part of the claim rather than colour.
assertEquals(Engine.FFMPEG.name, terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED))
val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
assertTrue("no output produced from a content:// input", out.exists() && out.length() > 0)
out.delete()
}
/**
* The same bridge on the join path, which has its own copy of the call (`ConcatEngine:36`).
*
* Driven through the engine rather than `ConcatWorker` because the engine is where the branch
* is; the worker adds a foreground service and nothing else this is about.
*/
@Test
fun contentUriInputsJoinThroughTheSafBridge(): Unit = runBlocking {
val out = File(context.cacheDir, "joined_from_content.mp4").apply { delete() }
val result = ConcatEngine(context).join(
listOf(FixtureContentProvider.uriFor(CLIP_A), FixtureContentProvider.uriFor(CLIP_B)),
out,
OutputFormat.MP4_H264,
)
assertTrue("no output produced from content:// inputs", result.output.length() > 0)
out.delete()
}
private companion object {
const val SAMPLE = "sample_h264.mp4"
const val CLIP_A = "clip_a.mp4"
const val CLIP_B = "clip_b.mp4"
const val TIMEOUT_MS = 300_000L
}
}
@@ -0,0 +1,135 @@
package org.libremediaconverter.saf;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/**
* A plain {@link ContentProvider} serving the committed media fixtures over {@code content://}.
*
* <p><b>Why this exists alongside {@link FixtureDocumentsProvider}.</b> Every passing convert and
* join test hands the worker a {@code Uri.fromFile(...)}, which takes the {@code uri.path} arm and
* never touches {@code FFmpegKitConfig.getSafParameterForRead}. That bridge is on 100% of real user
* conversions and was on 0% of tested ones; only its failure side was covered, by
* {@code UnopenableUriTest} pointing at an authority that does not exist.
*
* <p><b>Why not the documents provider.</b> It cannot be reached. Measured three ways on an API 34
* emulator: a {@code DOCUMENTS_PROVIDER} declared without {@code MANAGE_DOCUMENTS} is refused at
* install ("Provider must be protected by MANAGE_DOCUMENTS"); instrumentation runs in the target
* app's process, so {@code Instrumentation.getContext()} still carries the app's uid and is denied;
* and {@code adoptShellPermissionIdentity(MANAGE_DOCUMENTS)} is denied identically. The denial says
* what is required — <i>"you obtain access using ACTION_OPEN_DOCUMENT or related APIs"</i> — so a
* documents provider is reachable only through a picker-issued grant. See issue #226.
*
* <p>The bridge does not need one. {@code getSafParameterForRead} opens a file descriptor through
* the resolver and hands FFmpeg a {@code saf:} path; any readable {@code content://} URI exercises
* it. An ordinary provider may be exported without a permission, so this one is, and the whole test
* stays headless — no DocumentsUI, and none of the flake #190 records.
*
* <p>Unlike {@link FixtureDocumentsProvider} this may use {@code androidx} and Kotlin freely — it is
* loaded into the app process like any other provider, not into the bare test process. It is kept
* in Java anyway, next to its sibling, so the two read alike.
*/
public final class FixtureContentProvider extends ContentProvider {
/** Authority. Distinct from the documents provider's, and from anything the app declares. */
public static final String AUTHORITY = "org.libremediaconverter.test.content";
/** Builds a URI for one of this source set's committed assets, e.g. {@code sample_h264.mp4}. */
public static Uri uriFor(String assetName) {
return new Uri.Builder().scheme("content").authority(AUTHORITY).appendPath(assetName).build();
}
@Override
public boolean onCreate() {
return true;
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
if (!"r".equals(mode)) {
throw new FileNotFoundException("this provider is read-only: " + mode);
}
return ParcelFileDescriptor.open(unpack(assetOf(uri)), ParcelFileDescriptor.MODE_READ_ONLY);
}
/**
* Enough of {@link OpenableColumns} for {@code InputQuery.describe} to name and size the input.
*
* <p>Without these the app reaches the "Size unknown" screen, which is a different test.
*/
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] args, String sort) {
String asset = assetOf(uri);
File file;
try {
file = unpack(asset);
} catch (FileNotFoundException e) {
return null;
}
MatrixCursor cursor = new MatrixCursor(
new String[] {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE});
cursor.newRow().add(OpenableColumns.DISPLAY_NAME, asset).add(OpenableColumns.SIZE, file.length());
return cursor;
}
@Override
public String getType(Uri uri) {
return assetOf(uri).endsWith(".m4a") ? "audio/mp4" : "video/mp4";
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("read-only fixture provider");
}
@Override
public int delete(Uri uri, String selection, String[] args) {
throw new UnsupportedOperationException("read-only fixture provider");
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] args) {
throw new UnsupportedOperationException("read-only fixture provider");
}
private static String assetOf(Uri uri) {
String asset = uri.getLastPathSegment();
return asset == null ? "" : asset;
}
/**
* The asset on disk, unpacked the first time anything asks.
*
* <p>Reported as {@link FileNotFoundException} rather than swallowed: a provider answering with
* a zero-byte file would fail the conversion for a reason nothing states.
*/
private File unpack(String asset) throws FileNotFoundException {
File file = new File(getContext().getCacheDir(), "provided_" + asset);
if (file.length() > 0L) {
return file;
}
try (InputStream source = getContext().getAssets().open(asset);
OutputStream sink = new FileOutputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = source.read(buffer)) != -1) {
sink.write(buffer, 0, read);
}
} catch (IOException e) {
throw new FileNotFoundException("could not unpack " + asset + ": " + e);
}
return file;
}
}
@@ -104,6 +104,17 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
private static final String ROOT_DOCUMENT_ID = "root";
private static final String FIXTURE_DOCUMENT_ID = "root/" + FIXTURE_DISPLAY_NAME;
/**
* Prefix for documents this provider CREATES, as opposed to the one it serves for reading.
*
* <p>Two namespaces rather than one so a destination can never be confused with the fixture.
* The fixture is read-only and must stay that way for the picker tests; a destination is
* writable and deletable, which is what {@code PublishToRealSafDestinationTest} needs.
*/
public static final String DESTINATION_PREFIX = "dest/";
/** Document ids {@link #deleteDocument} was called with, newest last. Cleared by {@link #reset}. */
/** Already in this source set, and already a real H.264 MP4 the engines can open. */
private static final String FIXTURE_ASSET = "sample_h264.mp4";
@@ -147,7 +158,7 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
.add(Root.COLUMN_TITLE, ROOT_TITLE)
.add(Root.COLUMN_SUMMARY, "Instrumentation fixture")
.add(Root.COLUMN_MIME_TYPES, FIXTURE_MIME_TYPE)
.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY)
.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_CREATE)
.add(Root.COLUMN_ICON, android.R.drawable.ic_menu_gallery);
return cursor;
}
@@ -159,6 +170,8 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
addDirectoryRow(cursor);
} else if (FIXTURE_DOCUMENT_ID.equals(documentId)) {
addFixtureRow(cursor);
} else if (documentId != null && documentId.startsWith(DESTINATION_PREFIX)) {
addDestinationRow(cursor, documentId);
} else {
throw new FileNotFoundException("no such document: " + documentId);
}
@@ -178,10 +191,63 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
@Override
public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal)
throws FileNotFoundException {
if (!FIXTURE_DOCUMENT_ID.equals(documentId)) {
if (FIXTURE_DOCUMENT_ID.equals(documentId)) {
return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY);
}
if (documentId == null || !documentId.startsWith(DESTINATION_PREFIX)) {
throw new FileNotFoundException("no such document: " + documentId);
}
return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY);
int flags = "r".equals(mode)
? ParcelFileDescriptor.MODE_READ_ONLY
: ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_TRUNCATE;
return ParcelFileDescriptor.open(destinationFile(documentId), flags);
}
/**
* Creates a real, empty file and reports the document id for it.
*
* <p><b>Empty is the whole point, and this provider does not get to decide it.</b> The premise
* under test in {@code PublishToRealSafDestinationTest} is what <i>DocumentsUI</i> hands back
* from {@code ACTION_CREATE_DOCUMENT}, and {@code OutputPublisher.destinationIsKnownEmpty}
* authorises its cleanup delete only on a positive zero. This creates the file and writes
* nothing to it, which is what the SAF contract documents; the test asserts what actually came
* back rather than trusting either side.
*/
@Override
public String createDocument(String parentDocumentId, String mimeType, String displayName)
throws FileNotFoundException {
if (!ROOT_DOCUMENT_ID.equals(parentDocumentId)) {
throw new FileNotFoundException("cannot create in: " + parentDocumentId);
}
String documentId = DESTINATION_PREFIX + displayName;
File file = destinationFile(documentId);
try {
if (!file.createNewFile() && !file.exists()) {
throw new FileNotFoundException("could not create: " + documentId);
}
} catch (IOException e) {
throw new FileNotFoundException("could not create " + documentId + ": " + e);
}
return documentId;
}
@Override
public void deleteDocument(String documentId) throws FileNotFoundException {
if (documentId == null || !documentId.startsWith(DESTINATION_PREFIX)) {
throw new FileNotFoundException("refusing to delete: " + documentId);
}
destinationFile(documentId).delete();
}
/** Removes created destinations. The process outlives one class. */
public static void reset(File filesDir) {
File dir = new File(filesDir, "destinations");
File[] children = dir.listFiles();
if (children != null) {
for (File child : children) {
child.delete();
}
}
}
private void addDirectoryRow(MatrixCursor cursor) {
@@ -189,10 +255,32 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
.add(Document.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
.add(Document.COLUMN_DISPLAY_NAME, ROOT_TITLE)
.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
.add(Document.COLUMN_FLAGS, 0)
.add(Document.COLUMN_FLAGS, Document.FLAG_DIR_SUPPORTS_CREATE)
.add(Document.COLUMN_SIZE, null);
}
private void addDestinationRow(MatrixCursor cursor, String documentId) throws FileNotFoundException {
File file = destinationFile(documentId);
if (!file.exists()) {
throw new FileNotFoundException("no such document: " + documentId);
}
cursor.newRow()
.add(Document.COLUMN_DOCUMENT_ID, documentId)
.add(Document.COLUMN_DISPLAY_NAME, documentId.substring(DESTINATION_PREFIX.length()))
.add(Document.COLUMN_MIME_TYPE, FIXTURE_MIME_TYPE)
.add(Document.COLUMN_FLAGS, Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE)
.add(Document.COLUMN_SIZE, file.length())
.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
}
private File destinationFile(String documentId) throws FileNotFoundException {
File dir = new File(getContext().getFilesDir(), "destinations");
if (!dir.isDirectory() && !dir.mkdirs()) {
throw new FileNotFoundException("could not make the destinations directory");
}
return new File(dir, documentId.substring(DESTINATION_PREFIX.length()));
}
private void addFixtureRow(MatrixCursor cursor) throws FileNotFoundException {
File file = fixtureFile();
cursor.newRow()
@@ -1,29 +1,50 @@
package org.libremediaconverter.saf
import android.app.UiAutomation
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.compose.ui.test.ComposeTimeoutException
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.lifecycle.ActivityLifecycleCallback
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.Configurator
import androidx.test.uiautomator.StaleObjectException
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import androidx.work.WorkManager
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.FailsOnEmulatorApi37
import org.libremediaconverter.MainActivity
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.ui.TestTags
import java.io.File
import java.io.OutputStream
import java.util.concurrent.atomic.AtomicInteger
import java.util.regex.Pattern
/**
* Choosing a file, through the real system picker, and still having it after a rotation.
@@ -203,6 +224,8 @@ import org.libremediaconverter.ui.TestTags
* driven there at all. That is why this gap survived as long as it did.
* `tools/local-emulator/run-e2e.sh` runs API 33-36 on the development host, and both tests pass
* there: **59 / 0 / 0 / 2 at API 33 and again at API 36**, whole suite, 2026-08-24.
* (Since #223 the skip column reads 3 on an emulator — `HardwareFallbackTest` now announces
* that it cannot run without a hardware HEVC encoder rather than passing vacuously.)
*
* ### Why only the rotation test carries [FailsOnEmulatorApi37]
*
@@ -235,12 +258,96 @@ import org.libremediaconverter.ui.TestTags
* file".** That is what API 33 through 36 are for, and they answer it.
*/
@UnstableApi
/**
* Reads what SAF handed back, then publishes for real.
*
* The premise `OutputPublisher.destinationIsKnownEmpty` depends on has only ever been asserted
* against a fake built to match it — `OutputPublisherPublishTest` writes `ByteArray(0)` into
* `FakeSafProvider` before each case, under a comment stating this is how `CreateDocument` behaves.
* This records what stock DocumentsUI actually produced, at the moment `publish` sees it and before
* a byte is written, and then lets the real copy proceed. See #226.
*/
private class RecordingPublisher(private val app: Context) : OutputPublisher(app) {
override fun publish(staged: File, destination: Uri) {
seenDestination = destination
seenIsDocumentUri = DocumentsContract.isDocumentUri(app, destination)
seenSizeBefore = app.contentResolver
.query(destination, arrayOf(OpenableColumns.SIZE), null, null, null)
?.use { row ->
val column = row.getColumnIndex(OpenableColumns.SIZE)
if (column >= 0 && row.moveToFirst() && !row.isNull(column)) row.getLong(column) else null
}
// Read before the copy: the ViewModel deletes the staged file once publish returns.
savedBytes = staged.readBytes()
super.publish(staged, destination)
}
/**
* Refuses the write when [failOpen] is set, which is the forcing condition for #250.
*
* Returning null rather than throwing is deliberate: it is the arm `publish`'s
* `?: error("Could not open destination for writing")` exists for, and `openDestination`'s
* own KDoc says a provider that is present and declines is the half no fake can produce on
* demand. The size probe in `publish` has already run by the time this is reached, so
* `destinationWasEmpty` is true and `deletePartialOutput` is reached with the document
* genuinely empty — which is the whole point.
*/
override fun openDestination(destination: Uri): OutputStream? =
if (failOpen) null else super.openDestination(destination)
companion object {
var savedBytes: ByteArray = ByteArray(0)
var seenDestination: Uri? = null
var seenIsDocumentUri: Boolean? = null
var seenSizeBefore: Long? = null
/**
* Makes the next `publish` refuse to open its destination.
*
* A flag rather than a second publisher because `ConversionDependencies.publisher` is one
* seam and there is no orchestrator: every test in this process shares the instance the
* `init` block installed. [reset] clears it in teardown, so a test that sets it cannot
* leak a refusing publisher into the next class.
*/
var failOpen: Boolean = false
fun reset() {
savedBytes = ByteArray(0)
seenDestination = null
seenIsDocumentUri = null
seenSizeBefore = null
failOpen = false
}
}
}
@RunWith(AndroidJUnit4::class)
class SafPickerRoundTripTest {
/**
* Installs [RecordingPublisher] before the Activity exists.
*
* `ConversionViewModel` resolves its publisher through `ConversionDependencies` **at
* construction**, and the Compose rule launches `MainActivity` as part of the rule chain —
* which wraps `@Before`, so `@Before` is already too late. JUnit constructs the test instance
* before it evaluates the rules, so an initialiser is early enough, and it needs no
* `@BeforeClass` (this class's companion is private, and JUnit wants a public static there).
*
* Harmless for the other two tests: neither saves, so `publish` is never called and the
* subclass behaves exactly like `OutputPublisher`. `restoreOrientation` puts the seam back.
*/
init {
RecordingPublisher.reset()
ConversionDependencies.publisher = { RecordingPublisher(it) }
}
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
private val context: Context =
InstrumentationRegistry.getInstrumentation().targetContext
private val device: UiDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@@ -251,6 +358,21 @@ class SafPickerRoundTripTest {
/** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */
private var rotated = false
/** Counts [MainActivity] creations from the moment [watchForRecreation] is called. */
private val recreations = AtomicInteger()
/**
* Counts a rotation's recreation without asking the Activity anything.
*
* Deliberately not `composeRule.activity`, which resolves through `scenario.onActivity` and so
* blocks on the main thread. Polling *that* across a recreation is a plausible reading of the
* 20-minute wedges in #122, which would make the obvious barrier the bug it is meant to fix.
* The runner's lifecycle monitor is a callback: reading the counter touches no looper.
*/
private val recreationWatcher = ActivityLifecycleCallback { activity, stage ->
if (activity is MainActivity && stage == Stage.CREATED) recreations.incrementAndGet()
}
/**
* Leave the device the way it was found — and only if this test moved it.
*
@@ -270,13 +392,46 @@ class SafPickerRoundTripTest {
*/
@After
fun restoreOrientation() {
// The suite runs without Android Test Orchestrator, so a swapped seam outlives the class.
ConversionDependencies.reset()
RecordingPublisher.reset()
clearFinishedWork()
ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(recreationWatcher)
if (!rotated) return
device.setOrientationNatural()
device.unfreezeRotation()
device.waitForIdle()
}
/**
* **Marked for API 37 because of what it does to the image, not because it fails there.**
*
* This is the one place the marker's KDoc phrase "cannot pass on this image" does not fit, and
* the distinction is worth keeping rather than smoothing over. Across the four gating API 37
* runs whose logcats were read on 2026-09-05 — 34006456986, 34001744574, 34001377499 and the
* green 34002313300 — the leg carries exactly two `hasReadColorBufferDma` aborts before the
* suite starts (both `surfaceflinger`, during boot and the SystemUI disable) and then exactly
* **one** during it. Every time, that one is `system_server` on the `TaskSnapshotPer` thread,
* and every time it lands inside this test's window. No other test in the gating set reaches
* the mapper at all.
*
* So this test kills the framework on that image whether it passes or not, and whether the leg
* goes red is luck: 34001377499 passed it and lost the leg anyway (`failed: 0`, teardown
* broken), 34002313300 passed it 0.6 s after the abort and went green. That is #108, and it is
* why the leg was failing on unrelated PRs.
*
* `docs/api-37-emulator-crash.md` measured this test on 2026-08-24, recorded "passes, 4 aborts
* in the window", and concluded that a rotation reaches the mapper where starting DocumentsUI
* does not. The aborts were seen; what was not drawn out is that they are this test's own and
* are not intermittent.
*
* The marker is what routes it off the gating leg and into the advisory job beside its
* rotation sibling. **It is not a statement about the picker**: the same test passes on API
* 33–36 on the same runner and on the Pixel 10 Pro XL, which is where API 37's answer comes
* from.
*/
@Test
@FailsOnEmulatorApi37
fun pickingAFileThroughTheSystemPickerFillsInTheFileCard() {
pickTheFixture()
@@ -303,9 +458,11 @@ class SafPickerRoundTripTest {
// The identity hash rather than the Activity itself, so nothing here keeps a destroyed
// Activity reachable across the recreation it is being used to detect.
val before = System.identityHashCode(composeRule.activity)
watchForRecreation()
device.setOrientationLandscape()
rotated = true
awaitRecreation()
composeRule.waitForIdle()
// Two guards before the assertion that matters, because both of the ways this test could
@@ -354,6 +511,304 @@ class SafPickerRoundTripTest {
* are all warm and the only thing being waited on is one screen. That is what keeps the cost
* of a genuinely absent root bounded — see the class KDoc.
*/
/**
* The save side of SAF, end to end, against a document stock DocumentsUI created (#226).
*
* ## What this settles
*
* `publish` deletes a destination it could not write to — `docs/defect-audit.md` **D4**'s fix,
* so a failed save does not leave a truncated file at the name the user chose — but only when
* that destination was **positively zero bytes** first. `destinationIsKnownEmpty` is careful
* that "I could not tell" never authorises a delete, which is right, and which makes the
* precondition load-bearing.
*
* Until now that precondition was asserted only against a fake built to match it:
* `OutputPublisherPublishTest` writes `ByteArray(0)` into `FakeSafProvider` before each case,
* under a comment stating this is how `CreateDocument` behaves. **If it is false in production,
* D4's fix is inert and every existing test still passes.** [RecordingPublisher] reads what SAF
* actually handed over, at the moment `publish` sees it and before a byte is written.
*
* ## Why it has to go through the app, and through the picker
*
* Through the **picker** because a `DocumentsProvider` cannot be reached any other way —
* measured three ways and recorded as **E7** in `docs/e2e-read-findings.md`: an unprotected one
* is refused at install, instrumentation carries the app's uid so the test APK's own identity
* is no help, and shell identity is denied too, each denial naming `ACTION_OPEN_DOCUMENT`.
*
* Through the **app** because the same constraint sinks the obvious alternative. A host
* Activity in this source set that owns a `CreateDocument` launcher cannot be started:
* `ActivityScenario` refuses with *"Intent in process org.libremediaconverter resolved to
* different process org.libremediaconverter.test"*. Instrumentation runs in the target app's
* process, so the only Activity available to drive is the app's own — which is also the more
* faithful thing to drive.
*
* ## The conversion is setup, not subject
*
* Save is only offered on `Converted`, so the test converts first, at the screen's default
* `MP4_H265` / `FAST`. That is **not** codec-independent, and this KDoc claimed the opposite
* until 2026-09-06: an earlier draft used MP3 for exactly that reason, and the format had to
* move for a different constraint the picker imposes — [convertToTheDefaultFormat] has it.
* `MP4_H265` at `FAST` reaches `ConversionRouter`'s `canEncode(H265)` gate, so it runs on
* FFmpeg on the emulators (no hardware H265) and on Media3 on the Pixel.
*
* **That is tolerable here, and #223 is the reason it needs saying.** There, the routing
* decided whether the *subject* was reached, so a route to FFmpeg made the test pass while
* proving nothing. Here the conversion is setup: if it goes the other way and fails, this test
* fails loudly on the setup rather than quietly on the assertion. The subject is what `publish`
* was handed, which the engine that produced the file does not touch.
*
* ## Why it carries [FailsOnEmulatorApi37]
*
* By inheritance, not measurement. It opens the same picker as
* [pickingAFileThroughTheSystemPickerFillsInTheFileCard], which was marked for aborting
* `system_server` from the task-snapshot path (#108), and then a second DocumentsUI dialog on
* top of it. It has never been observed at API 37 either way: the rotation test truncates the
* advisory run first, so all four advisory runs at this baseline report
* `expected: 6, received: 4` without reaching either picker test. Marking it was the conservative choice and it is
* recorded as unmeasured in `FailsOnEmulatorApi37.kt` rather than dressed up as a measurement.
*/
@Test
@FailsOnEmulatorApi37
fun aSaveWritesToTheDocumentTheSystemPickerCreated() {
pickTheFixture()
convertToTheDefaultFormat()
saveThroughTheSystemPicker()
val destination = RecordingPublisher.seenDestination
assertNotNull("publish was never reached, so nothing was saved", destination)
assertTrue(
"SAF handed back something that is not a document URI, so publish's cleanup can " +
"never run and D4's fix is inert: $destination",
RecordingPublisher.seenIsDocumentUri == true,
)
assertEquals(
"SAF handed back a document that is not positively empty, so " +
"destinationIsKnownEmpty answers false and a failed save keeps its partial file",
0L,
RecordingPublisher.seenSizeBefore,
)
// And the bytes really arrived, which only the failure side was covered for on a device.
val staged = File(context.cacheDir, "conversions")
assertArrayEquals(
"the destination did not receive what was staged",
RecordingPublisher.savedBytes,
context.contentResolver.openInputStream(destination!!)!!.use { it.readBytes() },
)
assertTrue("staging should be empty after a successful save", staged.listFiles().isNullOrEmpty())
}
/**
* The other half of D4 (#250): a save that fails deletes the document it could not write.
*
* ## Why this is separate from the test above
*
* #226 proved the *premise* — SAF hands back a document reporting exactly zero bytes, so
* `destinationIsKnownEmpty` can answer true — and then drove the success path, where the
* `catch` is never entered. So `deletePartialOutput` had still never run against a real
* `DocumentsProvider`; its only assertions were `OutputPublisherPublishTest`'s, against
* `FakeSafProvider` under Robolectric. That is the same "asserted only against a fake built to
* match it" shape #226 was filed to break, one layer down.
*
* ## The forcing condition, and why it is a returned null
*
* [RecordingPublisher.failOpen] makes `openDestination` return null. `publish` turns that into
* `error("Could not open destination for writing")` **after** its size probe has already run,
* so the `catch` is reached with `destinationWasEmpty == true` on a document DocumentsUI
* created seconds earlier. Nothing is simulated: the URI, the grant, the provider and the
* delete are all real.
*
* Null rather than a throw because `openDestination`'s KDoc says a provider that is present
* and declines is the half no fake can produce on demand — so this is also the first time that
* arm has been taken against a live provider rather than a stub.
*
* ## The oracle, and why it is not a recorder inside the provider
*
* The obvious assertion — have the provider record what `deleteDocument` was called with, and
* read it back — **cannot work here, and finding that out is half of what this test cost.**
* `FixtureDocumentsProvider` is declared by the test APK and runs in
* `org.libremediaconverter.test`; instrumentation runs in the app's process. A `static` in the
* provider is therefore a different object from the one a test can see, and the accessor #226
* left behind read empty on every run. That is E7's process wall from a third side, after
* `ACTION_OPEN_DOCUMENT` and `ActivityScenario`.
*
* So the oracle is the document, which does cross the boundary because the app holds a URI
* grant for it. **This is still the path rather than the artefact**, because the two
* assertions are read together: the size query above proves the document *existed and was
* empty* moments earlier, and a `content://` document that no longer answers a query is one
* something deleted. Nothing else in the app deletes SAF documents.
*
* The staged file is asserted to **survive**, which is the deliberate other half of that
* `catch`: a failed save may leave the staged copy as the only copy of an hour of transcoding,
* so `ConversionViewModel` keeps it and puts "Try saving again" on screen.
*/
@Test
@FailsOnEmulatorApi37
fun aFailedSaveDeletesTheDocumentItCouldNotWrite() {
pickTheFixture()
convertToTheDefaultFormat()
RecordingPublisher.failOpen = true
saveThroughTheSystemPicker(settlesOn = TestTags.RETRY_SAVE)
val destination = RecordingPublisher.seenDestination
assertNotNull("publish was never reached, so the delete arm was not exercised", destination)
assertEquals(
"the document was not positively empty, so publish would refuse to delete it",
0L,
RecordingPublisher.seenSizeBefore,
)
assertFalse(
"publish did not delete the document it could not write: $destination",
documentStillExists(destination!!),
)
// The staged copy is kept on purpose -- see ConversionViewModel.save's onFailure.
val staged = File(context.cacheDir, "conversions")
assertTrue(
"a failed save must not delete the staged file; it may be the only copy",
staged.listFiles()?.isNotEmpty() == true,
)
}
/**
* Leaves nothing for the next test's launch to reattach to.
*
* **In teardown rather than at the end of a test, and that placement is the point.**
* `aFailedSaveDeletesTheDocumentItCouldNotWrite` proves that a failed save *keeps* its staged
* file — deliberately, since it may be the only copy — so it ends with a finished job and a
* live staged file, which is exactly what the app reattaches to on the next launch. Its
* sibling then opened on `Converted` with no "Choose file" to tap: measured, as a 30 s timeout
* on `converter.chooseFile` in a test that had nothing wrong with it.
*
* The first fix tapped "Start over" at the end of the test body. That works until the test
* fails, and then it does not run at all — measured too, on the mutation run that proved this
* suite bites: one real failure became two, and the second looked like an unrelated flake.
* **One cause must produce one red test**, so the cleanup belongs where it runs either way.
*
* **`pruneWork` and not `cancelAllWork`, on design grounds and not on a measurement.** Only
* finished work records need to go — that is all the next launch reattaches to — and
* `cancelAllWork` additionally cancels live work, which is a wider blast radius than teardown
* in a shared process needs. `pruneWork` cannot touch a job that has not run yet.
*
* `cancelAllWork` was **suspected** of causing an API 35 red here and did not cause it; see
* [CONVERSION_TIMEOUT_MS], which did. A local API 35 run with `cancelAllWork` passed, and the
* logcat showed the conversion encoding rather than cancelled. The narrower call is kept
* because it is the right one, not because it fixed anything.
*/
private fun clearFinishedWork() {
WorkManager.getInstance(context).pruneWork()
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
/** Whether [destination] still answers a metadata query. A deleted document does not. */
private fun documentStillExists(destination: Uri): Boolean = runCatching {
context.contentResolver
.query(destination, arrayOf(OpenableColumns.SIZE), null, null, null)
?.use { it.moveToFirst() } ?: false
}.getOrDefault(false)
/**
* Runs the conversion, leaving the screen on `Converted`.
*
* **The format is left at its default, and that is a constraint rather than laziness.**
* `ConverterScreen` registers `CreateDocument` with the *output's* MIME type, and
* [FixtureDocumentsProvider] advertises `Root.COLUMN_MIME_TYPES` of `video/mp4` — deliberately,
* so the picker's MIME filter has a mutation with a shape. DocumentsUI honours that on the save
* side too: choosing MP3 makes the destination type `audio/mpeg`, and the fixture root is then
* filtered out of the save dialog entirely. Measured, as *"the create-document dialog never
* showed LMC R38 fixtures"*. The default `MP4_H265` produces `video/mp4` and the root is
* offered.
*
* **The notification dialog is dismissed rather than pre-granted, and that is the honest
* version.** Convert never calls `convert()` directly — it launches `RequestPermission` for
* `POST_NOTIFICATIONS` and converts from the callback **whichever way the answer goes**. So the
* dialog only has to be got out of the way; denying it is a real user's path and the conversion
* still runs. Granting it programmatically was tried first and did not take —
* `GrantPermissionsActivity` appeared anyway, the click that followed went to it rather than to
* the app, and the screen sat in `Ready` with nothing enqueued.
*
* **Both taps scroll first.** On `Ready` the screen carries a file card, five pickers and then
* the button, so Convert is below the fold on a phone. `performClick` on an off-screen node
* dispatches at a position that hits nothing and throws nothing, and `assertIsEnabled` passes
* either way — the first version of this sat waiting for a `Converted` that could never come.
*/
private fun convertToTheDefaultFormat() {
composeRule.onNodeWithTag(TestTags.Converter.CONVERT)
.performScrollTo()
.assertIsEnabled()
.performClick()
dismissThePermissionDialog()
awaitNode(TestTags.SAVE_FILE, CONVERSION_TIMEOUT_MS)
}
/**
* Gets the `POST_NOTIFICATIONS` dialog out of the way, if this device shows one.
*
* Backing out of it is a denial, and a denial is fine here: the conversion starts either way,
* and what that costs the user is a progress notification confined to the Task Manager. Waiting
* only briefly, because on a device where the permission is already held no dialog appears at
* all and the conversion is already under way.
*/
private fun dismissThePermissionDialog() {
if (device.wait(Until.hasObject(By.pkg(PERMISSION_UI_PACKAGE)), PERMISSION_DIALOG_MS) != true) {
return
}
device.pressBack()
device.wait(Until.gone(By.pkg(PERMISSION_UI_PACKAGE)), PERMISSION_DIALOG_MS)
// And wait for the app to be in front again before anything asks Compose about it.
// Querying while another window still owns the screen raises "No compose hierarchies found
// in the app", which is what this test did on an API 35 leg: the back press had landed but
// the dialog had not finished going away.
//
// Asked of UiAutomator rather than through awaitAppFocus, which is the opposite of what the
// class KDoc argues for elsewhere and is right here: awaitAppFocus goes through
// composeRule.waitUntil, so it would raise the very error it is being used to avoid.
device.wait(Until.hasObject(By.pkg(context.packageName)), FOCUS_TIMEOUT_MS)
}
/**
* Taps Save and drives the create-document dialog into the fixture root.
*
* Retried whole, for the reason [pickTheFixture] documents: a dialog that came up unreadable
* cannot be recovered from inside, and a fresh one is the only answer.
*/
private fun saveThroughTheSystemPicker(settlesOn: String = TestTags.Converter.CONVERT_ANOTHER) {
var missing: BySelector? = null
repeat(PICK_ATTEMPTS) { attempt ->
requireAReadableScreen()
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performClick()
missing = walkTheSaveDialog(
if (attempt == 0) PICKER_TIMEOUT_MS else REOPENED_TIMEOUT_MS,
)
if (missing == null) {
// The node that says the save has *finished*, either way. Waiting on the success
// one when the save is meant to fail would time out on a test that is working.
awaitNode(settlesOn, SAVE_TIMEOUT_MS)
return
}
dismissThePicker()
}
throw AssertionError(
"the create-document dialog never showed $missing, in $PICK_ATTEMPTS separate " +
"dialogs (the last one left ${device.currentPackageName} in front)",
)
}
/** Into the fixture root, then Save. Returns the selector never found, or null. */
private fun walkTheSaveDialog(timeoutMs: Long): BySelector? {
val picker = By.pkg(DOCUMENTS_UI_PACKAGE)
val root = By.text(FixtureDocumentsProvider.ROOT_TITLE)
return when {
device.wait(Until.hasObject(picker), timeoutMs) != true -> picker
!tapPickerNode(root, timeoutMs, ifAbsent = ::openTheRootsDrawer) -> root
!tapPickerNode(SAVE_BUTTON, timeoutMs) -> SAVE_BUTTON
else -> null
}
}
private fun pickTheFixture() {
var missing: BySelector? = null
repeat(PICK_ATTEMPTS) { attempt ->
@@ -580,6 +1035,9 @@ class SafPickerRoundTripTest {
* It is also why this counts backs rather than pressing a fixed number of them. One back is
* enough from Recent and two are needed from inside the root, but a third from Recent would
* finish `MainActivity` and take the rest of the test with it.
*
* **[forceStopThePicker] is the escalation after the presses, and it exists because a back
* press is not always deliverable.** See its own KDoc for the measurement.
*/
private fun dismissThePicker() {
repeat(BACK_PRESSES) {
@@ -594,15 +1052,50 @@ class SafPickerRoundTripTest {
// The check after the last press, and not a spare one: `repeat` presses on its final
// iteration too, so without this a dismissal that worked on the last press would still be
// reported as a failure to close.
if (awaitAppFocus()) return
forceStopThePicker()
if (!awaitAppFocus()) {
throw AssertionError(
"the system picker would not close: after $BACK_PRESSES back presses the app " +
"still does not have the window focus, and ${device.currentPackageName} is " +
"in front. What could be seen: " + describeWindows(),
"the system picker would not close: after $BACK_PRESSES back presses and a " +
"force-stop of $DOCUMENTS_UI_PACKAGE the app still does not have the window " +
"focus, and ${device.currentPackageName} is in front. What could be seen: " +
describeWindows(),
)
}
}
/**
* Kills the picker's process, for when no back press can reach it.
*
* **The failure this exists for cannot be answered with input, and that is the whole point.**
* Measured on the gating API 37 legs of runs 34006456986 and 34001744574, which fail this way
* and whose logcats say the same thing in the same order. `UiObject2.click()` on the fixture's
* root is injected at the node's centre and the framework discards it —
* `InputDispatcher: No new touched window at (539.0, 525.0) in display 0` — because
* `PickActivity` has published accessibility nodes but has no touchable window there yet.
* `click()` cannot see that and returns normally, so the walk goes on to wait out
* [PICKER_TIMEOUT_MS] for a fixture that was never navigated to. By the time this function's
* caller starts pressing back, WindowManager is still saying
* `no window has focus but ...PickActivity may eventually add a window when it finishes
* starting up` — and goes on saying it for another 63 s. Every one of the four presses is
* dropped, and DocumentsUI ANRs on `Input dispatching timed out`.
*
* So the picker is in front, unreachable by key or by touch, and [pickTheFixture]'s whole
* point — that a second `PickActivity` rebuilds every window and list in it — is unreachable
* with it. `am force-stop` goes around input entirely: `UiAutomation` runs shell commands as
* uid 2000, which holds `FORCE_STOP_PACKAGES`, so the picker's process is killed, its
* activity leaves the task it was launched into, and `MainActivity` — the activity below it in
* that same task — is resumed with the focus.
*
* **Only on the failure path**, after every back press has been spent, so a picker that closes
* the ordinary way never reaches this and is not altered by it. If the framework itself is
* gone, this cannot help either, and the caller still reports what it could see.
*/
private fun forceStopThePicker() {
device.executeShellCommand("am force-stop $DOCUMENTS_UI_PACKAGE")
device.waitForIdle()
}
/** True once [MainActivity] has the window focus, false if it does not take it in time. */
private fun awaitAppFocus(): Boolean = try {
composeRule.waitUntil("the app has the window focus back", FOCUS_TIMEOUT_MS) {
@@ -675,9 +1168,65 @@ class SafPickerRoundTripTest {
* `Condition still not satisfied after 30000 ms` — which names neither the node nor the test.
* With the description it says which affordance never arrived, which is the whole finding.
*/
private fun awaitNode(tag: String) {
composeRule.waitUntil("a node tagged $tag exists", APP_TIMEOUT_MS) {
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
/** Starts counting [MainActivity] creations, so [awaitRecreation] can wait for the next one. */
private fun watchForRecreation() {
recreations.set(0)
ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(recreationWatcher)
}
/**
* Waits for the rotation to actually rebuild [MainActivity], which `waitForIdle` does not.
*
* **This is #122.** `waitForIdle()` waits for the compose hierarchy to settle. Immediately
* after a rotation the window manager has accepted but not yet delivered as a configuration
* change, the *old* Activity's composition is already idle — so it returns, `composeRule
* .activity` still resolves to the old instance, and the guard below reads an unchanged
* identity hash. That is the clean `AssertionError` seen on the API 33 gating leg of #217, and
* the wedges on #122 are the same race taken the other way: land while the composition is
* being torn down and there is nothing coherent for `waitForIdle` to settle on.
*
* A bounded wait is worth having even if that second half is wrong. It turns a 20-minute
* `WEDGE_TIMEOUT` — which costs the leg and names no test — into a fast failure that says which
* test and what it was waiting for.
*/
private fun awaitRecreation() {
composeRule.waitUntil(
"the rotation did not recreate MainActivity within $RECREATION_TIMEOUT_MS ms",
RECREATION_TIMEOUT_MS,
) {
recreations.get() > 0
}
}
/**
* Waits for [tag], treating "the app has no composition right now" as *not yet* rather than
* as a failure.
*
* `fetchSemanticsNodes` **throws** `IllegalStateException: No compose hierarchies found in the
* app` when nothing is attached at that instant, and `waitUntil` propagates it on the first
* poll instead of waiting out the deadline. This class spends much of its time with another
* app in front — the picker, the create-document dialog, the permission dialog — so there is
* always a window where the app is coming back and has no composition yet. Before this, that
* window was a hard failure: measured on the API 34 leg of run 34057196628, where **both** SAF
* tests died that way while the same commit passed API 33, 35, 36 and 37, and the previous
* commit passed API 34 and failed 35. A failing leg that moves between runs is #190's
* emulator flake, and this is the one place in the class that turned it into a red test.
*
* **The cost is honest and bounded**: an app that is genuinely gone now fails at the deadline
* rather than immediately, so the last composition error is carried into the message to keep
* that case diagnosable.
*/
private fun awaitNode(tag: String, timeoutMs: Long = APP_TIMEOUT_MS) {
var lastError: Throwable? = null
try {
composeRule.waitUntil("a node tagged $tag exists", timeoutMs) {
runCatching { composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() }
.onFailure { lastError = it }
.getOrDefault(false)
}
} catch (timeout: ComposeTimeoutException) {
val note = lastError?.let { "; last composition error: ${it.message}" } ?: ""
throw AssertionError("waited ${timeoutMs}ms for a node tagged $tag$note", timeout)
}
}
@@ -691,6 +1240,39 @@ class SafPickerRoundTripTest {
const val PICKER_TIMEOUT_MS = 30_000L
const val APP_TIMEOUT_MS = 30_000L
/** The runtime-permission dialog's package, so it can be recognised and dismissed. */
const val PERMISSION_UI_PACKAGE = "com.google.android.permissioncontroller"
/** Short: either the dialog is up almost immediately, or the permission was already held. */
const val PERMISSION_DIALOG_MS = 5_000L
/**
* Bounds a hang, and **the first number here was measured on one API level and wrong on
* another.** It read 120 s, on the strength of the whole test taking 11.8 s on the API 34
* CI leg (run 34043502322). API 35 is a different machine: on run 34056545386 the fixture's
* `libx265 -crf 24 -preset veryfast` encode started at `20:05:26.897` and the next job in
* the suite did not appear until `20:07:41.693` — **134.8 s**, so the encode was still
* running when the 120 s bound expired and the test failed with the conversion healthy.
*
* The logcat is what settles it: `ConversionWorker` logs the route and `FFmpegEngine` the
* command, and there is no cancel between them. A timeout that fires on a working
* conversion is worse than no bound, because it reads as a product failure.
*
* 300 s is chosen against that 134.8 s, not against API 34's 11.8 s. **Do not re-tighten
* it from a fast leg's timing** — the encode is software on every emulator here, and the
* spread between images is larger than any margin a single measurement would suggest.
*/
const val CONVERSION_TIMEOUT_MS = 300_000L
/** The copy is a few kilobytes, but it crosses a provider. */
const val SAVE_TIMEOUT_MS = 30_000L
/**
* DocumentsUI's save button. Case-insensitive because the label is "SAVE" on some images
* and "Save" on others, and the difference is not what this test is about.
*/
val SAVE_BUTTON: BySelector = By.text(Pattern.compile("save", Pattern.CASE_INSENSITIVE))
/**
* The same wait once a picker has already come and gone, and shorter for a reason.
*
@@ -703,6 +1285,15 @@ class SafPickerRoundTripTest {
*/
const val REOPENED_TIMEOUT_MS = 10_000L
/**
* How long a rotation is given to destroy and rebuild the Activity.
*
* Generous against the API 33 and 34 emulators #122 was measured on, where the rotation is
* slow enough for the gap this bound exists to cover to be observable at all — and still
* two orders of magnitude inside the 1200 s `WEDGE_TIMEOUT` it replaces.
*/
const val RECREATION_TIMEOUT_MS = 15_000L
/**
* How long the app is given to take the window focus back after a back press.
*
@@ -0,0 +1,129 @@
package org.libremediaconverter.work
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import java.io.File
import java.util.concurrent.TimeUnit
/**
* The Cancel button in the notification shade actually cancels the job.
*
* `ConversionNotifications.build` attaches one action, wired to
* `WorkManager.createCancelPendingIntent(id)`. Before this test `createCancelPendingIntent` had
* **no references anywhere outside its own declaration** — no JVM test, no instrumented test
* (#227).
*
* That matters more than an ordinary uncovered line. A conversion runs in a foreground service and
* the user is invited to leave the app; once they do, this action is the only way to stop it. If
* the `PendingIntent` carries the wrong id, the button does nothing, the notification stays, and
* the job runs to completion — with no error, no log, and no screen to look at.
*
* ## Why this fires the intent rather than reading the shade
*
* The obvious version asks `NotificationManager.getActiveNotifications()` for id 1001 and taps what
* it finds. That was rejected: the instrumented suite grants no runtime permissions, so
* `POST_NOTIFICATIONS` is denied throughout, and whether a suppressed foreground-service
* notification is returned there is a platform detail that varies — the test would be asserting
* something about notification *visibility* rather than about cancellation.
*
* The `PendingIntent` is the subject; where it is read from is incidental. Building the
* notification for a real, live work id and firing its action exercises exactly the thing that can
* be wrong — a real `PendingIntent` dispatch reaching real `WorkManager` — and does it the same way
* on every API level.
*
* ## Why the job is delayed rather than running
*
* A conversion of the committed 3 s fixture finishes in well under a second on an emulator
* (`HardwareFallbackTest` completed one in 448 ms), so racing a cancel against a running job would
* be flaky in the direction that fails. An initial delay keeps the job reliably `ENQUEUED`, which
* is a state `cancelWorkById` acts on identically — what is under test is whether firing the action
* reaches WorkManager with the right id, not which state it interrupts.
*
* *Mutation:* build the `PendingIntent` from `UUID.randomUUID()` instead of the request's id. The
* notification looks identical and the job is never cancelled.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class NotificationCancelActionTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val workManager = WorkManager.getInstance(context)
private lateinit var input: File
@Before
fun setUp() {
input = File(context.cacheDir, "cancel_action_sample.mp4")
InstrumentationRegistry.getInstrumentation().context.assets
.open("sample_h264.mp4")
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
}
@After
fun tearDown() {
input.delete()
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
@Test
fun theNotificationsCancelActionCancelsThatJob(): Unit = runBlocking {
val request = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = input.name,
sizeBytes = input.length(),
spec = OutputFormat.MP4_H264.spec,
quality = QualityTier.FAST,
).let { base ->
// Rebuild with a delay so the job stays ENQUEUED for the whole test. See the KDoc.
OneTimeWorkRequestBuilder<ConversionWorker>()
.setInputData(base.workSpec.input)
.setInitialDelay(1, TimeUnit.HOURS)
.build()
}
workManager.enqueue(request).result.get()
// The job is queued and waiting, which is the state the cancel has to interrupt.
assertEquals(
WorkInfo.State.ENQUEUED,
withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null }
}?.state,
)
val notification = ConversionNotifications(context)
.build(request.id, title = input.name, percent = 0, indeterminate = true)
val action = notification.actions?.firstOrNull()
assertNotNull("the progress notification carries no action to cancel with", action)
// The whole point: fire it the way the shade would, and see the job stop.
action!!.actionIntent.send()
val terminal = withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
assertEquals(
"firing the notification's Cancel action must cancel the job it was built for",
WorkInfo.State.CANCELLED,
terminal?.state,
)
}
private companion object {
const val TIMEOUT_MS = 30_000L
}
}
@@ -3,6 +3,7 @@ package org.libremediaconverter
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.libremediaconverter.convert.OutputPublisher
@@ -17,14 +18,36 @@ import org.libremediaconverter.convert.OutputPublisher
* ever becomes a `Converted` state, or a `reset()`'s delete is cancelled along with the
* Activity. Process start is the one moment those leftovers are reliably observable.
*/
class LibreMediaConverterApp : Application() {
open class LibreMediaConverterApp : Application() {
/**
* Deliberately process-lifetime and never cancelled: the work it carries is a single
* short task that should outlive nothing in particular and be interrupted by nothing.
* A `SupervisorJob` so a failure here could never take a sibling down with it.
*
* **`protected open` for #159.** Robolectric builds an `Application` for every test that asks
* for one, so on the JVM this is not one background sweep but one *per test* — all of them on
* `Dispatchers.IO`, all touching the same `cacheDir`, none of them joined by anything. That is
* a race against any test asserting about a file under `conversions/`, and it grew with the
* suite: wave 4 added ten Robolectric classes and took it from CI-only to roughly one local run
* in six. The JVM suite substitutes a scope that runs the sweep inline — see
* `app/src/test/resources/robolectric.properties` and `TestLibreMediaConverterApp`.
*
* A constructor parameter would be the ordinary way to inject this and is not available: the
* framework builds this class, so the seam has to be a member.
*/
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
protected open val sweepScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* The sweep [onCreate] last started, so a caller that needs it finished can wait for it.
*
* Nothing in production reads this — process start does not wait for its own housekeeping. It
* exists because the alternative for a test is a timed poll, and a poll cannot tell "the sweep
* has not run yet" from "the sweep ran and did nothing".
*/
@Volatile
var startupSweep: Job? = null
private set
override fun onCreate() {
super.onCreate()
@@ -53,6 +76,6 @@ class LibreMediaConverterApp : Application() {
//
// sweepStaging() also re-reads each timestamp immediately before deleting, which
// closes the window between listing the directory and acting on the listing.
appScope.launch { OutputPublisher(this@LibreMediaConverterApp).sweepStaging() }
startupSweep = sweepScope.launch { OutputPublisher(this@LibreMediaConverterApp).sweepStaging() }
}
}
@@ -24,9 +24,11 @@ import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.media3.common.util.UnstableApi
import org.libremediaconverter.convert.ConverterScreen
import org.libremediaconverter.join.JoinScreen
import org.libremediaconverter.ui.TestTags
import org.libremediaconverter.ui.theme.LibreMediaConverterTheme
/**
@@ -114,7 +116,7 @@ internal fun AppRoot(
if (useRail) {
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail {
NavigationRail(modifier = Modifier.testTag(TestTags.Shell.NAVIGATION_RAIL)) {
Destination.entries.forEach { item ->
NavigationRailItem(
selected = destination == item,
@@ -132,7 +134,7 @@ internal fun AppRoot(
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
NavigationBar {
NavigationBar(modifier = Modifier.testTag(TestTags.Shell.NAVIGATION_BAR)) {
Destination.entries.forEach { item ->
NavigationBarItem(
selected = destination == item,
@@ -21,6 +21,11 @@ import org.libremediaconverter.model.VideoCodec
* words, "cannot be tested for correctness". It is a hint, not a guarantee, which is
* why the router treats a failed hardware export as a signal to fall back rather
* than trusting this up front.
* - **An enumeration that fails answers no to everything**, which sends every job to
* FFmpeg. Empty sets are not a permissive default: `canEncode` looks a MIME type up in
* [hardwareEncodeMimes] and finds nothing there. That is the intended answer — FFmpeg
* can do whatever Media3 can, only slower — but it is the opposite of what this class
* said until #194, so it is written down rather than left to be re-derived.
*/
class AndroidDeviceCodecs private constructor(
private val hardwareEncodeMimes: Set<String>,
@@ -46,22 +51,62 @@ class AndroidDeviceCodecs private constructor(
fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
private fun probe(): AndroidDeviceCodecs {
/**
* One entry of the platform's codec list, reduced to what the rules below read.
*
* The five booleans and the type list are the whole of what [capabilitiesFrom] needs, and
* none of them can be set on a `MediaCodecInfo` from a test: Robolectric ships
* `MediaCodecInfoBuilder`, but it has no `setIsAlias` and no `setCanonicalName`, which is
* exactly the objection #133 raised against reaching this code through
* `ShadowMediaCodecList`. That objection is about the shadow. It does not apply to a
* function that takes its own entry type, which is why this exists.
*/
internal data class CodecEntry(
val canonicalName: String,
val isAlias: Boolean,
val isEncoder: Boolean,
val isHardwareAccelerated: Boolean,
val isSoftwareOnly: Boolean,
val supportedTypes: List<String>,
)
/**
* The enumeration rules, over entries a caller chooses.
*
* [probe] is the only production caller and supplies the real codec list; a test supplies
* its own, which is the point — the two rules this class's KDoc calls out as easy to get
* wrong, the alias skip and the canonical-name dedup, are unreachable any other way.
*
* **`enumerate` returns a `Sequence`, deliberately.** The `runCatching` has to wrap the
* *iteration* rather than a list built before it, because a `MediaCodecInfo` whose
* properties throw does so partway through — and when that happens the codecs already read
* are kept. Taking a `List` here would move that throw outside the loop and silently turn a
* partial answer into an empty one. That behaviour predates this seam; a `List` parameter
* would have changed it as a side effect of a refactor.
*
* **An enumeration that fails answers restrictively, and that is deliberate.** The sets
* come back empty, and `"video/avc" in emptySet()` is `false`, so [canEncode] and
* [canDecode] both answer no and every job routes to FFmpeg. FFmpeg can do everything
* Media3 can, only slower, so refusing the hardware path is the safe reading of "we could
* not find out what this device supports". This used to log "assuming permissive", which
* described the opposite of what the code does.
*/
internal fun capabilitiesFrom(enumerate: () -> Sequence<CodecEntry>): AndroidDeviceCodecs {
val encoders = mutableSetOf<String>()
val decoders = mutableSetOf<String>()
val seen = mutableSetOf<String>()
runCatching {
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.forEach { info ->
enumerate().forEach { entry ->
// Aliases point at the same underlying codec; counting both would
// double-count capabilities.
if (info.isAlias) return@forEach
if (!seen.add(info.canonicalName)) return@forEach
if (entry.isAlias) return@forEach
if (!seen.add(entry.canonicalName)) return@forEach
info.supportedTypes.forEach { mime ->
entry.supportedTypes.forEach { mime ->
if (!mime.startsWith("video/")) return@forEach
if (info.isEncoder) {
if (info.isHardwareAccelerated && !info.isSoftwareOnly) {
if (entry.isEncoder) {
if (entry.isHardwareAccelerated && !entry.isSoftwareOnly) {
encoders += mime
}
} else {
@@ -69,12 +114,32 @@ class AndroidDeviceCodecs private constructor(
}
}
}
}.onFailure { Log.w(TAG, "Codec enumeration failed; assuming permissive.", it) }
}.onFailure { Log.w(TAG, "Codec enumeration failed; routing everything to FFmpeg.", it) }
Log.i(TAG, "Hardware video encoders: $encoders")
return AndroidDeviceCodecs(encoders, decoders)
}
/**
* The thin edge: the real codec list, mapped onto [CodecEntry] one at a time.
*
* Lazily, so a property that throws does it inside [capabilitiesFrom]'s `runCatching` and
* on the entry that caused it — see that function's note on why the parameter is a
* `Sequence`.
*/
private fun probe(): AndroidDeviceCodecs = capabilitiesFrom {
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.asSequence().map { info ->
CodecEntry(
canonicalName = info.canonicalName,
isAlias = info.isAlias,
isEncoder = info.isEncoder,
isHardwareAccelerated = info.isHardwareAccelerated,
isSoftwareOnly = info.isSoftwareOnly,
supportedTypes = info.supportedTypes.toList(),
)
}
}
/**
* `internal` rather than `private` so the cross-check test can ask what a [VideoCodec]
* means here and compare it with what [NAME_TO_MIME] says the same codec's names mean.
@@ -6,6 +6,7 @@ import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.CoroutineDispatcher
@@ -71,6 +72,119 @@ data class InputFile(
val probe: InputProbe? = null,
)
/**
* One update about a running conversion, as WorkManager last reported it.
*
* Only the fields [conversionStateFrom] reads — the same shape, and for the same reason, as
* `JobSnapshot` beside `Reattachment.choose`: the rule stays testable on the JVM because nothing
* in it needs a `WorkInfo`, which a test cannot readily build.
*
* [outputData] stays a `Data` rather than being unpacked into five nullable strings. It is what a
* test already builds with `workDataOf` everywhere in this suite, so unpacking would move the same
* reads without making anything easier to drive.
*/
internal data class ConversionUpdate(
val state: WorkInfo.State,
val progressPercent: Int,
val runAttemptCount: Int,
val outputData: Data,
)
/**
* What the screen should show, given what WorkManager last said about the job.
*
* ## Why this is a function rather than the body of a `collect`
*
* It was the body of one. `workManager` is built in the constructor from `WorkManager.getInstance`,
* `observe` is private, and nothing could hand either a chosen `WorkInfo` — so every arm below ran
* only when a real worker happened to produce it. A real worker produces a terminal state with
* well-formed output, which meant six of these arms had never been chosen by any test: the progress
* read, both sides of the retry check, a success with no file, a failure with nothing to say, and
* the two that map to a state the user cannot otherwise reach.
*
* That is the argument #141 made for `MediaProbe`'s track walk, against `WorkManager` instead of a
* media fixture, and it takes the same answer: the branch matrix is a pure function, and what is
* left needing the framework — the flow, the null check, the ownership check — is the thin edge.
*
* ## What is deliberately *not* in here
*
* The ownership check stays at the call site. Its comment is explicit that it guards the file
* ownership the `SUCCEEDED` arm takes, not merely the assignment, so moving it inside would change
* what it protects. And this function takes no responsibility for the staged file: it returns the
* state, and the caller reads the file off it. A pure function that deletes files is not a seam.
*
* @param cancelled where a cancellation lands, which differs for a reattached job — see [observe].
* @param fallbackSpec the current settings, read only when finished work predates the worker
* reporting its own name and MIME type.
*/
@UnstableApi
internal fun conversionStateFrom(
update: ConversionUpdate,
input: InputFile,
cancelled: ConversionState,
fallbackSpec: OutputSpec,
): ConversionState = when (update.state) {
WorkInfo.State.RUNNING -> ConversionState.Converting(input, update.progressPercent)
// ENQUEUED after a run means a retry is pending. Either the six-hour foreground budget ran out
// mid-job, or the system refused to let the job start again while the app was in the background
// — the second being the likelier of the two, since it needs only a process restart. Nothing
// here can tell them apart, and nothing needs to: the answer is the same.
WorkInfo.State.ENQUEUED ->
if (update.runAttemptCount > 0) {
ConversionState.Waiting(input)
} else {
ConversionState.Converting(input, 0)
}
WorkInfo.State.SUCCEEDED -> convertedFrom(update.outputData, input, fallbackSpec)
// A worker that dies before it can report anything leaves no output data at all — a
// foreground-service start refused after a process restart is one way — and an exception's
// message can be an empty string. Both would read as a failure with nothing said, so blank
// falls back like missing does.
WorkInfo.State.FAILED -> ConversionState.Failed(
update.outputData.getString(ConversionWorker.KEY_ERROR)
?.takeIf { it.isNotBlank() }
?: ConversionWorker.GENERIC_FAILURE_MESSAGE,
)
WorkInfo.State.CANCELLED -> cancelled
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
}
/**
* The `SUCCEEDED` arm, which is the only one that reads more than one field.
*
* Split out so [conversionStateFrom] stays a table of one line per state. A success with no output
* path is a failure: the job said it finished and named nothing, and there is no file to offer.
*/
@UnstableApi
private fun convertedFrom(outputData: Data, input: InputFile, fallbackSpec: OutputSpec): ConversionState {
val path = outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
?: return ConversionState.Failed(SUCCEEDED_WITHOUT_A_FILE_MESSAGE)
return ConversionState.Converted(
input = input,
staged = File(path),
engineUsed = outputData.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
routeReason = outputData.getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(),
// Work enqueued before the worker reported this carries nothing, and WorkManager keeps
// finished work for about a week -- so this branch is ordinary for a few days rather than a
// corner. It is the old derivation, kept because it is the same guess the app already made
// and there is genuinely nothing better available for such a job. New work never reaches it.
suggestedName = outputData.getString(ConversionWorker.KEY_SUGGESTED_NAME)
?.takeIf { it.isNotBlank() }
?: ConversionWorker.outputNameFor(input.displayName, fallbackSpec),
mimeType = outputData.getString(ConversionWorker.KEY_MIME_TYPE)
?.takeIf { it.isNotBlank() }
?: fallbackSpec.mimeType,
)
}
/** A job that reported success and named no file. There is nothing to offer the user to save. */
internal const val SUCCEEDED_WITHOUT_A_FILE_MESSAGE: String =
"Conversion reported success but produced no file."
sealed interface ConversionState {
data object Idle : ConversionState
data class Ready(val input: InputFile) : ConversionState
@@ -101,7 +215,42 @@ sealed interface ConversionState {
val mimeType: String = "",
) : ConversionState
data class Saved(val displayName: String) : ConversionState
data class Failed(val message: String) : ConversionState
/**
* The job, or the save that followed it, could not be finished.
*
* [retry] is non-null for exactly one cause: a [ConversionViewModel.save] whose copy to the
* user's destination threw. That save deliberately keeps the staged file -- it can be the only
* copy of an hour of transcoding -- and this is what lets the screen offer it again. Every
* other failure leaves it null, because there is nothing staged to offer: a transcode that
* died produced no output, and a save that found the file gone has nothing left to save.
*
* Nullable rather than a `SaveFailed` state of its own. What the screen does with the message
* is identical either way, so a second variant would make every exhaustive `when` grow an arm
* that duplicates this one.
*
* A view of the file, not a second owner of it -- see [PendingSave].
*/
data class Failed(val message: String, val retry: PendingSave? = null) : ConversionState
}
/**
* The staged output a save would target from this state, or null when there is nothing to save.
*
* One function for two callers that have to agree. [ConversionViewModel.save] picks the file to
* copy with it, and `ConverterScreen` registers its `CreateDocument` contract with the MIME type
* it returns; when those two read the state separately, a retry offered after a failed save opened
* the dialog with the *picker's* current type instead of the finished job's -- wrong for any job
* whose spec has been edited since, and for every reattached job, whose spec was never in these
* settings at all.
*
* Top-level and `internal` rather than a member of the ViewModel, so the screen can call it
* without one -- which is also what makes the derivation testable on the JVM.
*/
internal fun ConversionState.pendingSave(): PendingSave? = when (this) {
is ConversionState.Converted -> PendingSave(staged, suggestedName, mimeType)
is ConversionState.Failed -> retry
else -> null
}
@UnstableApi
@@ -158,13 +307,34 @@ class ConversionViewModel @JvmOverloads constructor(
private var observer: Job? = null
private var activeWorkId: UUID? = null
/**
* Who is allowed to write to this screen — see [ScreenOwnership] for the rule and why
* cancelling the superseded coroutine is not one.
*
* Every write below that lands after a suspension point is guarded by it: the two in
* [onInputPicked] and the one in [observe].
*
* [save] is the one left out, deliberately — and not because it is safe in both directions.
* Nothing can overwrite what it writes: it is reachable only from [ConversionState.Converted]
* or a [ConversionState.Failed] carrying its file, so the only observation that could belongs
* to a job already in a terminal state, which will not emit again. What it can still do is
* land on top of a [reset] taken while its copy was in flight, putting `Saved` on a screen the
* user has just cleared. Guarding it would drop that write instead, reporting nothing for a
* file that may genuinely have reached the user's destination. Which of those two is right is
* a question about what the screen should offer during a save, not about this race, so it is
* filed as issue #123 rather than decided here in passing.
*/
private val ownership = ScreenOwnership()
/**
* The staged output this ViewModel is responsible for deleting.
*
* A field rather than something read back out of [_state], because the state machine
* cannot answer the question on the path that needs it most: a failed [save] lands on
* [ConversionState.Failed], which carries a message and no file at all. By then the
* only remaining reference would have been lost.
* A field rather than something read back out of [_state], and still one now that
* [ConversionState.Failed] carries a [PendingSave] after a failed [save]. That handle is a
* view for the screen to offer a retry through; this one is the single reference [reset]
* deletes through, and keeping the two apart is what stops a second owner appearing. Reading
* the file back out of the state machine instead would mean trusting every state that has no
* file -- `Idle`, `Saved`, a transcode failure -- to say so.
*/
private var pendingStaged: File? = null
@@ -207,6 +377,10 @@ class ConversionViewModel @JvmOverloads constructor(
* `Data` — see [ConversionState.Converted].
*/
private fun reattach() {
// Read before the launch, and before the query it is about to suspend in. This is the
// claim the answer will belong to: anything the user does from here on supersedes it, and
// reading it on the far side of the query would read whatever superseded it instead.
val token = ownership.current
viewModelScope.launch {
val reattachment = Reattachment.choose(
workManager.jobSnapshots(
@@ -217,8 +391,17 @@ class ConversionViewModel @JvmOverloads constructor(
// The query suspends, so by now the user may have picked a file or started a
// conversion of their own. Either owns the screen; reattaching over it would throw
// away what they just did. Both this check and the assignment below run on the main
// dispatcher with no suspension point between them, so nothing can interleave.
// away what they just did.
//
// This catches a pick that has already *landed*, and only that. It used to claim that
// "both this check and the assignment below run on the main dispatcher with no
// suspension point between them, so nothing can interleave" — which was the exact
// opposite of what happens. There is no assignment below. There is observe(), which
// launches a *separate* coroutine that must suspend on `collect` before it can write
// anything, so the check happens at one moment and the write lands at another with a
// whole pick able to fit in between. That was issue #49, and believing this comment is
// why it read as flaky CI for two days. What actually holds the line is the token
// observe() carries: see [ScreenOwnership].
if (_state.value !is ConversionState.Idle || activeWorkId != null) return@launch
// Only a job that is the sole explanation for its staged file gets to name the input.
@@ -244,7 +427,7 @@ class ConversionViewModel @JvmOverloads constructor(
activeWorkId = reattachment.job.id
// No initial state of our own: the flow's first emission carries the job's real
// state, so observe() maps it exactly as it would for a conversion started here.
observe(reattachment.job.id, input, cancelled = ConversionState.Idle)
observe(reattachment.job.id, input, cancelled = ConversionState.Idle, token = token)
}
}
@@ -260,25 +443,34 @@ class ConversionViewModel @JvmOverloads constructor(
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
fun setEnginePreference(preference: EnginePreference) = _settings.update { it.copy(enginePreference = preference) }
/**
* The tap is the claim, which is why [ScreenOwnership.claim] is called here and not inside the
* `launch`. A claim made in the coroutine would only be immediate for as long as
* `Dispatchers.Main.immediate` happened to run it inline, and a deferred claim leaves the same
* gap this closes: it is the difference between the user owning the screen from the moment
* they tapped and owning it from whenever their coroutine got around to running.
*/
fun onInputPicked(uri: Uri) {
val token = ownership.claim()
viewModelScope.launch {
// Both the metadata query and the probe touch disk, and the probe spawns FFprobe.
// Neither belongs on the main thread.
val file = withContext(pickDispatcher) { InputQuery.describe(getApplication(), uri) }
// Every write below the hop above is guarded, this one included: two picks in quick
// succession suspend here together, and without this the slower one would land last
// and put the file the user did not choose on screen.
if (!ownership.stillHeldBy(token)) return@launch
// Show the file as soon as its name and size are known. Probing now runs FFprobe on
// every pick, which is a native process spawn, and making the whole screen wait on it
// would read as the app having ignored the tap.
_state.value = ConversionState.Ready(file)
val probe = withContext(pickDispatcher) { probeOrUnreadable(uri) }
// Only fill in the probe if the user has not moved on in the meantime.
_state.update { current ->
if (current is ConversionState.Ready && current.input.uri == uri) {
ConversionState.Ready(file.copy(probe = probe))
} else {
current
}
}
// Only fill in the probe if the user has not moved on in the meantime. The claim is
// what says whether they have -- it covers a second pick of the same URI, which a
// comparison of URIs cannot, and every state a later claim could have written.
if (!ownership.stillHeldBy(token)) return@launch
_state.value = ConversionState.Ready(file.copy(probe = probe))
}
}
@@ -329,10 +521,13 @@ class ConversionViewModel @JvmOverloads constructor(
quality = settings.quality,
enginePreference = settings.enginePreference,
)
// Tapping Convert claims the screen for this job, which is what supersedes the pick's
// still-in-flight probe and any reattachment that has not finished asking.
val token = ownership.claim()
activeWorkId = request.id
workManager.enqueue(request)
_state.value = ConversionState.Converting(input, 0)
observe(request.id, input)
observe(request.id, input, token = token)
}
/**
@@ -340,81 +535,45 @@ class ConversionViewModel @JvmOverloads constructor(
* picked file, ready to convert again. For one picked up by [reattach] there is no picked
* file — the URI that job holds belongs to a process that no longer exists — so it lands
* on Idle instead, rather than offering a Convert button over a file nothing can open.
* @param token the claim this observation belongs to. Nothing here can write until `collect`
* has resumed with a `WorkInfo`, which is some time after the caller decided to observe, so
* the claim is checked again at the last possible moment rather than trusted from then. This
* is issue #49's fix and the only thing standing between a superseded observation and the
* user's screen — see [ScreenOwnership].
*/
private fun observe(id: UUID, input: InputFile, cancelled: ConversionState = ConversionState.Ready(input)) {
private fun observe(
id: UUID,
input: InputFile,
cancelled: ConversionState = ConversionState.Ready(input),
token: Long,
) {
observer?.cancel()
observer = viewModelScope.launch {
workManager.getWorkInfoByIdFlow(id).collect { info ->
if (info == null) return@collect
_state.value = when (info.state) {
WorkInfo.State.RUNNING -> ConversionState.Converting(
input,
info.progress.getInt(ConversionWorker.KEY_PROGRESS, 0),
)
// ENQUEUED after a run means a retry is pending. Either the six-hour
// foreground budget ran out mid-job, or the system refused to let the job
// start again while the app was in the background — the second being the
// likelier of the two, since it needs only a process restart. Nothing here
// can tell them apart, and nothing needs to: the answer is the same.
WorkInfo.State.ENQUEUED ->
if (info.runAttemptCount > 0) {
ConversionState.Waiting(input)
} else {
ConversionState.Converting(input, 0)
}
WorkInfo.State.SUCCEEDED -> {
val path = info.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
if (path == null) {
ConversionState.Failed("Conversion reported success but produced no file.")
} else {
val staged = File(path)
// Take responsibility for the file at the same moment the state
// starts referring to it, so the two cannot disagree.
pendingStaged = staged
ConversionState.Converted(
input = input,
staged = staged,
engineUsed = info.outputData
.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
routeReason = info.outputData
.getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(),
suggestedName = info.outputData
.getString(ConversionWorker.KEY_SUGGESTED_NAME)
?.takeIf { it.isNotBlank() }
// Work enqueued before the worker reported this carries
// nothing, and WorkManager keeps finished work for about a
// week -- so this branch is ordinary for a few days rather
// than a corner. It is the old derivation, kept because it is
// the same guess the app already made and there is genuinely
// nothing better available for such a job. New work never
// reaches it.
?: ConversionWorker.outputNameFor(
input.displayName,
_settings.value.spec,
),
mimeType = info.outputData
.getString(ConversionWorker.KEY_MIME_TYPE)
?.takeIf { it.isNotBlank() }
?: _settings.value.spec.mimeType,
)
}
}
// A worker that dies before it can report anything leaves no output data at
// all — a foreground-service start refused after a process restart is one
// way — and an exception's message can be an empty string. Both would read
// as a failure with nothing said, so blank falls back like missing does.
WorkInfo.State.FAILED -> ConversionState.Failed(
info.outputData.getString(ConversionWorker.KEY_ERROR)
?.takeIf { it.isNotBlank() }
?: "Conversion failed.",
)
WorkInfo.State.CANCELLED -> cancelled
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
}
// Ahead of the `when`, not merely ahead of the assignment: the SUCCEEDED branch
// takes ownership of the staged file, and a superseded observation must not do
// that either. The state and `pendingStaged` are meant to refer to the same file
// or to no file, and this is where that stays true.
if (!ownership.stillHeldBy(token)) return@collect
val next = conversionStateFrom(
ConversionUpdate(
state = info.state,
progressPercent = info.progress.getInt(ConversionWorker.KEY_PROGRESS, 0),
runAttemptCount = info.runAttemptCount,
outputData = info.outputData,
),
input = input,
cancelled = cancelled,
fallbackSpec = _settings.value.spec,
)
// Take responsibility for the file at the same moment the state starts referring
// to it, so the two cannot disagree. Read off the result rather than assigned
// inside the mapping: `Converted` is the only state that carries a staged file, so
// "the state and `pendingStaged` refer to the same file or to no file" is now the
// shape of the code rather than a rule two branches have to keep.
if (next is ConversionState.Converted) pendingStaged = next.staged
_state.value = next
}
}
}
@@ -426,35 +585,50 @@ class ConversionViewModel @JvmOverloads constructor(
/**
* Copies the staged result out to the destination the user picked.
*
* Reached from [ConversionState.Converted] and again from a [ConversionState.Failed] that an
* earlier save left carrying its file. [pendingSave] is what makes those one call rather than
* two, so a retry cannot drift from the first attempt in what it copies or what it calls it.
*
* The existence check is not redundant with the one reattachment already made. That one ran
* inside a tag query which, for a result offered on launch, can be hours older than the tap —
* and `cacheDir` is exactly the directory the OS empties when it wants space, which is also
* what the sweep does to anything a day old. Without it the file's absence arrived as
* `staged.inputStream()` throwing, and `e.message` put a raw ENOENT path on screen.
* `staged.inputStream()` throwing, and `e.message` put a raw ENOENT path on screen. A retry
* meets that same check a second time, which is the point of reusing it here.
*/
fun save(destination: Uri) {
val converted = _state.value as? ConversionState.Converted ?: return
if (!converted.staged.isFile) {
val pending = _state.value.pendingSave() ?: return
if (!pending.staged.isFile) {
// No retry handle: the file such a state would offer again is exactly the one that
// has gone, so carrying it would put a button on screen that cannot do anything.
_state.value = ConversionState.Failed(STAGED_FILE_GONE_MESSAGE)
return
}
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
publisher.publish(converted.staged, destination)
converted.staged.delete()
publisher.publish(pending.staged, destination)
pending.staged.delete()
}
}.onSuccess {
// publish() already deleted it; nothing left to clean up.
pendingStaged = null
_state.value = ConversionState.Saved(converted.suggestedName)
_state.value = ConversionState.Saved(pending.suggestedName)
}.onFailure { e ->
// Deliberately NOT cleared. A failed save may mean the staged file is the
// only copy of an hour of transcoding, and the user's destination did not
// receive it -- deleting here would destroy the work to tidy up a cache
// directory. It stays collectable: by a later reset(), or by the sweep once
// it is old enough to be certain nobody is coming back for it.
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
//
// `pending` rides on the state so the screen can offer that file again. It used
// to live only in `pendingStaged`, where nothing on screen could reach it -- so
// the single button this branch rendered was "Start over", which deletes the very
// file the paragraph above goes out of its way to keep. It is `pending` rather
// than a fresh handle for the second failure's sake: a retry that fails again
// lands back here still carrying the file, not on a bare Failed that would take
// the offer away.
_state.value = ConversionState.Failed(e.message ?: SAVE_FAILED_MESSAGE, pending)
}
}
}
@@ -468,8 +642,18 @@ class ConversionViewModel @JvmOverloads constructor(
* cancelled with [viewModelScope] if the Activity finishes first, so it is a best
* effort rather than a guarantee. `OutputPublisher.sweepStaging` is the backstop for
* the times it does not run.
*
* **It still deletes from a [ConversionState.Failed] carrying a [PendingSave], and that is a
* decision rather than something inherited.** Deletion is acceptable there only because the
* alternative was offered first: the screen puts "Try saving again" directly above this
* button, so reaching it is the user saying the work is not worth keeping. Until that button
* existed, this delete was the only thing a failed save could lead to — which was the defect.
*/
fun reset() {
// Start over is a claim like any other. The cancel below is a request honoured at the next
// suspension point, so a collector already on its way to a write has nothing left to
// honour it at; the claim is what actually stops that write landing on top of Idle.
ownership.claim()
observer?.cancel()
observer = null
activeWorkId = null
@@ -489,13 +673,23 @@ class ConversionViewModel @JvmOverloads constructor(
else -> null
}
private fun currentInput(): InputFile? = when (val s = _state.value) {
is ConversionState.Ready -> s.input
is ConversionState.Converting -> s.input
is ConversionState.Waiting -> s.input
is ConversionState.Converted -> s.input
else -> null
}
/**
* The input `convert()` may act on, which is only ever the one on a `Ready` screen.
*
* This used to answer for `Converting`, `Waiting` and `Converted` as well. Those arms were not
* reachable by tapping Convert -- the button renders only in the `Ready` branch -- but they
* were reachable through the POST_NOTIFICATIONS **result**, which `ConverterScreen.kt:91` wires
* to `convert()` rather than to the button. Reaching one of them enqueued a *second* job over a
* live one: `activeWorkId` was overwritten, and the first job kept running with its foreground
* notification orphaned and nothing left holding its id to cancel it.
*
* Narrowed under #202 rather than tested as it stood, because a test written against the old
* shape would have frozen the double-enqueue as intended behaviour -- the F1/F5 failure mode.
*
* `JoinViewModel.join()` has been `(_state.value as? JoinState.Ready)?.inputs ?: return` all
* along. The two screens are the same shape and only one of them was over-general.
*/
private fun currentInput(): InputFile? = (_state.value as? ConversionState.Ready)?.input
private companion object {
/**
@@ -73,8 +73,11 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
// stands: some providers rewrite a document's extension to match it, so an MP3 offered as
// video/webm can arrive with the wrong one. Read straight off the collected state, so this
// recomposes because it depends on that rather than because an unrelated line happens to.
// Through pendingSave() rather than a cast to Converted, so a retry offered after a failed
// save opens the dialog with the type its first attempt used -- the cast answered null for a
// Failed, and the fallback below is the current picker, which a reattached job never set.
// Remembered against the type so the launcher re-registers only when it actually changes.
val destinationMime = (state as? ConversionState.Converted)?.mimeType ?: settings.spec.mimeType
val destinationMime = state.pendingSave()?.mimeType ?: settings.spec.mimeType
val chooseDestination = rememberLauncherForActivityResult(
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
) { uri -> uri?.let(viewModel::save) }
@@ -91,24 +94,61 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
state = state,
settings = settings,
validation = validation,
actions = ConverterActions(
actions = converterActions(
viewModel = viewModel,
onPickInput = { pickInput.launch(arrayOf("*/*")) },
onPreset = viewModel::setPreset,
onContainer = viewModel::setContainer,
onVideoCodec = viewModel::setVideoCodec,
onAudioCodec = viewModel::setAudioCodec,
onSuggestion = viewModel::applySuggestion,
onQuality = viewModel::setQuality,
onEnginePreference = viewModel::setEnginePreference,
onConvert = { requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) },
onCancel = viewModel::cancel,
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
onReset = viewModel::reset,
),
modifier = modifier,
)
}
/**
* Which of the ViewModel's methods each affordance on the screen calls.
*
* ## Why this is a function rather than an argument list
*
* It was an argument list, inside [ConverterScreen], which no test reached: `ConverterScreenContent`
* builds its own [ConverterActions], so every test in the suite drove the stateless inner and none
* of them ever saw the wiring.
*
* Most of the list is safe without a test, and saying so is more useful than pretending otherwise:
* `onContainer`, `onVideoCodec`, `onAudioCodec`, `onPreset`, `onSuggestion`, `onQuality` and
* `onEnginePreference` each take a distinct type, so binding one to another's setter does not
* compile. Verified rather than assumed — swapping `onVideoCodec` and `onAudioCodec` fails with
* *"Inapplicable candidate(s): fun setAudioCodec(codec: AudioCodec)"*.
*
* **[ConverterActions.onCancel] and [ConverterActions.onReset] are the exception.** Both are
* `() -> Unit`, so swapping them compiles silently — also verified — and ships a Cancel button that
* throws the conversion away and a Start-over button that leaves it on screen. That pair is what
* `ConverterWiringTest` exists for.
*
* The three launcher-backed actions stay parameters: they need an `ActivityResultLauncher`, which
* is the part that genuinely needs the composition, and keeping them out means the rest can be
* checked without one.
*/
@UnstableApi
internal fun converterActions(
viewModel: ConversionViewModel,
onPickInput: () -> Unit,
onConvert: () -> Unit,
onSave: (suggestedName: String) -> Unit,
): ConverterActions = ConverterActions(
onPickInput = onPickInput,
onPreset = viewModel::setPreset,
onContainer = viewModel::setContainer,
onVideoCodec = viewModel::setVideoCodec,
onAudioCodec = viewModel::setAudioCodec,
onSuggestion = viewModel::applySuggestion,
onQuality = viewModel::setQuality,
onEnginePreference = viewModel::setEnginePreference,
onConvert = onConvert,
onCancel = viewModel::cancel,
onSave = onSave,
onReset = viewModel::reset,
)
/**
* Everything [ConverterScreenContent] can ask for, in one value.
*
@@ -325,13 +365,36 @@ internal fun ConverterScreenContent(
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = actions.onReset,
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.START_OVER),
) { Text("Start over") }
val retry = s.retry
if (retry == null) {
// Nothing was staged, so "Start over" is the whole of what is on
// offer and stays the primary button.
Button(
onClick = actions.onReset,
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.START_OVER),
) { Text("Start over") }
} else {
Button(
onClick = { actions.onSave(retry.suggestedName) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.RETRY_SAVE),
) { Text("Try saving again") }
// Start over still deletes the file this state is carrying, and that
// is deliberate: `reset()` is what stops a full-size output sitting in
// cache until the sweep. What makes the delete acceptable is the
// button above it. Deletion is the user's choice only once the
// alternative has been offered -- and until that button existed, this
// one was the only thing a failed save could lead to.
OutlinedButton(
onClick = actions.onReset,
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
) { Text("Start over") }
}
}
}
}
@@ -68,42 +68,64 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
): Unit = suspendCancellableCoroutine { cont ->
val plan = CopyPlanner.plan(request.spec, request.probe)
handler.post {
val transformer = runCatching { buildTransformer(plan, cont) }
.getOrElse {
cont.resumeWithException(it)
return@post
}
// Dropping the tracks the target does not have is what stops an audio-only export
// from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A
// produced an HEVC stream in a file named .m4a.
val item = EditedMediaItem.Builder(MediaItem.fromUri(input))
.setRemoveVideo(plan.video == VideoPlan.Drop)
.setRemoveAudio(plan.audio == AudioPlan.Drop)
.build()
// A Composition is the only way to ask for transmuxing; the plain
// start(EditedMediaItem, path) overload always re-encodes. This is the remux path.
val composition = Composition.Builder(EditedMediaItemSequence.Builder(item).build())
.setTransmuxVideo(plan.video == VideoPlan.Copy)
.setTransmuxAudio(plan.audio == AudioPlan.Copy)
.build()
cont.invokeOnCancellation {
// cancel() has the same single-thread requirement as start().
handler.post { runCatching { transformer.cancel() } }
}
runCatching { transformer.start(composition, output.absolutePath) }
.onFailure {
cont.resumeWithException(it)
return@post
}
pollProgress(transformer, cont, onProgress)
// One guard around the whole body, deliberately.
//
// This used to be two narrow ones — around `buildTransformer` and around
// `transformer.start` — with the two Media3 builders sitting unguarded between them.
// On this thread that is not a small gap: nothing here has a caller to throw back to,
// so an escaping exception reaches the HandlerThread's uncaught handler and takes the
// process down, while [cont] is never resumed either way. `EditedMediaItem.Builder`
// does exactly that for a plan that drops both tracks
// ("Audio and video cannot both be removed"), which a queued job can still carry.
// Widening the guard costs nothing on success and turns every such refusal into a
// failed job with a reason.
runCatching { startExport(input, output, plan, cont, onProgress) }
.onFailure { if (cont.isActive) cont.resumeWithException(it) }
}
}
/**
* Builds the export and hands it to Transformer. Runs on the HandlerThread; may throw.
*
* Everything Transformer's single-thread contract covers lives here, so that the caller has
* exactly one place to catch. Returning normally means the export is running and [cont] belongs
* to the listener; throwing means it never started and the caller owns resuming.
*/
private fun startExport(
input: Uri,
output: File,
plan: ConversionPlan,
cont: CancellableContinuation<Unit>,
onProgress: (Int) -> Unit,
) {
val transformer = buildTransformer(plan, cont)
// Dropping the tracks the target does not have is what stops an audio-only export
// from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A
// produced an HEVC stream in a file named .m4a.
val item = EditedMediaItem.Builder(MediaItem.fromUri(input))
.setRemoveVideo(plan.video == VideoPlan.Drop)
.setRemoveAudio(plan.audio == AudioPlan.Drop)
.build()
// A Composition is the only way to ask for transmuxing; the plain
// start(EditedMediaItem, path) overload always re-encodes. This is the remux path.
val composition = Composition.Builder(EditedMediaItemSequence.Builder(item).build())
.setTransmuxVideo(plan.video == VideoPlan.Copy)
.setTransmuxAudio(plan.audio == AudioPlan.Copy)
.build()
// Registered before start(), so a cancellation racing the export always finds a
// transformer to cancel.
cont.invokeOnCancellation {
// cancel() has the same single-thread requirement as start().
handler.post { runCatching { transformer.cancel() } }
}
transformer.start(composition, output.absolutePath)
pollProgress(transformer, cont, onProgress)
}
/**
* @throws IllegalArgumentException if [plan] names a container Media3 cannot mux. That is a
* routing bug rather than a runtime condition — [org.libremediaconverter.model.ConversionRouter]
@@ -50,19 +50,42 @@ object MediaProbe {
)
fun probe(context: Context, uri: Uri): InputProbe {
val extracted = probeWithExtractor(context, uri)
val info = probeWithFFprobe(context, uri)
val merged = merge(probeWithExtractor(context, uri), probeWithFFprobe(context, uri))
if (merged.kind == InputKind.UNPARSEABLE) {
// Not a failure: an unparseable input is a strong signal that this job belongs on
// FFmpeg. Reporting an unknown codec makes the router say so.
Log.i(TAG, "Neither MediaExtractor nor FFprobe could read $uri; routing to FFmpeg.")
}
return merged
}
/**
* What the two probes together say about one input.
*
* A pure function, and `internal` for the same reason [extractedFrom] is: the precedence rules
* below are the answer to "which probe wins", and until this was pulled out of [probe] the only
* way to ask was to have a real `MediaExtractor` and a real FFprobe **disagree**, which nothing
* on any source set can arrange. `RemuxTest` drives this on a device against committed
* fixtures, but only ever with one probe answering and the other agreeing or also failing --
* so every elvis here was taken in one direction and never the other.
*
* The rules, each of which is a decision rather than an accident:
*
* - **The extractor wins on codecs.** It is the platform's own view of what it can decode,
* which is the thing the router is about to ask about. FFprobe's name for the same track can
* differ, and the copy planner keys off these strings.
* - **FFprobe alone reports the container.** `MediaExtractor` cannot, which is why [InputProbe]
* carries a nullable one and `CopyPlanner` treats null as "container unknown".
* - **Duration is the larger of the two**, not the first non-zero. Either probe can report zero
* for a file the other times correctly, and a zero duration makes the FFmpeg progress
* percentage undefined.
*/
internal fun merge(extracted: Extracted?, info: FFprobeInfo?): InputProbe {
val videoCodec = extracted?.videoCodec ?: info?.videoCodec
val audioCodec = extracted?.audioCodec ?: info?.audioCodec
val kind = classify(extracted, info)
if (kind == InputKind.UNPARSEABLE) {
// Not a failure: an unparseable input is a strong signal that this job belongs on
// FFmpeg. Reporting an unknown codec makes the router say so.
Log.i(TAG, "Neither MediaExtractor nor FFprobe could read $uri; routing to FFmpeg.")
return UNREADABLE
}
if (kind == InputKind.UNPARSEABLE) return UNREADABLE
return InputProbe(
videoCodec = videoCodec,
@@ -83,7 +106,7 @@ object MediaProbe {
* audio file and a corrupt file indistinguishable. The source-info card cannot describe either
* honestly until they are separate, and neither can the copy planner.
*/
private fun classify(extracted: Extracted?, info: FFprobeInfo?): InputKind = when {
internal fun classify(extracted: Extracted?, info: FFprobeInfo?): InputKind = when {
info?.isImage == true -> InputKind.IMAGE
extracted == null && info == null -> InputKind.UNPARSEABLE
(extracted?.videoCodec ?: info?.videoCodec) != null -> InputKind.VIDEO
@@ -92,7 +115,12 @@ object MediaProbe {
else -> InputKind.UNPARSEABLE
}
private class Extracted(
/**
* `internal` rather than `private` so [extractedFrom] can be named from a test. The JVM test
* source set is a friend of `main`, so this stays invisible outside the module — the precedent
* is `MainActivity`'s `Destination`, and [containerFrom] beside it.
*/
internal class Extracted(
val videoCodec: String?,
val audioCodec: String?,
val durationMs: Long,
@@ -100,33 +128,55 @@ object MediaProbe {
val height: Int,
)
/**
* What a set of track formats says about a file.
*
* Split out of [probeWithExtractor] so the rules below can be tested against tracks a test
* *chooses*, rather than against whatever the committed fixtures happen to contain. The device
* tests exercise this through real files; none of them can construct a two-video-track input,
* a track that omits its duration, or an audio-before-video ordering on purpose.
*
* Three rules live here, and each is a decision rather than plumbing:
*
* - **First track of a type wins.** `video == null` is the whole guard. A file with two video
* tracks must report the first, because that is the one an engine will transcode.
* - **Duration is the maximum across tracks**, not the first one found or the last. A file
* whose audio outlasts its video is ordinary, and reporting the video's length would cut the
* progress bar short.
* - **A track that omits `KEY_DURATION` contributes nothing** rather than zero. `MediaExtractor`
* omits it for plenty of real tracks — see `MediaProbeTrackFieldsTest` — and `maxOf` against a
* fabricated 0 would still be correct here, but reading a key that is absent is not.
*/
internal fun extractedFrom(formats: List<MediaFormat>): Extracted {
var video: String? = null
var audio: String? = null
var durationUs = 0L
var width = 0
var height = 0
for (format in formats) {
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (format.containsKey(MediaFormat.KEY_DURATION)) {
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
}
when {
mime.startsWith("video/") && video == null -> {
video = shortName(mime)
width = format.intOr(MediaFormat.KEY_WIDTH)
height = format.intOr(MediaFormat.KEY_HEIGHT)
}
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
}
}
return Extracted(video, audio, durationUs / US_PER_MS, width, height)
}
private fun probeWithExtractor(context: Context, uri: Uri): Extracted? {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(context, uri, null)
var video: String? = null
var audio: String? = null
var durationUs = 0L
var width = 0
var height = 0
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (format.containsKey(MediaFormat.KEY_DURATION)) {
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
}
when {
mime.startsWith("video/") && video == null -> {
video = shortName(mime)
width = format.intOr(MediaFormat.KEY_WIDTH)
height = format.intOr(MediaFormat.KEY_HEIGHT)
}
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
}
}
Extracted(video, audio, durationUs / US_PER_MS, width, height)
extractedFrom(extractor.trackFormats())
} catch (e: Exception) {
Log.i(TAG, "Platform extractor could not read $uri.", e)
null
@@ -135,7 +185,11 @@ object MediaProbe {
}
}
private class FFprobeInfo(
/**
* `internal` rather than `private` for the same reason [Extracted] is, and it should have been
* from the start: [merge] cannot be named from a test while half its signature is private.
*/
internal class FFprobeInfo(
val container: Container?,
val videoCodec: String?,
val audioCodec: String?,
@@ -167,12 +221,39 @@ object MediaProbe {
null
}
private fun readMediaInformation(path: String): FFprobeInfo? {
// ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these have to go
// through the Java getters rather than property syntax.
val info: MediaInformation = FFprobeKit.getMediaInformation(path).getMediaInformation()
?: return null
/**
* The thin edge: spawn FFprobe, hand what it said to [ffprobeInfoFrom].
*
* Everything device-bound is on this line and the null check under it. What FFprobe *said* is a
* `MediaInformation`, which is an ordinary object over a `JSONObject` — so the reading of it is
* a decision a test can choose the inputs for, and it lives below rather than here.
*/
private fun readMediaInformation(path: String): FFprobeInfo? =
FFprobeKit.getMediaInformation(path).getMediaInformation()?.let(::ffprobeInfoFrom)
/**
* What FFprobe's answer means, as a function of the answer alone.
*
* `internal` for the same reason [Extracted] and [FFprobeInfo] are: a test cannot name it
* otherwise, and the JVM test source set is a friend of `main`.
*
* **JVM-safe, verified rather than assumed.** `javap` over the committed AAR's runtime jar:
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
* touches the native library — so a test builds its own without `libffmpegkit` being present.
* That is the whole reason this split is worth making: `readMediaInformation` was 114 missed
* instructions and 24 missed branches, of which exactly one line needed a device.
*
* The subtle part is the **second argument to [containerFrom]**. `matroska,webm` is reported
* for both MKV and WebM — they share a demuxer — so the video codec is the only thing that
* separates them, and dropping it silently turns every VP9 WebM into an MKV. `containerFrom`
* has thirty-three covered branches of its own and none of them can notice that, because the
* mistake is at the call rather than in the callee.
*
* ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these go through the
* Java getters rather than property syntax.
*/
internal fun ffprobeInfoFrom(info: MediaInformation): FFprobeInfo {
val streams = info.getStreams().orEmpty()
val video = streams.firstOrNull { it.getType() == "video" }
val audio = streams.firstOrNull { it.getType() == "audio" }
@@ -269,25 +350,7 @@ object MediaProbe {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(context, uri, null)
var video: String? = null
var audio: String? = null
var width = 0
var height = 0
var fps = 0
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith("video/") && video == null) {
video = shortName(mime)
width = format.intOr(MediaFormat.KEY_WIDTH)
height = format.intOr(MediaFormat.KEY_HEIGHT)
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
} else if (mime.startsWith("audio/") && audio == null) {
audio = shortName(mime)
}
}
ConcatInput(video, audio, width, height, fps)
concatInputFrom(extractor.trackFormats())
} catch (e: Exception) {
Log.i(TAG, "Could not probe $uri for concat; will re-encode.", e)
ConcatInput(null, null, 0, 0, 0)
@@ -296,6 +359,45 @@ object MediaProbe {
}
}
/**
* The join flow's read of the same track formats. See [extractedFrom] for why this is separate
* from the extractor.
*
* Deliberately **not** folded into [extractedFrom] despite the overlap. This one reads frame
* rate and does not read duration; that one reads duration and does not read frame rate. A
* merged version would have to compute both for every caller, and `ConcatPlanner` treats an
* unknown frame rate as "cannot prove a match" — so a field this flow does not need must not
* start arriving as a number.
*/
internal fun concatInputFrom(formats: List<MediaFormat>): ConcatInput {
var video: String? = null
var audio: String? = null
var width = 0
var height = 0
var fps = 0
for (format in formats) {
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith("video/") && video == null) {
video = shortName(mime)
width = format.intOr(MediaFormat.KEY_WIDTH)
height = format.intOr(MediaFormat.KEY_HEIGHT)
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
} else if (mime.startsWith("audio/") && audio == null) {
audio = shortName(mime)
}
}
return ConcatInput(video, audio, width, height, fps)
}
/**
* Every track format this extractor holds, read once.
*
* The thin edge the two pure functions above leave behind: a `trackCount` and a
* `getTrackFormat` per index, which is the whole of what needs a real `MediaExtractor`.
*/
private fun MediaExtractor.trackFormats(): List<MediaFormat> = (0 until trackCount).map(::getTrackFormat)
/**
* One track property as an Int, or [fallback] when the format has no Int to give.
*
@@ -5,6 +5,7 @@ import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import java.io.File
import java.io.OutputStream
/**
* What a save has to say when the staged file is not there any more.
@@ -23,6 +24,38 @@ const val STAGED_FILE_GONE_MESSAGE: String =
"The finished file is no longer in the cache, so there is nothing left to save. " +
"Start over to make it again."
/**
* What to tell the user when the copy into their chosen destination did not finish.
*
* A fallback, not the usual message: `publish` throws with a real reason most of the time — the
* volume filled, the provider revoked the grant — and that reason is better than this. This is for
* the exception that arrives with nothing to say, which would otherwise reach the screen as an
* empty failure.
*
* Kept next to [STAGED_FILE_GONE_MESSAGE] for exactly the reason that one names: **both ViewModels
* need it**, and saving is what it is about. It was written out twice before — `ConversionViewModel`
* and `JoinViewModel` each carried their own copy of the literal, agreeing by coincidence.
*/
const val SAVE_FAILED_MESSAGE: String = "Could not save the file."
/**
* A staged file that is still there to be saved, and everything the save dialog needs to offer it.
*
* The three travel together because a save cannot be repeated without all of them: the file to
* copy, the name to suggest, and the MIME type `CreateDocument` has to be registered with. None of
* them can be rederived from the pickers once the job is over -- they come from the job's own
* output `Data`, and a reattached job's spec was never in the current settings at all.
*
* Kept next to [STAGED_FILE_GONE_MESSAGE] for the same reason it is: both ViewModels need it and
* staging is what it is about.
*
* **A view of the staged file, never an owner of it.** The delete still runs through each
* ViewModel's own `pendingStaged` field, so a state carrying one of these can be dropped without
* losing the only reference -- which is what keeps "a `Failed` that carries a file" from being a
* new way to leak one.
*/
data class PendingSave(val staged: File, val suggestedName: String, val mimeType: String)
/**
* Staging and publication of conversion output.
*
@@ -152,7 +185,7 @@ open class OutputPublisher(private val context: Context) {
open fun publish(staged: File, destination: Uri) {
val destinationWasEmpty = destinationIsKnownEmpty(destination)
try {
val out = context.contentResolver.openOutputStream(destination)
val out = openDestination(destination)
?: error("Could not open destination for writing: $destination")
out.use { sink -> staged.inputStream().use { source -> source.copyTo(sink) } }
} catch (failure: Throwable) {
@@ -161,6 +194,22 @@ open class OutputPublisher(private val context: Context) {
}
}
/**
* Opens [destination] for writing, or null when the provider will not.
*
* A seam, and a narrow one: it exists because `openOutputStream` has **two** ways of refusing
* and only one of them is reachable from a test otherwise. A provider that has gone away throws
* `FileNotFoundException` from inside the call; a provider that is present and declines returns
* null. The two are not interchangeable here — the `?: error(...)` above is the only thing that
* turns the second into a failure rather than an NPE further down — and no fake provider can be
* asked to produce a null return on demand.
*
* `protected open` rather than injected, matching `hasSpaceFor` and `createStagingFile`:
* `WorkerStubs.kt`'s publishers already override one method to force one condition.
*/
protected open fun openDestination(destination: Uri): OutputStream? =
context.contentResolver.openOutputStream(destination)
/**
* True only when the destination is *positively known* to hold no bytes yet.
*
@@ -238,7 +287,7 @@ open class OutputPublisher(private val context: Context) {
open fun sweepStaging(nowMs: Long = System.currentTimeMillis()) {
val dir = stagingDir
val listing = dir.listFiles() ?: return
val entries = listing.map { StagingSweep.Entry(it.name, it.lastModified()) }
val entries = snapshot(listing)
StagingSweep.collectable(entries, nowMs).forEach { name ->
val file = File(dir, name)
// Re-read the timestamp rather than trusting the snapshot above. Between the
@@ -250,6 +299,22 @@ open class OutputPublisher(private val context: Context) {
}
}
/**
* The name and age of everything [sweepStaging] found, read once.
*
* A seam for the *race*, not for the clock — [sweepStaging] already takes `nowMs`, so the clock
* is the caller's. What has no seam otherwise is the window between this snapshot and the
* per-file re-read below it, and that window is the entire reason the re-read exists.
*
* **It has to be here and not around `listFiles()`.** A test that changes a file before the
* listing, or during it, changes what `StagingSweep.collectable` is given — so the file is
* never proposed for deletion and the re-read is never reached. The race being modelled is a
* file that *was* collectable when the snapshot was taken and is not by the time the delete
* comes round, which is exactly one worker resuming in this same process.
*/
protected open fun snapshot(listing: Array<File>): List<StagingSweep.Entry> =
listing.map { StagingSweep.Entry(it.name, it.lastModified()) }
private fun File.canonicalOrAbsolute(): File = runCatching { canonicalFile }.getOrDefault(absoluteFile)
private companion object {
@@ -0,0 +1,57 @@
package org.libremediaconverter.convert
/**
* Which of the things writing to a screen is still allowed to.
*
* Both ViewModels are a state machine written to from several coroutines that each suspend before
* they write: a pick hops to a dispatcher for the metadata query, a reattachment hops for the tag
* query, and an observation of a WorkManager job cannot write at all until its `collect` has
* resumed with a `WorkInfo`. Whoever resumes last wins, which is how issue #49 let a finished job
* from an earlier session take a screen the user had already picked a file on.
*
* The rule this makes enforceable is one line: **every write that lands after a suspension point
* checks the claim it was made under, and drops itself if that claim has been superseded.** The
* claim is taken synchronously, when the user acts; the check happens immediately before the
* write. Superseded work is *dropped*, not reordered — a dropped write cannot come back later.
*
* Cancelling the superseded coroutine is not a substitute and was never going to be. `Job.cancel`
* is a request, honoured at the next suspension point; a collector that has already resumed and is
* on its way to `_state.value = …` has no suspension point left to honour it at, so the write
* lands anyway. Cancellation also cannot help at all in the case #49 actually reported, where
* nothing supersedes the observation until after it has been launched. Both ViewModels still
* cancel their old observer, because leaving a collector running is a leak — but the guarantee
* does not rest on it.
*
* **Confined to the main dispatcher, and that confinement is the atomicity argument.** Every
* claim and every check runs there, with no suspension point between a check and the write it
* guards, so a claim can never land between the two. Nothing here is synchronized and nothing is
* `@Volatile`: making the field visible across threads would invite exactly the off-main use this
* cannot support, and would replace an argument that holds with one that only looks like it does.
*/
internal class ScreenOwnership {
private var claims = 0L
/**
* The claim in force now.
*
* Read by work that is about to suspend and will want to know, when it comes back, whether
* the screen it was reading is still the screen it is writing to. Read it *before* the
* suspension, not after — reading it afterwards would return whatever claim superseded it,
* which is the bug rather than the check for it.
*/
val current: Long get() = claims
/**
* Takes the screen, invalidating every write still in flight under an older claim.
*
* Called synchronously from the user's action rather than from inside the coroutine it
* starts. A claim made inside a `launch` is only immediate while the dispatcher happens to
* run it inline, and a deferred claim is no claim at all: it would leave the same gap this
* exists to close.
*/
fun claim(): Long = ++claims
/** Whether [token] is still the claim in force, and may therefore write. */
fun stillHeldBy(token: Long): Boolean = token == claims
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.ffmpeg.FFmpegEngine
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.DeviceCodecs
@@ -40,6 +41,27 @@ interface SoftwareTranscoder {
)
}
/**
* The join path. Implemented by [org.libremediaconverter.ffmpeg.ConcatEngine].
*
* Added last of the three, and the gap it closes was measured rather than guessed:
* `PerJobStagingTest`'s KDoc records that reverting `ConcatWorker` to a constant staging name left
* all 257 tests green, because nothing in the JVM suite can get past a `ConcatEngine` constructed
* in place. Everything after that line -- the failure mapping, the message fallback, the staged
* delete -- was untested on every source set.
*
* The result type stays nested in the implementation rather than being lifted here. Moving it would
* touch every call site to buy nothing: what a test needs is the ability to *not* run FFmpeg, and
* that is the method, not the type.
*/
interface ConcatJoiner {
suspend fun join(
inputs: List<Uri>,
output: File,
format: OutputFormat = OutputFormat.MP4_H264,
): ConcatEngine.Result
}
/**
* The seam that lets tests force failure paths.
*
@@ -69,6 +91,9 @@ object ConversionDependencies {
@Volatile
var software: () -> SoftwareTranscoder = { FFmpegEngine() }
@Volatile
var concat: (Context) -> ConcatJoiner = { ConcatEngine(it) }
@Volatile
var publisher: (Context) -> OutputPublisher = { OutputPublisher(it) }
@@ -103,6 +128,7 @@ object ConversionDependencies {
fun reset() {
hardware = { Media3Engine(it) }
software = { FFmpegEngine() }
concat = { ConcatEngine(it) }
publisher = { OutputPublisher(it) }
deviceCodecs = { AndroidDeviceCodecs.get() }
probe = { context, uri -> MediaProbe.probe(context, uri) }
@@ -5,8 +5,8 @@ import android.net.Uri
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.suspendCancellableCoroutine
import org.libremediaconverter.convert.ConcatJoiner
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.convert.StagingNames
import org.libremediaconverter.model.ConcatPlanner
@@ -24,11 +24,11 @@ import kotlin.coroutines.resumeWithException
* reliably fail when they differ — it can emit a file whose later segments are
* garbled. See [ConcatPlanner].
*/
class ConcatEngine(private val context: Context) {
class ConcatEngine(private val context: Context) : ConcatJoiner {
data class Result(val strategy: ConcatStrategy, val output: File)
suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat = OutputFormat.MP4_H264): Result {
override suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat): Result {
require(inputs.size >= 2) { "Joining needs at least two files." }
val paths = inputs.map { uri ->
@@ -65,16 +65,16 @@ class ConcatEngine(private val context: Context) {
private suspend fun execute(args: List<String>) = suspendCancellableCoroutine { cont ->
Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
val session = FFmpegKit.executeWithArgumentsAsync(args.toTypedArray()) { completed ->
val rc = completed.getReturnCode()
when {
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
ReturnCode.isCancel(rc) -> cont.cancel()
else -> cont.resumeWithException(
FFmpegEngine.FFmpegException(
"Joining failed (${rc?.value}): " +
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty(),
),
)
val outcome = sessionOutcome(
rc = completed.getReturnCode(),
prefix = "Joining",
failStackTrace = { completed.getFailStackTrace() },
logTail = { completed.getAllLogsAsString(LOG_TAIL_LIMIT) },
)
when (outcome) {
SessionOutcome.Success -> cont.resume(Unit)
SessionOutcome.Cancelled -> cont.cancel()
is SessionOutcome.Failed -> cont.resumeWithException(FFmpegEngine.FFmpegException(outcome.message))
}
}
cont.invokeOnCancellation { FFmpegKit.cancel(session.getSessionId()) }
@@ -35,6 +35,21 @@ object FFmpegConcatCommand {
add("concat")
add("-safe")
add("0")
// And -protocol_whitelist permits the *scheme* those paths carry, which is a
// separate gate (#238). Every input the user actually picks is a content:// URI --
// JoinScreen uses OpenMultipleDocuments -- so ConcatEngine maps it through
// FFmpegKitConfig.getSafParameterForRead and writes an `ffkitsaf:` path into the
// list file. The concat demuxer applies its own whitelist, defaulting to
// "file,crypto,data", and refused every one of them:
//
// [ffkitsaf @ ...] Protocol 'ffkitsaf' not on whitelist 'file,crypto,data'!
//
// This only widens that default. It is on the stream-copy branch alone because it
// is the only one that feeds the demuxer a list file -- REENCODE passes each input
// with its own -i, where the whitelist does not apply, which is why joining over SAF
// worked for mismatched clips and failed for matching ones.
add("-protocol_whitelist")
add(PROTOCOL_WHITELIST)
add("-i")
add(listFile.absolutePath)
add("-c")
@@ -84,4 +99,12 @@ object FFmpegConcatCommand {
add(output.absolutePath)
}
}
/**
* The concat demuxer's protocol whitelist: FFmpeg's own default, plus ffmpeg-kit's SAF scheme.
*
* Spelled out rather than appended to an unknown default, because the default is FFmpeg's and
* could change under us; naming all four keeps the command self-describing. See #238.
*/
private const val PROTOCOL_WHITELIST = "file,crypto,data,ffkitsaf"
}
@@ -4,7 +4,6 @@ import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.Level
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.suspendCancellableCoroutine
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest
@@ -51,19 +50,16 @@ class FFmpegEngine : SoftwareTranscoder {
val session = FFmpegKit.executeWithArgumentsAsync(
args.toTypedArray(),
{ completed ->
val rc = completed.getReturnCode()
when {
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
ReturnCode.isCancel(rc) ->
cont.cancel()
else -> cont.resumeWithException(
FFmpegException(
"FFmpeg failed (${rc?.value}): " +
completed.getFailStackTrace().orEmpty().ifBlank {
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
},
),
)
val outcome = sessionOutcome(
rc = completed.getReturnCode(),
prefix = "FFmpeg",
failStackTrace = { completed.getFailStackTrace() },
logTail = { completed.getAllLogsAsString(LOG_TAIL_LIMIT) },
)
when (outcome) {
SessionOutcome.Success -> cont.resume(Unit)
SessionOutcome.Cancelled -> cont.cancel()
is SessionOutcome.Failed -> cont.resumeWithException(FFmpegException(outcome.message))
}
},
{ log -> Log.d(TAG, log.message.trimEnd()) },
@@ -0,0 +1,54 @@
package org.libremediaconverter.ffmpeg
import com.arthenica.ffmpegkit.ReturnCode
/**
* What a finished FFmpegKit session means, as a function of its return code.
*
* Both engines had their own copy of this `when`, twelve lines apart in two files, and the copies
* had drifted: [FFmpegEngine] preferred the fail stack trace and fell back to the log tail, while
* [ConcatEngine] only ever read the log tail. Neither was tested — both live inside a callback
* handed to `FFmpegKit`, which does not run on the JVM — so the divergence was invisible.
*
* #203 decided to unify on the stack trace, so a join failure now carries the diagnostics a
* conversion failure always did. The *prefix* stays per-engine: unifying the strategy must not
* unify the sentence, since "FFmpeg failed" and "Joining failed" describe different jobs.
*/
internal sealed interface SessionOutcome {
/** rc 0. The suspension resumes normally. */
data object Success : SessionOutcome
/** rc 255. The suspension is cancelled rather than failed — the user asked for this. */
data object Cancelled : SessionOutcome
/** Anything else, with the sentence the user is shown. */
data class Failed(val message: String) : SessionOutcome
}
/**
* Maps a return code onto the outcome, and builds the failure sentence when there is one.
*
* **The two message parts arrive as lambdas, deliberately.** `getAllLogsAsString` and
* `getFailStackTrace` are calls onto a native session, and only the failure arm needs either. Taking
* them by value would put both on the happy path of every successful conversion, which is a cost the
* shape this replaced did not have — the old code read them inside the `else` branch. That is the
* same reason [org.libremediaconverter.codec.AndroidDeviceCodecs.capabilitiesFrom] takes a
* `Sequence`: a seam should not change what runs when.
*
* A null [rc] is a real input rather than a defensive one — `getReturnCode()` is nullable, and a
* session killed before it reported anything has none. It is neither success nor cancellation, so
* it fails, and the sentence says `null` where the number would be.
*/
internal fun sessionOutcome(
rc: ReturnCode?,
prefix: String,
failStackTrace: () -> String?,
logTail: () -> String?,
): SessionOutcome = when {
ReturnCode.isSuccess(rc) -> SessionOutcome.Success
ReturnCode.isCancel(rc) -> SessionOutcome.Cancelled
else -> SessionOutcome.Failed(
"$prefix failed (${rc?.value}): " + failStackTrace().orEmpty().ifBlank { logTail().orEmpty() },
)
}
@@ -46,26 +46,48 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
// The contract's MIME type comes from the finished job rather than from a literal: some
// providers rewrite a document's extension to match it, so naming MP4 for a join that is not
// one can hand the user a file the extension lies about. Remembered against that type so the
// launcher re-registers only when it actually changes.
val destinationMime = (state as? JoinState.Joined)?.mimeType ?: ConcatWorker.DEFAULT_FORMAT.mimeType
// one can hand the user a file the extension lies about. Through pendingSave() rather than a
// cast to Joined, so a retry after a failed save opens with the type its first attempt used.
// Remembered against that type so the launcher re-registers only when it actually changes.
val destinationMime = state.pendingSave()?.mimeType ?: ConcatWorker.DEFAULT_FORMAT.mimeType
val chooseDestination = rememberLauncherForActivityResult(
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
) { uri -> uri?.let(viewModel::save) }
JoinScreenContent(
state = state,
actions = JoinActions(
actions = joinActions(
viewModel = viewModel,
onPickInputs = { pickInputs.launch(arrayOf("video/*")) },
onJoin = viewModel::join,
onCancel = viewModel::cancel,
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
onReset = viewModel::reset,
),
modifier = modifier,
)
}
/**
* Which of the ViewModel's methods each affordance on the join screen calls.
*
* The join-side twin of `converterActions`, and the transposition risk here is worse: **three**
* `() -> Unit` bindings rather than two. `onJoin`, `onCancel` and `onReset` are mutually
* interchangeable as far as the compiler is concerned, so a Join button that cancels, or a Cancel
* button that starts the job, is a swap nothing but a test would catch.
*
* See `converterActions` for why the launcher-backed actions stay parameters.
*/
@UnstableApi
internal fun joinActions(
viewModel: JoinViewModel,
onPickInputs: () -> Unit,
onSave: (suggestedName: String) -> Unit,
): JoinActions = JoinActions(
onPickInputs = onPickInputs,
onJoin = viewModel::join,
onCancel = viewModel::cancel,
onSave = onSave,
onReset = viewModel::reset,
)
/**
* Everything [JoinScreenContent] can ask for, in one value.
*
@@ -226,13 +248,32 @@ internal fun JoinScreenContent(state: JoinState, actions: JoinActions, modifier:
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = actions.onReset,
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.START_OVER),
) { Text("Start over") }
val retry = s.retry
if (retry == null) {
// Nothing staged, so "Start over" is all there is and stays primary.
Button(
onClick = actions.onReset,
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.START_OVER),
) { Text("Start over") }
} else {
Button(
onClick = { actions.onSave(retry.suggestedName) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.RETRY_SAVE),
) { Text("Try saving again") }
// Start over still deletes the carried file, for the reason the
// converter screen writes out next to the same pair of buttons:
// the delete is a choice only once the alternative is on screen.
OutlinedButton(
onClick = actions.onReset,
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
) { Text("Start over") }
}
}
}
}
@@ -5,6 +5,7 @@ import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.CoroutineDispatcher
@@ -18,7 +19,10 @@ import kotlinx.coroutines.withContext
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.convert.InputQuery
import org.libremediaconverter.convert.PendingSave
import org.libremediaconverter.convert.SAVE_FAILED_MESSAGE
import org.libremediaconverter.convert.STAGED_FILE_GONE_MESSAGE
import org.libremediaconverter.convert.ScreenOwnership
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.JobTags
@@ -27,6 +31,108 @@ import org.libremediaconverter.work.jobSnapshots
import java.io.File
import java.util.UUID
/**
* One update about a running join, as WorkManager last reported it.
*
* The join-side twin of `ConversionUpdate`, and deliberately the same shape: this pair of seams is
* one refactor done twice, and letting them diverge would make the two flows harder to compare than
* the duplication costs. There is no `progressPercent` here because `ConcatWorker` publishes none —
* a join is indeterminate.
*/
internal data class JoinUpdate(val state: WorkInfo.State, val runAttemptCount: Int, val outputData: Data)
/**
* What the join screen should show, given what WorkManager last said about the job.
*
* The join-side twin of `conversionStateFrom`, extracted for the same reason and with the same two
* exclusions: the ownership check stays at the call site, and this takes no responsibility for the
* staged file. See that function's KDoc for the argument in full.
*
* Five arms had never been chosen by any test before this was cut out, because a real `ConcatWorker`
* only ever produces a terminal state with well-formed output.
*
* @param cancelled where a cancellation lands, which differs for a reattached job — see [observe].
*/
@UnstableApi
internal fun joinStateFrom(update: JoinUpdate, inputs: List<InputFile>, cancelled: JoinState): JoinState =
when (update.state) {
// BLOCKED is a job waiting on a prerequisite, which the user has nothing to do about and
// nothing useful to be told about. It reads as "starting", like a fresh ENQUEUED.
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
// ENQUEUED after a run means a retry is pending -- the same rule, and the same reasoning, as
// the convert side. See `conversionStateFrom`.
WorkInfo.State.ENQUEUED ->
if (update.runAttemptCount > 0) {
JoinState.Waiting(inputs)
} else {
JoinState.Joining(inputs)
}
WorkInfo.State.SUCCEEDED -> joinedFrom(update.outputData)
// A worker that dies before it can report anything leaves no output data at all, and an
// exception's message can be an empty string. Both would read as a failure with nothing said,
// so blank falls back like missing does.
WorkInfo.State.FAILED -> JoinState.Failed(
update.outputData.getString(ConcatWorker.KEY_ERROR)
?.takeIf { it.isNotBlank() }
?: ConcatWorker.GENERIC_FAILURE_MESSAGE,
)
WorkInfo.State.CANCELLED -> cancelled
}
/**
* The `SUCCEEDED` arm. A join that reported success and named no file has nothing to offer.
*/
@UnstableApi
private fun joinedFrom(outputData: Data): JoinState {
val path = outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
?: return JoinState.Failed(JOINED_WITHOUT_A_FILE_MESSAGE)
return JoinState.Joined(
staged = File(path),
strategy = strategyFrom(outputData.getString(ConcatWorker.KEY_STRATEGY)),
// A join enqueued before the worker reported these carries neither, and the fallback is
// the format such a job really used -- ConcatWorker.request has always defaulted to it,
// and the join screen has never offered a choice.
suggestedName = outputData.getString(ConcatWorker.KEY_SUGGESTED_NAME)
?.takeIf { it.isNotBlank() }
?: ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT),
mimeType = outputData.getString(ConcatWorker.KEY_MIME_TYPE)
?.takeIf { it.isNotBlank() }
?: ConcatWorker.DEFAULT_FORMAT.mimeType,
)
}
/**
* The strategy a finished join reported, or [ConcatStrategy.REENCODE] when it named none.
*
* **Looked up rather than `valueOf`, and that is a fix rather than a style choice.** `valueOf`
* throws `IllegalArgumentException` on a name this build does not define, and this runs inside a
* `viewModelScope` collect with no handler -- so the throw does not become a `Failed` state, it
* takes the process down.
*
* Reachable for the reason `WorkerEnumFallbackTest` and `JobTags` are both written on: WorkManager
* keeps finished work for about a week, so a downgrade or a rollback hands this build a job
* enqueued by another one. `ConcatWorker` writes `result.strategy.name` into the output `Data`, so
* a build that added a third strategy would leave this one crashing on its own completed joins.
*
* `ConcatWorker.kt` already made exactly this change for `KEY_FORMAT`, and says why in as many
* words: *"Looked up rather than `valueOf` … a format name this build does not define used to throw
* past the catch."* The same read on this side had not been changed with it.
*
* REENCODE is the safe default rather than an arbitrary one: it is the answer for inputs that do
* not match, so a job whose strategy cannot be read is described as the more conservative of the
* two rather than being claimed as a lossless stream copy.
*/
private fun strategyFrom(name: String?): ConcatStrategy =
ConcatStrategy.entries.firstOrNull { it.name == name } ?: ConcatStrategy.REENCODE
/** A join that reported success and named no file. There is nothing to offer the user to save. */
internal const val JOINED_WITHOUT_A_FILE_MESSAGE: String =
"Joining reported success but produced no file."
sealed interface JoinState {
data object Idle : JoinState
data class Ready(val inputs: List<InputFile>) : JoinState
@@ -44,7 +150,30 @@ sealed interface JoinState {
val mimeType: String,
) : JoinState
data class Saved(val displayName: String) : JoinState
data class Failed(val message: String) : JoinState
/**
* The join, or the save that followed it, could not be finished.
*
* [retry] is non-null for exactly one cause, and for the same reason as on
* `ConversionState.Failed`: a [JoinViewModel.save] whose copy to the user's destination threw
* keeps the staged file, and this is what lets the screen offer it again. Every other failure
* leaves it null — a join that died produced no output, and a save that found the file gone
* has nothing left to save.
*/
data class Failed(val message: String, val retry: PendingSave? = null) : JoinState
}
/**
* The staged output a save would target from this state, or null when there is nothing to save.
*
* The join tab's half of `ConversionState.pendingSave`, and it exists for the same reason: `save`
* and `JoinScreen`'s `CreateDocument` registration both have to answer this question, and answering
* it twice is how a retry ends up opening the dialog with a type the finished job never chose.
*/
internal fun JoinState.pendingSave(): PendingSave? = when (this) {
is JoinState.Joined -> PendingSave(staged, suggestedName, mimeType)
is JoinState.Failed -> retry
else -> null
}
@UnstableApi
@@ -52,6 +181,15 @@ class JoinViewModel @JvmOverloads constructor(
app: Application,
/** Where [reset] runs its delete. See the same parameter on `ConversionViewModel`. */
private val cleanupDispatcher: CoroutineDispatcher = Dispatchers.IO,
/**
* Where the metadata query behind a pick runs. See the same parameter on `ConversionViewModel`.
*
* The join side had no such seam, and the gap was not cosmetic: the one write `onInputsPicked`
* makes lands *after* this hop, so a test that wants to ask what happens while a pick is still
* in flight had no way to hold one there. Issue #49's race is exactly that question, and it
* went unasked on this side for as long as the dispatcher was a literal.
*/
private val pickDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : AndroidViewModel(app) {
private val workManager = WorkManager.getInstance(app)
@@ -66,13 +204,28 @@ class JoinViewModel @JvmOverloads constructor(
private var observer: Job? = null
private var activeWorkId: UUID? = null
/**
* Who is allowed to write to this screen -- see [ScreenOwnership], which carries the rule and
* the reason cancelling the superseded coroutine is not one.
*
* The convert side had issue #49 reported against it four times in two days; this side has the
* identical shape and was never reported, because nothing was watching. Every write below that
* lands after a suspension point is guarded: the one in [onInputsPicked] and the one in
* [observe]. [save] is the one left out, deliberately and with the same limit its counterpart
* in `ConversionViewModel` spells out: nothing can overwrite what it writes, but it can still
* land on top of a [reset] taken while its copy was in flight. Which way that should go is a
* question about the save screen rather than about this race -- issue #123.
*/
private val ownership = ScreenOwnership()
/**
* The staged output this ViewModel is responsible for deleting.
*
* Held here rather than read back out of [_state] for the same reason as in
* `ConversionViewModel`: a failed [save] lands on [JoinState.Failed], which carries a
* message and no file, so the state machine cannot answer this on the one path that
* most needs it.
* `ConversionViewModel`, and still held here now that [JoinState.Failed] carries a
* [PendingSave] after a failed [save]: that handle is a view for the screen to offer a retry
* through, this one is the single reference [reset] deletes through, and keeping the two
* apart is what stops a second owner of the file appearing.
*/
private var pendingStaged: File? = null
@@ -91,6 +244,10 @@ class JoinViewModel @JvmOverloads constructor(
* the rules about which job and why.
*/
private fun reattach() {
// Read before the launch, and before the query it is about to suspend in: this is the
// claim the answer belongs to. Reading it on the far side of the query would read whatever
// superseded it, which is the bug rather than the check for it.
val token = ownership.current
viewModelScope.launch {
val reattachment = Reattachment.choose(
workManager.jobSnapshots(
@@ -100,8 +257,15 @@ class JoinViewModel @JvmOverloads constructor(
) ?: return@launch
// The query suspends, so the user may have picked files or started a join in the
// meantime. Theirs wins. No suspension point between this check and the assignment
// below, and both run on the main dispatcher, so nothing can interleave.
// meantime. Theirs wins.
//
// This catches a pick that has already *landed*, and only that. It used to claim there
// was "no suspension point between this check and the assignment below", which was the
// opposite of what happens: there is no assignment below, only observe(), which
// launches a separate coroutine that cannot write until its `collect` resumes. The
// check happens at one moment and the write lands at another, with a whole pick able
// to fit in between -- issue #49. The token observe() carries is what holds that line;
// see [ScreenOwnership].
if (_state.value !is JoinState.Idle || activeWorkId != null) return@launch
// Joins used to stage under one constant name, so two finished joins always reported
@@ -121,19 +285,29 @@ class JoinViewModel @JvmOverloads constructor(
InputFile(Uri.EMPTY, "", sizeBytes = null)
}
activeWorkId = reattachment.job.id
observe(reattachment.job.id, inputs, cancelled = JoinState.Idle)
observe(reattachment.job.id, inputs, cancelled = JoinState.Idle, token = token)
}
}
/**
* The tap is the claim, which is why it is taken here rather than inside the `launch` -- and
* above the early return, so the refusal below is covered by it too. A claim made in the
* coroutine is only immediate while `Dispatchers.Main.immediate` happens to run it inline, and
* a deferred claim leaves exactly the gap this closes.
*/
fun onInputsPicked(uris: List<Uri>) {
val token = ownership.claim()
if (uris.size < 2) {
_state.value = JoinState.Failed("Pick at least two files to join.")
_state.value = JoinState.Failed(ConcatWorker.TOO_FEW_INPUTS_MESSAGE)
return
}
viewModelScope.launch {
val files = withContext(Dispatchers.IO) {
val files = withContext(pickDispatcher) {
uris.map { InputQuery.describe(getApplication(), it) }
}
// Guarded like every other write that lands after a hop: two picks in quick succession
// suspend here together, and the slower one would otherwise land last.
if (!ownership.stillHeldBy(token)) return@launch
_state.value = JoinState.Ready(files)
}
}
@@ -147,10 +321,13 @@ class JoinViewModel @JvmOverloads constructor(
// did answer would hand the space check a lower bound it would read as a total.
totalBytes = InputQuery.total(inputs.map { it.sizeBytes }),
)
// Tapping Join claims the screen for this job, superseding any reattachment that has not
// finished asking.
val token = ownership.claim()
activeWorkId = request.id
workManager.enqueue(request)
_state.value = JoinState.Joining(inputs)
observe(request.id, inputs)
observe(request.id, inputs, token = token)
}
/**
@@ -158,62 +335,38 @@ class JoinViewModel @JvmOverloads constructor(
* files, ready to join again. For one picked up by [reattach] there are no picked files —
* what that job holds are URIs granted to a process that no longer exists — so it lands on
* Idle rather than offering to re-join files nothing can open.
* @param token the claim this observation belongs to. Nothing here can write until `collect`
* has resumed with a `WorkInfo`, which is some time after the caller decided to observe, so
* the claim is checked again at the last possible moment rather than trusted from then. See
* [ScreenOwnership], and issue #49.
*/
private fun observe(id: UUID, inputs: List<InputFile>, cancelled: JoinState = JoinState.Ready(inputs)) {
private fun observe(
id: UUID,
inputs: List<InputFile>,
cancelled: JoinState = JoinState.Ready(inputs),
token: Long,
) {
observer?.cancel()
observer = viewModelScope.launch {
workManager.getWorkInfoByIdFlow(id).collect { info ->
if (info == null) return@collect
_state.value = when (info.state) {
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
WorkInfo.State.ENQUEUED ->
if (info.runAttemptCount > 0) {
JoinState.Waiting(inputs)
} else {
JoinState.Joining(inputs)
}
WorkInfo.State.SUCCEEDED -> {
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
val strategy = info.outputData.getString(ConcatWorker.KEY_STRATEGY)
?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
if (path == null) {
JoinState.Failed("Joining reported success but produced no file.")
} else {
val staged = File(path)
// Take responsibility for the file at the same moment the state
// starts referring to it, so the two cannot disagree.
pendingStaged = staged
JoinState.Joined(
staged = staged,
strategy = strategy,
// A join enqueued before the worker reported these carries
// neither, and the fallback is the format such a job really
// used -- ConcatWorker.request has always defaulted to it, and
// the join screen has never offered a choice.
suggestedName = info.outputData
.getString(ConcatWorker.KEY_SUGGESTED_NAME)
?.takeIf { it.isNotBlank() }
?: ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT),
mimeType = info.outputData
.getString(ConcatWorker.KEY_MIME_TYPE)
?.takeIf { it.isNotBlank() }
?: ConcatWorker.DEFAULT_FORMAT.mimeType,
)
}
}
// A worker that dies before it can report anything leaves no output data at
// all, and an exception's message can be an empty string. Both would read as
// a failure with nothing said, so blank falls back like missing does.
WorkInfo.State.FAILED -> JoinState.Failed(
info.outputData.getString(ConcatWorker.KEY_ERROR)
?.takeIf { it.isNotBlank() }
?: "Joining failed.",
)
WorkInfo.State.CANCELLED -> cancelled
}
// Ahead of the `when`, not merely ahead of the assignment: the SUCCEEDED branch
// takes ownership of the staged file, and a superseded observation must not do
// that either.
if (!ownership.stillHeldBy(token)) return@collect
val next = joinStateFrom(
JoinUpdate(
state = info.state,
runAttemptCount = info.runAttemptCount,
outputData = info.outputData,
),
inputs = inputs,
cancelled = cancelled,
)
// Read off the result rather than assigned inside the mapping -- see the same
// three lines in ConversionViewModel for why that is the better half of the swap.
if (next is JoinState.Joined) pendingStaged = next.staged
_state.value = next
}
}
}
@@ -229,29 +382,38 @@ class JoinViewModel @JvmOverloads constructor(
* join offered by reattachment was last seen during a tag query that may be hours old, and
* `cacheDir` is reclaimed by the OS and swept by this app. Without it the file's absence
* reached the screen as a raw ENOENT path.
*
* Reached from [JoinState.Joined] and again from a [JoinState.Failed] an earlier save left
* carrying its file; [pendingSave] is what makes those the same call.
*/
fun save(destination: Uri) {
val joined = _state.value as? JoinState.Joined ?: return
if (!joined.staged.isFile) {
val pending = _state.value.pendingSave() ?: return
if (!pending.staged.isFile) {
// No retry handle -- the file it would offer again is the one that has gone.
_state.value = JoinState.Failed(STAGED_FILE_GONE_MESSAGE)
return
}
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
publisher.publish(joined.staged, destination)
joined.staged.delete()
publisher.publish(pending.staged, destination)
pending.staged.delete()
}
}.onSuccess {
// publish() already deleted it; nothing left to clean up.
pendingStaged = null
_state.value = JoinState.Saved(joined.suggestedName)
_state.value = JoinState.Saved(pending.suggestedName)
}.onFailure { e ->
// Deliberately NOT cleared -- see the same branch in ConversionViewModel.
// A failed save can leave the staged file as the only copy of the work, so
// it is left for a later reset() or for the sweep to collect once its age
// makes it certain nobody is coming back for it.
_state.value = JoinState.Failed(e.message ?: "Could not save the file.")
//
// `pending` travels on the state so the screen can offer the file again rather
// than leaving "Start over" -- which deletes it -- as the only thing on offer.
// Passing `pending` rather than rebuilding it is what keeps a retry that fails
// again on a carrying Failed instead of a bare one.
_state.value = JoinState.Failed(e.message ?: SAVE_FAILED_MESSAGE, pending)
}
}
}
@@ -261,8 +423,16 @@ class JoinViewModel @JvmOverloads constructor(
*
* Best effort, not a guarantee: the delete is cancelled with [viewModelScope] if the
* Activity finishes first. `OutputPublisher.sweepStaging` is the backstop.
*
* It deletes from a [JoinState.Failed] carrying a [PendingSave] too, deliberately and for the
* reason `ConversionViewModel.reset` writes out: the screen offers "Try saving again" above
* this button, so deletion is what the user chose rather than all this state could do.
*/
fun reset() {
// Start over is a claim like any other. The cancel below is a request honoured at the next
// suspension point, so a collector already on its way to a write has nothing left to
// honour it at; the claim is what stops that write landing on top of Idle.
ownership.claim()
observer?.cancel()
observer = null
activeWorkId = null
@@ -129,9 +129,25 @@ object ContainerCapabilities {
}
}
if (spec.videoCodec == VideoCodec.NONE && spec.audioCodec == AudioCodec.NONE) {
// Two faces of one rule: the output would carry no tracks at all.
//
// The first is visible in the spec alone — NONE on both axes. The second only emerges once
// the spec meets the probe, because [CopyPlanner] drops a video track the *input* does not
// have no matter which codec was named for it, so "H.265 + no audio" on an MP3 plans to
// (Drop, Drop) exactly as "None + None" does. Asking the spec alone answered the first and
// missed the second, and the miss was not cosmetic: `EditedMediaItem.Builder` refuses that
// composition with IllegalStateException("Audio and video cannot both be removed"), on
// Transformer's own thread, where the user would have seen a dead app rather than a reason.
if (spec.audioCodec == AudioCodec.NONE && (spec.videoCodec == VideoCodec.NONE || !probe.hasVideo)) {
return Validation.Invalid(
"This would produce an empty file — keep at least one track.",
if (spec.videoCodec == VideoCodec.NONE) {
"This would produce an empty file — keep at least one track."
} else {
// Names both halves. "No video track" alone reads as though the video setting
// were the only thing wrong, and the user would fix that and still be stuck.
"This file has no video track, so turning the audio off too would produce an " +
"empty file."
},
suggestions(
// Ask for both tracks back, then let repair settle what this container and
// this input can actually give.
@@ -162,7 +178,12 @@ object ContainerCapabilities {
if (!probe.hasVideo) {
return Validation.Invalid(
"This file has no video track to copy.",
listOf(spec.copy(videoCodec = VideoCodec.NONE)),
// Dropping the video is the right shape of answer, but it is only half of one:
// `spec.copy(videoCodec = NONE)` is valid exactly when the audio axis already
// happened to be fine, and refused otherwise — a Vorbis or PCM source into MP4,
// an MP3 into WebM. Handing it to the shared path repairs both axes and drops
// anything that still fails, so the chip cannot lead to a second error.
suggestions(spec.copy(videoCodec = VideoCodec.NONE), probe, exclude = spec),
)
}
val source = CodecNames.videoFromName(probe.videoCodec)
@@ -284,8 +305,12 @@ object ContainerCapabilities {
private fun repairVideo(spec: OutputSpec, probe: InputProbe): VideoCodec {
val container = spec.container
if (spec.videoCodec == VideoCodec.NONE || !container.canHoldVideo) return VideoCodec.NONE
// There is no video track to make one out of, so naming a codec would be a suggestion
// [CopyPlanner] drops on the floor. It also read as a non-sequitur: before this line, the
// repair offered for an MP3 was "H.264", the first codec MP4 happens to encode.
if (!probe.hasVideo) return VideoCodec.NONE
val source = CodecNames.videoFromName(probe.videoCodec).takeIf { probe.hasVideo }
val source = CodecNames.videoFromName(probe.videoCodec)
val copyable = source != null && accepts(container, source, CodecMode.COPY)
return when {
@@ -45,6 +45,31 @@ object TestTags {
const val SAVE_FILE: String = "action.saveFile"
/**
* The retry a `Failed` offers after a save that threw, on both screens.
*
* Its own tag rather than [SAVE_FILE], because the two are different claims about the screen.
* [SAVE_FILE] is the first attempt from a finished job; this one may appear only where a staged
* file survived a failed save. Sharing a tag would collapse "a transcode failure offers nothing
* to save" and "a failed save offers the file again" into one query, and that first assertion
* is the one stopping a Save button from appearing where there is nothing to save.
*/
const val RETRY_SAVE: String = "action.retrySave"
/**
* The adaptive shell around both screens -- `AppRoot`'s two layouts.
*
* Named because there is no other way to tell them apart from a test. Both render the same two
* destinations with the same labels and the same selection state, so every assertion that could
* be written without these tags is satisfied by either layout, and transposing the two bodies
* passed the whole suite. Exactly one of the two exists at a time, which is what makes
* `assertExists` / `assertDoesNotExist` on this pair a statement about the width class.
*/
object Shell {
const val NAVIGATION_RAIL: String = "shell.navigationRail"
const val NAVIGATION_BAR: String = "shell.navigationBar"
}
/** `ConverterScreen`. */
object Converter {
const val CHOOSE_FILE: String = "converter.chooseFile"
@@ -25,8 +25,18 @@ private val LightColorScheme = lightColorScheme(
* Material 3 theme.
*
* Dynamic color (Material You) needs API 31+; minSdk is 33, so it is available
* unconditionally and no version guard is required. It stays switchable so users can
* opt back to the brand palette.
* unconditionally and no version guard is required.
*
* [dynamicColor] has no caller. `MainActivity` is the single call site and takes the
* default, so the parameter is always `true`, the two dynamic branches always win, and
* [DarkColorScheme] and [LightColorScheme] are dead: nothing in the app can opt back to the
* brand palette. `ThemeColorSchemeTest` reaches those two branches only by passing
* [dynamicColor] explicitly -- a test doing it, not a feature.
*
* That is known rather than an oversight. #68 holds the choice between adding a switch,
* deleting the dead branches together with the template palette, and replacing that palette
* first; it is undecided, so nothing here should be read as a promise that any of them
* happens.
*/
@Composable
fun LibreMediaConverterTheme(
@@ -8,6 +8,7 @@ import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkerParameters
import androidx.work.hasKeyWithValueOfType
import androidx.work.workDataOf
@@ -15,7 +16,6 @@ import kotlinx.coroutines.CancellationException
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.InputQuery
import org.libremediaconverter.convert.StagingNames
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.OutputFormat
/**
@@ -28,6 +28,10 @@ import org.libremediaconverter.model.OutputFormat
* Progress is not reported. FFmpeg's statistics callback gives a timestamp against a
* single input's duration, which is meaningless once several files are being
* concatenated; showing a fabricated percentage would be worse than showing none.
*
* Enqueued as **expedited** work for the same reasons, and with the same caveats, as
* [ConversionWorker] — its class KDoc carries both, and a join is user-initiated in exactly the
* way a conversion is.
*/
@UnstableApi
class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
@@ -37,9 +41,9 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
override suspend fun doWork(): Result {
val uris = inputData.getStringArray(KEY_INPUT_URIS)?.map(Uri::parse)
?: return Result.failure(workDataOf(KEY_ERROR to "No input files."))
?: return Result.failure(workDataOf(KEY_ERROR to NO_INPUTS_MESSAGE))
if (uris.size < 2) {
return Result.failure(workDataOf(KEY_ERROR to "Pick at least two files to join."))
return Result.failure(workDataOf(KEY_ERROR to TOO_FEW_INPUTS_MESSAGE))
}
// Absent, not zero, when the picker could not size every input -- see the same read in
// ConversionWorker and InputQuery for why the two are no longer one number.
@@ -69,15 +73,12 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
// which is where a WorkManager restart after process death always begins -- used to
// throw straight past this catch, taking the retry, the error message and the delete
// with it. See ConversionWorker.doWork and FailureOutcome.
setForeground(
ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true),
ConversionForegroundType.current(),
),
)
//
// Posted through getForegroundInfo() rather than built here a second time -- see that
// override, and its twin in ConversionWorker.
setForeground(getForegroundInfo())
val result = ConcatEngine(applicationContext).join(uris, staged, format)
val result = ConversionDependencies.concat(applicationContext).join(uris, staged, format)
Result.success(
workDataOf(
KEY_OUTPUT_PATH to staged.absolutePath,
@@ -107,7 +108,7 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
}
FailureOutcome.FAIL -> {
Log.e(TAG, "Joining failed.", e)
Result.failure(workDataOf(KEY_ERROR to (e.message ?: "Joining failed.")))
Result.failure(workDataOf(KEY_ERROR to (e.message ?: GENERIC_FAILURE_MESSAGE)))
}
}
}
@@ -130,13 +131,69 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
return publisher.hasSpaceFor(bytes)
}
override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, "Joining files", 0, indeterminate = true),
ConversionForegroundType.current(),
)
/**
* The notification a starting join posts, and now the only definition of it.
*
* WorkManager's hook for expedited work, which **will not call this on any device this app
* supports** — see [ConversionWorker.getForegroundInfo] for the measurement and for why
* `setExpedited` alone would have left these lines exactly as cold as they were. What makes
* them live is [doWork] posting this instead of building its own copy.
*
* It counts the inputs itself rather than being handed the number, so that it is still answerable
* before [doWork] has parsed anything — which is the contract WorkManager's own caller wants.
* The count is read from the same key, so the two cannot disagree. The `?: 0` arm is
* unreachable and named rather than covered: [doWork] refuses a job with no URI array several
* lines above this call, and nothing else calls it. It is the shape `docs/coverage-read-findings.md`
* calls F4 — a second line of defence that cannot be provoked.
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
val inputCount = inputData.getStringArray(KEY_INPUT_URIS)?.size ?: 0
return ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, joiningTitle(inputCount), 0, indeterminate = true),
ConversionForegroundType.current(),
)
}
companion object {
/**
* What the user is told when a join arrives with fewer than two inputs.
*
* Shared with `JoinViewModel`, which refuses the same condition one layer up so the picker
* can answer without enqueueing anything. Two copies of this sentence existed before, and
* only the one here was pinned by a test (#139) — so the wording could drift on the screen
* without a single test noticing, for one message the user sees from one condition.
*
* Here rather than in the ViewModel because the rule is the worker's: `request(...)` takes
* a `List<Uri>` and checks nothing about its length, so this is the guard that always runs.
*/
/**
* A job carrying no input array at all -- a downgrade, or a queue entry from a build that
* spelled the key differently.
*
* A constant rather than the literal it was, for the convention #158 established: a message
* the user can see is named once, so a test asserts the same string the worker writes
* rather than a copy of it that can drift.
*/
const val NO_INPUTS_MESSAGE: String = "No input files."
const val TOO_FEW_INPUTS_MESSAGE: String = "Pick at least two files to join."
/**
* The last resort when a join fails and the exception says nothing.
*
* Shared with `JoinViewModel`, whose `FAILED` arm falls back to the same sentence when the
* output `Data` carries no error at all — a worker killed before it could write one. The two
* are a chain rather than a coincidence: this is what the worker puts *in* `KEY_ERROR`, and
* that is what the ViewModel says when `KEY_ERROR` never arrived. The user cannot tell the
* two apart and should not have to, so they are one sentence.
*
* The `Log.e` above deliberately keeps its own literal. A log line has a different audience
* and carries the exception with it; coupling it to the user-facing wording would mean
* rewording the screen to change a log.
*/
const val GENERIC_FAILURE_MESSAGE: String = "Joining failed."
const val KEY_INPUT_URIS = "input_uris"
const val KEY_TOTAL_BYTES = "total_bytes"
const val KEY_FORMAT = "format"
@@ -167,6 +224,16 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
*/
fun outputNameFor(format: OutputFormat): String = "joined.${format.extension}"
/**
* What the progress notification says while a join runs.
*
* Named once, for the convention #158 established about strings the user can see. It was
* two strings until 2026-09-06 — `"Joining N files"` built inline in [doWork] and a
* countless `"Joining files"` in [getForegroundInfo] — for one notification that only ever
* had one job, and the copy nothing executed was free to drift from the one that did.
*/
fun joiningTitle(inputCount: Int): String = "Joining $inputCount files"
private const val NOTIFICATION_ID = 1002
private const val TAG = "ConcatWorker"
@@ -178,6 +245,10 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
*/
fun request(inputs: List<Uri>, totalBytes: Long?, format: OutputFormat = DEFAULT_FORMAT) =
OneTimeWorkRequestBuilder<ConcatWorker>()
// Expedited, exactly as ConversionWorker.request is and for the same reasons; that
// one's comment and class KDoc carry them. Nothing here sets an initial delay or a
// constraint, which is what makes it legal for `build()` to accept.
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag(JobTags.inputCount(inputs.size))
.setInputData(
Data.Builder()
@@ -8,6 +8,7 @@ import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkerParameters
import androidx.work.hasKeyWithValueOfType
import androidx.work.workDataOf
@@ -40,8 +41,28 @@ import java.io.File
* observe. That durability is what makes the six-hour foreground-service timeout
* recoverable instead of fatal.
*
* 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.
* Enqueued as **expedited** work, with `RUN_AS_NON_EXPEDITED_WORK_REQUEST`. This paragraph said
* the opposite until 2026-09-06 — "deliberately *not* used… the wrong shape for a multi-minute
* transcode" — and the quota it named does not reach a transcode the way it reads:
*
* - The quota belongs to the *JobScheduler* job, and `SystemJobInfoConverter:135` in
* work-runtime 2.11.2 sets `JobInfo.setExpedited(true)` only when `!isRetry && !isDelayed`.
* A retry is therefore scheduled exactly as every job is scheduled today.
* - A job the system stops mid-run does not get its answer from [FailureOutcome].
* `WorkerWrapper.interrupt` cancels the worker's coroutine with a `WorkerStoppedException`,
* which its `launch` resolves as `ResetWorkerStatus` — the worker's own `Result` is discarded
* and the work re-enqueued with backoff, whatever it returned. So a quota stop is a retry, and
* the `CancellationException` arm in [doWork] is what deletes the partial on the way through.
*
* What it buys is narrower than "conversions start sooner", and the narrowness is the honest part:
* `GreedyScheduler` starts unconstrained, undelayed work in-process the moment it is enqueued and
* carries no `expedited` branch at all, so a conversion begun from the open app runs exactly when
* it ran before — the common case does not move. The flag is for the job that has to go *through*
* JobScheduler because no process is left to start it: one still enqueued when the app died.
* `SystemJobScheduler.schedule` re-converts the spec every time it schedules, so such a job is
* expedited on the way back in, and a retried one is not. `RUN_AS_NON_EXPEDITED_WORK_REQUEST`
* rather than `DROP_WORK_REQUEST`: an invisible quota is no reason to throw a user's conversion
* away, and `SystemJobScheduler:198` degrades it to an ordinary job instead.
*
* That durability is not free, and the queue surviving is not the same as the job surviving.
* When WorkManager recovers a job after process death the app is by definition in the background,
@@ -60,7 +81,7 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
override suspend fun doWork(): Result {
val inputUri = inputData.getString(KEY_INPUT_URI)?.let(Uri::parse)
?: return Result.failure(workDataOf(KEY_ERROR to "No input file."))
val displayName = inputData.getString(KEY_DISPLAY_NAME) ?: "input"
val displayName = displayName()
// Absent, not zero, when nobody could say -- see InputQuery. `getLong(key, 0L)` is what
// made those two the same number, and `hasSpaceFor(0)` is only "is there 128 MB free".
val declaredSize = inputData
@@ -100,7 +121,10 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
// process death is. With it above the try that throw escaped doWork() entirely: no
// retry, no error in the output Data, and no staged.delete(). MediaProbe.probe below
// was outside for the same reason and had the same problem.
setForeground(foregroundInfo(displayName, percent = 0, indeterminate = true))
//
// Posted through getForegroundInfo() rather than built here a second time -- see that
// override for what the duplicate cost.
setForeground(getForegroundInfo())
// Through the seam rather than MediaProbe directly. The seam already existed for the
// ViewModel and the worker was the last caller bypassing it, which is why nothing on
@@ -313,7 +337,7 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
}
FailureOutcome.FAIL -> {
Log.e(TAG, "Conversion failed.", cause)
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: GENERIC_FAILURE_MESSAGE)))
}
}
@@ -339,8 +363,32 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
return OutputSpec(container, video, audio)
}
/**
* What this job's input is called, or [InputQuery.FALLBACK_DISPLAY_NAME] when nothing named it.
*
* One read rather than the two copies of `?: "input"` that [doWork] and [getForegroundInfo]
* each carried, and against `InputQuery`'s constant rather than a third literal of the same
* string: it is the same fallback the picker uses, and it reaches the save dialog as
* `input_converted.mp4`.
*/
private fun displayName(): String = inputData.getString(KEY_DISPLAY_NAME) ?: InputQuery.FALLBACK_DISPLAY_NAME
/**
* The notification a starting conversion posts, and now the only definition of it.
*
* This is WorkManager's hook for expedited work, and **it will not be called on any device
* this app supports.** `WorkForeground.kt:38` in work-runtime 2.11.2 opens with
* `if (!spec.expedited || Build.VERSION.SDK_INT >= 31) return`, that function is the library's
* only caller of `getForegroundInfoAsync()`, and `minSdk` is 33. So #252's premise — that
* enqueueing expedited work would make these lines live — is false, and `setExpedited` alone
* would have left them exactly as cold as the first instrumented coverage read found them.
*
* What makes them live is [doWork] posting *this* instead of building its own copy. The two
* were identical — same title, `percent = 0`, `indeterminate = true` — so one was a duplicate
* that could drift, and the one nothing executed is the one that would have drifted silently.
*/
override suspend fun getForegroundInfo(): ForegroundInfo = foregroundInfo(
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
displayName(),
percent = 0,
indeterminate = true,
)
@@ -352,6 +400,20 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
)
companion object {
/**
* The last resort when a conversion fails and the exception says nothing.
*
* Shared with `ConversionViewModel`, whose `FAILED` arm falls back to the same sentence when
* the output `Data` carries no error — a worker killed before it could write one, which a
* refused foreground start after a process restart produces. The two are a chain rather than
* a coincidence: this is what goes *into* `KEY_ERROR`, and that is what is said when
* `KEY_ERROR` never arrived. The user cannot tell those apart and should not have to.
*
* See [ConcatWorker.GENERIC_FAILURE_MESSAGE] for the join-side twin, and the note there
* about why the neighbouring `Log.e` keeps its own literal.
*/
const val GENERIC_FAILURE_MESSAGE: String = "Conversion failed."
const val KEY_INPUT_URI = "input_uri"
const val KEY_DISPLAY_NAME = "display_name"
const val KEY_SIZE_BYTES = "size_bytes"
@@ -402,6 +464,12 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
quality: QualityTier = QualityTier.FAST,
enginePreference: EnginePreference = EnginePreference.AUTO,
) = OneTimeWorkRequestBuilder<ConversionWorker>()
// Expedited, so the jobs that do go through JobScheduler are treated as the
// user-initiated work they are -- see the class KDoc for what that is and is not worth.
// Safe to set here and only because of what this builder does not do: `build()` refuses
// an expedited request carrying an initial delay or any constraint but network and
// storage, and none of the three is set below.
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag(JobTags.displayName(displayName))
// Neither the tag nor the Data entry is written for a size nobody knows. A `Data` has
// no null, so the absence of the key *is* the unknown — and a tag reading
@@ -0,0 +1,129 @@
package org.libremediaconverter
import androidx.activity.ComponentActivity
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* Which navigation affordance the shell actually renders, and which screen it actually shows.
*
* Assertion gaps rather than coverage gaps, both of them, and that is why they lasted.
* `AppRootRestorationTest` already drives `AppRoot` at `Compact` and `Expanded`, so JaCoCo is green
* on `useRail` -- but it asserts only that the *selected tab* survives recreation, through a stub
* `content` composable. Nothing anywhere queried for a rail or a bar, and nothing rendered the real
* screens. Two consequences, both measured before this file existed:
*
* - **Transposing the `NavigationRail` and `NavigationBar` bodies passed the entire suite.**
* - **Transposing `Content`'s two arms passed it too** -- a tablet showing the phone chrome, or the
* Convert tab opening the Join screen, and 546 tests with nothing to say about either.
*
* `AppRoot`'s own KDoc is why this matters more than it looks: from targetSdk 37 the app is resized
* and rotated whether or not it is ready, so the width class is not a preference, it is whatever
* the system hands over.
*
* ## Two things this needed that the rest of the suite does not
*
* **`createAndroidComposeRule`, not `createComposeRule`.** Rendering `AppRoot` with its *default*
* content reaches `ConverterScreen`'s `viewModel = viewModel()`, which needs a
* `ViewModelStoreOwner`; the plain rule supplies none. It works because both ViewModels are
* `@JvmOverloads constructor(app: Application, …)`, so `AndroidViewModelFactory` can build them,
* and because `app/build.gradle.kts` already puts `ui-test-manifest`'s `ComponentActivity` in the
* merged manifest the unit tests build against -- which that file says in terms.
*
* **Tags on the two bars.** They are in `TestTags`, applied inside `main`, for the reason that
* file's KDoc gives: a tag the test hands down proves only that the test set it.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class AdaptiveShellTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Before
fun setUp() {
val app = RuntimeEnvironment.getApplication()
installTestWorkManager(app, Data.EMPTY)
// The real screens are composed here, so their ViewModels are real too. Neither test is
// about probing or publishing; left alone they would reach the FFprobe loader and this
// machine's codec list, and decide things no assertion mentions.
ConversionDependencies.probe = { _, _ -> InputProbe() }
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `a phone gets the bottom bar and a tablet gets the rail`() {
setShell(WindowWidthSizeClass.Compact)
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertExists()
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertDoesNotExist()
}
@Test
fun `an expanded window gets the rail`() {
setShell(WindowWidthSizeClass.Expanded)
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertExists()
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertDoesNotExist()
}
/**
* The width class no test had ever passed.
*
* `useRail` is `!= Compact`, so Medium takes the rail with Expanded. Narrowing it to
* `== Expanded` is a one-character change that breaks every tablet and unfolded foldable and
* nothing else -- and until this test, nothing in either source set used `Medium` at all.
*/
@Test
fun `a medium window is a rail window, not a phone`() {
setShell(WindowWidthSizeClass.Medium)
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertExists()
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertDoesNotExist()
}
/**
* The mapping every other test stubs out: which screen each destination actually opens.
*
* Matched on each screen's own "choose a file" affordance rather than on a title, because those
* tags are applied by the screens themselves -- so this fails if the destinations are
* transposed, and it fails for the right reason.
*/
@Test
fun `Convert opens the converter and Join opens the join screen`() {
setShell(WindowWidthSizeClass.Compact)
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertExists()
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).assertDoesNotExist()
composeRule.onNodeWithText(Destination.JOIN.label).performClick()
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).assertExists()
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertDoesNotExist()
}
/** [AppRoot] with its real content, which is the half nothing else composes. */
private fun setShell(width: WindowWidthSizeClass) {
composeRule.setContent { AppRoot(width) }
}
}
@@ -1,8 +1,8 @@
package org.libremediaconverter
import org.junit.Assert.assertEquals
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -10,7 +10,6 @@ import org.libremediaconverter.convert.StagingSweep
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.concurrent.TimeUnit
/**
* That process start actually sweeps.
@@ -23,8 +22,19 @@ import java.util.concurrent.TimeUnit
* output ever became a `Converted` state, a `reset()` whose delete was cancelled with the Activity.
*
* `onCreate()` is called again rather than a second Application being built: it is what the
* framework calls at process start, the scope it launches on is already there, and the first test
* below is what pins that the framework calls it on *this* class.
* framework calls at process start, and the scope it launches on is already there.
*
* **What this class stopped covering in #159, deliberately.** It used to open by asserting that
* `RuntimeEnvironment.getApplication()` is a [LibreMediaConverterApp] — that the manifest's
* `android:name` points here, so the sweep is code that actually runs. That assertion cannot exist
* on the JVM any more: `robolectric.properties` now names [TestLibreMediaConverterApp] for the
* whole suite, and an `application=` override replaces the manifest rather than being checked
* against it — `applicationInfo.className` reports the override too, measured. So the manifest is
* not merely unasserted here, it is unobservable from this source set, and a rewritten version of
* that test would have asserted the override against itself. **The manifest link is a device-only
* guarantee now**, and it was traded knowingly for the race that override fixes. The cast in
* [setUp] still fails if [TestLibreMediaConverterApp] stops extending the real class, which is a
* smaller claim than the one withdrawn.
*/
@RunWith(RobolectricTestRunner::class)
class AppStartSweepTest {
@@ -34,17 +44,35 @@ class AppStartSweepTest {
@Before
fun setUp() {
// The cast is an assertion in itself: Robolectric builds the Application named in the
// merged manifest, so this fails if `android:name` ever stops pointing here -- in which
// case the sweep below would be perfectly correct code that never runs.
app = RuntimeEnvironment.getApplication() as LibreMediaConverterApp
stagingDir = File(app.cacheDir, "conversions").apply { mkdirs() }
stagingDir.listFiles()?.forEach { it.delete() }
}
/**
* The property the whole substitution exists for, asserted directly rather than waited on.
*
* #159 is not "the sweep is slow", it is "the sweep is still running while some later test
* reads the directory". [TestLibreMediaConverterApp] answers that by finishing the sweep before
* `onCreate()` returns, and this is the only place that claim is checked -- every other test in
* the suite benefits from it silently and would go back to racing without saying why.
*
* Deterministic in the direction that matters: `Dispatchers.Unconfined` runs a `launch` whose
* body never suspends to completion inline, so this cannot flake green-to-red. Putting the test
* app back on `Dispatchers.IO` makes it a race that the assertion loses essentially every time,
* which is what a six-run suite comparison could not show -- at the rate #159 was observed at,
* a clean six-run arm is a coin flip.
*/
@Test
fun `the application the manifest starts is the one that sweeps`() {
assertEquals(LibreMediaConverterApp::class.java, RuntimeEnvironment.getApplication().javaClass)
fun `the sweep is finished before onCreate returns`() {
app.onCreate()
val sweep = app.startupSweep
assertNotNull("onCreate() started no sweep", sweep)
assertTrue(
"the JVM suite's sweep outlived onCreate(), so it is in flight during test bodies again",
sweep?.isCompleted == true,
)
}
@Test
@@ -64,35 +92,23 @@ class AppStartSweepTest {
app.onCreate()
awaitGone(abandoned)
// Joined rather than polled. `onCreate` publishes the sweep it started, so this waits for
// that exact sweep -- where a timed poll could not tell "swept" from "not started yet", and
// answered the second case by failing after ten seconds.
val sweep = app.startupSweep
assertNotNull("onCreate() started no sweep to wait for", sweep)
runBlocking { sweep?.join() }
assertTrue("process start left ${abandoned.name} in staging; nothing swept it", !abandoned.exists())
// The other half, and the one that says the sweep is a sweep rather than a
// `clearStaging()`: the directory is shared by the convert tab, the join tab and
// ConcatEngine's list file, so deleting everything could take a file from a running job.
assertTrue("a file written moments ago belongs to a live job", live.exists())
}
/**
* Waits for [file] to be deleted.
*
* The sweep runs on `Dispatchers.IO`, deliberately: it lists a directory and stats every entry
* on the path that decides how long the launcher icon stays unresponsive. So there is nothing
* to join, and the wait is a bounded poll — long enough for a directory listing, short enough
* that a sweep which never happens fails rather than hangs.
*/
private fun awaitGone(file: File) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(AWAIT_TIMEOUT_SECONDS)
while (System.nanoTime() < deadline) {
if (!file.exists()) return
Thread.sleep(POLL_INTERVAL_MS)
}
fail("process start left ${file.name} in staging; nothing swept it")
}
private fun stagedFile(name: String): File = File(stagingDir, name).apply { writeBytes(ByteArray(4096)) }
private companion object {
const val ONE_MINUTE_MS = 60L * 1000
const val AWAIT_TIMEOUT_SECONDS = 10L
const val POLL_INTERVAL_MS = 5L
}
}
@@ -0,0 +1,28 @@
package org.libremediaconverter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* The [LibreMediaConverterApp] the JVM suite runs, differing from it in exactly one thing: the
* startup sweep runs inline on the thread that builds the Application instead of on
* `Dispatchers.Unconfined`.
*
* **This is #159.** Robolectric builds an `Application` per test class that asks for one, and each
* one launches a sweep over the shared `<cacheDir>/conversions/`. Nothing joins them, so a test
* asserting about a staged file is racing however many sweeps the classes before it left in
* flight — `OutputPublisherStagingTest` being the one that lost, at roughly one local run in six
* once wave 4 added ten more Robolectric classes. Making the sweep finish before `onCreate()`
* returns removes the race for every test at once rather than asking each to opt in; 27 of the
* suite's 58 Robolectric classes touch that directory, so opting in was not a real option.
*
* `Dispatchers.Unconfined` is what makes it inline: `sweepStaging()` is a plain function, so an
* `Unconfined` `launch` runs it to completion before returning. The `SupervisorJob` is kept so this
* differs from production in the dispatcher alone — a sweep that throws is logged and swallowed
* here exactly as it is there, rather than taking Application construction down with it and failing
* every test in the class for an unrelated reason.
*/
class TestLibreMediaConverterApp : LibreMediaConverterApp() {
override val sweepScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
}
@@ -0,0 +1,98 @@
package org.libremediaconverter.ci
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
/**
* That a hung unit-test run still ends by itself, and still says why.
*
* `:app:testDebugUnitTest` had no timeout of any kind until #125 was filed. That ticket is a real
* Java-level deadlock between Room's `TransactionExecutor` and WorkManager's `SerialExecutorImpl`,
* reached through the WorkInfo flow the ViewModel collects, and one local run sat in it for 47
* minutes. Nothing inside the suite could break it: the deadlock is monitor contention, which is
* not interruptible, so it runs until something outside the JVM gives up.
*
* Two numbers in `app/build.gradle.kts` are what bound it now, and neither compiles, so nothing
* else would notice their removal:
*
* - `timeout.set(...)` on every `Test` task, which stops the forked test JVM.
* - the watchdog's `dumpAfterNanos`, which jstacks that JVM *before* the timeout kills it.
*
* The second is the one worth guarding hardest, and the one that most looks like stray config.
* Gradle's timeout kills without a thread dump, and the jstack -- with its "Found one Java-level
* deadlock" section naming both monitors -- is the only reason #125 could be described at all.
* The ordering between the two numbers is what makes it work: dump first, kill second. Reverse
* them, or delete the watchdog, and the suite still stops hanging but every hang from then on
* reports as a bare "Timeout has been exceeded" with nothing to read. Measured against a probe
* that hung one test: no test XML was written for the class that hung, so the hanging test itself
* gets no attribution from the report at all.
*
* The range on the timeout is not decoration either, and it is the half a future edit is most
* likely to get wrong. Below it, a healthy-but-slow runner trips the bound and a real signal
* becomes noise people learn to re-run through; above it, CI's 30-minute job cap fires first and
* the bound never gets to say anything.
*
* `ReleasePermissionTest` is the precedent and its caveat applies here too. This asserts the two
* numbers are present, sanely sized and correctly ordered. It cannot assert that the timeout
* fires -- that needs a hang, which is what the whole change exists to prevent. Refs #125.
*/
class HangBoundTest {
@Test
fun `every Test task is bounded, and bounded between the slow runner and the job cap`() {
assertTrue(
"app/build.gradle.kts sets its Test task timeout to ${timeoutMinutes}m, which is " +
"outside $SANE_MINUTES. Under that range a slow CI runner trips a bound meant for " +
"deadlocks -- the slowest observed passing run of the whole invocation was 90s. " +
"Over it, the Unit tests job's own 30-minute cap kills the job first and the " +
"timeout never reports. `null` means the line is gone or the block was rewritten, " +
"and without it #125's deadlock has nothing to stop it: monitor contention breaks " +
"no interrupt, so it runs until CI gives up and reports a timeout with no cause.",
timeoutMinutes in SANE_MINUTES,
)
}
@Test
fun `the thread dump is taken before the timeout kills the JVM it would dump`() {
assertTrue(
"app/build.gradle.kts takes its hang thread dump after ${dumpAfterMinutes}m but times " +
"the task out at ${timeoutMinutes}m, so the JVM is already dead when jstack runs " +
"and every future hang reports as a bare `Timeout has been exceeded`. The dump " +
"has to come first -- it is the only attribution a hanging test gets, since the " +
"test XML never names it.",
(dumpAfterMinutes ?: 0) < (timeoutMinutes ?: 0),
)
}
/** Minutes given to a whole `Test` task before Gradle stops the forked JVM. */
private val timeoutMinutes: Int?
get() = minutesIn("""timeout\.set\(Duration\.ofMinutes\((\d+)\)\)""")
/** Minutes the watchdog waits before jstacking the forked JVM. */
private val dumpAfterMinutes: Int?
get() = minutesIn("""val dumpAfterNanos = Duration\.ofMinutes\((\d+)\)""")
/**
* Read out of the build script rather than from a model: the numbers live in a Kotlin DSL block
* that no unit test can instantiate, and a scan that reports `null` when the shape changes is a
* better trade than not checking them at all.
*/
private fun minutesIn(pattern: String): Int? =
Regex(pattern).find(buildScript.readText())?.groupValues?.get(1)?.toInt()
/**
* Found by walking up rather than by a fixed relative path: Gradle's working directory for the
* unit tests is the module, but that is a default rather than a promise.
*/
private val buildScript: File
get() = generateSequence(File(".").absoluteFile) { it.parentFile }
.map { File(it, "app/build.gradle.kts") }
.firstOrNull { it.isFile }
?: error("could not find app/build.gradle.kts above ${File(".").absolutePath}")
private companion object {
/** Above the slowest observed passing run, below the Unit tests job's `timeout-minutes`. */
val SANE_MINUTES = 3..29
}
}
@@ -0,0 +1,67 @@
package org.libremediaconverter.ci
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
/**
* That the release job still holds the one permission it needs to publish.
*
* `build.yml`'s `release` job declares `contents: write`, and nothing was checking it. Deleting
* those two lines leaves actionlint clean and CodeQL silent — a *narrower* permission is not an
* alert — and the job is `if: startsWith(github.ref, 'refs/tags/v')`, so no pull request and no
* merge to `main` can exercise it. Measured: with the declaration removed, every gating check
* still passes. The first thing that would notice is a release failing to publish, at the moment
* someone is trying to cut one.
*
* The deletion also looks like tidying. A top-level `permissions: contents: read` now sits
* directly above it, so a reader could reasonably take the job-level block for a duplicate. It is
* an override, not a duplicate, and a comment saying so is not a check.
*
* `BackupExclusionsTest` is the precedent: a file that is configuration rather than code, load
* bearing, and unguarded because nothing compiles it.
*
* **What this pins, and what it does not.** It asserts the declaration exists in the `release`
* job's block. It cannot assert that a release actually publishes — that needs a tag push, which
* is the thing no PR can do. So this is a tripwire against silent removal, not proof the release
* path works.
*/
class ReleasePermissionTest {
@Test
fun `the release job declares the write permission it needs to publish`() {
val release = jobBlock("release")
assertTrue(
"build.yml's `release` job no longer declares `contents: write`. It is the only " +
"permission that lets the job create a release, the top-level block above it is " +
"`contents: read`, and nothing else in CI would catch this until a tag failed to " +
"publish. If the release moved elsewhere, delete this test deliberately.",
release.any { it.trimStart().startsWith("contents: write") },
)
}
/**
* The lines of one top-level job, from its ` <name>:` header to the next job at that indent.
*
* Line-based rather than parsed: the module has no YAML dependency, and adding one to read two
* lines would be a worse trade than a scan that fails loudly when the shape changes.
*/
private fun jobBlock(name: String): List<String> {
val lines = workflow.readLines()
val start = lines.indexOfFirst { it == " $name:" }
check(start >= 0) { "no ` $name:` job in ${workflow.path} — has the file been restructured?" }
val rest = lines.drop(start + 1)
val end = rest.indexOfFirst { it.matches(Regex("^ {2}[A-Za-z0-9_-]+:.*")) }
return if (end < 0) rest else rest.take(end)
}
/**
* Found by walking up rather than by a fixed relative path: Gradle's working directory for the
* unit tests is the module, but that is a default rather than a promise.
*/
private val workflow: File
get() = generateSequence(File(".").absoluteFile) { it.parentFile }
.map { File(it, ".github/workflows/build.yml") }
.firstOrNull { it.isFile }
?: error("could not find .github/workflows/build.yml above ${File(".").absolutePath}")
}
@@ -0,0 +1,201 @@
package org.libremediaconverter.codec
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.VideoCodec
import org.robolectric.RobolectricTestRunner
/**
* The rules `AndroidDeviceCodecs.probe()` applies to the platform's codec list.
*
* ## Why this is not a third run of the #86/#133 spike
*
* #86 closed `probe()` as device-bound. #133 re-opened the question with
* `ShadowMediaCodecList` in hand and closed it again, for a reason that was right about what it
* was answering: `MediaCodecInfoBuilder` "has no `setIsAlias` and no `setCanonicalName`, so the
* alias skip and the canonical-name dedup — the two things the class's KDoc calls out as easy to
* get wrong — are not reachable through it."
*
* **That objection is about the shadow.** It does not apply to a function that takes its own entry
* type, which is what `capabilitiesFrom` now does. The half #133 named as unreachable is the half
* this file spends most of its cases on.
*
* ## What made the seam worth cutting, which is not coverage
*
* The `runCatching` fallback logged *"assuming permissive"* and returned empty sets — and empty
* sets are **restrictive**: `"video/avc" in emptySet()` is `false`, so `canEncode` and `canDecode`
* both answer no and every job routes to FFmpeg. The code was right and the message described the
* opposite of it. That is pinned below, so whichever reading a future change takes, it has to say
* so out loud.
*
* Robolectric only because `capabilitiesFrom` logs what it found; the rules themselves are pure.
*/
@RunWith(RobolectricTestRunner::class)
class CodecEnumerationTest {
/**
* The alias skip, in the one arrangement where it is observable — and finding that arrangement
* is the whole of this test.
*
* A first attempt listed the alias *after* the codec it aliases and passed with the skip
* deleted, because `canonicalName` is shared and the dedup below catches the second entry
* either way. The two rules overlap, so a fixture that does not separate them tests neither.
*
* What separates them is **order**. `MediaCodecInfo.getCanonicalName()` on an alias returns the
* underlying codec's name, so an alias arriving first claims that name in `seen` and has its
* own `supportedTypes` credited — and then the real codec is dropped by the dedup. Without the
* alias skip the device is described by whichever entry the platform happened to list first.
*
* That also says what the rule is worth. With a `Set` accumulator, an alias declaring the same
* types as its codec changes nothing whichever order they arrive in; the skip earns its place
* only when the two disagree, which is exactly when believing the wrong one matters.
*/
@Test
fun `an alias listed before the codec it aliases does not describe the device`() {
val codecs = capabilities(
entry("c2.qti.avc.encoder", encoder = true, types = listOf(HEVC), alias = true),
entry("c2.qti.avc.encoder", encoder = true, types = listOf(AVC)),
)
assertTrue("the real codec's types are the device's", codecs.canEncode(VideoCodec.H264))
assertFalse(
"an alias must not be credited with types the codec it aliases never claimed",
codecs.canEncode(VideoCodec.H265),
)
}
@Test
fun `two entries sharing a canonical name are read once`() {
val codecs = capabilities(
entry("c2.qti.avc.encoder", encoder = true, types = listOf(AVC)),
entry("c2.qti.avc.encoder", encoder = true, types = listOf(HEVC)),
)
assertEquals(setOf(AVC), codecs.hardwareEncoders())
}
/**
* Both halves of the hardware predicate, one arm at a time.
*
* A vendor may declare a codec hardware-accelerated *and* software-only; the class KDoc is
* explicit that the first flag "cannot be tested for correctness", so the second is what stops
* a mislabelled software encoder being treated as the fast path.
*/
@Test
fun `an encoder counts as hardware only when it is accelerated and not software-only`() {
assertEquals(
setOf(AVC),
capabilities(entry("hw", encoder = true, accelerated = true, types = listOf(AVC))).hardwareEncoders(),
)
assertEquals(
emptySet<String>(),
capabilities(entry("sw", encoder = true, accelerated = false, types = listOf(AVC))).hardwareEncoders(),
)
assertEquals(
"a codec claiming both must not be trusted as hardware",
emptySet<String>(),
capabilities(
entry("both", encoder = true, accelerated = true, softwareOnly = true, types = listOf(AVC)),
).hardwareEncoders(),
)
}
/**
* Decoders are collected regardless of the hardware flags, and that asymmetry is the design.
*
* `canDecode` asks whether the platform can read the input at all — a software decoder answers
* that as well as a hardware one. `canEncode` asks whether the *fast path* exists, which is a
* different question and why only encoders are filtered.
*/
@Test
fun `a software decoder still counts as something the platform can read`() {
val codecs = capabilities(
entry(
"c2.android.avc.decoder",
encoder = false,
accelerated = false,
softwareOnly = true,
types = listOf(AVC),
),
)
assertTrue(codecs.canDecode("h264"))
}
@Test
fun `audio types are ignored on both sides`() {
val codecs = capabilities(
entry("aac.encoder", encoder = true, accelerated = true, types = listOf("audio/mp4a-latm")),
entry("aac.decoder", encoder = false, types = listOf("audio/mp4a-latm")),
)
assertEquals(emptySet<String>(), codecs.hardwareEncoders())
// Not "the platform cannot decode AAC" -- `canDecode` is asked about *video* codec names,
// and an unknown name is answered permissively. The point is that nothing audio reached
// either set.
assertTrue("an unknown name stays permissive", codecs.canDecode("something-nobody-named"))
}
/**
* The failure fallback, pinned as the restrictive answer it actually is.
*
* #194 decided this rather than assuming it: the code stays, the message changes. If a later
* change wants the permissive reading its old log line described, this test is what makes that
* a decision instead of a drift.
*/
@Test
fun `an enumeration that fails sends every job to FFmpeg`() {
val codecs = AndroidDeviceCodecs.capabilitiesFrom { error("MediaCodecList exploded") }
assertFalse("a failed enumeration must not claim a hardware encoder", codecs.canEncode(VideoCodec.H264))
assertFalse(codecs.canDecode("h264"))
assertEquals(emptySet<String>(), codecs.hardwareEncoders())
}
/**
* A list that throws partway keeps what it already read.
*
* This predates the seam — `runCatching` has always wrapped the iteration rather than a list
* built before it — and it is asserted here because the seam is where it could quietly have
* been lost. Taking a `List` instead of a `Sequence` would move the throw outside the loop and
* turn this partial answer into an empty one, with no test to notice.
*/
@Test
fun `codecs read before a failing entry are kept`() {
val codecs = AndroidDeviceCodecs.capabilitiesFrom {
sequence {
yield(entry("good", encoder = true, accelerated = true, types = listOf(AVC)))
error("the sixth codec's properties threw")
}
}
assertEquals(setOf(AVC), codecs.hardwareEncoders())
}
private fun capabilities(vararg entries: AndroidDeviceCodecs.Companion.CodecEntry) =
AndroidDeviceCodecs.capabilitiesFrom { entries.asSequence() }
private fun entry(
canonicalName: String,
encoder: Boolean,
accelerated: Boolean = true,
softwareOnly: Boolean = false,
alias: Boolean = false,
types: List<String>,
) = AndroidDeviceCodecs.Companion.CodecEntry(
canonicalName = canonicalName,
isAlias = alias,
isEncoder = encoder,
isHardwareAccelerated = accelerated,
isSoftwareOnly = softwareOnly,
supportedTypes = types,
)
private companion object {
const val AVC = "video/avc"
const val HEVC = "video/hevc"
}
}
@@ -7,6 +7,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.CodecNames
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.VideoCodec
/**
@@ -133,6 +134,38 @@ class CodecVocabularyTest {
* landed, a device with no HEVC decoder answered true for `x265` and Media3 was handed a job it
* could not do; now the router sends it to FFmpeg without spending the attempt.
*/
/**
* The sentinel is not just another unknown name, and the difference is the whole guard.
*
* `canDecode` ends `?: true` -- a name neither table knows keeps the permissive answer, because
* the app would rather try than refuse a file it might handle. `InputProbe.UNPARSEABLE` has to
* be the exception: the platform has *already* failed to parse the input, so there is nothing
* for a decoder to be permissive about, and waving it through spends a Media3 attempt on a job
* that cannot start.
*
* The `cinepak` line is what makes the sentinel line mean something. Without it, deleting the
* early return leaves this test green -- both names would fall through to the same `?: true`.
* The pair is the assertion.
*
* `DeviceCodecs.PERMISSIVE` carries the same rule and `ConversionRouterTest` pins its routing
* consequence. This is the implementation that runs on a device.
*/
@Test
fun `the unparseable sentinel is refused even where an unknown name is waved through`() {
val everything = AndroidDeviceCodecs.forTesting(
encoders = emptySet(),
decoders = setOf("video/avc", "video/hevc"),
)
assertFalse(
"the platform could not parse this input, so there is nothing to decode with",
everything.canDecode(InputProbe.UNPARSEABLE),
)
assertTrue(
"a merely unknown name still keeps the permissive answer",
everything.canDecode("cinepak"),
)
}
@Test
fun `a device without the decoder now says so for the aliases it used to wave through`() {
val hevcOnly = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/hevc"))
@@ -0,0 +1,180 @@
package org.libremediaconverter.convert
import android.app.Application
import androidx.media3.common.util.UnstableApi
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.libremediaconverter.work.JobTags
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* That `cancel()` cancels the job, on both screens.
*
* ## Why this was missing, which is the interesting part
*
* Both `cancel()` methods are one line — `activeWorkId?.let(workManager::cancelWorkById)` — and
* **JaCoCo reports every line of both as covered**. `SettingsEditsTest`'s
* `cancelling with no active job does nothing rather than throwing` runs the method, and its own
* comment names which half it drives: "`activeWorkId?.let(...)` -- the null side". The other side
* had never been entered, and `JoinViewModel.cancel()` had no test at all.
*
* So no line-level coverage filter could see this. What surfaces it is a method-level read —
* `mi=11, ci=7, mb=1, cb=1` on both — a covered method with an arm nothing takes. That is the
* second of the two filters #194 records, and this is the gap that argued for it.
*
* The affordance tests are not this. `ConverterStateAffordancesTest` and `JoinStateAffordancesTest`
* click `TestTags.CANCEL` and assert the *action* fires into a stub; `ScreenWiringTest` asserts the
* action calls `viewModel.cancel()`. Both halves were pinned and the join between them was not, so
* nothing in 584 tests connected the button to WorkManager.
*
* ## Why the job is enqueued with a delay
*
* The test WorkManager runs on a `SynchronousExecutor`, so an ordinary request finishes inline —
* which is exactly why only the null half was ever covered: by the time a test could call
* `cancel()`, `convert()`'s job was already terminal. `setInitialDelay` is what `TestScheduler`
* honours, so the job sits in `ENQUEUED` until the test lets it go, and it never does.
*
* **Production never sets a delay**, so the request is built here rather than through
* `ConversionWorker.request`. The *state* is not synthetic: `ENQUEUED` at `runAttemptCount == 0` is
* what every job passes through before the scheduler picks it up, `Reattachment.choose` ranks it
* `QUEUED`, and `conversionStateFrom` maps it to `Converting(input, 0)`. The delay changes how long
* the job stays in a real state, not which state it is in.
*
* ## What is asserted, and in which order
*
* WorkManager's own record first, then the screen. The screen alone would be a weaker claim than it
* looks: `CANCELLED` maps to `Idle` for a reattached job, and `Idle` is also where a ViewModel that
* did nothing at all would sit.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class CancelReachesWorkManagerTest {
private lateinit var app: Application
private lateinit var workManager: WorkManager
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
ConversionDependencies.publisher = { RecordingPublisher(app) }
ConversionDependencies.probe = { _, _ -> InputProbe() }
installTestWorkManager(app, workDataOf())
workManager = WorkManager.getInstance(app)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `cancelling a queued conversion cancels that job`() {
val id = enqueueQueuedConversion()
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Converting") { it is ConversionState.Converting }
viewModel.cancel()
assertEquals(
"Cancel must reach WorkManager, not just the screen",
WorkInfo.State.CANCELLED,
stateOf(id),
)
awaitState(viewModel.state, "Idle") { it is ConversionState.Idle }
}
@Test
fun `cancelling a queued join cancels that job`() {
val id = enqueueQueuedJoin()
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Joining") { it is JoinState.Joining }
viewModel.cancel()
assertEquals(
"Cancel must reach WorkManager, not just the screen",
WorkInfo.State.CANCELLED,
stateOf(id),
)
awaitState(viewModel.state, "Idle") { it is JoinState.Idle }
}
/**
* The negative that bounds both: cancelling must cancel the job the screen is showing, and only
* that one.
*
* Without this, `cancel()` could cancel everything in the queue — `cancelAllWork()` in place of
* `cancelWorkById(activeWorkId)` — and both tests above would still pass.
*/
@Test
fun `cancelling one conversion leaves another queued job alone`() {
val bystander = enqueueQueuedConversion(displayName = "beach.mp4")
val id = enqueueQueuedConversion(displayName = "holiday.mp4")
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
val converting = awaitState(viewModel.state, "Converting") { it is ConversionState.Converting }
val onScreen = (converting as ConversionState.Converting).input.displayName
viewModel.cancel()
// Which of the two the ViewModel reattached to is the query's business, not this test's --
// the comparator leaves queued jobs tied deliberately, per Reattachment's ordering notes.
// So assert the shape rather than the identity: exactly one is cancelled, and the other is
// untouched.
val cancelled = listOf(id, bystander).filter { stateOf(it) == WorkInfo.State.CANCELLED }
assertEquals(
"exactly one job may be cancelled, with $onScreen on screen",
1,
cancelled.size,
)
}
private fun stateOf(id: UUID): WorkInfo.State =
requireNotNull(workManager.getWorkInfoById(id).get()) { "no WorkInfo for $id" }.state
/**
* A conversion sitting in the queue, which is where every job starts.
*
* Built by hand rather than through `ConversionWorker.request` for the reason in the class
* KDoc; the display-name tag is included because `reattach()` reads it for the file card, and a
* job without one would exercise the `UNKNOWN_INPUT_NAME` fallback instead of this test's
* subject.
*/
private fun enqueueQueuedConversion(displayName: String = "holiday.mp4"): UUID {
val request = OneTimeWorkRequestBuilder<ConversionWorker>()
.addTag(JobTags.displayName(displayName))
.setInitialDelay(QUEUE_HOLD_HOURS, TimeUnit.HOURS)
.build()
workManager.enqueue(request).result.get()
return request.id
}
private fun enqueueQueuedJoin(inputCount: Int = 2): UUID {
val request = OneTimeWorkRequestBuilder<ConcatWorker>()
.addTag(JobTags.inputCount(inputCount))
.setInitialDelay(QUEUE_HOLD_HOURS, TimeUnit.HOURS)
.build()
workManager.enqueue(request).result.get()
return request.id
}
private companion object {
/** Long enough that `TestScheduler` never releases the job during a test run. */
const val QUEUE_HOLD_HOURS = 1L
}
}
@@ -0,0 +1,238 @@
package org.libremediaconverter.convert
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.WorkInfo
import androidx.work.workDataOf
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
/**
* Every answer [conversionStateFrom] can give, chosen rather than stumbled into.
*
* ## What this revises
*
* The mapping is not cold code and never was: `ConversionViewModel$observe$1$1` reported 28 covered
* lines before this file existed, because every test that drives a real worker runs it. What no
* test did was **choose which arm it took**. A real worker reaches a terminal state with
* well-formed output, so `SUCCEEDED`-with-a-path and `FAILED`-with-a-message were the only arms any
* test had ever produced — the other six ran never.
*
* A `grep` for `WorkInfo.State.` across the JVM suite makes that look untrue: all six constants are
* there. They are in `ReattachmentTest`, driven into **`Reattachment.choose`** — a different
* function that encodes the same enqueued-means-retry rule. So that rule had a test in one of its
* two homes, and the copy the user's screen reads had none.
*
* ## Why the seam, and why these assertions
*
* `WorkManager.getInstance` is called in the ViewModel's constructor and `observe` is private, so
* nothing could hand this a chosen `WorkInfo`. Cutting the `when` out as a pure function over
* [ConversionUpdate] is the answer #141 took for `MediaProbe`, and `JobSnapshot` beside
* `Reattachment.choose` is the same shape again.
*
* The assertions are on the whole state, not on its type. `Converting(input, 40)` and
* `Converting(input, 0)` are both `Converting`, and a mapping that dropped the progress read would
* pass any test that only asked which class came back.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConversionStateMappingTest {
// --- running ------------------------------------------------------------
@Test
fun `a running job reports the progress it published`() {
// The percent is read from `progress`, not from `outputData`, and not from the settings.
// A mapping that returned Converting(input, 0) for every RUNNING would leave the bar
// pinned at zero for the whole conversion.
val state = map(WorkInfo.State.RUNNING, progress = 40)
assertEquals(ConversionState.Converting(INPUT, 40), state)
}
@Test
fun `a running job with no published progress reports zero rather than failing`() {
// getInt's default. A worker that has started but not yet called setProgress is ordinary,
// and must not read as an error.
val state = map(WorkInfo.State.RUNNING, progress = null)
assertEquals(ConversionState.Converting(INPUT, 0), state)
}
// --- enqueued: the rule that had a test only in its other home ----------
@Test
fun `an enqueued job that has already run is waiting to retry`() {
val state = map(WorkInfo.State.ENQUEUED, runAttemptCount = 1)
assertEquals(
"an ENQUEUED after a run is a pending retry, which the user is told about",
ConversionState.Waiting(INPUT),
state,
)
}
@Test
fun `an enqueued job that has never run is simply starting`() {
// The other side, and the reason the test above is not enough on its own: a mapping that
// ignored runAttemptCount and always answered Waiting would pass that one and fail this.
val state = map(WorkInfo.State.ENQUEUED, runAttemptCount = 0)
assertEquals(ConversionState.Converting(INPUT, 0), state)
}
// --- succeeded ----------------------------------------------------------
@Test
fun `a success that named no file is a failure, not an empty success`() {
// The job said it finished and named nothing. There is no file to offer, so `Converted`
// would put a Save button over a path that does not exist.
val state = map(WorkInfo.State.SUCCEEDED, data = Data.EMPTY)
assertEquals(ConversionState.Failed(SUCCEEDED_WITHOUT_A_FILE_MESSAGE), state)
}
@Test
fun `a success carries the worker's own name and type, not the current settings`() {
val state = map(
WorkInfo.State.SUCCEEDED,
data = workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to "/cache/conversions/out.mkv",
ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mkv",
ConversionWorker.KEY_MIME_TYPE to "video/x-matroska",
ConversionWorker.KEY_ENGINE_USED to "FFMPEG",
ConversionWorker.KEY_ROUTE_REASON to "container needs FFmpeg",
),
)
val converted = state as ConversionState.Converted
assertEquals("holiday.mkv", converted.suggestedName)
assertEquals("video/x-matroska", converted.mimeType)
assertEquals("FFMPEG", converted.engineUsed)
assertEquals("container needs FFmpeg", converted.routeReason)
}
@Test
fun `a success from older work falls back to the current settings for name and type`() {
// WorkManager keeps finished work about a week, so a job enqueued before the worker
// reported these is ordinary for a few days rather than a corner case.
val state = map(
WorkInfo.State.SUCCEEDED,
data = workDataOf(ConversionWorker.KEY_OUTPUT_PATH to "/cache/conversions/out.mp4"),
)
val converted = state as ConversionState.Converted
assertEquals(FALLBACK_SPEC.mimeType, converted.mimeType)
assertEquals(
ConversionWorker.outputNameFor(INPUT.displayName, FALLBACK_SPEC),
converted.suggestedName,
)
}
@Test
fun `a blank name or type falls back the same way a missing one does`() {
// A blank string is not an answer. Without takeIf, the save dialog opens named "" and
// registered for a MIME type of "", which no provider will accept.
val state = map(
WorkInfo.State.SUCCEEDED,
data = workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to "/cache/conversions/out.mp4",
ConversionWorker.KEY_SUGGESTED_NAME to "",
ConversionWorker.KEY_MIME_TYPE to " ",
),
)
val converted = state as ConversionState.Converted
assertEquals(FALLBACK_SPEC.mimeType, converted.mimeType)
assertEquals(
ConversionWorker.outputNameFor(INPUT.displayName, FALLBACK_SPEC),
converted.suggestedName,
)
}
// --- failed -------------------------------------------------------------
@Test
fun `a failure carries the reason the worker gave`() {
val state = map(
WorkInfo.State.FAILED,
data = workDataOf(ConversionWorker.KEY_ERROR to "Not enough free space to convert."),
)
assertEquals(ConversionState.Failed("Not enough free space to convert."), state)
}
@Test
fun `a failure with nothing said still says something`() {
// A worker killed before it could write output data leaves none at all -- a refused
// foreground start after a process restart is one way. Failed("") would render as a blank
// error card.
val state = map(WorkInfo.State.FAILED, data = Data.EMPTY)
assertEquals(ConversionState.Failed(ConversionWorker.GENERIC_FAILURE_MESSAGE), state)
}
@Test
fun `a failure whose message is blank falls back like a missing one`() {
val state = map(
WorkInfo.State.FAILED,
data = workDataOf(ConversionWorker.KEY_ERROR to " "),
)
assertEquals(ConversionState.Failed(ConversionWorker.GENERIC_FAILURE_MESSAGE), state)
}
// --- cancelled and blocked ---------------------------------------------
@Test
fun `a cancellation lands wherever the caller said it should`() {
// Not a fixed state: a conversion started here goes back to Ready with the picked file,
// while one picked up by reattach goes to Idle, because the URI that job holds belongs to
// a process that no longer exists. `observe`'s KDoc is where that distinction is set.
val toReady = map(WorkInfo.State.CANCELLED, cancelled = ConversionState.Ready(INPUT))
val toIdle = map(WorkInfo.State.CANCELLED, cancelled = ConversionState.Idle)
assertEquals(ConversionState.Ready(INPUT), toReady)
assertEquals(ConversionState.Idle, toIdle)
}
@Test
fun `a blocked job looks like one that is starting`() {
// BLOCKED is a job waiting on a prerequisite. There is nothing useful to say about it that
// differs from "starting", and inventing a state for it would put a word on screen the
// user cannot act on.
val state = map(WorkInfo.State.BLOCKED)
assertEquals(ConversionState.Converting(INPUT, 0), state)
}
private fun map(
state: WorkInfo.State,
progress: Int? = null,
runAttemptCount: Int = 0,
data: Data = Data.EMPTY,
cancelled: ConversionState = ConversionState.Ready(INPUT),
): ConversionState = conversionStateFrom(
ConversionUpdate(
state = state,
// Modelled on the call site, which reads `getInt(KEY_PROGRESS, 0)` -- so "no progress
// published" is the default reaching the mapping, not a null it has to handle.
progressPercent = progress ?: 0,
runAttemptCount = runAttemptCount,
outputData = data,
),
input = INPUT,
cancelled = cancelled,
fallbackSpec = FALLBACK_SPEC,
)
private companion object {
val INPUT = InputFile(Uri.parse("content://test/holiday.mov"), "holiday.mov", 4096L)
val FALLBACK_SPEC = OutputFormat.MP4_H265.spec
}
}
@@ -93,8 +93,12 @@ class ConversionViewModelCleanupTest {
assertTrue("a failed save must not destroy the only copy", staged.exists())
assertEquals(emptyList<File>(), publisher.discarded)
// Failed carries no file reference at all, so this only works because the handle is
// a ViewModel field rather than something read back out of the state machine.
// The handle is a ViewModel field rather than something read back out of the state
// machine, and stays one now that a save-failed `Failed` also carries a `PendingSave`:
// that is a view for the screen to offer a retry through, never a second owner of the
// file. This delete goes through the field, which is what keeps a state that is dropped
// rather than read from taking the only reference with it. What the state carries, and
// what the screen then does with it, are `FailedSaveRetryTest`'s.
viewModel.reset()
assertEquals(listOf(staged), publisher.discarded)
@@ -140,10 +140,16 @@ class ConversionViewModelProbeFailureTest {
private fun pickedProbe(): InputProbe? {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(INPUT)
// The predicate is the guard, and it is the only one needed. It requires `Ready`, so a
// pick that ended in `Failed` never satisfies it and `awaitState` fails on its timeout
// naming what it was waiting for -- "Ready with a probe" -- which says more than a
// separate assertion could. A `ready as? ConversionState.Failed` check used to sit here
// and was dead: `Ready` and `Failed` are sibling subtypes of one sealed interface, so
// the cast was always null and the assertNull could never fire. Measured, not assumed --
// flipping it to assertNotNull failed all three callers of this helper.
val ready = awaitState(viewModel.state, "Ready with a probe") {
it is ConversionState.Ready && it.input.probe != null
}
assertNull("nothing here should reach a terminal failure", (ready as? ConversionState.Failed))
return (ready as ConversionState.Ready).input.probe
}
@@ -53,10 +53,11 @@ import java.io.File
* it -- so it is unobservable from a JVM test, the same limit `FileCardTest` records for
* `HorizontalDivider`. The message text itself is asserted; the colour would need a screenshot.
* - **The three `assertDoesNotExist` checks on [TestTags.Converter.FILE_CARD] are compile-guarded,
* not guarded by this file.** `Idle` is a `data object`, and `Saved` and `Failed` carry only a
* `displayName` and a `message`; none of the three has an `input`, so `FileCard(s.input)` does not
* compile in those arms. The lines stay because they state the intent cheaply, but they are not
* what stops a `FileCard` appearing there and this file does not claim they are.
* not guarded by this file.** `Idle` is a `data object`, `Saved` carries a `displayName`, and
* `Failed` carries a message and -- after a failed save only -- the staged file it left behind;
* none of the three has an `input`, so `FileCard(s.input)` does not compile in those arms. The
* lines stay because they state the intent cheaply, but they are not what stops a `FileCard`
* appearing there and this file does not claim they are.
* - **Which constant each chip hands back** belongs to `ConverterPickerSelectionTest`, and **what
* the file card says about an unknown size** to `FileCardTest`. This file asserts that `Ready`
* puts those leaves on screen at all, not what they then do.
@@ -326,6 +327,22 @@ class ConverterStateAffordancesTest {
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
}
/**
* **The assertion that bounds #30's whole change**, and the one worth breaking things to keep.
*
* A transcode that died staged nothing, so its `Failed` carries no [PendingSave] and there is
* nothing for a save dialog to be handed. Making the retry unconditional -- or making the
* `WorkInfo.State.FAILED` arm of `ConversionViewModel.observe` carry a handle it has no file
* for -- puts a button on screen that can only fail, and this is what notices.
*/
@Test
fun `a transcode failure offers no way to save`() {
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertDoesNotExist()
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
}
@Test
fun `tapping start over after a failure resets and does nothing else`() {
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
@@ -335,6 +352,50 @@ class ConverterStateAffordancesTest {
assertEquals(listOf("reset"), fired)
}
// ----------------------------------------------------- Failed, carrying a file
/**
* The defect in #30, stated as what the branch must render.
*
* `save()` keeps the staged file on a failure deliberately -- it can be the only copy of an
* hour of transcoding -- and before this the only control here was "Start over", wired to
* `reset()`, which deletes exactly that file. Both buttons, not one: the restart has to stay
* reachable, because leaving a full-size file in cache is the outcome it exists to avoid.
*/
@Test
fun `a failed save offers the file again as well as a restart`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertExists()
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
}
/**
* The name, not just that something fired: it comes from the finished job, and a retry wired to
* a literal or to the picker's current guess would hand the dialog a name the job never chose.
*/
@Test
fun `tapping try saving again hands back the name the job chose`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).performScrollTo().performClick()
assertEquals(listOf("save:holiday.mp4"), fired)
}
/**
* Start over from here still resets, and resetting still deletes -- see `reset()`'s KDoc for
* why that is acceptable now and was not before. What it must not do is save on the way past.
*/
@Test
fun `tapping start over after a failed save resets and does not save`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
assertEquals(listOf("reset"), fired)
}
// ------------------------------------------------------------------ Harness
private fun input() = InputFile(
@@ -348,6 +409,21 @@ class ConverterStateAffordancesTest {
* missing file rather than throwing, so the size line reads `0 B` and no temporary folder is
* needed to render the arm.
*/
/**
* A `Failed` an earlier save left carrying its file, which is the only way [retry] is non-null.
*
* The same missing `staged` path as [converted], and for the same reason: this arm renders no
* size line at all, so nothing here ever touches the filesystem.
*/
private fun failedSave() = ConversionState.Failed(
message = "There was not enough room on the destination.",
retry = PendingSave(
staged = File("no-such-staged-output.mp4"),
suggestedName = "holiday.mp4",
mimeType = "video/mp4",
),
)
private fun converted(routeReason: String = "") = ConversionState.Converted(
input = input(),
staged = File("no-such-staged-output.mp4"),
@@ -0,0 +1,192 @@
package org.libremediaconverter.convert
import com.arthenica.ffmpegkit.MediaInformation
import com.arthenica.ffmpegkit.StreamInformation
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.Container
import org.robolectric.RobolectricTestRunner
/**
* What FFprobe's answer means, read as a function of the answer alone.
*
* `readMediaInformation` was 114 missed instructions and 24 missed branches — the second-biggest
* block on the wave-4 report — of which **exactly one line needed a device**:
*
* ```kotlin
* FFprobeKit.getMediaInformation(path).getMediaInformation()
* ```
*
* Everything after it reads an ordinary object. `javap` over the committed AAR's runtime jar:
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
* loads the native library — so the fixtures below are built without `libffmpegkit` present.
*
* ## The one that matters
*
* `containerFrom(formatName, video?.getCodec())`. FFprobe reports `matroska,webm` for **both** MKV
* and WebM, because they share a demuxer, so the video codec is the only thing separating them.
* `containerFrom` has thirty-three covered branches of its own and not one of them can notice the
* argument being dropped — the mistake would be at the call, not in the callee, and every existing
* `containerFrom` test would stay green while every VP9 WebM quietly became an MKV.
*
* Robolectric only for `org.json`, which is a stub in a plain JVM test.
*/
@RunWith(RobolectricTestRunner::class)
class FFprobeMappingTest {
@Test
fun `the video codec decides between matroska and webm`() {
assertEquals(
Container.WEBM,
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "vp9"))).container,
)
assertEquals(
Container.MKV,
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "h264"))).container,
)
}
/**
* The same format name with no video stream at all, which is what makes the case above about
* the *argument* rather than about the format string.
*/
@Test
fun `a matroska container with no video track cannot be told from webm and is not guessed`() {
val read = MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("audio", "opus")))
assertEquals(Container.MKV, read.container)
assertNull(read.videoCodec)
}
@Test
fun `the first stream of each type wins`() {
val read = MediaProbe.ffprobeInfoFrom(
info(
"mov,mp4,m4a,3gp,3g2,mj2",
stream("video", "h264", width = 1920, height = 1080),
stream("video", "hevc", width = 640, height = 480),
stream("audio", "aac"),
stream("audio", "mp3"),
),
)
assertEquals("h264", read.videoCodec)
assertEquals("aac", read.audioCodec)
assertEquals(1920, read.width)
assertEquals(1080, read.height)
}
/**
* Dimensions come from the stream the codec came from, not from whichever stream has some.
*
* The fixture is deliberately awkward: the chosen video stream carries **no** dimensions and a
* later one does. That is a real shape — FFprobe omits `width`/`height` for a stream it could
* not measure — and it is the only arrangement that separates the two readings.
*
* A first version of this file asserted the dimensions inside the case above, where the chosen
* stream was also the first one carrying any. Replacing `video?.getWidth()` with
* `streams.firstNotNullOfOrNull { it.getWidth() }` gave the same answer there and **the
* mutation survived**. It reddens here.
*/
@Test
fun `a video stream with no dimensions reports none rather than borrowing another stream's`() {
val read = MediaProbe.ffprobeInfoFrom(
info(
"mov,mp4,m4a,3gp,3g2,mj2",
stream("video", "h264"),
stream("video", "hevc", width = 640, height = 480),
),
)
assertEquals("h264", read.videoCodec)
assertEquals(0, read.width)
assertEquals(0, read.height)
}
/**
* Stream order is the file's, not a promise. An audio-first container must read the same as a
* video-first one.
*/
@Test
fun `an audio track listed first does not become the video track`() {
val read = MediaProbe.ffprobeInfoFrom(
info("mov,mp4,m4a,3gp,3g2,mj2", stream("audio", "aac"), stream("video", "h264")),
)
assertEquals("h264", read.videoCodec)
assertEquals("aac", read.audioCodec)
}
@Test
fun `a duration in seconds becomes milliseconds`() {
assertEquals(12_345L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "12.345")).durationMs)
}
/**
* Both ways a duration can be absent, and neither may throw.
*
* FFprobe reports `"N/A"` for a stream it could not measure, and omits the key entirely for
* some containers. `toDoubleOrNull` is what keeps the second from being an exception on the
* file-pick path, where there is no user-visible failure to report it as.
*/
@Test
fun `a duration that is not a number is no duration rather than a crash`() {
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "N/A")).durationMs)
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = null)).durationMs)
}
@Test
fun `a file with no streams reports nothing rather than defaults that look measured`() {
val read = MediaProbe.ffprobeInfoFrom(info("mp4"))
assertNull(read.videoCodec)
assertNull(read.audioCodec)
assertEquals(0, read.width)
assertEquals(0, read.height)
}
@Test
fun `an image format is reported as one`() {
assertTrue(MediaProbe.ffprobeInfoFrom(info("png_pipe", stream("video", "png"))).isImage)
assertFalse(MediaProbe.ffprobeInfoFrom(info("mp4", stream("video", "h264"))).isImage)
}
private fun stream(type: String, codec: String, width: Int? = null, height: Int? = null) = StreamInformation(
JSONObject().apply {
put(StreamInformation.KEY_TYPE, type)
put(StreamInformation.KEY_CODEC, codec)
width?.let { put(StreamInformation.KEY_WIDTH, it) }
height?.let { put(StreamInformation.KEY_HEIGHT, it) }
},
)
/**
* The format properties are **nested** under `"format"`, which is how FFprobe reports them and
* what `MediaInformation` reads: `getFormat()` resolves through `getStringFormatProperty`, not
* off the top-level object. A first version of this helper put the keys at the top level and
* every format-dependent case failed with a null container, which is worth recording here so
* the next fixture does not have to rediscover it.
*
* Streams are the other half and are *not* nested — they come from the constructor argument.
*/
private fun info(formatName: String, vararg streams: StreamInformation, duration: String? = "1.0") =
MediaInformation(
JSONObject().apply {
put(
MediaInformation.KEY_FORMAT_PROPERTIES,
JSONObject().apply {
put(MediaInformation.KEY_FORMAT, formatName)
duration?.let { put(MediaInformation.KEY_DURATION, it) }
},
)
},
streams.toList(),
emptyList(),
)
}
@@ -0,0 +1,373 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.join.pendingSave
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* A failed save has to leave the file *offerable*, not merely undeleted.
*
* The defect is #30, and both halves of it were already written down in `main`. `save()`'s
* `onFailure` kept the staged file on purpose -- "deleting here would destroy the work to tidy up
* a cache directory" -- and then handed the screen a `Failed` carrying a message and nothing else,
* so the single control that branch rendered was "Start over", wired to `reset()`, which deletes
* exactly that file. The intent and the affordance disagreed, and the affordance won.
*
* `ConversionViewModelCleanupTest` already pins the *keeping*: after a failed save the file is
* still on disk and nothing has been discarded. It stays green with the state carrying nothing,
* because it reads the filesystem rather than the state. This file asserts the other half -- that
* the handle reaches the state a screen can read -- and the negative that bounds it: a failure
* with nothing staged behind it must not sprout a save button.
*
* Both ViewModels in one class, following `MissingStagedFileTest`. They are separate state
* machines that can each hold a staged file at once, but this defect and its fix are the same
* shape in both, and splitting them would put the two halves of one invariant in two files.
*
* ### Not asserted here, so each is a decision rather than an omission
*
* - **That the destination received the bytes.** [RecordingPublisher.publish] is a stub, which is
* the only way to make a save fail deterministically -- and making it fail is what every case
* here needs. `OutputPublisherPublishTest` owns what a real publish writes.
* - **The screen's two buttons.** `ConverterStateAffordancesTest` and `JoinStateAffordancesTest`
* own what each state renders; this file owns what each state carries.
* - ~~**`ConverterScreen`'s `destinationMime` line itself.**~~ **Withdrawn 2026-09-02 (#201).** The
* exemption read: "it lives in the entry point, above the `ScreenContent` seam, and reaching it
* needs a real ViewModel inside a composition". That was true when written and is no longer:
* `AdaptiveShellTest` (#173) established composing the real screens with real ViewModels, and
* #200 added the `ShadowActivity` mechanics for reading what a launcher launched. `RetrySaveMimeTest`
* now asserts the line directly. What this file still owns is the half below the seam -- what each
* state *carries* -- which is why `pendingSave()?.mimeType` is also asserted here.
* - **Picking a new input while a `Failed` carries a file.** `onInputPicked` overwrites the state
* without discarding, from `Converted` exactly as much as from a carrying `Failed`, and neither
* branch renders a picker. It is a pre-existing path this change neither opens nor widens: the
* carried handle is a view of `pendingStaged`, never a second owner of the file.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class FailedSaveRetryTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
// MediaProbe spawns FFprobe, whose loader throws with no native library present.
ConversionDependencies.probe = { _, _ -> InputProbe() }
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
// ------------------------------------------------------------------ Convert
/**
* The bite named in #30's fix. Emitting a plain `Failed` from `save()`'s `onFailure` -- which
* is what `main` did -- reddens this case on the null handle, and nothing else in the suite.
*/
@Test
fun `a failed save leaves the staged file offerable, not merely undeleted`() {
val viewModel = failedSaveViewModel()
val failed = viewModel.state.value as ConversionState.Failed
val retry = failed.retry
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
assertEquals("the retry must name the file the conversion actually produced", staged, retry?.staged)
// Both from the job's own output Data rather than from the pickers, so a retry opens the
// same dialog the first attempt did.
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
assertTrue("a failed save must not destroy the only copy", staged.exists())
}
/**
* The whole point of carrying the handle: the second attempt is a real save, not a new job.
*
* `publishFailure` is cleared between the two calls, so one `RecordingPublisher` plays both a
* full destination and an empty one -- which is exactly the user's situation.
*/
@Test
fun `retrying a failed save publishes the file and leaves nothing staged`() {
val viewModel = failedSaveViewModel()
publisher.publishFailure = null
viewModel.save(DESTINATION)
val saved = awaitState(viewModel.state, "Saved") { it is ConversionState.Saved }
assertEquals(SUGGESTED_NAME, (saved as ConversionState.Saved).displayName)
assertFalse("a successful retry should have removed the staged file", staged.exists())
// Nothing to collect afterwards: the retry published it, so reset() has no work left.
viewModel.reset()
assertEquals(emptyList<File>(), publisher.discarded)
}
/**
* The second failure must not eat the file the first one kept.
*
* A `Failed` built fresh from `e.message` alone would drop the handle here while every other
* assertion in this file stayed green -- the file is still on disk, and the first failure
* already proved the state can carry it.
*/
@Test
fun `a retry that fails again still carries the file rather than dropping it`() {
val viewModel = failedSaveViewModel()
publisher.publishFailure = IllegalStateException("destination volume still full")
viewModel.save(DESTINATION)
// Waited for by the *second* message rather than by `is Failed`: the state was already
// Failed when the retry started, so the type alone would be satisfied before it ran.
val failed = awaitState(viewModel.state, "the second failure") {
it is ConversionState.Failed && it.message == "destination volume still full"
} as ConversionState.Failed
assertEquals("the second failure must offer the same file the first one did", staged, failed.retry?.staged)
assertTrue(staged.exists())
assertEquals(emptyList<File>(), publisher.discarded)
}
/**
* "Start over" still deletes, and that is the decision `reset()`'s KDoc records: acceptable
* only because "Try saving again" is on screen beside it. Exactly once, through the publisher.
*/
@Test
fun `start over from a failed save discards the carried file exactly once`() {
val viewModel = failedSaveViewModel()
viewModel.reset()
assertEquals(ConversionState.Idle, viewModel.state.value)
assertEquals(listOf(staged), publisher.discarded)
assertFalse(staged.exists())
}
/**
* A retry meets the same existence check the first attempt did, so a file collected by the
* sweep or by the OS in between is reported as a sentence rather than as a raw ENOENT path.
* And the state that reports it carries nothing: there is no file left to offer.
*/
@Test
fun `a retry whose staged file has gone says so and offers nothing further`() {
val viewModel = failedSaveViewModel()
assertTrue("the fixture must start with a real staged file", staged.delete())
publisher.publishFailure = null
viewModel.save(DESTINATION)
val failed = viewModel.state.value as ConversionState.Failed
assertEquals(STAGED_FILE_GONE_MESSAGE, failed.message)
assertNull("a file that has gone cannot be offered again", failed.retry)
}
/**
* The negative that bounds the whole change, and the reason `retry` is nullable.
*
* A transcode that died staged nothing, so there is no file to hand back -- and a `Failed`
* that carried one anyway would put a save button on a screen with nothing to save. Driven
* through a worker that really fails rather than by constructing the state, because the line
* under test is the `WorkInfo.State.FAILED` arm of `observe`.
*/
@Test
fun `a transcode failure carries nothing to save`() {
installFailingTestWorkManager(app, workDataOf(ConversionWorker.KEY_ERROR to "The encoder gave up."))
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.parse("content://test/holiday.mp4"))
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
viewModel.convert()
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed } as ConversionState.Failed
assertEquals("The encoder gave up.", failed.message)
assertNull("a transcode failure has nothing staged, so it must offer no save", failed.retry)
assertNull("and nothing for the save dialog to open with either", failed.pendingSave())
}
/**
* What the save dialog reopens with, which is the entry point's only reader of this state.
*
* The pickers are moved *after* the job finishes, which is what makes this bite: a retry that
* asked the current settings would offer `audio/mpeg` for a file the job wrote as MP4. The
* same gap is permanent for a reattached job, whose spec was never in these settings at all.
*/
@Test
fun `a retry offers the type the job chose, not the one the pickers now show`() {
val viewModel = failedSaveViewModel()
viewModel.setPreset(OutputFormat.MP3)
assertEquals(
"the fixture needs the pickers to disagree with the job",
"audio/mpeg",
viewModel.settings.value.spec.mimeType,
)
assertEquals(JOB_MIME_TYPE, viewModel.state.value.pendingSave()?.mimeType)
}
// --------------------------------------------------------------------- Join
@Test
fun `a failed join save leaves the staged file offerable, not merely undeleted`() {
val viewModel = failedJoinSaveViewModel()
val failed = viewModel.state.value as JoinState.Failed
val retry = failed.retry
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
assertEquals(staged, retry?.staged)
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
assertTrue(staged.exists())
}
@Test
fun `retrying a failed join save publishes the file and leaves nothing staged`() {
val viewModel = failedJoinSaveViewModel()
publisher.publishFailure = null
viewModel.save(DESTINATION)
val saved = awaitState(viewModel.state, "Saved") { it is JoinState.Saved }
assertEquals(SUGGESTED_NAME, (saved as JoinState.Saved).displayName)
assertFalse(staged.exists())
viewModel.reset()
assertEquals(emptyList<File>(), publisher.discarded)
}
@Test
fun `a join retry that fails again still carries the file rather than dropping it`() {
val viewModel = failedJoinSaveViewModel()
publisher.publishFailure = IllegalStateException("destination volume still full")
viewModel.save(DESTINATION)
// By the second message, not by `is Failed` -- see the converter case above.
val failed = awaitState(viewModel.state, "the second failure") {
it is JoinState.Failed && it.message == "destination volume still full"
} as JoinState.Failed
assertEquals(staged, failed.retry?.staged)
assertTrue(staged.exists())
assertEquals(emptyList<File>(), publisher.discarded)
}
@Test
fun `start over from a failed join save discards the carried file exactly once`() {
val viewModel = failedJoinSaveViewModel()
viewModel.reset()
assertEquals(JoinState.Idle, viewModel.state.value)
assertEquals(listOf(staged), publisher.discarded)
assertFalse(staged.exists())
}
@Test
fun `a join failure carries nothing to save`() {
installFailingTestWorkManager(app, workDataOf(ConcatWorker.KEY_ERROR to "The files could not be joined."))
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
viewModel.join()
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed } as JoinState.Failed
assertEquals("The files could not be joined.", failed.message)
assertNull("a join failure has nothing staged, so it must offer no save", failed.retry)
assertNull(failed.pendingSave())
}
// ------------------------------------------------------------------ Harness
/**
* A ViewModel driven to `Converted` and then through a save that threw.
*
* The WorkManager is installed here rather than in `@Before`, because two cases in this class
* need one whose workers fail instead.
*/
private fun failedSaveViewModel(): ConversionViewModel {
installTestWorkManager(app, conversionOutput())
// Unconfined so reset()'s delete runs inline instead of on a real IO thread.
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
viewModel.convert()
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
publisher.publishFailure = IllegalStateException("destination volume full")
viewModel.save(DESTINATION)
awaitState(viewModel.state, "Failed") { it is ConversionState.Failed }
return viewModel
}
/** The join tab's equivalent, driven to `Joined` and then through a save that threw. */
private fun failedJoinSaveViewModel(): JoinViewModel {
installTestWorkManager(app, joinOutput())
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
viewModel.join()
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
publisher.publishFailure = IllegalStateException("destination volume full")
viewModel.save(DESTINATION)
awaitState(viewModel.state, "Failed") { it is JoinState.Failed }
return viewModel
}
/**
* The output `Data` a finished conversion reports.
*
* The name and type are set rather than left out, so the assertions above are about what the
* *job* chose. Both ViewModels fall back to a derivation when they are missing, and a fixture
* that omitted them would be asserting the fallback while looking like it asserted the job.
*
* Spelled out per worker rather than shared with [joinOutput], even though the two constants
* hold the same strings today. A test that leaned on that would be asserting a coincidence.
*/
private fun conversionOutput() = workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConversionWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
ConversionWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
)
/** The output `Data` a finished join reports. See [conversionOutput]. */
private fun joinOutput() = workDataOf(
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConcatWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
ConcatWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
)
private companion object {
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
const val SUGGESTED_NAME = "holiday.mp4"
/** What the job wrote. [OutputFormat.MP3]'s `audio/mpeg` is what the pickers move to. */
const val JOB_MIME_TYPE = "video/mp4"
}
}
@@ -0,0 +1,234 @@
package org.libremediaconverter.convert
import android.content.ComponentName
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.content.IntentFilter
import android.content.pm.ProviderInfo
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf
import java.io.File
/**
* Content providers more than one test needs, and the registration dance they all repeat.
*
* Only that. A stub that serves one test stays in that test, next to the assertion it exists for —
* the rule `work/WorkerStubs.kt` states, and the reason `UnreliableOutputStream` is still private to
* `OutputPublisherPublishTest`.
*
* These started life inside `OutputPublisherPublishTest`, which is the only thing that needed a
* provider at all. They moved here when `InputQuery`'s cursor reads turned out to need the same
* provider answering *badly* — see [RowShape].
*/
internal const val DOCUMENTS_AUTHORITY = "org.libremediaconverter.test.documents"
internal const val PLAIN_AUTHORITY = "org.libremediaconverter.test.plain"
/**
* How [FakeSafProvider] answers a metadata query.
*
* A provider is another app. It can be uninstalled, revoke its grant, crash, or simply answer
* something the caller did not expect — and "answered something unexpected" is not one case but
* several, which is why this is an enum rather than a boolean.
*
* The distinction that matters most to callers is **null versus missing versus zero versus
* negative**. `InputQuery` exists to stop the last three being conflated: `hasSpaceFor(0)` is only
* "is there 128 MB free", so a size nobody could determine must not arrive as `0`, and
* `OutputPublisher.destinationIsKnownEmpty` must answer `false` — never "empty, go ahead and
* delete" — for every one of them.
*
* Column-level granularity is deliberate. `OutputPublisher` reads only `SIZE`; `InputQuery` reads
* both, and reaches a different answer depending on which one is bad.
*/
internal enum class RowShape {
/** What a healthy provider answers: the file's real name and real length. */
NORMAL,
/** A row is present and its `DISPLAY_NAME` cell is null. */
NULL_DISPLAY_NAME,
/** A row is present and its `SIZE` cell is null. */
NULL_SIZE,
/** The cursor carries no `DISPLAY_NAME` column at all — `getColumnIndex` gives `-1`. */
NO_DISPLAY_NAME_COLUMN,
/** The cursor carries no `SIZE` column at all — `getColumnIndex` gives `-1`. */
NO_SIZE_COLUMN,
/**
* A size of `-1`.
*
* Not a corrupt provider: it is what anything without a fixed length reports — a pipe, or a
* provider streaming its answer — and it is a third way of saying "unknown", distinct from a
* null cell and from a missing column.
*/
NEGATIVE_SIZE,
/**
* A cursor with the right columns and no rows in it.
*
* Distinct from returning `null`, which is what a provider that does not recognise the URI
* does. Both mean "no answer", and code that treats one as an answer and the other as an
* absence is wrong about one of them.
*/
NO_ROWS,
/**
* The query itself throws.
*
* A resolver call is a call into another app, and that app can have been uninstalled, revoked
* its grant, or simply crashed. `InputQuery.firstRow`'s KDoc is explicit that "a file picker is
* not a place to bring the process down from", so this is the shape that proves the guard is
* one.
*/
QUERY_THROWS,
}
/**
* A stand-in for the provider behind a SAF destination.
*
* It answers only what its callers ask of a document -- how many bytes are already there, what it
* is called, and delete it -- backed by a real file so the assertions are about the filesystem
* rather than about a mock's call log alone. The rest of the `ContentProvider` surface is stubbed.
*
* Writing is deliberately NOT routed through it. Robolectric's `ShadowContentResolver`
* consults its registered-stream map before it reaches any provider, which is what lets a
* test hand out a stream that writes some bytes and then fails -- a condition a real provider
* cannot be asked to produce on demand.
*/
internal open class FakeSafProvider : ContentProvider() {
override fun onCreate() = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? {
if (rowShape == RowShape.QUERY_THROWS) throw SecurityException("provider revoked the grant")
val file = backingFile(uri)
if (!file.exists()) return null
return MatrixCursor(columnsFor(rowShape)).apply {
if (rowShape != RowShape.NO_ROWS) addRow(cellsFor(rowShape, file))
}
}
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
if (method != METHOD_DELETE_DOCUMENT) return null
val target = extras?.getParcelable(EXTRA_URI, Uri::class.java) ?: return null
deleteRequests += target
deleteFailure?.let { throw it }
backingFile(target).delete()
return Bundle()
}
override fun getType(uri: Uri) = "video/mp4"
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
companion object {
// DocumentsContract.METHOD_DELETE_DOCUMENT and EXTRA_URI are hidden from the public
// SDK, so they cannot be referenced. These are the wire names
// DocumentsContract.deleteDocument() actually sends, which is what a provider sees.
const val METHOD_DELETE_DOCUMENT = "android:deleteDocument"
const val EXTRA_URI = "uri"
/** Where the "documents" really live. Set per test to a Robolectric temp path. */
lateinit var root: File
/** Every delete this provider was asked for, in order. Empty is an assertion too. */
val deleteRequests = mutableListOf<Uri>()
/** Armed by the test that needs the cleanup itself to fail. */
var deleteFailure: RuntimeException? = null
/**
* How the next query answers. [reset] puts it back to [RowShape.NORMAL], so a test that
* does not care never has to think about it.
*/
var rowShape: RowShape = RowShape.NORMAL
fun backingFile(uri: Uri) = File(root, uri.lastPathSegment.orEmpty())
fun reset(directory: File) {
root = directory
deleteRequests.clear()
deleteFailure = null
rowShape = RowShape.NORMAL
}
private fun columnsFor(shape: RowShape): Array<String> = when (shape) {
RowShape.NO_DISPLAY_NAME_COLUMN -> arrayOf(OpenableColumns.SIZE)
RowShape.NO_SIZE_COLUMN -> arrayOf(OpenableColumns.DISPLAY_NAME)
else -> arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
}
private fun cellsFor(shape: RowShape, file: File): Array<Any?> = when (shape) {
RowShape.NO_DISPLAY_NAME_COLUMN -> arrayOf(file.length())
RowShape.NO_SIZE_COLUMN -> arrayOf<Any?>(file.name)
RowShape.NULL_DISPLAY_NAME -> arrayOf(null, file.length())
RowShape.NULL_SIZE -> arrayOf(file.name, null)
RowShape.NEGATIVE_SIZE -> arrayOf(file.name, UNKNOWN_LENGTH)
else -> arrayOf(file.name, file.length())
}
/** What `statSize` reports for anything without a fixed length. See [RowShape.NEGATIVE_SIZE]. */
private const val UNKNOWN_LENGTH = -1L
}
}
/**
* The same provider, registered WITHOUT the documents-provider intent filter.
*
* A separate class because the package manager keys providers by component name, so two
* authorities need two components. It exists to prove the guard is a guard: a content URI
* from something that is not a documents provider must not be handed to `deleteDocument`.
*/
internal class FakePlainProvider : FakeSafProvider()
/**
* Stands [provider] up on [authority] so `contentResolver` and the package manager both know it.
*
* `isDocumentUri()` does not look at the URI alone: it asks the package manager whether anything
* answers `ACTION_DOCUMENTS_PROVIDER` for that authority. Registering the provider with the
* resolver is not enough, which is the whole reason [asDocumentsProvider] is a parameter rather
* than always true — the negative case is a test.
*/
internal fun registerProvider(
context: Context,
provider: Class<out FakeSafProvider>,
authority: String,
asDocumentsProvider: Boolean,
) {
val info = ProviderInfo().apply {
this.authority = authority
packageName = context.packageName
name = provider.name
exported = true
grantUriPermissions = true
}
Robolectric.buildContentProvider(provider).create(info)
val packageManager = shadowOf(context.packageManager)
packageManager.addOrUpdateProvider(info)
if (asDocumentsProvider) {
packageManager.addIntentFilterForProvider(
ComponentName(context.packageName, provider.name),
IntentFilter(DocumentsContract.PROVIDER_INTERFACE),
)
}
}
@@ -205,6 +205,34 @@ class FileCardTest {
assertNoRow("Length")
}
/**
* A video the app knows a great deal about and cannot name the container of.
*
* Not an edge case. `InputProbe.container`'s own KDoc says `MediaExtractor` cannot report a
* container at all -- it comes from FFprobe -- so any run where FFprobe did not answer produces
* exactly this: real codec, real dimensions, real duration, `container = null`.
*
* **The twin was already tested and this one was not**, which is the argument for adding it.
* `FileCard` renders `probe.container?.label ?: "Unknown"` twice, once in the `AUDIO_ONLY`
* branch (`ConverterScreen.kt:660`) and once in the `VIDEO` branch (`:668`), and
* `an audio-only file nothing else could describe degrades one row at a time` drives only the
* first. Same expression, same fallback, one kind covered. That asymmetry is the same one
* `CLAUDE.md` records for including `ContainerCapabilities:94`.
*
* The other rows are asserted alongside so this is not a copy of the audio-only case: there,
* everything is unknown at once; here, one field is missing from a probe that is otherwise
* complete, and the rest must be unaffected by it.
*/
@Test
fun `a video file whose container nothing identified says so and keeps its other rows`() {
setFileCard(input(probe = VIDEO_PROBE.copy(container = null)))
assertRow("Container", "Unknown")
assertRow("Video", "${VideoCodec.H264.label} · 1920×1080")
assertRow("Audio", AudioCodec.AAC.label)
assertRow("Length", "1:30")
}
/**
* The row is one node, not a label node beside a value node. A test matching on `"Container"`
* alone would pass against either shape.
@@ -0,0 +1,187 @@
package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* What [InputQuery] makes of a metadata row.
*
* ## Why this is a separate file from `UnknownInputSizeTest`
*
* That test drives the case where **no provider is registered** — the query returns null and
* `measure()` answers instead — and it drives it thoroughly. What it never does is hand `InputQuery`
* a row. Before this file, nothing did: `firstRow`'s body, `displayNameOrNull` and `sizeOrNull` had
* never executed in the JVM suite, so every branch inside them was untested.
*
* ## What is actually being pinned
*
* Not "does it read a cursor" — that would pass against almost any implementation. The rule is that
* **a size nobody could determine must not arrive as a number**, and there are four separate ways a
* provider fails to determine one: a null cell, a missing column, a negative value, and no row at
* all. `InputQuery`'s KDoc states the stake:
*
* > a worker's input `Data` carries the size the *picker* found … `hasSpaceFor(0)` is only "is there
* > 128 MB free".
*
* So each of those four must produce `null`, and `null` specifically — not `0`, not `-1`. A test
* that asserted only "not the file's length" would pass on `0`, which is the exact conflation the
* class exists to end.
*
* ## Why every fall-through lands on null here
*
* [FakeSafProvider] does not implement `openFile`, so `measure()` cannot answer for these URIs
* either. That is deliberate: it isolates the cursor half. The other direction — the cursor says
* nothing and `measure()` succeeds — is `UnknownInputSizeTest`'s
* `a picked file no provider describes is measured rather than reported as empty`, and is not
* repeated here.
*
* ## What the mutations say, including the one that does not bite
*
* Measured against `MatrixCursor`, which is what these tests drive:
*
* | call on a null cell | result |
* |---|---|
* | `getString` | returns `null` |
* | `getLong` | returns **`0`** |
*
* That second row is why `sizeOrNull`'s `!isNull(it)` guard is load-bearing and why these tests
* bite: remove it and a null size arrives as `0`, a real number indistinguishable from an empty
* file, which is the precise conflation this class exists to end. Removing it reddens
* `a null size is unknown rather than zero`. Removing the trailing `takeIf { it >= 0 }` reddens
* `a negative size is unknown rather than reported`.
*
* **Named exemption: `displayNameOrNull`'s `!isNull(it)` guard is not pinned by anything here, and
* cannot be.** `getString` returns null for a null cell, so the fallback applies with or without
* the guard — removing it leaves every test in this file green. The guard is not redundant in
* production: `Cursor.getString`'s contract states that whether it throws on a null column is
* *implementation-defined*, and a real `ContentProvider` is free to throw where `MatrixCursor`
* returns null. It should stay. It simply cannot be falsified with this cursor, and saying so is
* better than implying `a null display name falls back without disturbing the size` covers it —
* that test pins the behaviour, not the guard.
*/
@RunWith(RobolectricTestRunner::class)
class InputQueryCursorTest {
private lateinit var context: Context
private lateinit var uri: Uri
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
FakeSafProvider.reset(File(context.cacheDir, "picked").apply { mkdirs() })
registerProvider(context, FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
uri = Uri.parse("content://$DOCUMENTS_AUTHORITY/document/holiday.mp4")
FakeSafProvider.backingFile(uri).writeBytes(ByteArray(PAYLOAD_BYTES))
}
@Test
fun `a provider that answers properly supplies both the name and the size`() {
val described = InputQuery.describe(context, uri)
assertEquals("holiday.mp4", described.displayName)
assertEquals(PAYLOAD_BYTES.toLong(), described.sizeBytes)
}
@Test
fun `a null display name falls back without disturbing the size`() {
FakeSafProvider.rowShape = RowShape.NULL_DISPLAY_NAME
val described = InputQuery.describe(context, uri)
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
// The two columns are read independently. A provider that cannot name the file can still
// size it, and losing the size here would be a bug the name assertion alone would miss.
assertEquals(PAYLOAD_BYTES.toLong(), described.sizeBytes)
}
@Test
fun `a cursor with no display name column falls back rather than throwing`() {
// getColumnIndex returns -1 rather than throwing, so the `it >= 0` guard is the only thing
// between this and an IllegalArgumentException out of getString.
FakeSafProvider.rowShape = RowShape.NO_DISPLAY_NAME_COLUMN
val described = InputQuery.describe(context, uri)
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
assertEquals(PAYLOAD_BYTES.toLong(), described.sizeBytes)
}
@Test
fun `a null size is unknown rather than zero`() {
FakeSafProvider.rowShape = RowShape.NULL_SIZE
assertNull(unknownSizeMessage("a null cell"), InputQuery.sizeOf(context, uri))
}
@Test
fun `a cursor with no size column is unknown rather than zero`() {
FakeSafProvider.rowShape = RowShape.NO_SIZE_COLUMN
assertNull(unknownSizeMessage("a missing column"), InputQuery.sizeOf(context, uri))
}
@Test
fun `a negative size is unknown rather than reported`() {
// What anything without a fixed length reports -- a pipe, or a provider streaming its
// answer. Passing -1 through would be worse than passing 0: hasSpaceFor compares it
// against free space, so it would read as "needs less than nothing".
FakeSafProvider.rowShape = RowShape.NEGATIVE_SIZE
assertNull(unknownSizeMessage("a negative size"), InputQuery.sizeOf(context, uri))
}
@Test
fun `a cursor with no rows is unknown rather than zero`() {
// Distinct from the provider returning null, which UnknownInputSizeTest covers. A cursor
// that exists and holds nothing still has to reach the same answer.
FakeSafProvider.rowShape = RowShape.NO_ROWS
val described = InputQuery.describe(context, uri)
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
assertNull(unknownSizeMessage("an empty cursor"), described.sizeBytes)
}
@Test
fun `a provider that throws is survived rather than propagated`() {
// The guard firstRow's KDoc exists for: "a resolver call is a call into another app ... and
// a file picker is not a place to bring the process down from". Without the runCatching,
// this SecurityException reaches the caller and takes the pick with it.
FakeSafProvider.rowShape = RowShape.QUERY_THROWS
val described = InputQuery.describe(context, uri)
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
assertNull(unknownSizeMessage("a provider that threw"), described.sizeBytes)
}
@Test
fun `a join total is unknown when any one input could not be sized`() {
// The consequence the four cases above exist for, asserted once at the place it lands.
// Summing the inputs that did answer would produce a lower bound indistinguishable from a
// real total, which is what the space check cannot tell apart.
FakeSafProvider.rowShape = RowShape.NULL_SIZE
val unsizable = InputQuery.sizeOf(context, uri)
FakeSafProvider.rowShape = RowShape.NORMAL
val sizable = InputQuery.sizeOf(context, uri)
assertEquals(PAYLOAD_BYTES.toLong(), sizable)
assertNull(unsizable)
assertNull("one unknown input makes the whole total unknown", InputQuery.total(listOf(sizable, unsizable)))
}
private fun unknownSizeMessage(cause: String) =
"$cause means nobody could size the file; that must be null, not 0 -- hasSpaceFor(0) is only a headroom check"
private companion object {
const val PAYLOAD_BYTES = 4096
}
}
@@ -0,0 +1,178 @@
package org.libremediaconverter.convert
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinScreen
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowActivity
/**
* The launcher layer above the `ScreenContent` seam — registered, and until now never resulted.
*
* ## The hazard this exists for
*
* `ConversionViewModel.onInputPicked(uri: Uri)` and `.save(destination: Uri)` are **both
* `(Uri) -> Unit`**, so swapping the two launcher callbacks at `ConverterScreen.kt:70` and `:83`
* compiles, renders, and passes the entire suite. Picking a file would attempt a save to it, and
* choosing a destination would load it as input.
*
* That is precisely the defect class `ScreenWiringTest` exists for, on the one pair it declines to
* cover: it drives `converterActions` directly and says the launcher-backed actions stay
* parameters. Correct for the `actions` seam, and it leaves the edge above that seam unpinned.
*
* Join's equivalents (`JoinScreen.kt:45`, `:55`) are `List<Uri>` and `Uri`, so they are **not**
* transposable and need no such test. The picker filter is a different matter and is covered below
* for both screens.
*
* ## The two mechanics, verified before the assertions were written
*
* Neither is used anywhere else in the suite, so both were spiked first:
*
* - **Reading what was launched** — `shadowOf(activity).nextStartedActivityForResult`, which returns
* the `Intent` with its `EXTRA_MIME_TYPES` intact.
* - **Delivering a result** — `shadowOf(activity).receiveResult(...)`, which reaches
* `ComponentActivity`'s `ActivityResultRegistry` and fires the `rememberLauncherForActivityResult`
* callback.
*
* `createAndroidComposeRule`, as `AdaptiveShellTest` uses and for the reason it gives: the screens
* compose real ViewModels through `viewModel()`, and the plain rule supplies no `ViewModelStoreOwner`.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class LauncherWiringTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Before
fun setUp() {
val app = RuntimeEnvironment.getApplication()
installTestWorkManager(app, Data.EMPTY)
// The real screen composes a real ViewModel; neither test here is about probing.
ConversionDependencies.probe = { _, _ -> InputProbe() }
}
@After
fun tearDown() = ConversionDependencies.reset()
/**
* The transposition guard. A picked file has to reach `onInputPicked`, which is observable as
* the screen arriving at `Ready` with the file card showing — `save()` from `Idle` returns at
* its own guard and leaves nothing behind.
*
* ## Why this waits rather than asserting straight away (#220)
*
* `onInputPicked` does not reach `Ready` on the calling thread. It hops twice —
* `withContext(pickDispatcher) { InputQuery.describe(...) }` and then the probe — and
* `pickDispatcher` defaults to `Dispatchers.IO`, a real background thread that Compose's
* idling does not know about. `deliver` therefore returns with the state still `Idle` more
* often than not, and asserting immediately was a race the test usually won.
*
* It lost five times on CI in one day, on PRs whose diffs were instrumented tests and
* documentation, which is what #220 was filed for. `waitUntil` polls through
* `waitForIdle`, so it drains the main looper each time round and sees the recomposition that
* the IO hop eventually posts back.
*
* **Injecting the dispatcher would be better and is not available here.** `pickDispatcher` is
* a constructor parameter precisely so a test can pin it, but this test composes the real
* `ConverterScreen`, which resolves its own ViewModel through `viewModel()` — the seam exists
* one layer below the thing under test. Pinning it would mean not testing the launcher edge,
* which is the whole point of this class.
*
* The wait does not weaken the assertion: transposing the two callbacks leaves the screen in
* `Idle` forever, so it fails on the timeout with the same meaning it failed with before.
*/
@Test
fun `a picked document is loaded as input rather than saved to`() {
composeRule.setContent { ConverterScreen() }
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
deliver(Uri.parse("content://test/holiday.mkv"))
composeRule.waitUntil(PICK_TIMEOUT_MS) {
composeRule.onAllNodesWithTag(TestTags.Converter.FILE_CARD_NAME)
.fetchSemanticsNodes()
.isNotEmpty()
}
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertIsDisplayed()
}
/**
* `ConverterScreen.kt:65-67` records why the all-types wildcard is load-bearing rather than lazy:
*
* > the picker is images and video only, offers no audio at all, and will not reliably surface
* > .mkv/.flac/.webm
*
* Narrowing it would make every audio conversion unreachable from the file picker, and nothing
* would have gone red. (The literal is spelled only in the assertion below: a KDoc cannot
* contain it, because the wildcard's second half closes the comment.)
*/
@Test
fun `the converter picker asks for every type, not just the ones a photo picker offers`() {
composeRule.setContent { ConverterScreen() }
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
val intent = launched().intent
assertEquals(Intent.ACTION_OPEN_DOCUMENT, intent.action)
assertEquals(listOf("*/*"), intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList())
}
@Test
fun `the join picker asks for video and accepts more than one file`() {
composeRule.setContent { JoinScreen() }
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).performClick()
val intent = launched().intent
assertEquals(Intent.ACTION_OPEN_DOCUMENT, intent.action)
assertEquals(listOf("video/*"), intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList())
// A join of one file is not a join; the contract is what asks for several.
assertEquals(true, intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false))
}
private fun launched(): ShadowActivity.IntentForResult {
composeRule.waitForIdle()
return requireNotNull(shadowOf(composeRule.activity).nextStartedActivityForResult) {
"nothing was launched for a result"
}
}
private fun deliver(uri: Uri) {
val started = launched()
shadowOf(composeRule.activity).receiveResult(
started.intent,
Activity.RESULT_OK,
Intent().setData(uri),
)
composeRule.waitForIdle()
}
private companion object {
/**
* Long enough that a slow CI runner is not the reason this fails, short enough that a
* genuinely transposed callback does not stall the suite. The pick normally lands in
* single-digit milliseconds.
*/
const val PICK_TIMEOUT_MS = 10_000L
}
}
@@ -0,0 +1,105 @@
package org.libremediaconverter.convert
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.AudioPlan
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.CopyPlanner
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.VideoPlan
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.concurrent.CancellationException
/**
* What happens when Media3 refuses the export before it starts.
*
* `EditedMediaItem.Builder` rejects a composition with both tracks removed —
* checkState("Audio and video cannot both be removed") — and [Media3Engine] builds it on its own
* HandlerThread. That build used to sit *between* two narrow `runCatching` blocks, one around
* `buildTransformer` and one around `start`, so the exception escaped `handler.post`'s body: it
* reached the thread's uncaught handler, which on Android takes the process down, and the
* continuation was left unresumed either way.
*
* Robolectric runs the real [android.os.HandlerThread] and the real Media3 builders, so the whole
* sequence happens here — the engine really posts, really builds, and really throws. What it cannot
* reproduce is the *consequence* of an escaped throw: a JVM background thread dying is not process
* death. So the assertion is on the half that is observable everywhere and is the half that
* matters to the user — the suspension is resolved, with the reason, rather than left hanging.
* `Media3EngineTest.aPlanThatRemovesBothTracksFailsInsteadOfKillingTheProcess` is the same case on
* a device.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class Media3EngineEmptyCompositionTest {
@Test
fun `a plan that removes both tracks fails the job instead of escaping the handler thread`() {
val context = RuntimeEnvironment.getApplication()
val engine = Media3Engine(context)
val request = ConversionRequest(
spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
probe = InputProbe(
videoCodec = null,
audioCodec = "mp3",
hasVideo = false,
container = Container.MP3,
kind = InputKind.AUDIO_ONLY,
),
)
// Asserted rather than assumed: ConversionRequest's default probe says hasVideo = true, and
// with it this same spec plans to (Encode, Drop), nothing throws, and the test would pass
// over a code path it never entered.
val plan = CopyPlanner.plan(request.spec, request.probe)
assertEquals(VideoPlan.Drop, plan.video)
assertEquals(AudioPlan.Drop, plan.audio)
val failure = try {
runCatching {
runBlocking {
withTimeout(TIMEOUT_MS) {
engine.transcode(Uri.parse("file:///dev/null"), File(context.cacheDir, "empty.mp4"), request) {}
}
}
}.exceptionOrNull()
} finally {
engine.close()
}
// Both halves are load-bearing, and the second is not pedantry: withTimeout raises
// TimeoutCancellationException, and `java.util.concurrent.CancellationException` *extends*
// IllegalStateException — so testing only the first would call an unresumed continuation a
// pass. This assertion was written that way, and the mutation is what found it.
assertFalse(
"the continuation was never resumed — the failure escaped instead of being reported: $failure",
failure is CancellationException,
)
assertTrue(
"the builder's refusal must surface as a failed job; got $failure",
failure is IllegalStateException,
)
}
private companion object {
/**
* Short on purpose. Nothing is decoded, encoded or muxed on this path — the builder refuses
* the input outright — so anything approaching this is a hang, which is the failure mode
* this test is looking for.
*/
const val TIMEOUT_MS = 10_000L
}
}
@@ -0,0 +1,99 @@
package org.libremediaconverter.convert
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.AudioPlan
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.CopyPlanner
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.VideoPlan
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.concurrent.CancellationException
/**
* A job that reached Media3 with a container Media3 cannot mux.
*
* [Media3Muxers]' own KDoc names the defect this guards: *"the router claimed five containers while
* the engine silently wrote MP4 for all of them."* `factoryFor` answers null for fourteen of the
* app's containers, and `buildTransformer` turns that null into a failed job rather than letting
* `Transformer` fall back to its default muxer.
*
* The guard had never fired. `Media3Engine$buildTransformer$3` -- the `requireNotNull` message
* lambda -- was four lines and four branches at 0%, which is to say the entire repair for a defect
* the codebase went to the trouble of writing down was untested. Weakening it would restore that
* bug silently, because the wrong output is a *playable file with the wrong container*, not a crash.
*
* Same harness and same two disciplines as [Media3EngineEmptyCompositionTest]: assert the plan
* really is the one the test needs before driving the engine, and rule out
* `CancellationException` so an unresumed continuation cannot read as a pass.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class Media3MuxerGuardTest {
@Test
fun `a container Media3 cannot mux fails the job rather than silently writing MP4`() {
val context = RuntimeEnvironment.getApplication()
val engine = Media3Engine(context)
val request = ConversionRequest(
spec = OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS),
probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MP4),
)
// The premise, asserted rather than assumed -- three separate ways this test could pass
// over a path it never entered.
val plan = CopyPlanner.plan(request.spec, request.probe)
assertEquals("the plan has to still be WebM by the time the engine sees it", Container.WEBM, plan.container)
assertNull("...and Media3 really has no muxer for it", Media3Muxers.factoryFor(plan.container))
// Not the empty-composition refusal, which fires earlier and is a different test's subject.
assertNotEquals(VideoPlan.Drop, plan.video)
assertNotEquals(AudioPlan.Drop, plan.audio)
val failure = try {
runCatching {
runBlocking {
withTimeout(TIMEOUT_MS) {
engine.transcode(Uri.parse("file:///dev/null"), File(context.cacheDir, "guard.webm"), request) {
}
}
}
}.exceptionOrNull()
} finally {
engine.close()
}
assertFalse(
"the continuation was never resumed -- the refusal escaped instead of failing the job: $failure",
failure is CancellationException,
)
// Type *and* message, and the message half is the load-bearing one. Replacing the
// requireNotNull with a fallback factory does not make the export succeed here: it lets it
// run on and fail some other way, which a bare type assertion would happily accept.
assertTrue("expected the muxer guard to refuse the job, got $failure", failure is IllegalArgumentException)
assertTrue(
"the refusal has to name the container it could not mux, got: ${failure?.message}",
failure?.message.orEmpty().contains("cannot mux") &&
failure?.message.orEmpty().contains(Container.WEBM.name),
)
}
private companion object {
/** Nothing is decoded or muxed on this path -- the guard refuses before any of that. */
const val TIMEOUT_MS = 10_000L
}
}
@@ -0,0 +1,166 @@
package org.libremediaconverter.convert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
/**
* Which of the two probes wins, when they disagree.
*
* [MediaProbe.probe] runs `MediaExtractor` and FFprobe independently and then merges the two, and
* every rule in that merge is a decision. None of them had a test, for a reason that is structural
* rather than an oversight: `RemuxTest` drives the whole thing on a device against committed
* fixtures, but only ever with **one probe answering and the other agreeing or also failing**.
* Nothing on any source set can arrange for a real extractor and a real FFprobe to disagree, so
* every elvis in the merge was taken in one direction and never the other.
*
* Cutting `merge` out of `probe` is what makes the question askable. Both halves of its signature
* had to become `internal` for that -- `Extracted` already was, with a KDoc giving this exact
* reason; `FFprobeInfo` simply never got the same treatment.
*/
class MediaProbeMergeTest {
/**
* The rule with the loudest failure mode, and `isImageFormat`'s own KDoc names it: a false
* positive here "makes the source-info card describe a video as an image". So the image verdict
* has to beat a real video codec from the extractor, and the ordering that makes it do so is
* the first arm of `classify` rather than anything a reader would infer from the fields.
*/
@Test
fun `an image verdict from FFprobe beats a video codec from the extractor`() {
val merged = MediaProbe.merge(
extracted = extracted(video = "h264"),
info = info(video = "mjpeg", isImage = true),
)
assertEquals(InputKind.IMAGE, merged.kind)
}
@Test
fun `the extractor wins on codecs, because it is the view the router will act on`() {
val merged = MediaProbe.merge(
extracted = extracted(video = "h264", audio = "aac"),
info = info(video = "hevc", audio = "mp3"),
)
assertEquals("h264", merged.videoCodec)
assertEquals("aac", merged.audioCodec)
}
@Test
fun `FFprobe answers for a file the extractor could not open`() {
val merged = MediaProbe.merge(extracted = null, info = info(video = "vp9", audio = "opus"))
assertEquals("vp9", merged.videoCodec)
assertEquals("opus", merged.audioCodec)
assertEquals(InputKind.VIDEO, merged.kind)
}
@Test
fun `the extractor answers for a file FFprobe could not read`() {
val merged = MediaProbe.merge(extracted = extracted(video = "h264", audio = "aac"), info = null)
assertEquals("h264", merged.videoCodec)
assertEquals("aac", merged.audioCodec)
assertNull("only FFprobe can name the container, so it stays unknown here", merged.container)
}
/**
* The larger of the two, not the first non-zero.
*
* Either probe can report zero for a file the other times correctly, and a zero duration makes
* the FFmpeg progress percentage undefined -- `FFmpegEngine` divides by it. Both orderings are
* asserted because "take the extractor's" and "take the larger" agree in one direction and not
* the other, and only one of them is the rule.
*/
@Test
fun `duration is the longer of the two readings, whichever probe supplied it`() {
assertEquals(
5_000L,
MediaProbe.merge(extracted(duration = 0L), info(duration = 5_000L)).durationMs,
)
assertEquals(
5_000L,
MediaProbe.merge(extracted(duration = 5_000L), info(duration = 0L)).durationMs,
)
}
@Test
fun `dimensions come from the extractor, and from FFprobe only when it has none`() {
assertEquals(1920, MediaProbe.merge(extracted(width = 1920), info(width = 640)).width)
assertEquals(640, MediaProbe.merge(extracted = null, info = info(width = 640)).width)
assertEquals(0, MediaProbe.merge(extracted(width = 0), info(width = 0)).width)
}
@Test
fun `the container comes from FFprobe, which is the only probe that can name one`() {
val merged = MediaProbe.merge(extracted(video = "h264"), info(container = Container.MKV))
assertEquals(Container.MKV, merged.container)
}
@Test
fun `a file with audio and no video is audio-only, not unparseable`() {
val merged = MediaProbe.merge(extracted(video = null, audio = "mp3"), info = null)
assertEquals(InputKind.AUDIO_ONLY, merged.kind)
assertFalse(merged.hasVideo)
}
@Test
fun `a file neither probe could open is the one unreadable answer`() {
val merged = MediaProbe.merge(extracted = null, info = null)
assertEquals(MediaProbe.UNREADABLE, merged)
assertEquals(InputProbe.UNPARSEABLE, merged.videoCodec)
}
/**
* The arm the ticket was filed for: parsed, and carrying no stream either probe recognised.
*
* Distinct from "neither probe could open it" -- here the extractor opened the file happily and
* found nothing convertible, which is what a container holding only subtitles looks like. It
* has to reach the same [MediaProbe.UNREADABLE] answer, because the router keys off that and
* there is nothing here for Media3 to do either way.
*
* Its input was already being built elsewhere in the suite -- `MediaProbeTrackWalkTest` calls
* `extractedFrom(emptyList())` and gets exactly this -- and had simply never been handed to the
* merge.
*/
@Test
fun `a file that parsed but carries no recognised stream is unreadable too`() {
val merged = MediaProbe.merge(extracted = MediaProbe.extractedFrom(emptyList()), info = null)
assertEquals(InputKind.UNPARSEABLE, merged.kind)
assertEquals(MediaProbe.UNREADABLE, merged)
}
@Test
fun `hasVideo follows the codec that survived the merge, not either probe alone`() {
assertTrue(MediaProbe.merge(extracted(video = null), info(video = "vp9")).hasVideo)
assertFalse(MediaProbe.merge(extracted(video = null, audio = "aac"), info(video = null)).hasVideo)
}
private fun extracted(
video: String? = "h264",
audio: String? = "aac",
duration: Long = 1_000L,
width: Int = 1280,
height: Int = 720,
) = MediaProbe.Extracted(video, audio, duration, width, height)
private fun info(
container: Container? = null,
video: String? = "h264",
audio: String? = "aac",
duration: Long = 1_000L,
width: Int = 1280,
height: Int = 720,
isImage: Boolean = false,
) = MediaProbe.FFprobeInfo(container, video, audio, duration, width, height, isImage)
}
@@ -0,0 +1,221 @@
package org.libremediaconverter.convert
import android.media.MediaFormat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* The rules `MediaProbe` applies to a set of track formats.
*
* ## Why this exists, and what it revises
*
* Issue #84 classified `probeWithExtractor` and `probeForConcat` as device-bound and explicitly not
* a gap:
*
* > These are exercised by `RemuxTest`, `ConcatEngineTest` and `RealMediaBenchmark` in
* > `androidTest` … **Do not read their 0% as untested.**
*
* That was right about the measurement boundary and right about FFprobe. It was not right that
* these are only orchestration. The track walk is a **branch matrix**, and `androidTest` reaches it
* only through whatever the committed fixtures happen to contain — so none of the rules below is
* *chosen* by any test there. A fixture with two video tracks, a track that omits its duration, or
* an audio-before-video ordering is not something a device test would produce on purpose.
*
* The seam is the answer #133 preferred over driving `ShadowMediaExtractor`: the walk is a pure
* function over `List<MediaFormat>`, and what is left needing a device — `setDataSource`,
* `getTrackFormat`, `release` — is the thin edge `androidTest` should be covering. This is the
* `work/FailureOutcome.kt` pattern `CLAUDE.md` names.
*
* `MediaFormat` is a real one throughout, not a stub. `MediaProbeTrackFieldsTest` records why that
* matters: it is a heterogeneous map whose getters throw rather than coerce, and a hand-rolled
* double would not reproduce that.
*/
@RunWith(RobolectricTestRunner::class)
class MediaProbeTrackWalkTest {
// --- extractedFrom: the conversion flow's read ---------------------------
@Test
fun `the first video track wins when a file carries two`() {
// `video == null` is the entire guard. A file with two video tracks must report the first,
// because that is the one an engine will transcode -- and the width and height must come
// from the same track, not be mixed across them.
val extracted = MediaProbe.extractedFrom(
listOf(
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1920, height = 1080),
video(MediaFormat.MIMETYPE_VIDEO_HEVC, width = 640, height = 480),
),
)
assertEquals("h264", extracted.videoCodec)
assertEquals(1920, extracted.width)
assertEquals(1080, extracted.height)
}
@Test
fun `the first audio track wins when a file carries two`() {
val extracted = MediaProbe.extractedFrom(
listOf(
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
audio(MediaFormat.MIMETYPE_AUDIO_OPUS),
),
)
assertEquals("aac", extracted.audioCodec)
}
@Test
fun `duration is the longest track, not the first or the last`() {
// A file whose audio outlasts its video is ordinary. Taking the video's length would cut
// the progress bar short; taking the last track's would be right only by accident of order.
val extracted = MediaProbe.extractedFrom(
listOf(
video(MediaFormat.MIMETYPE_VIDEO_AVC, durationUs = 10_000_000),
audio(MediaFormat.MIMETYPE_AUDIO_AAC, durationUs = 12_500_000),
audio(MediaFormat.MIMETYPE_AUDIO_OPUS, durationUs = 1_000_000),
),
)
assertEquals(12_500L, extracted.durationMs)
}
@Test
fun `a track that does not declare its duration contributes nothing to it`() {
// MediaExtractor omits KEY_DURATION for plenty of real tracks -- MediaProbeTrackFieldsTest
// records the same for KEY_FRAME_RATE. Reading a key that is absent is what containsKey
// stands between us and.
val extracted = MediaProbe.extractedFrom(
listOf(
video(MediaFormat.MIMETYPE_VIDEO_AVC),
audio(MediaFormat.MIMETYPE_AUDIO_AAC, durationUs = 7_000_000),
),
)
assertEquals(7_000L, extracted.durationMs)
}
@Test
fun `declaring audio before video changes nothing`() {
// Track order is a property of the container, not of the content. Both orderings have to
// reach the same answer or the same file remuxed twice would probe differently.
val videoFirst = MediaProbe.extractedFrom(
listOf(
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1280, height = 720),
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
),
)
val audioFirst = MediaProbe.extractedFrom(
listOf(
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1280, height = 720),
),
)
assertEquals(videoFirst.videoCodec, audioFirst.videoCodec)
assertEquals(videoFirst.audioCodec, audioFirst.audioCodec)
assertEquals(videoFirst.width, audioFirst.width)
assertEquals(videoFirst.height, audioFirst.height)
}
@Test
fun `a track that is neither audio nor video is ignored`() {
// Subtitle and timed-metadata tracks are common in MKV and MP4. Neither prefix matches, so
// neither slot is filled -- and, importantly, a subtitle track must not be mistaken for the
// absence of an audio track by some later `else`.
val extracted = MediaProbe.extractedFrom(
listOf(
MediaFormat().apply { setString(MediaFormat.KEY_MIME, "text/vtt") },
video(MediaFormat.MIMETYPE_VIDEO_AVC),
),
)
assertEquals("h264", extracted.videoCodec)
assertNull(extracted.audioCodec)
}
@Test
fun `a file with no tracks reports nothing rather than zero-width video`() {
val extracted = MediaProbe.extractedFrom(emptyList())
assertNull(extracted.videoCodec)
assertNull(extracted.audioCodec)
assertEquals(0L, extracted.durationMs)
assertEquals(0, extracted.width)
assertEquals(0, extracted.height)
}
@Test
fun `an audio-only file reports no video codec at all`() {
// The distinction MediaProbe.classify turns into InputKind.AUDIO_ONLY, and the reason
// `hasVideo` exists: an audio file and a corrupt file must not look alike.
val extracted = MediaProbe.extractedFrom(listOf(audio(MediaFormat.MIMETYPE_AUDIO_AAC)))
assertNull(extracted.videoCodec)
assertEquals("aac", extracted.audioCodec)
assertEquals(0, extracted.width)
}
// --- concatInputFrom: the join flow's read -------------------------------
@Test
fun `the join read takes frame rate from the first video track`() {
val input = MediaProbe.concatInputFrom(
listOf(
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1920, height = 1080, frameRate = 30),
video(MediaFormat.MIMETYPE_VIDEO_HEVC, width = 640, height = 480, frameRate = 60),
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
),
)
assertEquals("h264", input.videoCodec)
assertEquals("aac", input.audioCodec)
assertEquals(1920, input.width)
assertEquals(1080, input.height)
assertEquals(30, input.frameRate)
}
@Test
fun `a video track with no declared frame rate reports zero rather than guessing`() {
// ConcatPlanner treats 0 as "cannot prove a match" and re-encodes. A guessed 30 would read
// as agreement and produce a stream copy of clips that do not actually match -- the failure
// its KDoc says the whole flow is arranged to avoid.
val input = MediaProbe.concatInputFrom(listOf(video(MediaFormat.MIMETYPE_VIDEO_AVC)))
assertEquals(0, input.frameRate)
}
@Test
fun `a file with no tracks joins as entirely unknown`() {
val input = MediaProbe.concatInputFrom(emptyList())
assertNull(input.videoCodec)
assertNull(input.audioCodec)
assertEquals(0, input.width)
assertEquals(0, input.height)
assertEquals(0, input.frameRate)
}
private fun video(
mime: String,
width: Int = 1920,
height: Int = 1080,
durationUs: Long? = null,
frameRate: Int? = null,
): MediaFormat = MediaFormat.createVideoFormat(mime, width, height).apply {
durationUs?.let { setLong(MediaFormat.KEY_DURATION, it) }
frameRate?.let { setInteger(MediaFormat.KEY_FRAME_RATE, it) }
}
private fun audio(mime: String, durationUs: Long? = null): MediaFormat =
MediaFormat.createAudioFormat(mime, SAMPLE_RATE, CHANNELS).apply {
durationUs?.let { setLong(MediaFormat.KEY_DURATION, it) }
}
private companion object {
const val SAMPLE_RATE = 48_000
const val CHANNELS = 2
}
}
@@ -0,0 +1,213 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.UUID
/**
* A failure that says nothing still has to say something.
*
* Three sites, all `ci == 0` before this file, and all the same rule:
*
* ```
* work/ConversionWorker.kt:316 cause.message ?: GENERIC_FAILURE_MESSAGE
* convert/ConversionViewModel.kt:631 e.message ?: SAVE_FAILED_MESSAGE
* join/JoinViewModel.kt:416 e.message ?: SAVE_FAILED_MESSAGE
* ```
*
* Every existing test throws *with* a message, so the right-hand side had never been evaluated
* anywhere in the suite. A `Throwable` carrying none is not exotic — `RuntimeException()`,
* `IOException()` and most platform exceptions raised without an argument all have a null message.
*
* ## Held in one class, against the ticket's suggestion
*
* #193 proposed putting each case beside the behaviour it neighbours. They are together instead,
* because they are one rule at three layers and because the trap below has to be explained once
* rather than three times. `FailedSaveRetryTest` sets the precedent for both ViewModels in one
* file; this extends it by one worker.
*
* ## The trap, which is why the worker case asserts what it does
*
* `ConversionStateMappingTest`'s *"a failure with nothing said still says something"* looks like it
* already covers the worker site. It does not: it drives the **read** side, `map(FAILED, Data.EMPTY)`,
* and that side has a fallback of its own (`ConversionViewModel.kt:147-149`):
*
* ```kotlin
* update.outputData.getString(ConversionWorker.KEY_ERROR)
* ?.takeIf { it.isNotBlank() }
* ?: ConversionWorker.GENERIC_FAILURE_MESSAGE
* ```
*
* So mutating the worker's fallback to `.orEmpty()` writes `KEY_ERROR to ""`, and the ViewModel
* turns that straight back into the same constant. **A test asserting on the resulting `Failed`
* state stays green under the mutation**, which is most likely why the write-side fallback survived
* three waves of test work. The worker case therefore reads `KEY_ERROR` off the worker's own
* `Result`, before anything downstream can repair it.
*
* The two save cases have no such second line: both write `_state.value` directly, so the state is
* the right thing to assert there.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class MessagelessFailureTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* The engine gives up without saying why, which is what a native crash looks like from here.
*
* Asserted on the worker's own output `Data` rather than on a screen — see the class KDoc.
*/
@Test
fun `a conversion that fails without a message still reports one`() {
installTestWorkManager(app, Data.EMPTY)
ConversionDependencies.software = { MessagelessTranscoder }
val result = runBlocking { failingWorker().doWork() }
assertTrue("the job must fail rather than retry, got $result", result is ListenableWorker.Result.Failure)
assertEquals(
"a failure with no message must still put something on screen",
ConversionWorker.GENERIC_FAILURE_MESSAGE,
(result as ListenableWorker.Result.Failure).outputData.getString(ConversionWorker.KEY_ERROR),
)
}
@Test
fun `a save that fails without a message still reports one`() {
installTestWorkManager(app, conversionOutput())
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
viewModel.convert()
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
publisher.publishFailure = RuntimeException()
viewModel.save(DESTINATION)
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed } as ConversionState.Failed
assertEquals(SAVE_FAILED_MESSAGE, failed.message)
// The handle travels even on the wordless path. Without this, a fallback that also dropped
// `pending` would pass -- and the file would be unreachable from the screen that just said
// the save failed.
assertNotNull("a wordless failure must still offer the file again", failed.retry)
}
@Test
fun `a join save that fails without a message still reports one`() {
installTestWorkManager(app, joinOutput())
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
viewModel.join()
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
publisher.publishFailure = RuntimeException()
viewModel.save(DESTINATION)
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed } as JoinState.Failed
assertEquals(SAVE_FAILED_MESSAGE, failed.message)
assertNotNull("a wordless failure must still offer the file again", failed.retry)
}
/**
* `FORCE_SOFTWARE` so the failure comes straight out of `runFFmpeg`.
*
* `AUTO` would enter `runMedia3OrFallBack`, whose catch runs the job a second time in software
* — the same exception would arrive, but through a path this test is not about and which
* `HardwareFallbackTest` already owns.
*/
private fun failingWorker(): ConversionWorker {
val spec = OutputFormat.MP4_H265.spec
return TestListenableWorkerBuilder<ConversionWorker>(
context = app,
inputData = workDataOf(
ConversionWorker.KEY_INPUT_URI to "file:///tmp/holiday.mp4",
ConversionWorker.KEY_DISPLAY_NAME to "holiday.mp4",
ConversionWorker.KEY_CONTAINER to spec.container.name,
ConversionWorker.KEY_VIDEO_CODEC to spec.videoCodec.name,
ConversionWorker.KEY_AUDIO_CODEC to spec.audioCodec.name,
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
),
runAttemptCount = 0,
).setId(JOB_ID).build()
}
private fun conversionOutput() = workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConversionWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
ConversionWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
)
private fun joinOutput() = workDataOf(
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConcatWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
ConcatWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
)
private companion object {
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-00000000019a")
const val SUGGESTED_NAME = "holiday.mp4"
const val JOB_MIME_TYPE = "video/mp4"
}
}
/**
* An engine that gives up without saying why.
*
* `RuntimeException()` rather than a subclass with a blank message: `Throwable.message` is *null*
* here, which is the case the elvis exists for. A blank-but-present message takes the left-hand
* side and is a different path — `ConversionStateMappingTest` covers that one, on the read side.
*/
@UnstableApi
private object MessagelessTranscoder : SoftwareTranscoder {
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
): Unit = throw RuntimeException()
}
@@ -1,17 +1,7 @@
package org.libremediaconverter.convert
import android.content.ComponentName
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.content.IntentFilter
import android.content.pm.ProviderInfo
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -20,7 +10,6 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
@@ -31,90 +20,8 @@ import java.io.OutputStream
/** What a destination volume says when it fills up mid-write. */
private const val NO_SPACE = "No space left on device"
private const val DOCUMENTS_AUTHORITY = "org.libremediaconverter.test.documents"
private const val PLAIN_AUTHORITY = "org.libremediaconverter.test.plain"
/**
* A stand-in for the provider behind a SAF destination.
*
* It answers only what `publish()` asks of a destination -- how many bytes are already there,
* and delete it -- backed by a real file so the assertions are about the filesystem rather
* than about a mock's call log alone. The rest of the `ContentProvider` surface is stubbed.
*
* Writing is deliberately NOT routed through it. Robolectric's `ShadowContentResolver`
* consults its registered-stream map before it reaches any provider, which is what lets a
* test hand out a stream that writes some bytes and then fails -- the condition this whole
* file exists for, and one a real provider cannot be asked to produce on demand.
*/
internal open class FakeSafProvider : ContentProvider() {
override fun onCreate() = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? {
val file = backingFile(uri)
if (!file.exists()) return null
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply {
addRow(arrayOf<Any?>(file.name, file.length()))
}
}
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
if (method != METHOD_DELETE_DOCUMENT) return null
val target = extras?.getParcelable(EXTRA_URI, Uri::class.java) ?: return null
deleteRequests += target
deleteFailure?.let { throw it }
backingFile(target).delete()
return Bundle()
}
override fun getType(uri: Uri) = "video/mp4"
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
companion object {
// DocumentsContract.METHOD_DELETE_DOCUMENT and EXTRA_URI are hidden from the public
// SDK, so they cannot be referenced. These are the wire names
// DocumentsContract.deleteDocument() actually sends, which is what a provider sees.
const val METHOD_DELETE_DOCUMENT = "android:deleteDocument"
const val EXTRA_URI = "uri"
/** Where the "documents" really live. Set per test to a Robolectric temp path. */
lateinit var root: File
/** Every delete this provider was asked for, in order. Empty is an assertion too. */
val deleteRequests = mutableListOf<Uri>()
/** Armed by the test that needs the cleanup itself to fail. */
var deleteFailure: RuntimeException? = null
fun backingFile(uri: Uri) = File(root, uri.lastPathSegment.orEmpty())
fun reset(directory: File) {
root = directory
deleteRequests.clear()
deleteFailure = null
}
}
}
/**
* The same provider, registered WITHOUT the documents-provider intent filter.
*
* A separate class because the package manager keys providers by component name, so two
* authorities need two components. It exists to prove the guard is a guard: a content URI
* from something that is not a documents provider must not be handed to `deleteDocument`.
*/
internal class FakePlainProvider : FakeSafProvider()
/** How far a failing copy gets before the volume "fills up". Any value below the payload does. */
private const val PARTIAL_BYTES = 512
/**
* A sink that behaves like a volume filling up.
@@ -171,6 +78,8 @@ class OutputPublisherPublishTest {
private val payload = ByteArray(8192) { (it % 251).toByte() }
/** How far a failing copy gets before the volume "fills up". Any value below the payload does. */
private val documentUri: Uri = Uri.parse("content://$DOCUMENTS_AUTHORITY/document/holiday.mp4")
private val plainUri: Uri = Uri.parse("content://$PLAIN_AUTHORITY/document/holiday_plain.mp4")
private val deadUri: Uri = Uri.parse("content://org.libremediaconverter.nonexistent/document/gone.mp4")
@@ -179,8 +88,8 @@ class OutputPublisherPublishTest {
fun setUp() {
context = RuntimeEnvironment.getApplication()
FakeSafProvider.reset(File(context.cacheDir, "destinations").apply { mkdirs() })
register(FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
register(FakePlainProvider::class.java, PLAIN_AUTHORITY, asDocumentsProvider = false)
registerProvider(context, FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
registerProvider(context, FakePlainProvider::class.java, PLAIN_AUTHORITY, asDocumentsProvider = false)
// SAF's CreateDocument contract hands back a document that already exists and is
// empty, so that is the state every destination starts in here.
@@ -299,6 +208,76 @@ class OutputPublisherPublishTest {
assertEquals(emptyList<Uri>(), FakeSafProvider.deleteRequests)
}
@Test
fun `a destination whose size cannot be determined is never deleted`() {
// The three short-circuits in destinationIsKnownEmpty, and the reason its KDoc gives for
// each of them answering false:
//
// "this decides whether a delete is allowed and 'I could not tell' must never authorise
// one."
//
// The contrast is `a copy that fails partway leaves nothing at the destination` above: a
// provider that *does* say zero gets the delete. These say nothing, so they must not.
// Getting this backwards costs the user a file they already had, on a save that failed.
//
// Named exemption: of the three conjuncts, `size >= 0` cannot be falsified behaviourally.
// Measured -- getColumnIndex returns -1 for an absent column, and isNull(-1) throws
// CursorIndexOutOfBoundsException, which the surrounding runCatching already turns into
// `?: false`. So relaxing it to `size >= -1` leaves this test green: same answer, reached
// by the exception path instead. The guard should stay -- control flow through an exception
// is worse than a comparison, and another Cursor implementation need not throw -- but no
// assertion here pins it, and saying so beats implying the missing-column case covers it.
// `!row.isNull(size)` and `row.moveToFirst()` do both bite.
listOf(
RowShape.NO_SIZE_COLUMN to "a cursor with no SIZE column",
RowShape.NULL_SIZE to "a cursor whose SIZE cell is null",
RowShape.NO_ROWS to "a cursor holding no rows",
// The third case the KDoc names -- "a resolver call that throws" -- and the one the
// list was missing. It reaches `?: false` through `runCatching` rather than through a
// cursor answer, so it is the only one of the four that proves the catch is load
// bearing: a provider that revokes its grant between the picker and the write must not
// have its document deleted on the way out.
RowShape.QUERY_THROWS to "a provider that throws out of query",
).forEach { (shape, description) ->
FakeSafProvider.deleteRequests.clear()
FakeSafProvider.backingFile(documentUri).writeBytes(ByteArray(0))
FakeSafProvider.rowShape = shape
failMidCopy(documentUri, afterBytes = PARTIAL_BYTES)
assertThrows(IOException::class.java) { publisher.publish(staged, documentUri) }
assertEquals(
"$description must not authorise a delete",
emptyList<Uri>(),
FakeSafProvider.deleteRequests,
)
assertTrue(
"$description must leave the destination where it was",
FakeSafProvider.backingFile(documentUri).exists(),
)
}
}
@Test
fun `a provider that declines by returning null fails with the destination named`() {
// openOutputStream has two ways of refusing, and only one of them is otherwise reachable.
// `a destination the provider will not open...` above drives the throwing one -- a provider
// that has gone away. This is the other: a provider that is present, answers, and hands
// back null. Without the `?: error(...)` that becomes an NPE inside `use`, which reaches
// the user as "Conversion failed." with a null message.
val nullOpening = object : OutputPublisher(context) {
override fun openDestination(destination: Uri): OutputStream? = null
}
val failure = runCatching { nullOpening.publish(staged, documentUri) }.exceptionOrNull()
assertTrue("a null stream must not appear to succeed, got $failure", failure != null)
assertTrue(
"the failure must name the destination rather than being a bare NPE; got ${failure?.message}",
failure?.message?.contains("Could not open destination for writing") == true,
)
}
@Test
fun `a copy that succeeds delivers every byte and deletes nothing`() {
shadowOf(context.contentResolver).registerOutputStreamSupplier(documentUri) {
@@ -326,28 +305,4 @@ class OutputPublisherPublishTest {
)
}
}
private fun register(provider: Class<out FakeSafProvider>, authority: String, asDocumentsProvider: Boolean) {
val info = ProviderInfo().apply {
this.authority = authority
packageName = context.packageName
name = provider.name
exported = true
grantUriPermissions = true
}
Robolectric.buildContentProvider(provider).create(info)
// isDocumentUri() does not look at the URI alone: it asks the package manager whether
// anything answers ACTION_DOCUMENTS_PROVIDER for that authority. Registering the
// provider with the resolver is not enough, which is the whole reason the negative
// case above can exist.
val packageManager = shadowOf(context.packageManager)
packageManager.addOrUpdateProvider(info)
if (asDocumentsProvider) {
packageManager.addIntentFilterForProvider(
ComponentName(context.packageName, provider.name),
IntentFilter(DocumentsContract.PROVIDER_INTERFACE),
)
}
}
}
@@ -1,6 +1,8 @@
package org.libremediaconverter.convert
import android.app.Application
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -26,14 +28,18 @@ import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class OutputPublisherStagingTest {
private lateinit var app: Application
private lateinit var cacheDir: File
private lateinit var publisher: OutputPublisher
@Before
fun setUp() {
val context = RuntimeEnvironment.getApplication()
cacheDir = context.cacheDir
publisher = OutputPublisher(context)
// Held as a field rather than a local: the race test below builds an anonymous
// OutputPublisher, and inside that `object` expression a bare `context` resolves to the
// superclass's own constructor property, which is not initialised at the super call.
app = RuntimeEnvironment.getApplication()
cacheDir = app.cacheDir
publisher = OutputPublisher(app)
}
@Test
@@ -99,4 +105,104 @@ class OutputPublisherStagingTest {
publisher.sweepStaging()
}
@Test
fun `the sweep tolerates a staging path that is not a directory`() {
// The other half of `listFiles() ?: return`, and not the same as the case above: a missing
// directory is created by `stagingDir`'s own mkdirs() and lists as empty. Only a path that
// cannot be a directory makes listFiles() answer null, and a sweep that dereferenced that
// would take the app down on a launch rather than on a conversion -- AppStartSweepTest is
// where this runs from.
val stagingPath = stagingPathAsRegularFile()
publisher.sweepStaging()
assertTrue("the sweep must not have replaced the fixture", stagingPath.isFile)
}
/**
* Makes `cacheDir/conversions` a regular file, which is the whole precondition of the test
* above -- and does it in a loop, because a single delete-then-write once lost a race that CI
* caught and this machine did not reproduce.
*
* **That race is closed at the source as of #159, and the loop is kept anyway.**
* `LibreMediaConverterApp.onCreate` launched its staging sweep on `Dispatchers.IO`, and
* `sweepStaging` reads `stagingDir`, whose getter calls `mkdirs()`. Robolectric builds an
* application for every test class that asks for one, so that background `mkdirs()` was in
* flight across the whole suite, on a thread the paused main looper does not control. Between
* deleting this path and writing it there is a window where the path does not exist and that
* `mkdirs()` could win -- `FileNotFoundException: ... (Is a directory)` out of `writeBytes`,
* run 33069641674 on #149, once, against 468 tests that passed here. The JVM suite now runs
* `TestLibreMediaConverterApp`, whose sweep finishes before `onCreate()` returns, so nothing is
* sweeping while a test body runs.
*
* The loop stays because it is what would catch that substitution being undone. Without it the
* regression returns as this one class failing rarely on CI -- the exact shape that took #159
* from a single run on #149 to a wave-4 flake before anyone chased it. Retrying closes the
* window rather than narrowing it, because the race is not symmetric: `mkdirs()` fails on an
* existing regular file, so the invariant only has to survive being *established*.
*/
private fun stagingPathAsRegularFile(): File {
val stagingPath = File(cacheDir, "conversions")
repeat(FIXTURE_ATTEMPTS) {
if (stagingPath.isFile) return stagingPath
stagingPath.deleteRecursively()
runCatching { stagingPath.writeBytes(ByteArray(FIXTURE_BYTES)) }
}
check(stagingPath.isFile) {
"the fixture needs $stagingPath to be a regular file and it is a directory; " +
"something recreated it $FIXTURE_ATTEMPTS times -- see #159"
}
return stagingPath
}
@Test
fun `a file that stops being collectable between the listing and the delete survives`() {
// The race the second timestamp read exists for, and the only branch of it that had never
// run. The comment in sweepStaging states the cost precisely: a worker resumed by
// WorkManager -- in this same process -- could have started writing this very file, and
// unlinking an inode a running job still holds open ends with the job reporting success for
// a path that no longer exists.
//
// So: a file old enough to collect at listing time, touched to now before the delete is
// reached. StagingSweep.collectable already said yes; isCollectable has to say no.
val orphan = publisher.createStagingFile(
StagingNames.forJob(UUID.randomUUID(), "mp4"),
).apply { writeBytes(ByteArray(4096)) }
assertTrue(orphan.setLastModified(System.currentTimeMillis() - StagingSweep.GRACE_PERIOD_MS - 60_000))
// Touched *after* the snapshot is taken, which is the only window that reaches the
// re-read. Doing it around listFiles() instead changes what StagingSweep.collectable is
// given, so the file is never proposed for deletion and the guard is never exercised --
// measured, and the reason the seam sits where it does.
val racing = object : OutputPublisher(app) {
override fun snapshot(listing: Array<File>): List<StagingSweep.Entry> =
super.snapshot(listing).also { orphan.setLastModified(System.currentTimeMillis()) }
}
racing.sweepStaging()
assertTrue(
"a file a live job started writing after the listing must not be unlinked",
orphan.exists(),
)
}
@Test
fun `discarding a file with no parent at all is refused`() {
// A relative name has no parent directory, so `staged.parentFile` is null. The handle
// reaches the ViewModel as a path string out of WorkInfo.outputData and is turned straight
// into a File, so this is not a shape the caller can rule out -- and the guard has to
// answer false rather than dereference it.
val parentless = File("holiday.mp4")
assertNull("the fixture is supposed to have no parent", parentless.parentFile)
assertFalse("a file with no parent is not in staging", publisher.discardStaged(parentless))
}
private companion object {
/** Enough to outlast a burst of application-scope sweeps; one attempt is what CI lost. */
const val FIXTURE_ATTEMPTS = 50
const val FIXTURE_BYTES = 8
}
}
@@ -0,0 +1,63 @@
package org.libremediaconverter.convert
import kotlinx.coroutines.CoroutineDispatcher
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.coroutines.CoroutineContext
/**
* A dispatcher that holds a pick in flight until the test lets it finish.
*
* Issue #49 is about what a ViewModel does *while* a pick is between the tap and the write it
* eventually makes. Both ViewModels put a blocking hop there — the metadata query, and on the
* convert side the probe as well — and both hops go through an injectable dispatcher. Handing
* them this one turns "the pick has been made but has not landed yet" from a window a test has
* to race into a state it can simply sit in.
*
* Nothing here is a fake pick. The real `InputQuery.describe` still runs, on this thread,
* whenever [runAll] is called; the only thing under the test's control is *when*.
*
* Confined to the thread that drives the test. Both ViewModels reach `withContext(pickDispatcher)`
* from a coroutine on the main dispatcher, so [dispatch] is only ever called from there — the
* queue is concurrent anyway, because a dispatcher that quietly dropped a block from another
* thread would fail as a hang rather than as an assertion.
*/
class ParkedPickDispatcher : CoroutineDispatcher() {
private val parked = ConcurrentLinkedQueue<Runnable>()
/**
* How many blocks are waiting.
*
* Asserted on before the interesting part of a test, because "the pick was in flight" is a
* premise rather than a detail: a zero here means the pick had already landed and whatever
* the test went on to prove was proved about a different situation.
*/
val parkedCount: Int get() = parked.size
override fun dispatch(context: CoroutineContext, block: Runnable) {
parked += block
}
/**
* Removes everything parked, oldest first, and hands it to the caller to run.
*
* What [runAll] cannot express: two picks are two hops through this dispatcher, and the defect
* they can produce is the *first* one finishing last. Running them in the order they arrived
* is the one order in which nothing goes wrong, so a test has to be able to choose.
*/
fun takeParked(): List<Runnable> = generateSequence { parked.poll() }.toList()
/**
* Runs everything parked, and everything that parks as a result.
*
* The loop is not defensive: `ConversionViewModel.onInputPicked` makes two hops through this
* dispatcher — the metadata query, then the probe — and the second is only enqueued once the
* first has run. Draining once would leave the probe parked for the rest of the process.
*/
fun runAll() {
while (true) {
val next = parked.poll() ?: return
next.run()
}
}
}
@@ -0,0 +1,125 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.workDataOf
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* The half of issue #49 that is not about reattachment at all.
*
* `onInputPicked` makes two writes and both of them land after a hop off the main thread, so both
* belong to whichever pick was in flight rather than to whichever pick the user last made. Nothing
* was enforcing that. Two taps in quick succession — an easy thing to do while a `content://`
* metadata query is slow — put the loser's file on screen if its query happened to come back
* second, which is the same defect the ticket reported against reattachment with a different
* coroutine on the losing side.
*
* Both cases below were measured rather than assumed: deleting either guard turns the matching
* test red, and deleting the probe one turns nine other tests red with it. Neither was ever
* reported, because a pick that loses to another pick still shows *a* file the user chose -- which
* is what made it worth closing alongside #49 rather than leaving as a second thing to find.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class PickOwnershipTest {
private lateinit var app: Application
private lateinit var parkedPick: ParkedPickDispatcher
private lateinit var viewModel: ConversionViewModel
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
ConversionDependencies.publisher = { RecordingPublisher(app) }
ConversionDependencies.probe = { _, _ -> PROBE }
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to "/dev/null"))
parkedPick = ParkedPickDispatcher()
viewModel = ConversionViewModel(app, pickDispatcher = parkedPick)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* Two taps, and the first one's metadata query is the slow one.
*
* The order is chosen rather than raced: both queries are parked, and this runs the second
* before the first. Without an ownership check the straggler writes last and the screen ends
* up showing a file the user moved off two taps ago.
*/
@Test
fun `the slower of two picks does not land on top of the faster one`() {
viewModel.onInputPicked(FIRST)
viewModel.onInputPicked(SECOND)
val queries = parkedPick.takeParked()
assertEquals("both picks should be in flight", 2, queries.size)
// The second pick's query comes back first; the first pick's is the straggler.
queries[1].run()
queries[0].run()
val current = viewModel.state.value
assertEquals(
"a pick the user has already replaced took the screen: $current",
SECOND,
(current as ConversionState.Ready).input.uri,
)
}
/**
* The second of `onInputPicked`'s two writes, which lands a whole probe later.
*
* The probe hop is a native process spawn, so it is the longest gap in a pick and the easiest
* one to pick again during. This used to be guarded by comparing URIs against the state, which
* answers a narrower question than the one that matters — it cannot tell a second pick of the
* same file from the first, and it reads a state that a later claim may not have written yet,
* which is exactly this case: the newer pick has claimed the screen but its own query has not
* come back, so the state still names the older file and the comparison waves it through.
*/
@Test
fun `a probe from a pick the user has moved off does not fill the card in`() {
viewModel.onInputPicked(FIRST)
parkedPick.takeParked().single().run()
assertEquals(FIRST, (viewModel.state.value as ConversionState.Ready).input.uri)
// The user picks again while the first pick is still probing.
viewModel.onInputPicked(SECOND)
val pending = parkedPick.takeParked()
assertEquals("the first probe and the second query should both be waiting", 2, pending.size)
pending[0].run()
assertNull(
"a probe belonging to a pick the user replaced must not reach the card",
(viewModel.state.value as ConversionState.Ready).input.probe,
)
// And the pick that did win still fills its own card in, probe included.
pending[1].run()
parkedPick.runAll()
val settled = viewModel.state.value as ConversionState.Ready
assertEquals(SECOND, settled.input.uri)
assertNotNull("the winning pick's own probe still has to land", settled.input.probe)
}
private companion object {
val FIRST: Uri = Uri.fromFile(File("/tmp/first.mp4"))
val SECOND: Uri = Uri.fromFile(File("/tmp/second.mp4"))
val PROBE = InputProbe(videoCodec = "h264")
}
}
@@ -0,0 +1,156 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* Issue #49, on the JVM and without the race.
*
* `ReattachOnLaunchTest.doesNotOverwriteAPickTheUserHasAlreadyMade` has been catching this on
* devices since 2026-08-24 — four occurrences, spread across API 33, 35 and 36, which is what
* ruled out an emulator-image quirk. Every one was on attempt 1 and every one passed on re-run,
* which is why it was read as flaky infrastructure for two days. It is not. The assertion it fails
* on is `expected null, but was:<Converted>`: a finished job from an earlier session taking a
* screen the user had already picked a file on.
*
* The defect is a check-then-act whose act is deferred into another coroutine. `reattach()` reads
* `_state.value` and then calls `observe()`, which *launches* a collector that has to suspend on
* `getWorkInfoByIdFlow(...).collect` before it can write anything. So the check happens at one
* moment and the write lands at another:
*
* 1. `init` starts the tag query and suspends in it.
* 2. The user picks a file; `onInputPicked` suspends in its metadata query.
* 3. The query comes back. `_state.value` is still `Idle` — step 2 has not written yet — so the
* guard passes and an observation of the old job is launched.
* 4. The pick lands. `Ready(picked)`. The user owns the screen.
* 5. The observation's first `WorkInfo` arrives and writes `Converted(yesterday)` over it.
*
* The comment above that guard claimed "no suspension point between this check and the assignment
* below, so nothing can interleave". There is no assignment below, and the check and the write
* are in different coroutines.
*
* [ReattachGuardsTest] covers the case where the pick has already *landed*, which the plain guard
* does catch. This covers the one where it is still in flight, which it does not.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ReattachmentOwnershipTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var workManager: WorkManager
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
staged = publisher.createStagingFile("holiday_converted.mp4").apply { writeBytes(ByteArray(4096)) }
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath))
workManager = WorkManager.getInstance(app)
// The situation reattachment exists for: a conversion that finished in a process that is
// gone, with its output still in the cache and nothing in the UI holding its id.
workManager.enqueue(
ConversionWorker.request(
inputUri = Uri.parse("content://test/holiday.mp4"),
displayName = "holiday.mp4",
sizeBytes = 4_096L,
),
).result.get()
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* The race, made into a state the test can sit in rather than one it has to catch.
*
* The pick is parked on a dispatcher this test owns, so it stays in flight — issued, not yet
* written — for as long as the assertions need it to be. Everything else is production: a
* real `WorkManager` holding a real finished job, the real `reattach`, the real `observe`.
*
* Determinism comes from where Robolectric leaves the main looper. `reattach`'s tag query hops
* to a real [kotlinx.coroutines.Dispatchers.IO] thread, so its continuation can only come back
* as a message posted to the main looper — and that looper is paused, so it cannot run until
* something pumps it. `onInputPicked` is an ordinary synchronous call from this thread. The
* pick is therefore *always* issued before the guard runs; none of it is left to timing, which
* is the whole point of writing it here rather than relying on the rare device sighting.
*/
@Test
fun `a conversion found while the user was picking never reaches the screen`() {
val picked = Uri.fromFile(File(app.cacheDir, "beach.mp4").apply { writeBytes(ByteArray(2048)) })
val parkedPick = ParkedPickDispatcher()
val viewModel = ConversionViewModel(app, pickDispatcher = parkedPick)
viewModel.onInputPicked(picked)
assertEquals(
"the pick must still be in flight, or this proves something about a different situation",
1,
parkedPick.parkedCount,
)
// The control, and the reason this test does not rest on a settle window being long
// enough. A second ViewModel with nothing to supersede it reattaches to the same job
// through the same code; when it has arrived, the whole query-guard-observe-write path has
// demonstrably run to completion. `viewModel` started its own reattachment first, so it
// has had at least as long. Waiting on this rather than on a sleep is what makes the
// assertion below "it did not happen" rather than "it had not happened yet".
reattachmentHasRunToCompletion()
val current = viewModel.state.value
assertTrue("reattachment took the screen from the user: $current", current is ConversionState.Idle)
// And the pick, when it lands, is what stays there.
parkedPick.runAll()
val ready = awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
assertEquals(picked, (ready as ConversionState.Ready).input.uri)
reattachmentHasRunToCompletion()
assertEquals("the user's pick must survive a late reattachment", ready, viewModel.state.value)
}
/**
* The other half of the contract: a reattachment nobody has superseded still takes the screen.
*
* Without this, dropping every reattachment on the floor would pass the test above. Same job,
* same WorkManager, same production path — only the pick is missing.
*/
@Test
fun `a conversion nobody has superseded still reaches the screen`() {
val converted = awaitState(ConversionViewModel(app).state, "Converted") {
it is ConversionState.Converted
}
assertEquals(staged.absolutePath, (converted as ConversionState.Converted).staged.absolutePath)
assertEquals("holiday.mp4", converted.input.displayName)
}
/**
* Drives a throwaway ViewModel through a whole reattachment, and returns once it has landed.
*
* [awaitState] pumps the main looper, which is what runs every reattachment continuation
* waiting on it — this one's, and the one belonging to the ViewModel under test, which was
* posted earlier and therefore runs first.
*/
private fun reattachmentHasRunToCompletion() {
awaitState(ConversionViewModel(app).state, "Converted") { it is ConversionState.Converted }
}
}
@@ -0,0 +1,136 @@
package org.libremediaconverter.convert
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.ui.TestTags
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import java.io.File
/**
* The save dialog opens with the type the *job* produced, not the type the picker is showing now.
*
* `ConverterScreen.kt:80` — `state.pendingSave()?.mimeType ?: settings.spec.mimeType` — had never
* taken its left-hand side. Its comment records what the line is for:
*
* > a retry offered after a failed save opens the dialog with the type its first attempt used —
* > the cast answered null for a `Failed`, and the fallback below is the current picker, which a
* > reattached job never set.
*
* So the untested half is the fix, and the tested half is the fallback it was added to stop being
* used.
*
* ## This revises a named exemption, deliberately
*
* `FailedSaveRetryTest`'s KDoc lists this line under "Not asserted here, so each is a decision
* rather than an omission":
*
* > It lives in the entry point, above the `ScreenContent` seam, and reaching it needs a real
* > ViewModel inside a composition.
*
* That was true when written. `AdaptiveShellTest` (#173) then established exactly that capability,
* and #200 added the two `ShadowActivity` mechanics that let a test read what a launcher launched.
* The reason the exemption gave no longer holds, so the exemption is withdrawn rather than left to
* be taken at face value — the same shape as #141 revising #84's boundary. That KDoc is corrected
* in this change.
*
* ## Why the job is reattached rather than run
*
* The screen composes its own ViewModel through `viewModel()`, so nothing can be injected into it.
* A job finished before the composition is the one route to a `Converted` state carrying output
* `Data` this test chose — and it is also the case the line exists for, since a reattached job's
* spec "was never in these settings at all".
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class RetrySaveMimeTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var app: Application
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
ConversionDependencies.probe = { _, _ -> InputProbe() }
staged = OutputPublisher(app).createStagingFile("holiday.mkv").apply { writeBytes(ByteArray(4096)) }
}
@After
fun tearDown() = ConversionDependencies.reset()
@Test
fun `the save dialog offers the type the job produced, not the one the picker is showing`() {
finishAJobProducing(JOB_MIME_TYPE)
composeRule.setContent { ConverterScreen() }
composeRule.waitForIdle()
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
composeRule.waitForIdle()
val intent = requireNotNull(shadowOf(composeRule.activity).nextStartedActivityForResult) {
"the save dialog was never launched"
}.intent
assertEquals(Intent.ACTION_CREATE_DOCUMENT, intent.action)
assertEquals(JOB_MIME_TYPE, intent.type)
// The fixture is only meaningful while the two differ; without this the assertion above
// would pass just as well against the fallback.
assertNotEquals(
"the picker's own type must differ, or this test proves nothing",
JOB_MIME_TYPE,
OutputFormat.MP4_H265.spec.mimeType,
)
}
/**
* A conversion that finished while nothing was watching, which is what `reattach()` picks up.
*
* `SucceedingWorkerFactory` reports this output `Data` for whatever is enqueued, so the job
* lands `SUCCEEDED` carrying a staged path that exists — the two things `Reattachment.choose`
* requires of a finished job.
*/
private fun finishAJobProducing(mimeType: String) {
installTestWorkManager(
app,
workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mkv",
ConversionWorker.KEY_MIME_TYPE to mimeType,
),
)
WorkManager.getInstance(app).enqueue(
ConversionWorker.request(
inputUri = Uri.parse("content://test/holiday.mkv"),
displayName = "holiday.mkv",
sizeBytes = 4_096L,
),
).result.get()
}
private companion object {
/** Matroska, against the MP4 the picker defaults to. */
const val JOB_MIME_TYPE = "video/x-matroska"
}
}
@@ -0,0 +1,241 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.workDataOf
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.join.joinActions
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.work.ConcatWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* That each affordance is wired to the ViewModel method it is named after.
*
* ## What this covers that no other test can
*
* `ConverterScreenContentTest`, `ConverterStateAffordancesTest` and `JoinScreenContentTest` all
* drive the **stateless** content composables, which build their own `ConverterActions`. So the
* wiring — the list of `viewModel::` references the stateful outer hands down — was seen by nothing
* in the suite.
*
* ## The hazard is narrower than "seventeen bindings", and this says so
*
* #156 was filed claiming a transposition of any two bindings would survive the suite. That is not
* true, and it was worth checking rather than testing on the assumption:
*
* | swap | result |
* |---|---|
* | `onVideoCodec` ↔ `onAudioCodec` | **rejected by the compiler** |
* | `onCancel` ↔ `onReset` | **compiles** |
*
* Every typed binding — container, both codecs, preset, suggestion, quality, engine preference —
* takes a distinct parameter type, so the compiler is already the test. Writing assertions for
* those would be theatre.
*
* **The `() -> Unit` bindings are the real gap**, because they are interchangeable to the compiler:
* two on the converter screen (`onCancel`, `onReset`) and three on the join screen (`onJoin`,
* `onCancel`, `onReset`). A Cancel that discards the finished file, or a Join that cancels, is a
* one-character mistake that ships.
*
* ## How they are told apart
*
* By effect, not by a recording double. `reset()` sets the state to `Idle`; `cancel()` with no
* active job leaves it alone (`ConversionViewModel.cancel` is `activeWorkId?.let(...)`, and
* `SettingsEditsTest` pins that). Driving each from a non-`Idle` state is therefore enough to say
* which one ran.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ScreenWiringTest {
private lateinit var app: Application
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
ConversionDependencies.publisher = { RecordingPublisher(app) }
ConversionDependencies.probe = { _, _ -> org.libremediaconverter.model.InputProbe() }
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
// --- the converter screen ----------------------------------------------
@Test
fun `Start over resets, and Cancel does not`() {
// The transposition that compiles. If onReset were bound to cancel, this stays on Ready.
installTestWorkManager(app, Data.EMPTY)
val pick = ParkedPickDispatcher()
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
val actions = converterActions(viewModel, onPickInput = {}, onConvert = {}, onSave = {})
viewModel.onInputPicked(INPUT_URI)
pick.runAll()
assertNotEquals(
"the fixture needs a non-Idle state or neither action is observable",
ConversionState.Idle,
viewModel.state.value,
)
actions.onReset()
assertEquals(ConversionState.Idle, viewModel.state.value)
}
@Test
fun `Cancel leaves the picked file on screen`() {
// The other half. Without it, a wiring with BOTH actions bound to reset passes the test
// above -- and that is exactly what a copy-paste of the wrong line produces.
installTestWorkManager(app, Data.EMPTY)
val pick = ParkedPickDispatcher()
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
val actions = converterActions(viewModel, onPickInput = {}, onConvert = {}, onSave = {})
viewModel.onInputPicked(INPUT_URI)
pick.runAll()
val before = viewModel.state.value
actions.onCancel()
assertEquals(
"Cancel must not throw away the pick the way Start over does",
before,
viewModel.state.value,
)
}
@Test
fun `each settings affordance reaches the setting it is named after`() {
// The typed bindings. The compiler already rejects a transposition among these, so this is
// not that assertion -- it is the cheaper one that each is bound to *something*, and that a
// binding dropped to `{}` during an edit would be caught.
installTestWorkManager(app, Data.EMPTY)
val pick = ParkedPickDispatcher()
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
val actions = converterActions(viewModel, onPickInput = {}, onConvert = {}, onSave = {})
actions.onPreset(OutputFormat.WEBM_VP9)
assertEquals(OutputFormat.WEBM_VP9.spec, viewModel.settings.value.spec)
actions.onContainer(Container.MKV)
assertEquals(Container.MKV, viewModel.settings.value.spec.container)
actions.onVideoCodec(VideoCodec.H264)
assertEquals(VideoCodec.H264, viewModel.settings.value.spec.videoCodec)
actions.onAudioCodec(AudioCodec.FLAC)
assertEquals(AudioCodec.FLAC, viewModel.settings.value.spec.audioCodec)
actions.onQuality(QualityTier.BEST)
assertEquals(QualityTier.BEST, viewModel.settings.value.quality)
actions.onEnginePreference(EnginePreference.FORCE_SOFTWARE)
assertEquals(EnginePreference.FORCE_SOFTWARE, viewModel.settings.value.enginePreference)
actions.onSuggestion(OutputFormat.MP4_H264.spec)
assertEquals(OutputFormat.MP4_H264.spec, viewModel.settings.value.spec)
}
@Test
fun `the launcher-backed actions are the ones the screen supplies`() {
// Not wired to the ViewModel at all, deliberately -- they need an ActivityResultLauncher.
// Asserted so that a later edit routing one of them at the ViewModel is noticed.
installTestWorkManager(app, Data.EMPTY)
val pick = ParkedPickDispatcher()
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
val called = mutableListOf<String>()
val actions = converterActions(
viewModel,
onPickInput = { called += "pick" },
onConvert = { called += "convert" },
onSave = { called += "save:$it" },
)
actions.onPickInput()
actions.onConvert()
actions.onSave("holiday.mp4")
assertEquals(listOf("pick", "convert", "save:holiday.mp4"), called)
}
// --- the join screen, where three are interchangeable -------------------
@Test
fun `Start over resets the join, and Cancel does not`() {
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
val pick = ParkedPickDispatcher()
val viewModel = JoinViewModel(app, pickDispatcher = pick)
val actions = joinActions(viewModel, onPickInputs = {}, onSave = {})
viewModel.onInputsPicked(TWO_INPUTS)
pick.runAll()
assertTrue(
"the fixture needs a non-Idle state: ${viewModel.state.value}",
viewModel.state.value !is JoinState.Idle,
)
actions.onReset()
assertEquals(JoinState.Idle, viewModel.state.value)
}
@Test
fun `Cancel leaves the picked files on screen`() {
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
val pick = ParkedPickDispatcher()
val viewModel = JoinViewModel(app, pickDispatcher = pick)
val actions = joinActions(viewModel, onPickInputs = {}, onSave = {})
viewModel.onInputsPicked(TWO_INPUTS)
pick.runAll()
val before = viewModel.state.value
actions.onCancel()
assertEquals(before, viewModel.state.value)
}
@Test
fun `Join starts the job rather than cancelling or resetting it`() {
// The third of the join screen's interchangeable trio, and the one whose transposition is
// worst: a Join button bound to cancel does nothing at all, which reads as a dead button.
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
val pick = ParkedPickDispatcher()
val viewModel = JoinViewModel(app, pickDispatcher = pick)
val actions = joinActions(viewModel, onPickInputs = {}, onSave = {})
viewModel.onInputsPicked(TWO_INPUTS)
pick.runAll()
actions.onJoin()
assertTrue(
"Join must leave Ready for a running state, not sit still and not go Idle: " +
"${viewModel.state.value}",
viewModel.state.value is JoinState.Joining || viewModel.state.value is JoinState.Joined,
)
}
private companion object {
val INPUT_URI: Uri = Uri.parse("content://test/holiday.mov")
val TWO_INPUTS = listOf(
Uri.parse("content://test/a.mp4"),
Uri.parse("content://test/b.mp4"),
)
}
}
@@ -0,0 +1,197 @@
package org.libremediaconverter.convert
import android.app.Application
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* The seven one-line edits the settings sheet makes, and what each one leaves alone.
*
* ## Why these needed a file of their own
*
* `setPreset` was covered. The six beside it — `setContainer`, `setVideoCodec`, `setAudioCodec`,
* `applySuggestion`, `setQuality`, `setEnginePreference` — and `cancel()` had **no coverage at
* all**, which is the tell: they are reachable from the JVM suite by exactly the route `setPreset`
* already takes, and nothing had asked.
*
* ## What is actually being asserted
*
* Not "the setter sets something". Each of these copies into a nested `OutputSpec`, so the failure
* worth catching is **a setter that writes the right value into the wrong field, or that rebuilds
* the spec and silently discards the other two**. So every test here asserts the field it changed
* *and* that the rest of the spec survived — a `setContainer` implemented as
* `it.copy(spec = OutputFormat.MP4_H265.spec.copy(container = container))` would pass a test that
* only checked the container.
*
* `ConverterScreenContentTest` cannot cover this: it builds `ConverterActions` itself and never
* touches the ViewModel. That the *screen* calls these is #156's, and neither implies the other.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class SettingsEditsTest {
private lateinit var app: Application
private lateinit var viewModel: ConversionViewModel
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
installTestWorkManager(app, Data.EMPTY)
viewModel = ConversionViewModel(app)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `choosing a preset replaces the whole spec`() {
viewModel.setPreset(OutputFormat.WEBM_VP9)
assertEquals(OutputFormat.WEBM_VP9.spec, viewModel.settings.value.spec)
}
@Test
fun `changing the container leaves both codecs alone`() {
// Moved off the default spec first, and that is load-bearing rather than tidiness. The
// default IS `OutputFormat.MP4_H265.spec`, so a `setContainer` that rebuilt the spec from
// that preset instead of from the current one produced an identical answer and the
// mutation went green. Editing the codecs away from the default first is what makes
// "the other two survived" an assertion rather than a coincidence.
viewModel.setPreset(OutputFormat.WEBM_VP9)
val before = viewModel.settings.value.spec
viewModel.setContainer(Container.MKV)
val after = viewModel.settings.value.spec
assertEquals(Container.MKV, after.container)
assertEquals("the video codec is not the container's to change", before.videoCodec, after.videoCodec)
assertEquals("the audio codec is not the container's to change", before.audioCodec, after.audioCodec)
}
@Test
fun `changing the video codec leaves the container and the audio codec alone`() {
// The transposition this guards against is real: setVideoCodec and setAudioCodec take
// different enum types, but a copy(...) naming the wrong field compiles wherever the types
// happen to line up, and the picker would silently set the other one.
val before = viewModel.settings.value.spec
viewModel.setVideoCodec(VideoCodec.VP9)
val after = viewModel.settings.value.spec
assertEquals(VideoCodec.VP9, after.videoCodec)
assertEquals(before.container, after.container)
assertEquals(before.audioCodec, after.audioCodec)
}
@Test
fun `changing the audio codec leaves the container and the video codec alone`() {
val before = viewModel.settings.value.spec
viewModel.setAudioCodec(AudioCodec.OPUS)
val after = viewModel.settings.value.spec
assertEquals(AudioCodec.OPUS, after.audioCodec)
assertEquals(before.container, after.container)
assertEquals(before.videoCodec, after.videoCodec)
}
@Test
fun `applying a suggestion replaces the spec without disturbing quality or engine`() {
// A suggestion comes from ContainerCapabilities when the current spec is invalid, so it is
// a whole spec by construction. What it must not do is reset the two settings beside it.
viewModel.setQuality(QualityTier.BEST)
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
viewModel.applySuggestion(OutputFormat.MKV_H264.spec)
val settings = viewModel.settings.value
assertEquals(OutputFormat.MKV_H264.spec, settings.spec)
assertEquals(QualityTier.BEST, settings.quality)
assertEquals(EnginePreference.FORCE_SOFTWARE, settings.enginePreference)
}
@Test
fun `changing the quality leaves the spec and the engine preference alone`() {
// Both neighbours are moved off their defaults first. Asserting against AUTO -- which is
// what `ConversionSettings` starts with -- let a `setQuality` that also reset the engine
// preference to AUTO pass, because the reset and the survival looked identical.
viewModel.setPreset(OutputFormat.WEBM_VP9)
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
val before = viewModel.settings.value.spec
viewModel.setQuality(QualityTier.BEST)
val settings = viewModel.settings.value
assertEquals(QualityTier.BEST, settings.quality)
assertEquals(before, settings.spec)
assertEquals(
"quality is not the engine preference's to change",
EnginePreference.FORCE_SOFTWARE,
settings.enginePreference,
)
}
@Test
fun `changing the engine preference leaves the spec and the quality alone`() {
// Off the defaults for the same reason as the test above: QualityTier.FAST is the starting
// value, so asserting it here would have been satisfied by a reset as readily as by a
// survival.
viewModel.setPreset(OutputFormat.WEBM_VP9)
viewModel.setQuality(QualityTier.BEST)
val before = viewModel.settings.value.spec
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
val settings = viewModel.settings.value
assertEquals(EnginePreference.FORCE_SOFTWARE, settings.enginePreference)
assertEquals(before, settings.spec)
assertEquals(
"the engine preference is not the quality's to change",
QualityTier.BEST,
settings.quality,
)
}
@Test
fun `editing past every preset leaves no matching preset`() {
// `matchingPreset` is what the settings sheet reads to decide whether to show a preset as
// selected or to say "Custom". Editing one field of a preset must drop it out of the list
// rather than leaving the old one highlighted.
viewModel.setPreset(OutputFormat.MP4_H265)
assertEquals(OutputFormat.MP4_H265, viewModel.settings.value.matchingPreset)
viewModel.setAudioCodec(AudioCodec.FLAC)
assertNull(
"an edited spec is no longer any preset, and the sheet says Custom",
viewModel.settings.value.matchingPreset,
)
assertNotEquals(OutputFormat.MP4_H265.spec, viewModel.settings.value.spec)
}
@Test
fun `cancelling with no active job does nothing rather than throwing`() {
// `activeWorkId?.let(...)` -- the null side. A user can reach Cancel through a state that
// has already finished, and taking the app down for it would be worse than doing nothing.
viewModel.cancel()
assertEquals(ConversionState.Idle, viewModel.state.value)
}
}
@@ -82,6 +82,28 @@ class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
}
}
/**
* Stands in for a worker that died, reporting [outputData] on the way out.
*
* The counterpart to [SucceedingWorkerFactory], and needed for the same reason: the real workers
* cannot run on the JVM, so the only way to ask what a ViewModel does with a `FAILED` `WorkInfo` is
* to produce one. A `Result.failure` carrying `KEY_ERROR` is exactly what both real workers report
* when their engine gives up, and it is the one path where nothing has ever been staged.
*
* `runAttemptCount` is irrelevant here: `Result.failure` is terminal, so WorkManager does not retry
* it and the state goes straight to `Failed` rather than through `Waiting`.
*/
class FailingWorkerFactory(private val outputData: Data) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker = object : Worker(appContext, workerParameters) {
override fun doWork(): Result = Result.failure(outputData)
}
}
/**
* Installs a synchronous test WorkManager whose workers succeed with [outputData].
*
@@ -89,6 +111,16 @@ class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
*/
fun installTestWorkManager(context: Context, outputData: Data): SucceedingWorkerFactory {
val factory = SucceedingWorkerFactory(outputData)
installWorkManager(context, factory)
return factory
}
/** Installs a synchronous test WorkManager whose workers fail, reporting [outputData]. */
fun installFailingTestWorkManager(context: Context, outputData: Data) {
installWorkManager(context, FailingWorkerFactory(outputData))
}
private fun installWorkManager(context: Context, factory: WorkerFactory) {
WorkManagerTestInitHelper.initializeTestWorkManager(
context,
Configuration.Builder()
@@ -98,7 +130,6 @@ fun installTestWorkManager(context: Context, outputData: Data): SucceedingWorker
.setWorkerFactory(factory)
.build(),
)
return factory
}
/**
@@ -0,0 +1,147 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkManager
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* An answer that arrives after the screen has moved on does nothing.
*
* Four refusal arms, cold before this file:
*
* ```
* convert/ConversionViewModel.kt:513 currentInput() ?: return
* convert/ConversionViewModel.kt:600 pendingSave() ?: return
* join/JoinViewModel.kt:316 (as? Ready)?.inputs ?: return
* join/JoinViewModel.kt:390 pendingSave() ?: return
* ```
*
* They are not merely defensive. `ConverterScreen.kt:91` wires `convert()` to the
* **POST_NOTIFICATIONS result**, and `:83` wires `save()` to the CreateDocument result — so both
* are entered by a system callback rather than by a tap, and a result redelivered after process
* death arrives at a brand-new ViewModel sitting on `Idle`.
*
* ## The production change that came with this
*
* `currentInput()` used to answer for `Converting`, `Waiting` and `Converted` as well as `Ready`.
* Those arms were unreachable by tapping Convert but reachable through that permission callback,
* and reaching one enqueued a **second** job over a live one — `activeWorkId` overwritten, the
* first job still running with an orphaned notification and nothing holding its id.
*
* #202 decided to narrow rather than to test it as it stood, because a test written against the old
* shape would have frozen the double-enqueue as intended behaviour. `JoinViewModel.join()` has been
* `(_state.value as? JoinState.Ready)?.inputs ?: return` all along; the two screens are the same
* shape and only one was over-general.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class StaleLauncherResultTest {
private lateinit var app: Application
private lateinit var workManager: WorkManager
private lateinit var staged: java.io.File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
val publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
// A real staged file, because a SUCCEEDED job with no output path maps to Failed rather
// than Converted -- and Converted is the state this file's second case has to reach.
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
installTestWorkManager(
app,
workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mp4",
ConversionWorker.KEY_MIME_TYPE to "video/mp4",
),
)
workManager = WorkManager.getInstance(app)
}
@After
fun tearDown() = ConversionDependencies.reset()
@Test
fun `a permission answer arriving on an empty screen enqueues nothing`() {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Idle") { it is ConversionState.Idle }
viewModel.convert()
assertEquals(ConversionState.Idle, viewModel.state.value)
assertEquals("nothing may be enqueued for a file that is not there", 0, conversionJobs())
}
/**
* The narrowing itself: a permission answer that arrives while a conversion is already running
* must not start a second one.
*
* Reached by converting once — the synchronous test WorkManager finishes it inline, so the
* screen is `Converted`, which is one of the three arms `currentInput()` used to answer for.
* Calling `convert()` again from there is precisely what the permission callback can do.
*/
@Test
fun `a permission answer arriving after the job finished does not start a second one`() {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
viewModel.convert()
val converted = awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
assertEquals("the fixture needs exactly one job to start with", 1, conversionJobs())
viewModel.convert()
assertEquals("a second job must not be enqueued over the first", 1, conversionJobs())
assertEquals("and the screen must not move", converted, viewModel.state.value)
}
@Test
fun `a save answer arriving on an empty screen does nothing`() {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Idle") { it is ConversionState.Idle }
viewModel.save(DESTINATION)
assertEquals(ConversionState.Idle, viewModel.state.value)
}
@Test
fun `a join answer arriving on an empty screen enqueues nothing`() {
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Idle") { it is JoinState.Idle }
viewModel.join()
viewModel.save(DESTINATION)
assertEquals(JoinState.Idle, viewModel.state.value)
assertEquals(0, joinJobs())
}
private fun conversionJobs() = jobsTagged(ConversionWorker::class.java.name)
private fun joinJobs() = jobsTagged(ConcatWorker::class.java.name)
private fun jobsTagged(tag: String) = workManager.getWorkInfosByTag(tag).get().size
private companion object {
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
}
}
@@ -0,0 +1,70 @@
package org.libremediaconverter.convert
import android.net.Uri
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.ConcatPlanner
import org.libremediaconverter.model.ConcatStrategy
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* A clip in a join that nothing could read, from the probe all the way to the strategy.
*
* Both halves of this are covered already, and separately: `MediaProbeTrackWalkTest` pins what
* `concatInputFrom` makes of a track list, and `ConcatPlannerTest`'s
* `an unknown codec is not treated as a match` pins what the planner does with a hand-built
* `ConcatInput(video = null)`. **Nothing spanned the two**, and the span is the load-bearing part:
* the planner's safety rests on the probe really producing that shape, and the hand-built fixture
* would go on passing if it stopped.
*
* Measured rather than asserted: mutating `concatInputFrom`'s initial `video` to a non-null
* placeholder leaves `ConcatPlannerTest` green and turns this red.
*
* ## The asymmetry this protects
*
* `ConcatPlanner` guards its video check against a null codec (`ConcatStrategy.kt:51`) and its
* audio check not at all (`:54`). **That is correct, not an oversight.** `MediaProbe.shortName`
* returns a non-null `String`, so in `concatInputFrom` a null `audioCodec` means the track is
* *absent* — and two clips with no audio genuinely do match. A null `videoCodec` carries both
* meanings, absent or unreadable, which is why only that one is guarded.
*
* So the audio check is safe *because* the video guard fires first on a clip nothing could read.
* Nothing wrote that coupling down and nothing held it.
*
* ## What this deliberately does not cover
*
* `probeForConcat`'s `catch` arm (`MediaProbe.kt:300-302`). It is **not reachable on the JVM**:
* Robolectric's `MediaExtractor` never throws from `setDataSource`, measured across an
* unregistered `content://` authority, a missing `file://`, a file of garbage bytes and an `http://`
* URL — all four returned normally with `trackCount = 0`. So the failure arrives here as an empty
* track list rather than as an exception, which reaches the same `ConcatInput(null, null, 0, 0, 0)`
* by the other road. The catch stays device-only, and this file does not pretend otherwise.
*/
@RunWith(RobolectricTestRunner::class)
class UnreadableJoinInputTest {
@Test
fun `a clip nothing could read probes as unknown, and an unknown clip is re-encoded`() {
val unreadable = MediaProbe.probeForConcat(RuntimeEnvironment.getApplication(), UNREADABLE)
assertNull("an unreadable clip proves nothing about its video codec", unreadable.videoCodec)
assertNull("nor about its audio codec", unreadable.audioCodec)
assertEquals("nor about its dimensions", 0, unreadable.width)
assertEquals(0, unreadable.height)
assertEquals(0, unreadable.frameRate)
assertEquals(
"a clip nothing could read is not evidence of a match with anything",
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(unreadable, unreadable)),
)
}
private companion object {
/** `content://` so the probe takes the SAF branch a real pick takes. Nothing answers it. */
val UNREADABLE: Uri = Uri.parse("content://test/vanished.mp4")
}
}
@@ -166,6 +166,54 @@ class FFmpegCommandBuilderTest {
assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
}
/**
* The arm most conversions actually take, and the only one in `audioArgs` with no test.
*
* `flac wav and opus select the right encoders` above covers the three named arms; MP3 has its
* own. AAC arrives through the `else`, so nothing named it and nothing pinned either half of
* what it emits -- neither `aac` nor `192k` appeared anywhere in this file. Both are shipped
* defaults: MP4 and M4A are the formats the picker offers first, so this is the audio
* every ordinary conversion gets.
*
* The bitrate is asserted as well as the encoder because it is the half a refactor is likelier
* to lose. An `-b:a` that quietly changed would not fail anything, would not look wrong in a
* command line, and would show up only as files that sound different from the ones the app
* produced last month.
*/
@Test
fun `aac is the default encoder, at the bitrate the app ships`() {
assertPair(cmd(OutputFormat.MP4_H264), "-c:a", "aac")
assertPair(cmd(OutputFormat.MP4_H264), "-b:a", "192k")
// Through the `else` rather than through a named arm, so an AAC branch added above it later
// has to keep answering the same way.
assertPair(cmd(OutputFormat.M4A_AAC), "-c:a", "aac")
assertPair(cmd(OutputFormat.M4A_AAC), "-b:a", "192k")
}
/**
* Turning audio off, which the Advanced picker offers and nothing had ever built a command for.
*
* `audioArgs`' `Drop` arm was `ci == 0`. The suite's only `-an` assertion is in
* `gif generates a palette to avoid banding and drops audio`, and that one comes from the image
* path (`FFmpegCommandBuilder.kt:79`/`:90`), which emits `-an` directly and never reaches
* `audioArgs`. Two sites, one string, one tested.
*
* It is a live path rather than defensive code: `AdvancedPicker` renders all of
* `AudioCodec.entries` including `NONE`, `ContainerCapabilities.validate` permits audio-off
* whenever the input has video, and MKV routes the job to FFmpeg.
*
* Both halves are asserted. `-an` alone would still pass if the arm fell through to the `else`
* and emitted an AAC encoder beside it -- a file that is silent because the flag won, carrying
* an encoder nobody asked for.
*/
@Test
fun `turning audio off drops the track instead of encoding one`() {
val args = cmd(OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.NONE))
assertTrue("audio turned off must emit -an, got $args", args.contains("-an"))
assertFalse("a dropped track must not also carry an encoder, got $args", args.contains("-c:a"))
}
@Test
fun `audio only formats never carry a video encoder`() {
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
@@ -56,6 +56,33 @@ class FFmpegConcatCommandTest {
assertEquals("0", args[args.indexOf("-safe") + 1])
}
/**
* The gate that `-safe 0` does not open, and the one every real join needs (#238).
*
* `-safe 0` permits absolute *paths*; the concat demuxer separately whitelists the *protocol*,
* defaulting to `file,crypto,data`. `JoinScreen` picks with `OpenMultipleDocuments`, so real
* inputs are `content://` and `ConcatEngine` writes `ffkitsaf:` paths into the list file — which
* the demuxer refused outright, failing every stream-copy join a user could actually start.
*
* The re-encode strategy has no equivalent assertion because it needs none: it passes each
* input with its own `-i` and never feeds the demuxer a list file. That asymmetry is exactly
* why the defect survived — joining mismatched clips over SAF worked.
*/
@Test
fun `stream copy whitelists the protocol its list file entries actually use`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY,
inputs,
listFile,
output,
OutputFormat.MP4_H264,
)
val whitelist = args[args.indexOf("-protocol_whitelist") + 1].split(",")
assertTrue("ffmpeg-kit's SAF scheme must be permitted, got $whitelist", "ffkitsaf" in whitelist)
// The defaults have to survive too: the list file itself is opened over `file`.
assertTrue("the demuxer still reads the list file itself, got $whitelist", "file" in whitelist)
}
@Test
fun `re-encode passes every input separately and builds a filter graph`() {
val args = FFmpegConcatCommand.build(
@@ -0,0 +1,127 @@
package org.libremediaconverter.ffmpeg
import com.arthenica.ffmpegkit.ReturnCode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* What a finished FFmpegKit session means, for both engines at once.
*
* `FFmpegEngine` and `ConcatEngine` each carried their own copy of this `when`, and the copies had
* drifted: one preferred the fail stack trace and fell back to the log tail, the other only ever
* read the log tail. Neither was tested, because both live inside a callback handed to `FFmpegKit`,
* which does not run on the JVM — so nothing could see that the two disagreed.
*
* **JVM-safe, verified rather than assumed.** `javap` over the committed AAR's runtime jar shows
* `ReturnCode(int)` as a plain public constructor with `SUCCESS`/`CANCEL` int constants and pure
* static `isSuccess`/`isCancel`; its `<clinit>` is constant initialisation and loads no native
* library.
*
* The unification is #203's decision, so the tests pin it as one: a join failure now carries the
* stack trace a conversion failure always did, while the two prefixes stay distinct.
*/
class SessionOutcomeTest {
@Test
fun `a return code of zero is success`() {
assertEquals(SessionOutcome.Success, outcome(ReturnCode(ReturnCode.SUCCESS)))
}
/**
* Cancellation is a separate outcome from failure, and the distinction is the point: the engines
* resume the continuation *cancelled* rather than exceptionally, so a user who pressed Cancel
* does not get an error card.
*/
@Test
fun `a return code of 255 is a cancellation, not a failure`() {
assertEquals(SessionOutcome.Cancelled, outcome(ReturnCode(ReturnCode.CANCEL)))
}
@Test
fun `any other return code fails, and the sentence carries the number`() {
val failed = outcome(ReturnCode(1), stackTrace = "boom") as SessionOutcome.Failed
assertTrue("the code belongs in the message, got: ${failed.message}", failed.message.contains("(1)"))
}
/**
* The half that was different between the two engines before #203, now the same in both.
*/
@Test
fun `the stack trace is preferred over the log tail`() {
val failed = outcome(ReturnCode(1), stackTrace = "the real cause", logTail = "…noise…")
as SessionOutcome.Failed
assertTrue(failed.message.contains("the real cause"))
assertTrue("the log tail must not be appended as well", !failed.message.contains("noise"))
}
@Test
fun `a blank stack trace falls back to the log tail`() {
val blank = outcome(ReturnCode(1), stackTrace = " ", logTail = "the last few lines") as SessionOutcome.Failed
val absent = outcome(ReturnCode(1), stackTrace = null, logTail = "the last few lines") as SessionOutcome.Failed
assertTrue(blank.message.contains("the last few lines"))
assertTrue("a null stack trace is a blank one", absent.message.contains("the last few lines"))
}
/**
* Both sources empty still has to produce a sentence. A message ending in a dangling colon is
* thin, but it is what the user gets when FFmpeg said nothing at all, and it must not be an
* exception on the way to the screen.
*/
@Test
fun `a failure with nothing to say still names the code`() {
val failed = outcome(ReturnCode(1), stackTrace = null, logTail = null) as SessionOutcome.Failed
assertEquals("FFmpeg failed (1): ", failed.message)
}
/**
* `getReturnCode()` is nullable and a session killed before it reported anything has none.
* Neither success nor cancellation, so it fails — and the sentence says so rather than throwing.
*/
@Test
fun `a session with no return code at all fails`() {
val failed = outcome(null, logTail = "whatever was logged") as SessionOutcome.Failed
assertTrue("got: ${failed.message}", failed.message.startsWith("FFmpeg failed (null): "))
}
/**
* Unifying the *strategy* must not unify the *sentence*: the two engines describe different
* jobs, and a join that reports "FFmpeg failed" is a worse message than the one it replaced.
*/
@Test
fun `each engine keeps its own prefix`() {
val join = sessionOutcome(ReturnCode(1), "Joining", { "cause" }, { null }) as SessionOutcome.Failed
assertTrue(join.message.startsWith("Joining failed (1): "))
}
/**
* Neither message source is read unless the outcome is a failure.
*
* They are calls onto a native session, and reading them on the happy path is work every
* successful conversion would do for nothing — which the shape this replaced did not, since it
* read them inside the `else` branch. That is why the parameters are lambdas, and this is what
* would notice if they stopped being.
*/
@Test
fun `a session that succeeded reads neither the stack trace nor the log`() {
var reads = 0
fun counted(): String? {
reads++
return null
}
sessionOutcome(ReturnCode(ReturnCode.SUCCESS), "FFmpeg", ::counted, ::counted)
sessionOutcome(ReturnCode(ReturnCode.CANCEL), "FFmpeg", ::counted, ::counted)
assertEquals("neither source may be touched unless the session failed", 0, reads)
}
private fun outcome(rc: ReturnCode?, stackTrace: String? = null, logTail: String? = null) =
sessionOutcome(rc, "FFmpeg", { stackTrace }, { logTail })
}
@@ -0,0 +1,87 @@
package org.libremediaconverter.join
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.workDataOf
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.ParkedPickDispatcher
import org.libremediaconverter.convert.RecordingPublisher
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.work.ConcatWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* `PickOwnershipTest`'s case on the join side.
*
* `onInputsPicked` makes one write and it lands after a hop off the main thread, so it belongs to
* whichever pick was in flight rather than to whichever set of files the user last chose. Two
* selections in quick succession — likelier here than on the convert side, since a join picks
* several files at a time and the metadata query is per file — put the loser's files on screen if
* its query came back second.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JoinPickOwnershipTest {
private lateinit var app: Application
private lateinit var parkedPick: ParkedPickDispatcher
private lateinit var viewModel: JoinViewModel
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
ConversionDependencies.publisher = { RecordingPublisher(app) }
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
parkedPick = ParkedPickDispatcher()
viewModel = JoinViewModel(app, pickDispatcher = parkedPick)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* Two selections, with the first one's metadata query the slow one.
*
* The order is chosen rather than raced: both queries are parked, and this runs the second
* before the first.
*/
@Test
fun `the slower of two selections does not land on top of the faster one`() {
viewModel.onInputsPicked(FIRST)
viewModel.onInputsPicked(SECOND)
val queries = parkedPick.takeParked()
assertEquals("both selections should be in flight", 2, queries.size)
// The second selection's query comes back first; the first one's is the straggler.
queries[1].run()
queries[0].run()
val current = viewModel.state.value
assertEquals(
"a selection the user has already replaced took the screen: $current",
SECOND,
(current as JoinState.Ready).inputs.map { it.uri },
)
}
private companion object {
val FIRST = listOf(
Uri.parse("content://test/first-a.mp4"),
Uri.parse("content://test/first-b.mp4"),
)
val SECOND = listOf(
Uri.parse("content://test/second-a.mp4"),
Uri.parse("content://test/second-b.mp4"),
)
}
}
@@ -0,0 +1,166 @@
package org.libremediaconverter.join
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.ParkedPickDispatcher
import org.libremediaconverter.convert.RecordingPublisher
import org.libremediaconverter.convert.awaitState
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* Issue #49 on the join side, where nothing was watching for it.
*
* `reattach()` checks that the screen is still free, and then hands the answer to `observe()`,
* which writes from a *different* coroutine that has to suspend on `collect` before it can write
* anything at all. So the check happens at one moment and the write lands at another, with a
* whole pick able to fit in between:
*
* 1. `init` starts the tag query and suspends in it.
* 2. The user picks files; `onInputsPicked` suspends in its metadata query.
* 3. The query comes back. The screen is still `Idle` — step 2 has not written yet — so the
* guard passes and an observation of the old job is launched.
* 4. The pick lands. `Ready(picked)`. The user owns the screen.
* 5. The observation's first `WorkInfo` arrives and writes `Joined(yesterday's file)` over it.
*
* The comment above that guard used to say "no suspension point between this check and the
* assignment below, so nothing can interleave". There is no assignment below, and the two lines
* are in different coroutines.
*
* The convert side has been failing this on CI for two days — four occurrences across three API
* levels, each read as flaky infrastructure. `JoinViewModel` has the identical shape and no test
* at all, which is why this one was written before the fix rather than after it.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JoinReattachmentOwnershipTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var workManager: WorkManager
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
staged = publisher.createStagingFile("joined-yesterday.mp4").apply { writeBytes(ByteArray(4096)) }
installTestWorkManager(
app,
workDataOf(
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
),
)
workManager = WorkManager.getInstance(app)
// The situation reattachment exists for: a join that finished in a process that is gone,
// with its output still in the cache and nothing in the UI holding its id.
workManager.enqueue(
ConcatWorker.request(
inputs = listOf(
Uri.parse("content://test/yesterday-a.mp4"),
Uri.parse("content://test/yesterday-b.mp4"),
),
totalBytes = 8_192L,
),
).result.get()
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* The race, made into a state the test can sit in rather than one it has to catch.
*
* The pick is parked on a dispatcher this test owns, so it is in flight — issued, not yet
* written — for as long as the assertions need it to be. Everything else is real: a real
* `WorkManager` holding a real finished job, the production `reattach`, the production
* `observe`.
*
* Determinism comes from where Robolectric leaves the main looper. `reattach`'s tag query
* hops to a real [kotlinx.coroutines.Dispatchers.IO] thread, so its continuation can only
* come back as a message posted to the main looper — and that looper is paused, so it cannot
* run until something pumps it. `onInputsPicked` is an ordinary synchronous call from this
* thread. The pick is therefore always issued before the guard runs, with nothing left to
* timing.
*/
@Test
fun `a join found while the user was picking never reaches the screen`() {
val parkedPick = ParkedPickDispatcher()
val viewModel = JoinViewModel(app, pickDispatcher = parkedPick)
viewModel.onInputsPicked(PICKED)
assertEquals(
"the pick must still be in flight, or this proves something about a different situation",
1,
parkedPick.parkedCount,
)
// The control, and the reason this test does not rest on a settle window being long
// enough. A second ViewModel with nothing to supersede it reattaches to the same job
// through the same code; when it has arrived, the whole query-guard-observe-write path
// has demonstrably run to completion. `viewModel` started its own reattachment first, so
// it has had at least as long. Waiting on this rather than on a sleep is what makes the
// assertion below "it did not happen" instead of "it had not happened yet".
reattachmentHasRunToCompletion()
val current = viewModel.state.value
assertTrue("reattachment took the screen from the user: $current", current is JoinState.Idle)
// And the pick, when it lands, is what stays there.
parkedPick.runAll()
val ready = awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
assertEquals(PICKED, (ready as JoinState.Ready).inputs.map { it.uri })
reattachmentHasRunToCompletion()
assertEquals("the user's pick must survive a late reattachment", ready, viewModel.state.value)
}
/**
* The other half of the contract: a reattachment nobody has superseded still takes the screen.
*
* Without this, dropping every reattachment on the floor would pass the test above. It is the
* same job, the same WorkManager and the same production path — only the pick is missing.
*/
@Test
fun `a join nobody has superseded still reaches the screen`() {
val joined = awaitState(JoinViewModel(app).state, "Joined") { it is JoinState.Joined }
assertEquals(staged.absolutePath, (joined as JoinState.Joined).staged.absolutePath)
}
/**
* Drives a throwaway ViewModel through a whole reattachment, and returns once it has landed.
*
* [awaitState] pumps the main looper, which is what runs every reattachment continuation
* waiting on it — this one's and the one belonging to the ViewModel under test, which was
* posted earlier and therefore runs first.
*/
private fun reattachmentHasRunToCompletion() {
awaitState(JoinViewModel(app).state, "Joined") { it is JoinState.Joined }
}
private companion object {
val PICKED = listOf(
Uri.parse("content://test/clip-one.mp4"),
Uri.parse("content://test/clip-two.mp4"),
)
}
}
@@ -18,6 +18,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.convert.PendingSave
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
@@ -219,6 +220,51 @@ class JoinStateAffordancesTest {
assertEquals(listOf("reset"), events)
}
/**
* The negative that bounds #30 on this screen. A join that died staged nothing, so its `Failed`
* carries no [PendingSave] and there is nothing a save dialog could be handed. A retry button
* rendered unconditionally here could only fail, and this is what notices.
*/
@Test
fun `a failed join offers no way to save`() {
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertDoesNotExist()
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
}
/**
* #30 on this screen: `save()` keeps the staged file when the copy out throws, and until this
* branch grew a second button the only control it rendered was "Start over" -- `reset()`, which
* deletes exactly that file. Both, not one: the restart still has to be reachable.
*/
@Test
fun `a failed join save offers the file again as well as a restart`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertExists()
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
}
/** The name comes from the job, so a retry wired to a literal would hand back the wrong one. */
@Test
fun `tapping try saving again hands back the name the join chose`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).performScrollTo().performClick()
assertEquals(listOf("save:joined.mp4"), events)
}
@Test
fun `tapping start over after a failed join save resets and does not save`() {
setContent(failedSave())
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
assertEquals(listOf("reset"), events)
}
/** Anything `FileRow` tagged, whichever file it is showing. The prefix comes from the table. */
private val isFileRow = SemanticsMatcher("is a join file row") { node ->
node.config.getOrNull(SemanticsProperties.TestTag)?.startsWith(TestTags.Join.fileRow("")) == true
@@ -230,6 +276,19 @@ class JoinStateAffordancesTest {
sizeBytes = 4_000_000L,
)
/**
* A `Failed` an earlier save left carrying its file, which is the only way `retry` is non-null.
* `staged` names a missing file for the same reason [joined] does -- this arm reads no length.
*/
private fun failedSave() = JoinState.Failed(
message = "There was not enough room on the destination.",
retry = PendingSave(
staged = File("no-such-staged-output.mp4"),
suggestedName = "joined.mp4",
mimeType = "video/mp4",
),
)
/** `staged` names a missing file deliberately -- see the same helper in `JoinScreenContentTest`. */
private fun joined(strategy: ConcatStrategy) = JoinState.Joined(
staged = File("no-such-staged-output.mp4"),
@@ -0,0 +1,200 @@
package org.libremediaconverter.join
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.WorkInfo
import androidx.work.workDataOf
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import org.robolectric.RobolectricTestRunner
/**
* Every answer [joinStateFrom] can give, chosen rather than stumbled into.
*
* The join-side twin of `ConversionStateMappingTest`, and the argument is the same one: the mapping
* ran on every test that drove a real `ConcatWorker`, but a real worker only ever reaches a terminal
* state with well-formed output, so five arms had never been *chosen* by anything.
*
* ## The one that is not just coverage
*
* `an unknown strategy name is read as a re-encode rather than thrown` covers a real defect this
* seam exposed. The line it replaces was:
*
* ```kotlin
* .getString(ConcatWorker.KEY_STRATEGY)?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
* ```
*
* `valueOf` throws on a name this build does not define, and this runs inside a `viewModelScope`
* collect with no handler — so it does not become a `Failed` state, it takes the process down.
* `ConcatWorker.kt` had already made this exact change for `KEY_FORMAT` and written down why; the
* matching read on this side had not been changed with it.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JoinStateMappingTest {
@Test
fun `a running join is joining`() {
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.RUNNING))
}
@Test
fun `a blocked join looks like one that is starting`() {
// Folded into the RUNNING arm deliberately: a job waiting on a prerequisite is nothing the
// user can act on, and a separate word for it would be noise.
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.BLOCKED))
}
@Test
fun `an enqueued join that has already run is waiting to retry`() {
assertEquals(JoinState.Waiting(INPUTS), map(WorkInfo.State.ENQUEUED, runAttemptCount = 1))
}
@Test
fun `an enqueued join that has never run is simply starting`() {
// The other side. Without it, a mapping that ignored runAttemptCount passes the test above.
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.ENQUEUED, runAttemptCount = 0))
}
@Test
fun `a success that named no file is a failure, not an empty success`() {
assertEquals(
JoinState.Failed(JOINED_WITHOUT_A_FILE_MESSAGE),
map(WorkInfo.State.SUCCEEDED, data = Data.EMPTY),
)
}
@Test
fun `a success carries the strategy the worker actually used`() {
// Not cosmetic: the join screen tells the user whether their files were stream-copied or
// re-encoded, which is the difference between lossless and lossy.
val joined = map(
WorkInfo.State.SUCCEEDED,
data = workDataOf(
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
),
) as JoinState.Joined
assertEquals(ConcatStrategy.STREAM_COPY, joined.strategy)
}
@Test
fun `an unknown strategy name is read as a re-encode rather than thrown`() {
// The defect. A build that added a third strategy leaves finished joins in the queue naming
// it, and WorkManager keeps those about a week -- the premise WorkerEnumFallbackTest and
// JobTags are both written on. With `valueOf` this throws IllegalArgumentException inside a
// viewModelScope collect that has no handler, so it is not a Failed state, it is a crash.
//
// REENCODE rather than STREAM_COPY because it is the conservative answer: describing an
// unknown join as lossless would be a claim the app cannot support.
val joined = map(
WorkInfo.State.SUCCEEDED,
data = workDataOf(
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
ConcatWorker.KEY_STRATEGY to "SMART_CONCAT_V2",
),
) as JoinState.Joined
assertEquals(ConcatStrategy.REENCODE, joined.strategy)
}
@Test
fun `a success with no strategy at all falls back the same way`() {
val joined = map(
WorkInfo.State.SUCCEEDED,
data = workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4"),
) as JoinState.Joined
assertEquals(ConcatStrategy.REENCODE, joined.strategy)
}
@Test
fun `a success from older work falls back to the format such a job really used`() {
val joined = map(
WorkInfo.State.SUCCEEDED,
data = workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4"),
) as JoinState.Joined
assertEquals(ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT), joined.suggestedName)
assertEquals(ConcatWorker.DEFAULT_FORMAT.mimeType, joined.mimeType)
}
@Test
fun `a blank name or type falls back the same way a missing one does`() {
val joined = map(
WorkInfo.State.SUCCEEDED,
data = workDataOf(
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
ConcatWorker.KEY_SUGGESTED_NAME to "",
ConcatWorker.KEY_MIME_TYPE to " ",
),
) as JoinState.Joined
assertEquals(ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT), joined.suggestedName)
assertEquals(ConcatWorker.DEFAULT_FORMAT.mimeType, joined.mimeType)
}
@Test
fun `a failure carries the reason the worker gave`() {
assertEquals(
JoinState.Failed("Not enough free space to join these files."),
map(
WorkInfo.State.FAILED,
data = workDataOf(
ConcatWorker.KEY_ERROR to "Not enough free space to join these files.",
),
),
)
}
@Test
fun `a failure with nothing said still says something`() {
assertEquals(
JoinState.Failed(ConcatWorker.GENERIC_FAILURE_MESSAGE),
map(WorkInfo.State.FAILED, data = Data.EMPTY),
)
}
@Test
fun `a failure whose message is blank falls back like a missing one`() {
assertEquals(
JoinState.Failed(ConcatWorker.GENERIC_FAILURE_MESSAGE),
map(WorkInfo.State.FAILED, data = workDataOf(ConcatWorker.KEY_ERROR to " ")),
)
}
@Test
fun `a cancellation lands wherever the caller said it should`() {
// A join started here goes back to Ready with the picked files; one picked up by reattach
// goes to Idle, because those URIs belong to a process that no longer exists.
assertEquals(
JoinState.Ready(INPUTS),
map(WorkInfo.State.CANCELLED, cancelled = JoinState.Ready(INPUTS)),
)
assertEquals(JoinState.Idle, map(WorkInfo.State.CANCELLED, cancelled = JoinState.Idle))
}
private fun map(
state: WorkInfo.State,
runAttemptCount: Int = 0,
data: Data = Data.EMPTY,
cancelled: JoinState = JoinState.Ready(INPUTS),
): JoinState = joinStateFrom(
JoinUpdate(state = state, runAttemptCount = runAttemptCount, outputData = data),
inputs = INPUTS,
cancelled = cancelled,
)
private companion object {
val INPUTS = listOf(
InputFile(Uri.parse("content://test/a.mp4"), "a.mp4", 1024L),
InputFile(Uri.parse("content://test/b.mp4"), "b.mp4", 2048L),
)
}
}
@@ -0,0 +1,102 @@
package org.libremediaconverter.join
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.RecordingPublisher
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.work.ConcatWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* The two layers that refuse a short join, refusing it with one sentence.
*
* ## Why this is not "assert a constant equals itself"
*
* `ConcatWorker` and `JoinViewModel` both reject a join of fewer than two files, and before #158
* each carried **its own copy of the literal**. Only the worker's was pinned — by `RefusedJobTest`,
* added in #139 — so the wording on the screen could drift away from the wording in the job with no
* test saying anything, for one message the user sees from one condition.
*
* Sharing a constant makes them agree by construction. What it does *not* do is prove that both
* layers still reach it: a refactor that stops `JoinViewModel` refusing at all, or that gives it a
* different message, passes any test that only reads `TOO_FEW_INPUTS_MESSAGE`. So each layer is
* driven for real here — the ViewModel through `onInputsPicked`, the worker through `doWork` — and
* the assertion is that the two answers are **the same string**, taken from two running layers
* rather than from one declaration.
*
* That is the shape `CLAUDE.md` asks for: revert the sharing and this goes red, because the two
* sites drift the moment they are allowed to.
*
* ## Scope
*
* The arity guard's own behaviour on the ViewModel side — that it refuses one file, that it accepts
* two, that it claims ownership first — is #155's, and this deliberately does not duplicate it.
* This file is about the *agreement between layers*, which is what #158 changed.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class SharedFailureMessagesTest {
private lateinit var app: Application
private lateinit var viewModel: JoinViewModel
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
ConversionDependencies.publisher = { RecordingPublisher(app) }
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
viewModel = JoinViewModel(app)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `both layers refuse a one-file join with the same sentence`() {
// The ViewModel, refusing before anything is enqueued.
viewModel.onInputsPicked(listOf(ONE_FILE))
val fromScreen = (viewModel.state.value as JoinState.Failed).message
// The worker, refusing a job that reached the queue anyway -- which it can, because
// ConcatWorker.request(...) takes a List<Uri> and checks nothing about its length.
val result = runBlocking { worker(ONE_FILE).doWork() }
val fromJob = (result as ListenableWorker.Result.Failure)
.outputData.getString(ConcatWorker.KEY_ERROR)
assertEquals(
"the screen and the job must say the same thing about the same refusal",
fromScreen,
fromJob,
)
// And that the shared sentence is the one either layer would have written on its own,
// rather than both having drifted together to something else.
assertEquals(ConcatWorker.TOO_FEW_INPUTS_MESSAGE, fromScreen)
}
private fun worker(vararg inputs: Uri): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
context = app,
inputData = workDataOf(
ConcatWorker.KEY_INPUT_URIS to inputs.map(Uri::toString).toTypedArray(),
ConcatWorker.KEY_TOTAL_BYTES to 1024L,
),
runAttemptCount = 0,
).build()
private companion object {
val ONE_FILE: Uri = Uri.parse("content://test/holiday.mp4")
}
}
@@ -11,6 +11,11 @@ import org.junit.Test
* `OutputFormat` used to be twelve hand-picked triples, and its KDoc defended that on the grounds
* that a closed set was what made routing decidable. Opening it up moves that burden here, so this
* is where decidability now has to be proven.
*
* That includes what a refusal offers instead. `Validation.Invalid` promises every suggestion is
* itself valid and names this class as the proof, so a branch that assembles its own suggestion
* list rather than going through `suggestions()` is only checked here if some row happens to reach
* it — which is how a dead-end chip survived two widenings of that table.
*/
class ContainerCapabilitiesTest {
@@ -20,6 +25,44 @@ class ContainerCapabilitiesTest {
container = Container.MP4,
)
/**
* An MP3, and the reason several rules below need a second probe.
*
* `hasVideo = false` is the load-bearing field. Every rule that reads only the spec answers the
* same for this input as for a video file, which is exactly how a spec naming a video codec was
* called valid for a file with no video track to put in it.
*/
private val mp3Source = InputProbe(
videoCodec = null,
audioCodec = "mp3",
hasVideo = false,
kind = InputKind.AUDIO_ONLY,
container = Container.MP3,
)
/**
* An audio-only input carrying a codec MP4 has no place for at all.
*
* Vorbis lives in Ogg and Matroska; MP4 carries AAC, MP3, Opus and FLAC. That gap is what turns
* a suggestion which merely drops the video track into a second refusal.
*/
private val vorbisSource = InputProbe(
videoCodec = null,
audioCodec = "vorbis",
hasVideo = false,
kind = InputKind.AUDIO_ONLY,
container = Container.OGG,
)
/** The same shape, for the other codec MP4 refuses. One case is a coincidence; two is the rule. */
private val pcmSource = InputProbe(
videoCodec = null,
audioCodec = "pcm_s16le",
hasVideo = false,
kind = InputKind.AUDIO_ONLY,
container = Container.WAV,
)
// --- copy and encode are different questions ----------------------------
/**
@@ -82,20 +125,56 @@ class ContainerCapabilitiesTest {
}
}
/** A suggestion that is itself invalid is worse than no suggestion. */
/**
* A suggestion that is itself invalid is worse than no suggestion.
*
* Only a branch that assembles its own suggestion list can break that promise: [suggestions]
* ends by filtering on `validate(...).isValid`, so everything routed through it is valid by
* construction. Those branches are what this table has to cover — the image output, and copy
* the video from a file that has none, which built its list by hand and came back refused for
* a Vorbis or PCM source into MP4 and an MP3 into WebM. The Advanced picker showed a one-tap
* fix that led straight to a second error, through two widenings of this table that never
* reached the branch.
*/
@Test
fun `every suggestion is itself valid`() {
val broken = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC)
val result = ContainerCapabilities.validate(broken, h264Source)
val cases = listOf(
OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC) to h264Source,
// The audio-only input. Every rejection it can reach used to hand back `None + None`
// — a spec validation refuses in the next breath — because these branches built their
// suggestion by hand instead of going through the repair-and-filter path.
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE) to mp3Source,
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE) to mp3Source,
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE) to mp3Source,
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.AAC) to mp3Source,
// Copy-the-video-from-a-file-with-no-video, the last branch that built its offer by
// hand. It escaped the five rows above because `spec.copy(videoCodec = NONE)` is valid
// exactly when the audio axis happens to be fine — true for the AAC and MP3 sources
// used there, false for any audio the target container cannot carry.
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY) to vorbisSource,
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY) to pcmSource,
OutputSpec(Container.WEBM, VideoCodec.COPY, AudioCodec.COPY) to mp3Source,
// The same branch with audio the container *can* hold, which is the half that already
// worked and must keep working: the repair here is a copy, so the offer is the very
// spec the caller handed to `suggestions`. It survives only because the exclusion is
// against what the user asked for rather than against the repair.
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY) to mp3Source,
// The one branch that still builds its list by hand, so that it is asserted rather
// than merely reasoned about: an image container takes `None + None` and nothing else,
// which makes its single offer valid by construction.
OutputSpec(Container.GIF, VideoCodec.H264, AudioCodec.AAC) to h264Source,
)
val invalid = result as? Validation.Invalid
?: throw AssertionError("expected H.264 in WebM to be rejected")
assertTrue("no alternatives offered", invalid.suggestions.isNotEmpty())
invalid.suggestions.forEach { suggestion ->
assertTrue(
"suggested $suggestion is itself invalid",
ContainerCapabilities.validate(suggestion, h264Source).isValid,
)
cases.forEach { (spec, probe) ->
val invalid = ContainerCapabilities.validate(spec, probe) as? Validation.Invalid
?: throw AssertionError("expected $spec to be rejected")
assertTrue("no alternatives offered for $spec on $probe", invalid.suggestions.isNotEmpty())
invalid.suggestions.forEach { suggestion ->
assertTrue(
"suggested $suggestion for $spec on $probe is itself invalid",
ContainerCapabilities.validate(suggestion, probe).isValid,
)
}
}
}
@@ -127,6 +206,117 @@ class ContainerCapabilitiesTest {
assertTrue((result as Validation.Invalid).suggestions.isNotEmpty())
}
/**
* The same rule, seen only against the probe.
*
* A video codec named for a file with no video track is dropped, not encoded — so
* MP4/H.265/None on an MP3 empties the output exactly as None/None does. Reading the spec
* alone answered "valid" because the spec names a video codec, and the job went to Media3,
* where `EditedMediaItem.Builder` refuses a composition with both tracks removed by throwing
* on Transformer's own HandlerThread.
*/
@Test
fun `a video codec named for a file with no video track and no audio is refused`() {
ContainerCapabilities.encodableVideo(Container.MP4).forEach { codec ->
val spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE)
val result = ContainerCapabilities.validate(spec, mp3Source)
assertFalse(
"MP4/${codec.label}/None on an audio-only input plans to (Drop, Drop) and would " +
"produce an empty file; it must be refused. Got $result",
result.isValid,
)
}
}
/**
* The refusal is only worth having if it leads somewhere.
*
* The COPY form of this was already refused, but its one hand-built suggestion was
* `None + None` — which validation refuses in the next breath, so the Advanced picker offered
* a one-tap fix that fixed nothing. Every face of the rule now goes through the shared
* suggestion path, so the offer keeps the one track the input actually has.
*/
@Test
fun `refusing an empty output still offers a way to keep the audio`() {
listOf(VideoCodec.H265, VideoCodec.H264, VideoCodec.COPY, VideoCodec.NONE).forEach { codec ->
val spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE)
val invalid = ContainerCapabilities.validate(spec, mp3Source) as? Validation.Invalid
?: throw AssertionError("expected MP4/${codec.label}/None to be rejected")
assertTrue(
"a refusal with no way out is a dead end in the Advanced picker",
invalid.suggestions.isNotEmpty(),
)
assertTrue(
"every suggestion must keep a track, got ${invalid.suggestions}",
invalid.suggestions.all { it.audioCodec != AudioCodec.NONE },
)
}
}
/**
* A repair must not name a track the input does not have.
*
* `repairVideo` used to fall through to "the first codec this container can encode" whenever
* nothing else fitted, and for an MP3 that produced the non-sequitur `MP4 · H.264 · Copy`.
* It validated, so nothing caught it — but [CopyPlanner] drops that video track anyway, which
* makes the codec in the offer a fiction.
*/
@Test
fun `a repair for a file with no video track never names a video codec`() {
listOf(
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE),
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE),
).forEach { spec ->
val invalid = ContainerCapabilities.validate(spec, mp3Source) as Validation.Invalid
invalid.suggestions.forEach {
assertEquals(
"offering ${it.videoCodec.label} for a file with no video track is a fiction; " +
"CopyPlanner drops it. Suggested $it for $spec",
VideoCodec.NONE,
it.videoCodec,
)
}
}
}
/**
* The rule stated as the property it is, over the whole matrix.
*
* A plan of (Drop, Drop) is precisely the composition `EditedMediaItem.Builder` refuses to
* build, so no non-image spec that reaches it may be called valid. Sweeping every container ×
* codec × codec against both probes is what stops the next container or codec from
* reintroducing the gap on an axis nobody thought to write a case for.
*
* Image outputs are exempt and deliberately so: GIF and PNG frames carry no codecs at all, and
* `None + None` is the only spec they accept — but they never reach Media3, because the router
* sends every image output to FFmpeg.
*/
@Test
fun `no valid non-image spec plans to remove both tracks`() {
val specs = Container.entries
.filterNot { it == Container.GIF || it == Container.IMAGE_SEQUENCE }
.flatMap { container -> VideoCodec.entries.map { container to it } }
.flatMap { (container, video) -> AudioCodec.entries.map { OutputSpec(container, video, it) } }
val cases = specs.flatMap { spec -> listOf(h264Source, mp3Source).map { spec to it } }
val empties = cases.filter { (spec, probe) ->
val plan = CopyPlanner.plan(spec, probe)
plan.video == VideoPlan.Drop && plan.audio == AudioPlan.Drop
}
assertTrue("the sweep found nothing to check — the filter has gone wrong", empties.isNotEmpty())
empties.forEach { (spec, probe) ->
assertFalse(
"$spec on $probe plans to (Drop, Drop) — an empty file, and the composition " +
"Media3 cannot build — so it must not validate",
ContainerCapabilities.validate(spec, probe).isValid,
)
}
}
@Test
fun `copying is offered as the fix when the codec is right but unencodable`() {
val av1Source = InputProbe(videoCodec = "av1", audioCodec = "aac", container = Container.MKV)
@@ -168,4 +358,213 @@ class ContainerCapabilitiesTest {
assertEquals(emptyList<VideoCodec>(), ContainerCapabilities.encodableVideo(container))
}
}
// --- the audio axis -----------------------------------------------------
//
// Every rule below has a video twin already tested above. The two halves of `validate` were
// written together and only one of them was ever checked, so these are deliberately shaped like
// their twins rather than as a fresh idea about what to assert.
@Test
fun `an unidentifiable source audio codec cannot be copied`() {
// The audio twin of `an unidentifiable source codec cannot be copied`. Never guess: a copy
// of an unidentified codec is how you ship a file that does not play.
val unknownAudio = InputProbe(videoCodec = "h264", audioCodec = null, container = Container.MP4)
val spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.COPY)
val invalid = ContainerCapabilities.validate(spec, unknownAudio) as? Validation.Invalid
?: throw AssertionError("copying an unidentified audio codec must be refused")
assertTrue(invalid.message, invalid.message.contains("could not be identified"))
assertEverySuggestionValid(invalid, unknownAudio)
}
@Test
fun `copying an audio codec the container cannot hold is refused`() {
// MP4 carries AAC, MP3, Opus and FLAC. Vorbis lives in Ogg and Matroska, so a stream copy
// out of a Vorbis source into MP4 has nowhere to put the track.
val vorbisAudio = InputProbe(videoCodec = "h264", audioCodec = "vorbis", container = Container.MKV)
val spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.COPY)
val invalid = ContainerCapabilities.validate(spec, vorbisAudio) as? Validation.Invalid
?: throw AssertionError("Vorbis copied into MP4 must be refused")
assertEquals("MP4 cannot hold Vorbis audio.", invalid.message)
assertEverySuggestionValid(invalid, vorbisAudio)
}
@Test
fun `an audio codec the container cannot hold is refused on the encode path too`() {
// WAV carries PCM and nothing else. The twin is `H265 in AVI is refused`.
val spec = OutputSpec(Container.WAV, VideoCodec.NONE, AudioCodec.AAC)
val invalid = ContainerCapabilities.validate(spec, mp3Source) as? Validation.Invalid
?: throw AssertionError("AAC in WAV must be refused")
assertEquals("WAV cannot hold AAC audio.", invalid.message)
assertEverySuggestionValid(invalid, mp3Source)
}
@Test
fun `an audio codec this app cannot encode is refused, and copying is offered instead`() {
// Matroska carries Vorbis; nothing here encodes it. The refusal has to say so *and* say
// what would work, which is the audio twin of `copying is offered as the fix when the codec
// is right but unencodable`.
val spec = OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.VORBIS)
val invalid = ContainerCapabilities.validate(spec, h264Source) as? Validation.Invalid
?: throw AssertionError("encoding Vorbis must be refused")
assertEquals(
"This app cannot encode Vorbis audio. It can still be copied from a Vorbis source.",
invalid.message,
)
assertEverySuggestionValid(invalid, h264Source)
}
@Test
fun `copying a video codec the container cannot hold is refused`() {
// Not the audio axis, but the one video refusal with no test: AVI predates H.265, so a
// stream copy out of an HEVC source into AVI has nowhere to put the track. `H265 in AVI is
// refused` covers the matrix; this covers what validate() does with it.
val h265Source = InputProbe(videoCodec = "hevc", audioCodec = "mp3", container = Container.MP4)
val spec = OutputSpec(Container.AVI, VideoCodec.COPY, AudioCodec.MP3)
val invalid = ContainerCapabilities.validate(spec, h265Source) as? Validation.Invalid
?: throw AssertionError("H.265 copied into AVI must be refused")
assertEquals("AVI cannot hold H.265 video.", invalid.message)
assertEverySuggestionValid(invalid, h265Source)
}
@Test
fun `no audio track is accepted by every container in both modes`() {
// The audio twin of VideoCodec.NONE -> true. A container that refused "no audio" would make
// every video-only output invalid.
Container.entries.forEach { container ->
listOf(CodecMode.COPY, CodecMode.ENCODE).forEach { mode ->
assertTrue(
"$container should accept no audio track ($mode)",
ContainerCapabilities.accepts(container, AudioCodec.NONE, mode),
)
}
}
}
/**
* The video twin of `no audio track is accepted by every container in both modes`.
*
* Dead in production today, and deliberately so: every caller guards `NONE` before asking the
* matrix, so nothing reaches this arm through the app. **The asymmetry is the argument, not the
* reachability** -- its audio counterpart at the top of the same `when` has had a dedicated
* test since #136, and one of a matched pair being covered is how a later reader concludes the
* other was considered and exempted. It was not; it was simply missed.
*
* Not the same shape as the two `COPY -> error(...)` arms, which `docs/coverage-read-findings.md`
* records as a named exemption (F4). Those are guards that must not be provokable. This is a
* documented answer -- "no video track fits anywhere" -- and an answer is a thing to pin.
*/
@Test
fun `no video track is accepted by every container in both modes`() {
Container.entries.forEach { container ->
listOf(CodecMode.COPY, CodecMode.ENCODE).forEach { mode ->
assertTrue(
"$container should accept no video track ($mode)",
ContainerCapabilities.accepts(container, VideoCodec.NONE, mode),
)
}
}
}
/**
* A suggestion that keeps the codec the user asked for, rather than falling back to the
* container's first encodable one.
*
* `repairVideo`'s third arm -- "the request is not a copy, and this container can encode it" --
* is the one that preserves intent, and it was the only arm of the four nothing reached. The
* property test above executes `repairVideo` on every case it walks and lands elsewhere each
* time: an explicit COPY that works, a source the container can carry untouched, or no video
* track at all.
*
* The route is indirect because it is the only one the app has. VP9 into WebM is a perfectly
* good video request; what makes it invalid is the *audio* -- WebM carries Opus and Vorbis, not
* AAC. So `validateAudio` refuses, `suggestions` looks for a container that can hold what was
* asked for, and MP4 can encode VP9. The suggestion has to come back carrying VP9: swapping to
* the container's first encodable codec would discard the choice the user made.
*/
@Test
fun `a repaired suggestion keeps the video codec the user chose`() {
val invalid = ContainerCapabilities.validate(
OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.AAC),
h264Source,
)
assertTrue("WebM cannot hold AAC, so this spec is invalid", invalid is Validation.Invalid)
val suggestions = (invalid as Validation.Invalid).suggestions
assertTrue(
"expected a suggestion that still encodes VP9, got $suggestions",
suggestions.any { it.videoCodec == VideoCodec.VP9 },
)
assertEverySuggestionValid(invalid, h264Source)
}
/**
* The fallback in `firstContainerHolding`: when the input's own container cannot hold the
* codec the user asked for, any container that can will do.
*
* The preferred half -- "the container the input already uses" -- is what every other case
* reaches, because they all start from a file whose own container carries the codec in
* question. The elvis after it had never run.
*
* AVI is the input that makes it run: AVI predates H.265 and has no mapping for it, so asking
* an AVI for H.265 is refused, and the container the input already uses cannot be part of the
* answer. Without the fallback the only candidates left are AVI itself and the container
* holding the *source* codec -- also AVI -- so the refusal still offers something, but what it
* offers is H.264: the app quietly declines the codec the user asked for instead of moving them
* to a container that supports it.
*
* That is why this asserts the codec survives rather than that the list is non-empty. A
* non-empty assertion passes with the fallback deleted -- measured, not assumed.
*/
@Test
fun `an input whose container cannot hold the requested codec is moved, not downgraded`() {
val aviSource = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.AVI)
val invalid = ContainerCapabilities.validate(
OutputSpec(Container.AVI, VideoCodec.H265, AudioCodec.AAC),
aviSource,
)
assertTrue("AVI has no mapping for H.265", invalid is Validation.Invalid)
val suggestions = (invalid as Validation.Invalid).suggestions
assertTrue(
"expected a container that can actually hold H.265, got $suggestions",
suggestions.any { it.videoCodec == VideoCodec.H265 },
)
assertEverySuggestionValid(invalid, aviSource)
}
@Test
fun `resolving audio COPY before asking the matrix is required`() {
// The audio twin of `resolving COPY before asking the matrix is required`, and the reason is
// identical: silently answering "false" would refuse a perfectly good remux.
runCatching { ContainerCapabilities.accepts(Container.MP4, AudioCodec.COPY, CodecMode.COPY) }
.onSuccess { throw AssertionError("expected audio COPY to be rejected by the matrix") }
}
/**
* Every alternative a refusal offers has to be one the same input could actually take.
*
* `Validation.Invalid` promises exactly this and names this class as the proof. The global
* property test walks the presets; these paths reach `suggestions()` through `validateAudio`,
* which no preset does.
*/
private fun assertEverySuggestionValid(invalid: Validation.Invalid, probe: InputProbe) {
invalid.suggestions.forEach {
assertTrue(
"suggestion $it is itself invalid, so the chip leads to a second error",
ContainerCapabilities.validate(it, probe).isValid,
)
}
}
}
@@ -350,6 +350,34 @@ class ConversionRouterTest {
}
}
/**
* Why `Media3Engine` still needs a guard of its own.
*
* `ContainerCapabilities.validate` now refuses "a video codec with the audio off" for an input
* with no video track, so neither the picker nor `ConversionWorker` will start one. Routing is
* a separate question and still answers MEDIA3 — nothing about a dropped track makes the job
* un-hardware-able — so a request that skips validation, from a direct
* `ConversionWorker.request(...)` or a job queued before the settings changed, arrives at the
* engine with a plan Media3 cannot build. That has to fail the job, not the process.
*/
@Test
fun `a plan that drops both tracks still routes to media3`() {
val audioOnly = InputProbe(
videoCodec = null,
audioCodec = "mp3",
hasVideo = false,
container = Container.MP3,
kind = InputKind.AUDIO_ONLY,
)
val spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE)
val plan = CopyPlanner.plan(spec, audioOnly)
assertEquals(VideoPlan.Drop, plan.video)
assertEquals(AudioPlan.Drop, plan.audio)
assertEquals(Engine.MEDIA3, route(spec, probe = audioOnly).engine)
}
@Test
fun `audio-only formats are flagged as such`() {
assertEquals(true, OutputFormat.MP3.isAudioOnly)
@@ -146,6 +146,33 @@ class CopyPlannerTest {
assertTrue("copying the only track is still a remux", plan.isPureRemux)
}
/**
* The one plan `Media3Engine` cannot be handed.
*
* `EditedMediaItem.Builder` refuses a composition with both tracks removed —
* checkState("Audio and video cannot both be removed") — and this is how an ordinary-looking
* spec reaches it: a video codec named for a file that has no video, with the audio switched
* off. Neither half is unusual on its own, which is why validation could read the spec, see a
* video codec, and call it fine.
*/
@Test
fun `an audio-only source with the audio dropped removes both tracks`() {
val audioOnly = InputProbe(
videoCodec = null,
audioCodec = "mp3",
hasVideo = false,
container = Container.MP3,
kind = InputKind.AUDIO_ONLY,
)
val plan = CopyPlanner.plan(
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
audioOnly,
)
assertEquals(VideoPlan.Drop, plan.video)
assertEquals(AudioPlan.Drop, plan.audio)
assertTrue("an empty plan is not a remux", !plan.isPureRemux)
}
@Test
fun `copying one track and encoding the other is not a pure remux`() {
val plan = CopyPlanner.plan(
@@ -28,6 +28,7 @@ class TagTableUniquenessTest {
fun `every tag constant has its own value`() {
val tags = tagsIn(
TestTags::class.java,
TestTags.Shell::class.java,
TestTags.Converter::class.java,
TestTags.Join::class.java,
)
@@ -0,0 +1,131 @@
package org.libremediaconverter.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* The theme has to resolve the scheme its arguments name, and `ThemeKt` had no test at all --
* 23 lines, none of them covered, which is how #68 was found.
*
* Only two of the four branches in [LibreMediaConverterTheme]'s `when` are reachable from the
* app. `MainActivity` is the single call site and passes no arguments, so `dynamicColor` is
* always `true` and the live choice is between the dynamic dark and dynamic light schemes.
* Those two are what ships, and asserting on them survives whichever way #68 is decided.
*
* **The other two branches have no caller.** `dynamicColor = false` is passed below by this
* test and by nothing else in `app/src`, so the coverage it produces is not evidence that a
* switch exists -- misreading it that way is the whole reason #68 was filed. #68 is the open
* decision about whether one ever will exist.
*
* What the assertions distinguish the branches on was measured under Robolectric `sdk=36`
* rather than assumed. The dynamic palette resolves to the platform's own default there --
* dark background `#121318` against light `#FAF8FF`, dark primary `#B0C6FF` -- and that is a
* different hue from the brand palette's [Purple80] / [Purple40]. A dynamic scheme reads the
* device, so those exact values belong to the Robolectric stub and to no particular phone,
* which is why the live-branch tests compare the two resolved schemes against each other
* instead of hard-coding either one.
*/
@RunWith(RobolectricTestRunner::class)
class ThemeColorSchemeTest {
// The **v2** rule (`androidx.compose.ui.test.junit4.v2`), as everywhere else in this
// source set.
@get:Rule
val composeRule = createComposeRule()
/**
* Every scheme the `when` can produce, read out of [MaterialTheme] inside the content
* lambda -- the only place that shows what the theme actually chose, rather than what the
* caller hoped for.
*
* All four are resolved in one composition because `setContent` may be called once per
* test, and a comparison needs at least two of them.
*/
private fun resolveAll(): Schemes {
lateinit var dynamicDark: ColorScheme
lateinit var dynamicLight: ColorScheme
lateinit var brandDark: ColorScheme
lateinit var brandLight: ColorScheme
composeRule.setContent {
LibreMediaConverterTheme(darkTheme = true) { dynamicDark = MaterialTheme.colorScheme }
LibreMediaConverterTheme(darkTheme = false) { dynamicLight = MaterialTheme.colorScheme }
LibreMediaConverterTheme(darkTheme = true, dynamicColor = false) {
brandDark = MaterialTheme.colorScheme
}
LibreMediaConverterTheme(darkTheme = false, dynamicColor = false) {
brandLight = MaterialTheme.colorScheme
}
}
composeRule.waitForIdle()
return Schemes(dynamicDark, dynamicLight, brandDark, brandLight)
}
private class Schemes(
val dynamicDark: ColorScheme,
val dynamicLight: ColorScheme,
val brandDark: ColorScheme,
val brandLight: ColorScheme,
)
/**
* The live branches, and the one assertion that catches them being swapped: both dynamic
* schemes come from the same device palette, so they are similar enough that identity or a
* bare inequality would prove nothing. Background luminance is not similar -- it is the
* thing dark mode is for.
*/
@Test
fun `dark mode resolves a darker scheme than light mode`() {
val schemes = resolveAll()
val dark = schemes.dynamicDark.background.luminance()
val light = schemes.dynamicLight.background.luminance()
assertTrue(
"darkTheme = true should resolve the dynamic dark scheme, whose background " +
"luminance ($dark) is below the light scheme's ($light)",
dark < light,
)
}
/**
* Which of the two dark branches ran, and which of the two light ones: the scheme a live
* call resolves is the dynamic one, not the brand palette sitting next to it.
*/
@Test
fun `the live branches take the dynamic palette rather than the brand one`() {
val schemes = resolveAll()
assertNotEquals(
"the default dynamicColor = true should not resolve the brand dark palette",
Purple80,
schemes.dynamicDark.primary,
)
assertNotEquals(
"the default dynamicColor = true should not resolve the brand light palette",
Purple40,
schemes.dynamicLight.primary,
)
}
/**
* The two dead branches. Nothing in `app/src` passes `dynamicColor = false`; this test
* does it directly, because the parameter is public, and that is the only way either
* branch runs. Covered so a later decision on #68 starts from a tested `when` -- not
* because the brand palette is reachable in the app.
*/
@Test
fun `the brand palette branches run only when dynamicColor is passed explicitly`() {
val schemes = resolveAll()
assertEquals(Purple80, schemes.brandDark.primary)
assertEquals(Purple40, schemes.brandLight.primary)
}
}
@@ -0,0 +1,89 @@
package org.libremediaconverter.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* The theme called the way the app calls it: with no arguments at all.
*
* [ThemeColorSchemeTest] resolves every branch of the `when` and always passes `darkTheme`
* explicitly, so the `$default` bridge is never entered and **`isSystemInDarkTheme()` is never
* called**. `MainActivity.kt:79` is its only default-argument caller and does not execute on the
* JVM, which left the app's actual call shape the one nothing exercised —
* `LibreMediaConverterTheme` reported `mi=21, mb=6, cb=12` at method level.
*
* ## Not #68
*
* #68 is about the two **unreachable** arms, `DarkColorScheme` and `LightColorScheme`, which cannot
* run because `dynamicColor` is always `true` and nothing can flip it. That is an open product
* decision. This is the reachable half — whether the default follows the system — and closing it
* does not close that.
*
* ## Why the assertion compares schemes rather than reading a number
*
* A luminance threshold would be a guess about the device palette. What is asserted instead is that
* the no-argument call resolves to **the same scheme** an explicit `darkTheme` of the matching
* value does, and a different one from its opposite. That holds whatever palette the platform
* hands back, and it is exactly the claim: the default reads the system rather than picking a side.
*
* Both schemes are resolved in one composition because `setContent` may be called once per test.
*/
@RunWith(RobolectricTestRunner::class)
class ThemeFollowsSystemTest {
@get:Rule
val composeRule = createComposeRule()
@Test
@Config(qualifiers = "+night")
fun `with no arguments the theme follows a system in dark mode`() {
val resolved = resolve()
assertEquals("the default must resolve what darkTheme = true does", resolved.dark, resolved.bare)
assertNotEquals(resolved.light, resolved.bare)
}
@Test
@Config(qualifiers = "+notnight")
fun `with no arguments the theme follows a system in light mode`() {
val resolved = resolve()
assertEquals("the default must resolve what darkTheme = false does", resolved.light, resolved.bare)
assertNotEquals(resolved.dark, resolved.bare)
}
/**
* The three colours are read together as one value, because any single one could coincide
* between the two schemes on some palette while the schemes themselves differ. Background is
* what dark mode is chiefly about; primary and surface are along to make a coincidence
* implausible rather than merely unlikely.
*/
private data class Fingerprint(val background: Long, val primary: Long, val surface: Long)
private fun ColorScheme.fingerprint() =
Fingerprint(background.value.toLong(), primary.value.toLong(), surface.value.toLong())
private class Resolved(val bare: Fingerprint, val dark: Fingerprint, val light: Fingerprint)
private fun resolve(): Resolved {
lateinit var bare: Fingerprint
lateinit var dark: Fingerprint
lateinit var light: Fingerprint
composeRule.setContent {
// No arguments — the call MainActivity makes, and the one nothing exercised.
LibreMediaConverterTheme { bare = MaterialTheme.colorScheme.fingerprint() }
LibreMediaConverterTheme(darkTheme = true) { dark = MaterialTheme.colorScheme.fingerprint() }
LibreMediaConverterTheme(darkTheme = false) { light = MaterialTheme.colorScheme.fingerprint() }
}
composeRule.waitForIdle()
return Resolved(bare, dark, light)
}
}
@@ -0,0 +1,237 @@
package org.libremediaconverter.work
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConcatJoiner
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.StagingNames
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.UUID
/**
* What a join does when the engine fails partway.
*
* Everything past `ConcatWorker`'s `setForeground` was untested on **every** source set, and the
* repo had already measured the cost: `PerJobStagingTest`'s KDoc records that reverting
* `ConcatWorker` to a constant staging name left all 257 tests green, because nothing in the JVM
* suite can get past a `ConcatEngine` constructed in place. `RefusedJobTest` says the same from the
* other side -- "the next thing past the count guard is `ConcatEngine`, which is native".
* `ConcatEngineTest` on a device tests the engine directly, bypassing the worker, and
* `ConcatWorkerTest` covers only the too-few-inputs guard and the happy path.
*
* `ConversionDependencies.concat` is the seam that closes it, added here to sit beside the
* `.hardware` and `.software` that `ConversionWorker` has had all along -- the asymmetry between the
* two workers was the whole reason one of them had a tested failure path and the other did not.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConcatFailureTest {
private lateinit var app: Application
private lateinit var publisher: AlwaysRoomPublisher
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = AlwaysRoomPublisher(app)
ConversionDependencies.publisher = { publisher }
installTestWorkManager(app, Data.EMPTY)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `a join whose engine fails reports the engine's own reason`() {
ConversionDependencies.concat = { FailingJoiner { error(DEMUXER_MESSAGE) } }
val result = runBlocking { joinWorker().doWork() }
assertEquals(
ListenableWorker.Result.failure(workDataOf(ConcatWorker.KEY_ERROR to DEMUXER_MESSAGE)),
result,
)
}
/**
* A failure carrying no message at all, which Kotlin and Java both allow and FFmpegKit's
* wrappers can produce.
*
* Without the fallback the user is shown an empty error, and `JoinViewModel` cannot tell that
* from a job that reported nothing -- the two would be one blank screen with different causes.
*/
@Test
fun `a failure with no message of its own still says something`() {
ConversionDependencies.concat = { FailingJoiner { throw RuntimeException() } }
val result = runBlocking { joinWorker().doWork() }
assertEquals(
ListenableWorker.Result.failure(
workDataOf(ConcatWorker.KEY_ERROR to ConcatWorker.GENERIC_FAILURE_MESSAGE),
),
result,
)
}
/**
* The staged file is deleted on the way out.
*
* The joiner writes before it fails, exactly as `PartialThenFailingTranscoder` does on the
* conversion side: a stub that only threw would let a missing `staged.delete()` pass. What it
* costs to lose is a full-size partial per failed join, sitting in cache until the sweep is old
* enough to be sure nobody is coming back for it.
*
* Asserted against the file the joiner was actually handed rather than by scanning the staging
* directory for a name. The first draft did scan, for a `"join-"` prefix that
* `StagingNames.forJob` does not produce -- it names files `<jobId>.<ext>` -- so the assertion
* was trivially true and the mutation walked straight through it.
*/
@Test
fun `a failed join leaves nothing behind in staging`() {
val joiner = FailingJoiner { error(DEMUXER_MESSAGE) }
ConversionDependencies.concat = { joiner }
runBlocking { joinWorker().doWork() }
val staged = requireNotNull(joiner.lastOutput) { "the joiner never ran, so this proves nothing" }
assertEquals(
"the fixture has to write before it fails, or the delete is unobservable",
PARTIAL_BYTES,
joiner.bytesWritten,
)
assertFalse("a failed join must not leave its partial behind: $staged", staged.exists())
}
/**
* The success path, and the staging name #159's fixture and `PerJobStagingTest` both care about.
*
* Worth its place rather than a happy-path formality: `PerJobStagingTest`'s KDoc records that
* **reverting `ConcatWorker` to a constant staging name left all 257 tests green**, because
* nothing could reach the line that names the file. This is the test that was missing when that
* was written -- the join's output `Data` had never been read by anything on the JVM.
*
* The staged path is asserted to carry the job id, not a constant: two joins of the same format
* sharing one name is the defect, and `ConcatEngine`'s list file collided harder still.
*/
@Test
fun `a join that works reports its own staged file, strategy and name`() {
val joiner = SucceedingJoiner()
ConversionDependencies.concat = { joiner }
val result = runBlocking { joinWorker().doWork() }
assertTrue("got $result", result is ListenableWorker.Result.Success)
val data = (result as ListenableWorker.Result.Success).outputData
assertEquals(
"the staged file has to be this job's, not a name every join shares",
File(publisherStagingDir(), StagingNames.forJob(JOB_ID, OutputFormat.MP4_H264.extension)).absolutePath,
data.getString(ConcatWorker.KEY_OUTPUT_PATH),
)
assertEquals(ConcatStrategy.STREAM_COPY.name, data.getString(ConcatWorker.KEY_STRATEGY))
assertEquals(OutputFormat.MP4_H264.mimeType, data.getString(ConcatWorker.KEY_MIME_TYPE))
assertTrue(
"the save dialog needs a name with the right extension, got ${data.getString(
ConcatWorker.KEY_SUGGESTED_NAME,
)}",
data.getString(ConcatWorker.KEY_SUGGESTED_NAME).orEmpty().endsWith(".${OutputFormat.MP4_H264.extension}"),
)
}
/**
* The arm beside the count guard: no URI array at all.
*
* Covered today only by `UnopenableUriTest` on a device, although it runs before staging and
* before any native code. It is the exact sibling of `RefusedJobTest`'s ConversionWorker twin,
* and it belongs on the JVM with it -- a device test for a branch that needs no device is a
* slower test that reports later.
*/
@Test
fun `a join with no input array at all is refused with a message`() {
val result = runBlocking {
TestListenableWorkerBuilder<ConcatWorker>(
context = app,
inputData = workDataOf(ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name),
runAttemptCount = 0,
).setId(JOB_ID).build().doWork()
}
assertEquals(
ListenableWorker.Result.failure(workDataOf(ConcatWorker.KEY_ERROR to ConcatWorker.NO_INPUTS_MESSAGE)),
result,
)
}
private fun publisherStagingDir(): File? = publisher.createStagingFile("probe").parentFile
private fun joinWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
context = app,
inputData = workDataOf(
ConcatWorker.KEY_INPUT_URIS to arrayOf(FIRST.toString(), SECOND.toString()),
ConcatWorker.KEY_TOTAL_BYTES to TOTAL_BYTES,
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
),
runAttemptCount = 0,
).setId(JOB_ID).build()
private companion object {
val FIRST: Uri = Uri.parse("file:///tmp/one.mp4")
val SECOND: Uri = Uri.parse("file:///tmp/two.mp4")
const val TOTAL_BYTES = 2048L
const val DEMUXER_MESSAGE = "the demuxer rejected the input list"
const val PARTIAL_BYTES = 2048
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-00000000000b")
}
}
/** A joiner that writes something and then fails, so a missing `staged.delete()` cannot pass. */
private class FailingJoiner(private val failure: () -> Nothing) : ConcatJoiner {
/** The handle the worker created, kept so a test can ask whether it survived the failure. */
var lastOutput: File? = null
var bytesWritten = 0
override suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat): ConcatEngine.Result {
lastOutput = output
output.writeBytes(ByteArray(PARTIAL_BYTES))
bytesWritten = PARTIAL_BYTES
failure()
}
private companion object {
const val PARTIAL_BYTES = 2048
}
}
/** The joiner that finishes, so the success path and the output `Data` can be read on the JVM. */
private class SucceedingJoiner : ConcatJoiner {
override suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat): ConcatEngine.Result {
output.writeBytes(ByteArray(OUTPUT_BYTES))
return ConcatEngine.Result(ConcatStrategy.STREAM_COPY, output)
}
private companion object {
const val OUTPUT_BYTES = 4096
}
}
@@ -27,9 +27,6 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.UUID
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
/**
* That a refused foreground-service start does not end the job.
@@ -125,6 +122,40 @@ class DeniedForegroundStartTest {
)
}
@Test
fun `a join denied past the attempt bound fails with a message the user can act on`() {
// The join twin of the conversion case above. ConcatWorker reaches the same FailureOutcome
// through its own `when`, and that arm was the only one of its three with no test -- so a
// join that gave up silently, or gave up with an empty Data, would have looked identical to
// one that retried.
val worker = concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS)
val result = runBlocking { worker.doWork() }
assertEquals(
ListenableWorker.Result.failure(
workDataOf(ConcatWorker.KEY_ERROR to FailureOutcome.FOREGROUND_DENIED_MESSAGE),
),
result,
)
}
@Test
fun `a join that gives up collects the partial it had already staged`() {
// The delete lives on ConcatWorker's `catch (e: Throwable)` path, which every give-up goes
// through. Written first so a missing delete cannot pass by asking whether a file nobody
// wrote is absent.
concatStagedFile().writeBytes(ByteArray(PARTIAL_BYTES))
runBlocking { concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS).doWork() }
assertEquals(
"a join that gave up must not orphan what it staged",
emptyList<String>(),
stagedNames(),
)
}
private fun conversionWorker(runAttemptCount: Int = 0): ConversionWorker =
TestListenableWorkerBuilder<ConversionWorker>(
context = app,
@@ -141,18 +172,22 @@ class DeniedForegroundStartTest {
.setForegroundUpdater(DenyingForegroundUpdater)
.build()
private fun concatWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
private fun concatWorker(runAttemptCount: Int = 0): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
context = app,
inputData = workDataOf(
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "content://test/second.mp4"),
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
ConcatWorker.KEY_FORMAT to CONCAT_FORMAT.name,
),
runAttemptCount = 0,
runAttemptCount = runAttemptCount,
).setId(CONCAT_ID)
.setForegroundUpdater(DenyingForegroundUpdater)
.build()
/** The staging path the join will compute, asked for rather than spelled out here. */
private fun concatStagedFile(): File =
publisher.createStagingFile(StagingNames.forJob(CONCAT_ID, CONCAT_FORMAT.extension))
/** The staging path the worker will compute, asked for rather than spelled out here. */
private fun stagedFile(): File = publisher.createStagingFile(StagingNames.forJob(CONVERSION_ID, SPEC.extension))
@@ -164,6 +199,7 @@ class DeniedForegroundStartTest {
const val INPUT_BYTES = 1024L
const val PARTIAL_BYTES = 2048
val SPEC = OutputFormat.MP4_H265.spec
val CONCAT_FORMAT = OutputFormat.MP4_H264
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000001")
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000002")
}
@@ -182,18 +218,3 @@ private object DenyingForegroundUpdater : ForegroundUpdater {
),
)
}
/**
* An already-failed future, written out rather than pulled from a futures library.
*
* `await()` takes the `isDone` fast path and unwraps the `ExecutionException`, which is what puts
* the platform's own exception in front of the worker's catch rather than a wrapper.
*/
private class FailedFuture(private val failure: Throwable) : ListenableFuture<Void> {
override fun addListener(listener: Runnable, executor: Executor): Unit = executor.execute(listener)
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
override fun isCancelled(): Boolean = false
override fun isDone(): Boolean = true
override fun get(): Void = throw ExecutionException(failure)
override fun get(timeout: Long, unit: TimeUnit): Void = throw ExecutionException(failure)
}
@@ -0,0 +1,102 @@
package org.libremediaconverter.work
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Constraints
import androidx.work.OutOfQuotaPolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Both workers enqueue **expedited** work, and stay legal doing it.
*
* The two questions are separate and only one of them is about the flag.
*
* - **Is it set.** `expedited` is `false` by default, so `assertTrue` here is what a deleted
* `setExpedited(...)` reddens. That mutation was run.
* - **Is it legal.** `WorkRequest.Builder.build()` refuses an expedited request that carries an
* initial delay or any constraint but network and storage — `require(workSpec.initialDelay <= 0)
* { "Expedited jobs cannot be delayed" }` in work-runtime 2.11.2. Neither `request` sets either
* today, so both `build()` calls pass and the `IllegalArgumentException` is a *future* hazard
* rather than a current one. The delay and constraints assertions below are what name it: add a
* delay to either builder and this class fails on the throw, in the same second, instead of the
* app failing to enqueue a conversion on a device.
*
* **The policy assertion bites less than it reads, and that is worth writing down rather than
* leaving to be rediscovered.** `WorkSpec.outOfQuotaPolicy` *defaults* to
* `RUN_AS_NON_EXPEDITED_WORK_REQUEST`, so it is already this value on a request that was never
* expedited at all — deleting `setExpedited` does not redden it. What it does pin is the one
* alternative: `DROP_WORK_REQUEST` throws a user's conversion away because an invisible quota ran
* out, and that mutation *is* red here.
*
* The delay is not hypothetical either. Three tests deliberately build a delayed request to hold a
* job in `ENQUEUED` — `NotificationCancelActionTest`, `ReattachOnLaunchTest` and
* `CancelReachesWorkManagerTest` — and every one of them builds its own
* `OneTimeWorkRequestBuilder` rather than adding a delay to what `request` returns. That is why
* making these expedited broke none of them; the one that starts from `request` takes only
* `base.workSpec.input` from it.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ExpeditedRequestTest {
@Test
fun `a conversion is enqueued as expedited work`() {
val spec = ConversionWorker.request(INPUT, DISPLAY_NAME, INPUT_BYTES).workSpec
assertTrue("a conversion the user asked for has to be expedited work", spec.expedited)
assertEquals(
"a quota nobody can see is no reason to drop a conversion",
OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST,
spec.outOfQuotaPolicy,
)
}
@Test
fun `a join is enqueued as expedited work`() {
val spec = ConcatWorker.request(listOf(INPUT, SECOND_INPUT), TOTAL_BYTES).workSpec
assertTrue("a join the user asked for has to be expedited work", spec.expedited)
assertEquals(
"a quota nobody can see is no reason to drop a join",
OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST,
spec.outOfQuotaPolicy,
)
}
/**
* The two properties that keep `build()` from throwing, asserted on both requests at once
* because the rule is WorkManager's rather than either worker's.
*/
@Test
fun `neither expedited request carries what would make it illegal`() {
val requests = listOf(
ConversionWorker.request(INPUT, DISPLAY_NAME, INPUT_BYTES).workSpec,
ConcatWorker.request(listOf(INPUT, SECOND_INPUT), TOTAL_BYTES).workSpec,
)
requests.forEach { spec ->
assertEquals(
"expedited work cannot be delayed: ${spec.workerClassName}",
0L,
spec.initialDelay,
)
assertEquals(
"expedited work takes only network and storage constraints: ${spec.workerClassName}",
Constraints.NONE,
spec.constraints,
)
}
}
private companion object {
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
val SECOND_INPUT: Uri = Uri.parse("file:///tmp/holiday2.mp4")
const val DISPLAY_NAME = "holiday.mp4"
const val INPUT_BYTES = 1_024L
const val TOTAL_BYTES = 2_048L
}
}
@@ -0,0 +1,203 @@
package org.libremediaconverter.work
import android.app.Application
import android.app.Notification
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConcatJoiner
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.UUID
/**
* The first thing either worker posts is what its own `getForegroundInfo()` builds.
*
* **Both overrides were dead code until 2026-09-06, and #252 is where that was found** — the first
* instrumented coverage read reported `ConversionWorker:342-346` and `ConcatWorker:132-136` among
* the 32 lines *neither* suite reaches. The ticket's premise was that enqueueing expedited work
* would make them live, since `getForegroundInfo()` is WorkManager's expedited-work hook.
*
* **That premise is false at this `minSdk`, which is the finding underneath the fix.**
* `WorkForeground.kt:38` in work-runtime 2.11.2 opens `workForeground` with
* `if (!spec.expedited || Build.VERSION.SDK_INT >= 31) return`, that function is the library's only
* caller of `getForegroundInfoAsync()`, and `minSdk` is 33. So `setExpedited` alone would have left
* both overrides exactly as cold as the read found them, and a test written to drive them through
* WorkManager would be testing a code path no device this app supports can take — E1's failure
* mode, where a test asserts and never reaches.
*
* What makes them live is a single-definition change instead. Each worker had **two** definitions
* of one notification: the override, and an identical `ForegroundInfo` built inline in `doWork`.
* `doWork` now posts the override's, so the copy nothing executed is gone and the one that remains
* runs on every job.
*
* These tests are what hold that wiring. Each asserts the notification's *contents* against
* constants rather than against `worker.getForegroundInfo()` — comparing the two would move
* together under every mutation and stay green — and the mutations that redden them are named on
* each test.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ForegroundNotificationTest {
private lateinit var app: Application
private lateinit var updater: RecordingForegroundUpdater
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
updater = RecordingForegroundUpdater()
ConversionDependencies.publisher = { AlwaysRoomPublisher(app) }
ConversionDependencies.probe = { _, _ -> InputProbe() }
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
ConversionDependencies.software = { WritingTranscoder }
ConversionDependencies.concat = { WritingJoiner }
// The notification carries a WorkManager cancel PendingIntent, so without this the worker
// fails building the notification rather than on anything these tests are about.
installTestWorkManager(app, Data.EMPTY)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* Mutation that must go red, and did: inside `ConversionWorker.getForegroundInfo`, replace
* `displayName()` with a literal, or `percent = 0` with anything else. Both are in the override's
* own body, so a red here is proof `doWork` executes it rather than a copy of it.
*/
@Test
fun `a conversion's first foreground post is the one getForegroundInfo builds`() {
runBlocking { conversionWorker().doWork() }
val first = updater.infos.first()
val extras = first.notification.extras
assertEquals(
"the notification has to name the file the user picked",
DISPLAY_NAME,
extras.getString(Notification.EXTRA_TITLE),
)
assertEquals("a conversion starts at zero", 0, extras.getInt(Notification.EXTRA_PROGRESS))
assertTrue(
"nothing is known about the length of the job yet, so the bar is indeterminate",
extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE),
)
assertEquals(
"the foreground service type is the regime's, not zero",
ConversionForegroundType.current(),
first.foregroundServiceType,
)
}
/**
* The count is the point.
*
* `getForegroundInfo` said `"Joining files"` and `doWork` said `"Joining N files"` — one
* notification with two texts, and the one nothing ran was free to drift. Now there is one,
* and it reads the input array itself so it can still answer before `doWork` has parsed
* anything.
*
* Mutation that must go red, and did: replace the array read in `ConcatWorker.getForegroundInfo`
* with a constant `0`, which yields `"Joining 0 files"`. Asserting merely that the title starts
* with "Joining" would survive that, which is why the whole string is pinned.
*/
@Test
fun `a join's first foreground post counts the files it was given`() {
runBlocking { joinWorker().doWork() }
val first = updater.infos.first()
assertEquals(
"the notification has to say how many files are being joined",
ConcatWorker.joiningTitle(INPUTS.size),
first.notification.extras.getString(Notification.EXTRA_TITLE),
)
assertEquals(
"the foreground service type is the regime's, not zero",
ConversionForegroundType.current(),
first.foregroundServiceType,
)
}
/**
* And the title is really the file's name rather than any string at all.
*
* [ConcatWorker.joiningTitle] is asserted above through the constant the worker itself uses, so
* that assertion cannot catch the sentence being reworded — deliberately, since the wording is
* not what the test is about. This one can: two files, two names, one worker each.
*/
@Test
fun `two conversions of differently named files post differently named notifications`() {
runBlocking { conversionWorker(displayName = OTHER_NAME).doWork() }
assertEquals(
OTHER_NAME,
updater.infos.first().notification.extras.getString(Notification.EXTRA_TITLE),
)
}
private fun conversionWorker(displayName: String = DISPLAY_NAME) = TestListenableWorkerBuilder<ConversionWorker>(
context = app,
inputData = workDataOf(
ConversionWorker.KEY_INPUT_URI to INPUT.toString(),
ConversionWorker.KEY_DISPLAY_NAME to displayName,
ConversionWorker.KEY_SIZE_BYTES to INPUT_BYTES,
// FORCE_SOFTWARE is the one preference that decides without consulting the input,
// and a file:// URI keeps the worker out of FFmpegKit's native SAF bridge.
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
),
runAttemptCount = 0,
).setId(JOB_ID)
.setForegroundUpdater(updater)
.build()
private fun joinWorker() = TestListenableWorkerBuilder<ConcatWorker>(
context = app,
inputData = workDataOf(
ConcatWorker.KEY_INPUT_URIS to INPUTS.map(Uri::toString).toTypedArray(),
ConcatWorker.KEY_TOTAL_BYTES to TOTAL_BYTES,
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
),
runAttemptCount = 0,
).setId(JOB_ID)
.setForegroundUpdater(updater)
.build()
private companion object {
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
val INPUTS: List<Uri> = listOf(INPUT, Uri.parse("file:///tmp/holiday2.mp4"))
const val DISPLAY_NAME = "holiday.mp4"
const val OTHER_NAME = "birthday.mkv"
const val INPUT_BYTES = 1_024L
const val TOTAL_BYTES = 2_048L
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000252")
}
}
/** A joiner that writes an output and reports a strategy; nothing here is about the engine. */
private object WritingJoiner : ConcatJoiner {
override suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat): ConcatEngine.Result {
output.writeBytes(ByteArray(OUTPUT_BYTES))
return ConcatEngine.Result(ConcatStrategy.STREAM_COPY, output)
}
private const val OUTPUT_BYTES = 512
}
@@ -0,0 +1,88 @@
package org.libremediaconverter.work
import android.content.pm.ServiceInfo
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* [ConversionForegroundType.current] answers differently on each of the three API regimes, and
* until this file only one of them was ever executed.
*
* `app/src/test/resources/robolectric.properties` pins the whole JVM suite to `sdk=36`, so every
* Robolectric test that reaches a `ForegroundInfo` takes the `mediaProcessing` arm and no other.
* The 33 and 34 arms were cold: 3 lines and 3 of 4 branches, measured on `main` at `d354f64`.
*
* **The instrumented test is not a substitute, and the reason is specific.**
* `ConversionWorkerTest.foregroundTypeMatchesTheRunningApiLevel` asserts against whichever API the
* leg happens to be — one arm per leg, never the other two — and the legs that would cover 33 and
* 34 are the ones issue #122 wedges. `docs/coverage-read-findings.md` records an API 33 run that
* reported `received: 60` and `failed: unknown`: the regime *was* exercised, and that leg could
* not have said so if it had broken. Four `@Config` classes here pin all three arms
* deterministically, in the same `./gradlew` invocation as everything else.
*
* `minSdk` is 33, so none of these is dead code — each is a device someone is running the app on.
*
* **SDK 35 is in the list for the boundary, not for the answer.** It shares its answer with 36,
* which would make it look redundant. It is not: relaxing `>= VANILLA_ICE_CREAM` to `>` is invisible
* at every level except exactly 35, so without this class that mutation survives the suite.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class ForegroundTypeApi33Test {
/**
* Zero rather than a named constant because there is no constant to name: API 33 does not
* require a type, and `mediaProcessing` does not exist here to pass. `ForegroundInfo` reads 0
* as "no type at all", which is what this regime wants.
*/
@Test
fun `api 33 asks for no foreground service type`() {
assertEquals(0, ConversionForegroundType.current())
}
}
/**
* API 34 makes a type mandatory and still has no `mediaProcessing`, so `dataSync` is the only
* sensible fit. See [ForegroundTypeApi33Test] for why this file exists.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ForegroundTypeApi34Test {
@Test
fun `api 34 falls back to dataSync, the only type that fits`() {
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ConversionForegroundType.current())
}
}
/**
* The first level with `mediaProcessing`, and therefore the one that tells `>=` from `>`.
* See [ForegroundTypeApi33Test].
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ForegroundTypeApi35Test {
@Test
fun `api 35 is the first level that takes mediaProcessing`() {
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, ConversionForegroundType.current())
}
}
/**
* The level the rest of the suite runs at, asserted here rather than assumed — it is the one arm
* that was already covered, and leaving it out would make this file look like it is about the old
* levels rather than about all three regimes. See [ForegroundTypeApi33Test].
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class ForegroundTypeApi36Test {
@Test
fun `api 36 keeps mediaProcessing`() {
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, ConversionForegroundType.current())
}
}
@@ -0,0 +1,226 @@
package org.libremediaconverter.work
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.HardwareTranscoder
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.UUID
/**
* What happens when the hardware engine does not finish the job.
*
* `runMedia3OrFallBack` was eleven lines at 0% on the JVM and `isCancellation` had never been
* called by any unit test at all. Its own KDoc calls the fallback the protection against vendor
* hardware encoders that "cannot be tested for correctness", so it is the branch most likely to
* matter on a device nobody here owns — and it was reachable the whole time through
* `ConversionDependencies.hardware`, which no unit test had ever used.
*
* The sharp one is cancellation. `runMedia3OrFallBack` catches `Throwable`, so without the
* `isCancellation` re-throw a user cancelling a hardware transcode would have the app quietly
* start a *second* conversion in software — the one thing cancelling is supposed to prevent.
*
* `ForcedFailureTest` covers the failure half on a device. It does not cover the cancellation half,
* and this host cannot run it either way.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class HardwareFallbackTest {
private lateinit var app: Application
private lateinit var hardware: RecordingHardwareTranscoder
private lateinit var software: RecordingSoftwareTranscoder
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
hardware = RecordingHardwareTranscoder()
software = RecordingSoftwareTranscoder()
ConversionDependencies.publisher = { AlwaysRoomPublisher(app) }
ConversionDependencies.hardware = { hardware }
ConversionDependencies.software = { software }
// A probe with real codecs, not the default: `InputProbe()` reports UNPARSEABLE, which
// PERMISSIVE.canDecode refuses, and the router would send every job here straight to
// FFmpeg without any of these tests mentioning why.
ConversionDependencies.probe = { _, _ -> H264_SOURCE }
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
installTestWorkManager(app, Data.EMPTY)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `a hardware failure runs the job again in software, on a clean staging file`() {
hardware.failWith = { error("the vendor encoder produced nothing usable") }
val result = runBlocking { worker().doWork() }
assertTrue("the job should still succeed, got $result", result is ListenableWorker.Result.Success)
assertEquals("the hardware engine gets exactly one attempt", 1, hardware.attempts)
assertEquals("and the job then goes to software", 1, software.attempts)
// The `staged.delete()` between the two, asserted where it is observable: FFmpeg must not
// find a half-written hardware output sitting at the path it is about to write.
assertFalse(
"the partial hardware output must be gone before FFmpeg starts",
software.outputExistedOnEntry,
)
assertEquals("the hardware engine is closed either way", 1, hardware.closes)
}
@Test
fun `a cancelled hardware transcode is not quietly retried in software`() {
hardware.failWith = { throw CancellationException("the user pressed Cancel") }
assertThrows(CancellationException::class.java) { runBlocking { worker().doWork() } }
assertEquals("the hardware engine ran", 1, hardware.attempts)
assertEquals(
"cancelling must not start a second conversion -- that is the whole point of cancelling",
0,
software.attempts,
)
assertEquals("and the engine is still closed on the way out", 1, hardware.closes)
}
@Test
fun `a hardware transcode that works never reaches the software engine`() {
val result = runBlocking { worker().doWork() }
assertTrue("got $result", result is ListenableWorker.Result.Success)
assertEquals(1, hardware.attempts)
assertEquals("the fallback is a fallback, not a second pass", 0, software.attempts)
assertEquals(1, hardware.closes)
}
/**
* #169: the display-name fallback, which reaches further than the notification title.
*
* `inputData.getString(KEY_DISPLAY_NAME) ?: "input"` had never taken its right-hand side. The
* value is not only the foreground notification's title: it feeds `outputNameFor`, so it is
* also the filename offered in the user's save dialog. A job enqueued by an older build, or
* built by hand, carries no such key.
*/
@Test
fun `a job that names no input file still suggests an output name`() {
val result = runBlocking { worker(displayName = null).doWork() }
assertTrue("got $result", result is ListenableWorker.Result.Success)
val suggested = (result as ListenableWorker.Result.Success)
.outputData.getString(ConversionWorker.KEY_SUGGESTED_NAME)
assertTrue(
"expected a name built from the fallback, got $suggested",
suggested.orEmpty().startsWith("input"),
)
}
private fun worker(displayName: String? = DISPLAY_NAME): ConversionWorker {
val spec = OutputFormat.MP4_H265.spec
val entries = buildMap<String, Any> {
put(ConversionWorker.KEY_INPUT_URI, INPUT.toString())
displayName?.let { put(ConversionWorker.KEY_DISPLAY_NAME, it) }
put(ConversionWorker.KEY_SIZE_BYTES, INPUT_BYTES)
put(ConversionWorker.KEY_CONTAINER, spec.container.name)
put(ConversionWorker.KEY_VIDEO_CODEC, spec.videoCodec.name)
put(ConversionWorker.KEY_AUDIO_CODEC, spec.audioCodec.name)
// AUTO rather than FORCE_SOFTWARE, which is what every other worker test uses and is
// exactly why this path had no coverage: forcing software never enters the function.
put(ConversionWorker.KEY_ENGINE_PREFERENCE, EnginePreference.AUTO.name)
}
return TestListenableWorkerBuilder<ConversionWorker>(
context = app,
inputData = Data.Builder().putAll(entries).build(),
runAttemptCount = 0,
).setId(JOB_ID).build()
}
private companion object {
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
const val DISPLAY_NAME = "holiday.mp4"
const val INPUT_BYTES = 1024L
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000009")
val H264_SOURCE = InputProbe(
videoCodec = "h264",
audioCodec = "aac",
container = Container.MP4,
durationMs = 1_000,
)
}
}
/**
* A hardware engine that writes something before it fails, and remembers being closed.
*
* Writing first is the point, exactly as it is for `PartialThenFailingTranscoder`: an engine that
* only threw would let a missing `staged.delete()` pass unnoticed.
*/
@UnstableApi
private class RecordingHardwareTranscoder : HardwareTranscoder {
var attempts = 0
var closes = 0
var failWith: (() -> Unit)? = null
override suspend fun transcode(input: Uri, output: File, request: ConversionRequest, onProgress: (Int) -> Unit) {
attempts++
output.writeBytes(ByteArray(PARTIAL_BYTES))
failWith?.invoke()
}
override fun close() {
closes++
}
private companion object {
const val PARTIAL_BYTES = 2048
}
}
/** The software engine, recording whether the hardware attempt's leftovers were cleared first. */
private class RecordingSoftwareTranscoder : SoftwareTranscoder {
var attempts = 0
var outputExistedOnEntry = false
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
) {
attempts++
outputExistedOnEntry = output.exists()
output.writeBytes(ByteArray(OUTPUT_BYTES))
}
private companion object {
const val OUTPUT_BYTES = 512
}
}
@@ -16,6 +16,7 @@ import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -125,6 +126,39 @@ class JobSnapshotsTest {
assertEquals(newer.absolutePath, Reattachment.choose(snapshots)?.job?.outputPath)
}
/**
* A job in the tag query that never recorded an output path at all.
*
* Distinct from the three cases above, which all *have* a path and differ in what it names. A
* job still running, or one that finished without writing its result key, carries no path at
* all -- and `getWorkInfosByTagFlow` returns it alongside the finished ones, because the tag is
* the worker class and every attempt ever enqueued carries it.
*
* The guard is the `?.` in `path?.let(::File)`. Without it the null goes straight into a `File`
* constructor. What this pins is the consequence rather than the null check: such a job must
* not be offered as a result, so `Reattachment.choose` has to walk past it to the job that
* really produced a file. Choosing it would put a Converted screen in front of the user with a
* Save button that has nothing to save.
*/
@Test
fun `a job that recorded no output path is not offered as a result`() {
val real = stagedFile("real.mp4", bytes = 4096)
finishedWithOutput(real)
finishedWithNoOutput()
val snapshots = snapshots()
assertEquals("both jobs carry the tag, so both come back", 2, snapshots.size)
val silent = snapshots.single { it.outputPath == null }
assertFalse("no path means no output, not an empty one", silent.outputExists)
assertEquals("and no time either, for the same reason", 0L, silent.outputModifiedAt)
assertEquals(
"the reattachment has to walk past it to the job that really produced a file",
real.absolutePath,
Reattachment.choose(snapshots)?.job?.outputPath,
)
}
private fun snapshots(): List<JobSnapshot> = runBlocking {
workManager.jobSnapshots(
tag = ConversionWorker::class.java.name,
@@ -152,6 +186,11 @@ class JobSnapshotsTest {
).result.get()
}
/** A job that carries the tag and no result key -- still running, or finished without one. */
private fun finishedWithNoOutput() {
workManager.enqueue(OneTimeWorkRequestBuilder<ConversionWorker>().build()).result.get()
}
private companion object {
/** Two fixed moments a day apart, so the ordering is stated rather than raced for. */
const val OLDER_MS = 1_700_000_000_000L
@@ -0,0 +1,89 @@
package org.libremediaconverter.work
import android.app.Notification
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.installTestWorkManager
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.util.UUID
/**
* The two things a progress notification can say, and that they are not the same thing.
*
* An assertion gap rather than a coverage one, and the distinction is the reason this file exists.
* JaCoCo is green on `build`'s `if (indeterminate)`, because `ProgressNotificationTest` drives it
* through a real worker -- but that test reads only the notification id and
* `Notification.EXTRA_PROGRESS`. **Nothing had ever read the text.** Swapping the two branches, or
* collapsing them into one string, passed the entire suite.
*
* What it costs to get wrong is small and constant: a conversion that has been running for four
* minutes still saying "Preparing", or one that has not started reporting yet claiming 0%. Neither
* is a crash, and neither would be found by anything else here -- which is exactly the kind of
* thing that survives for a long time.
*
* Nothing else in the suite constructs [ConversionNotifications] directly.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class NotificationProgressTextTest {
/**
* `build` reaches `WorkManager.getInstance` for the Cancel action's PendingIntent, so the
* notification cannot be built at all without one. That coupling is why nothing had ever
* constructed this class directly and read what it produced.
*/
@Before
fun setUp() {
installTestWorkManager(RuntimeEnvironment.getApplication(), Data.EMPTY)
}
@Test
fun `an indeterminate notification says something different from a measured one`() {
val context = RuntimeEnvironment.getApplication()
val notifications = ConversionNotifications(context)
val preparing = notifications.build(JOB_ID, TITLE, percent = 0, indeterminate = true).text()
val measured = notifications.build(JOB_ID, TITLE, percent = 42, indeterminate = false).text()
assertNotEquals(
"the two states have to read differently, or the text says nothing at all",
preparing,
measured,
)
assertTrue(
"a measured notification has to carry its percentage, got \"$measured\"",
measured.contains("42"),
)
assertTrue(
"an indeterminate one must not invent one, got \"$preparing\"",
!preparing.contains("42") && !preparing.contains("0"),
)
}
/**
* The title is the caller's, not the builder's -- it is the file the user picked, and it is what
* tells two simultaneous conversions apart in the shade.
*/
@Test
fun `the notification is titled with the file it is converting`() {
val context = RuntimeEnvironment.getApplication()
val built = ConversionNotifications(context).build(JOB_ID, TITLE, percent = 10)
assertEquals(TITLE, built.extras.getString(Notification.EXTRA_TITLE))
}
private fun Notification.text(): String = extras.getString(Notification.EXTRA_TEXT).orEmpty()
private companion object {
const val TITLE = "holiday.mp4"
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-00000000000a")
}
}

Some files were not shown because too many files have changed in this diff Show More