Compare commits

..
Author SHA1 Message Date
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
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 a1d79c212a Merge branch 'main' into ci/actionlint 2026-08-25 09:51:15 -05:00
Jason Ross bc66906dc3 Merge pull request #98 from JMR-dev/test/device-codecs-encode-consequence
Hold the two codec MIME claims that only existed in prose
2026-08-25 09:51: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 3fb25235c0 Merge branch 'main' into test/device-codecs-encode-consequence 2026-08-25 09:41:33 -05:00
Jason Ross c0d99f7f86 Merge pull request #97 from JMR-dev/fix/sdkmanager-pipefail
Read sdkmanager's status, not the status of the yes feeding it
2026-08-25 09:41:22 -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
JMR-dev 1535b61a96 Merge branch 'main' into fix/sdkmanager-pipefail 2026-08-25 09:02:54 -05:00
Jason Ross 2efd1f9a0d Merge pull request #95 from JMR-dev/docs/readme-restart-claim
Say which conversions come back, rather than that they all do
2026-08-25 09:02:23 -05:00
JMR-dev 240528facb Merge branch 'main' into docs/readme-restart-claim 2026-08-25 08:42:54 -05:00
Jason Ross f98e49942f Merge pull request #96 from JMR-dev/fix/saf-picker-root-discovery
Close the ANR dialog that was hiding every window from UiAutomator
2026-08-25 08:42:07 -05:00
JMR-devandClaude Opus 5 25f162923c Close the ANR dialog that was hiding every window from UiAutomator
SafPickerRoundTripTest began failing on gating legs at API 33, 34, 35 and 37
ninety minutes after it landed, on diffs that cannot cause it -- two KDoc
comments, a MIME lookup table, a README paragraph. Every failure named the
fixture root, so #93 was filed as a root-discovery race. It was not one, and
finding out what it was took making the test say something else first.

DocumentsUI was fine throughout: its own `ProvidersAccess: Matched roots` names
the fixture authority five times inside the sixty seconds the test spent failing.
What failed was reading any window at all -- 1095 `Retrieving node with selector`
against 1095 `Node not found` on that leg, against 7 and 2 on the green one. So
this now asks whether the app's OWN window is readable before it opens a picker,
and prints the accessibility window list when it is not.

That list named the culprit on the next occurrence:

    What it could see: com.android.systemui[type=3], android[type=3]

No TYPE_APPLICATION window at all, on a device that had just logged `Displayed
org.libremediaconverter/.MainActivity`. `android[type=3]` is system_server, and
the same logcat says what it was holding, minutes before this class ran:

    ANR in com.google.android.apps.nexuslauncher
    Reason: Input dispatching timed out (Application does not have a focused window)
    Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}

The launcher ANRs on a loaded runner emulator and the dialog it leaves behind
never goes away. It is opaque and fullscreen, so AccessibilityWindowManager drops
every application window beneath it -- which is how the app can be Displayed and
unreadable at once, the contradiction that made this look like a SAF bug for six
PRs. Present on both legs examined, API 33 and 34, at the failure timestamp.

So the dialog is dismissed, by resource id rather than by localised button text,
`aerr_wait` first so the app under it is left alone. Waking the device and
rebuilding the UiAutomation connection are kept behind it and are recorded as
measured non-causes rather than as fixes.

A second PickActivity is not a remedy for this either, and that was measured: the
failing leg opened one for the second test, in the same DocumentsUI process, and
read as little from it. The whole pick is still retried, but for a smaller and
separate claim -- a picker whose lists were built before their data arrived, which
#80's node-level re-find cannot reach because it re-acquires a handle inside the
one picker.

One API 37 run failed a step deeper, on the file rather than the root. That shape
has not been reproduced or diagnosed; the reopen covers it because a fresh pick
re-walks from Recent, and the KDoc says that rather than claiming more.

Two things the retry must not become. It must not tolerate an absent root, or
#64's MIME mutation goes vacuous -- so a missing node is reported rather than
retried away, and the mutation was re-run: both tests still fail, still with "the
system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E']", in 126 s and
127 s against the 1200 s wrapper timeout. And it must not decide the picker has
closed by asking the same accessibility window list that is broken -- so the back
presses are counted against Activity.hasWindowFocus, which comes from the
framework.

Each new path was forced on and measured rather than trusted: the injected-failure
run showed the reopen recovering, with four OPEN_DOCUMENT starts for two tests;
the rebuild was forced unconditionally and the suite stayed green, ruling out a
connection that comes back without FLAG_RETRIEVE_INTERACTIVE_WINDOWS; the dialog
dismissal was forced with no dialog present, ruling out a blind click breaking a
healthy run. Dismissing a real ANR dialog has not been observed, because the fault
has never reproduced locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 00:52:25 -05:00
JMR-dev d0b9745220 Merge branch 'main' into docs/readme-restart-claim 2026-08-25 00:41:13 -05:00
Jason Ross b36d56c932 Merge pull request #94 from JMR-dev/fix/probe-dispatcher-seam
Give the probe hop an injectable dispatcher, and delete the drain it replaces
2026-08-25 00:40:58 -05:00
JMR-dev 3f140fc2b1 Lint the bash inside the workflows, not only the bash in files
The shellcheck step added a few hours ago reads `git ls-files '*.sh'`. That is four files.
It does not read the inline `run:` blocks, and a good deal of this repo's bash lives there:
the release verification in build.yml, the emulator setup and teardown in status_check.yml
and api37-debug.yml. "shellcheck runs in CI" was true of the files and not of the blocks,
and CLAUDE.md said so rather than pretending otherwise.

actionlint closes that half. It parses each workflow and runs shellcheck over every `run:`,
on top of its own checks for expression syntax, `needs:` references, matrix keys and action
input names.

Pinned by digest, for the reason shellcheck is pinned -- a new rule making untouched files
fail is a red build whose diff cannot explain it -- and for a second reason of its own.
actionlint's documented install is

  bash <(curl -s https://raw.githubusercontent.com/.../download-actionlint.bash)

off a moving branch. Running that in a repository that pins every action by SHA would
contradict its own supply-chain posture more than the linter is worth. That is why #70 was
filed instead of bolted onto the shellcheck commit.

It reported exactly one finding, and it is fixed here rather than suppressed: build.yml
parsed `ls` to pick the release APK (SC2012). The glob was already in the line, so a bash
array reads it without the pipe. Gradle's output names have no spaces today, which is the
kind of assumption that holds right up until it does not.

Proved it catches something, rather than trusting a green run: planting `if [ $UNQUOTED =
bad ]` into a build.yml `run:` block produces

  shellcheck reported issue in this script: SC2086:info:4:6:

Removed again afterwards. A linter that cannot be shown to catch a plant is not wired in,
it is just running -- and SC2086 in a `run:` block is invisible to the .sh-file step, which
is the whole argument for this commit.

CLAUDE.md loses the "does not cover inline run: blocks" caveat, because it no longer does.
Both linters verified clean at their pinned digests.

Closes #70.
2026-08-25 00:26:07 -05:00
JMR-devandClaude Opus 5 b3208ef8c7 Hold the two claims the codec MIME tables only asserted in prose
Two reasoned decisions were sitting in comments with nothing under them.

`AndroidDeviceCodecs.mimeFor`'s `COPY, NONE -> null` arm explains itself by
naming a consequence at another seam: returning null is what makes `canEncode`
answer true, because a copied or absent track places no demand on the hardware.
#90 pinned the null; nothing pinned the answer. Put a MIME in that arm and a
device with no matching encoder starts refusing stream copies — jobs that encode
nothing — and the router hands FFmpeg a re-mux Media3 could have done. Asserted
now against `forTesting(encoders = emptySet())`, with an H.264 refusal alongside
so a `canEncode` that simply said yes could not satisfy it.

The second is a whole table. `Media3Engine.videoMimeTypeFor` is `VideoCodec ->
MIME` on the same axis as `mimeFor`, and until #85 and #87 widened both to
`internal` no test could see them together. Each had per-arm coverage pinning its
own answers, which is exactly the shape that cannot notice the two tables
describing different codecs: change one arm and its own expectation together and
both suites stay green while the device is asked about H.265 and Transformer is
told to produce H.264.

They do not agree everywhere, and forcing them to would be a regression, so the
test sorts every codec into the three buckets that exist and asserts the fourth
is empty. H.264 and H.265 must match. VP8, VP9 and AV1 are named by the device
table and not by Transformer's, deliberately: `setVideoMimeType` rejects them so
the router never asks Media3, while the device may genuinely own a VP9 encoder
and `canEncode` has to answer about it truthfully. COPY and NONE are named by
neither. Sorting rather than filtering means a convergence fails too, so moving
the line requires saying so in the file.

Audio has no partner — `AndroidDeviceCodecs` enumerates video MIME types only,
so `audioMimeTypeFor` has nothing to cross-check against and a missing audio
encoder is still discovered by failing rather than up front. Named in the KDoc
as unfinished rather than left as an unexplained asymmetry.

Closes #86

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 00:21:04 -05:00
JMR-dev 5a8aedf53d Read sdkmanager's status, not the status of the yes feeding it
run-e2e.sh installs a missing system image with

  yes | sdkmanager --install "$pkg" > /dev/null 2>&1 || { echo "  FAILED to install"; ... }

`yes` never ends. The moment sdkmanager exits and closes the pipe, `yes` dies of SIGPIPE
with 141, and this script runs under `pipefail`, which takes the rightmost non-zero status.
So a package that installed perfectly reported "FAILED to install $pkg" and returned 1.

R32 filed this PLAUSIBLE on shell semantics, unexecuted. It is demonstrated now:

  set -o pipefail; yes | true             -> 141   (three runs, three times)
  set -o pipefail; yes | sh -c 'exit 3'   -> 3
  ${PIPESTATUS[1]} for those two          -> 0 and 3

The pipeline status genuinely cannot tell a clean install from a broken one; PIPESTATUS
can. That is the whole change -- no restructuring of the licence flow, so a fresh SDK still
gets its licences accepted exactly as before.

`echo no | avdmanager` eleven lines below is deliberately left alone, and the comment says
so. One line fits the pipe buffer, so echo has already exited before the close and there is
no signal to receive: `echo no | true` measured 0 on five consecutive runs against `yes |
true`'s 141 on three. Only an unbounded producer is exposed. Someone reading this fix later
would otherwise "fix" the echo too and change a line that was never wrong.

Why it went unnoticed: it only misfires when the image is ABSENT, and every existing
checkout already has the images. R32 noted the branch that made this the normal path. The
failure is also silent in the worst way -- the install succeeds, the script says it failed,
and the AVD is then created from a package that is really there.

shellcheck clean at the pinned digest (0.11.0, the version CI runs), bash -n clean.

Closes #41.
2026-08-25 00:16:27 -05:00
JMR-dev a0b6a3dde8 Merge branch 'main' into fix/probe-dispatcher-seam 2026-08-25 00:11:12 -05:00
Jason Ross ba27b8306b Merge pull request #91 from JMR-dev/test/media3engine-mime-tables
Check the MIME types Media3Engine hands Transformer, and the claim above them
2026-08-25 00:10:51 -05:00
JMR-dev bda5abea6c Merge branch 'main' into test/media3engine-mime-tables 2026-08-24 23:50:06 -05:00
Jason Ross 8bd5fedcc8 Merge pull request #92 from JMR-dev/test/mediaprobe-pure-helpers
Test the three pure MediaProbe helpers, and report the arms no test can bite
2026-08-24 23:49:58 -05:00
JMR-dev 21eeb6f3f8 Merge branch 'main' into test/mediaprobe-pure-helpers 2026-08-24 23:32:41 -05:00
Jason Ross a83cb60c61 Merge pull request #90 from JMR-dev/fix/codec-vocabulary-drift
Make the two codec tables answer for each other, and stop describeAudio printing a NUL
2026-08-24 23:32:21 -05:00
JMR-devandClaude Opus 5 2063fe06aa Point the coroutines-test comments at the file that still uses it
Both the dependency declaration and its catalog entry named
EscapedCoroutineErrors.kt as the sole reason kotlinx-coroutines-test is on the
test classpath. That file is gone, and nothing in the gate -- not ktlint, not
detekt, not lint -- fails on prose naming a deleted file, so this would have
survived as a reference a reader could only resolve through git history.

The dependency itself stays, and for a reason worth restating where it is
declared: `runTest` is what registers the collector callback, so the one test
that deliberately lets an error escape is the scope that receives it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 23:17:14 -05:00
JMR-dev 47a423413b Say which conversions come back, rather than that they all do
README promised, without qualification:

  "Conversions run as durable background work, so they survive leaving the app and
   are restored after a restart."

The first half is true and the reattachment work made it truer. The second half has one
exception the sentence does not admit, and it is the case a user is most likely to hit
without understanding it.

When Android refuses a foreground-service start, FailureOutcome retries -- ten attempts on
the default exponential backoff, 30 s doubling to a five-hour clamp, about eight and a half
hours in total -- and then returns FOREGROUND_DENIED on a FAILED job. Reattachment excludes
FAILED (Reattachment.kt:176). So the job is not restored, and neither is the message
explaining why: the user opens the app to an empty screen.

FailureOutcome's own KDoc already says this plainly -- "a user who was not watching when
the eleventh attempt ran will find an empty screen rather than the explanation". The code
was honest and the README was not, which is the wrong way round for the two documents.

The replacement says what actually happens and ends with the thing the user can act on:
reopening the app is what grants permission to run, so a conversion stalled this way should
be started again rather than waited on. That is the same reasoning FOREGROUND_DENIED_MESSAGE
is written on -- "open the app and start it again" is the fix, not filler.

Deliberately not claimed: that the app tells you. It does not, and #16 is the open ticket
for giving a present, willing user a way to make that retry happen now. Writing "you will
be told" here would be the same defect this commit is fixing, one release earlier.

Verified against the current code rather than the finding's date -- R35 was filed as
PLAUSIBLE on 2026-08-22 and both mechanisms it names are still in place.

Closes #44.
2026-08-24 23:15:51 -05:00
JMR-dev dab28d5f44 Merge branch 'main' into fix/codec-vocabulary-drift 2026-08-24 23:13:20 -05:00
Jason Ross aed4d83e70 Merge pull request #89 from JMR-dev/docs/robolectric-rationale-correction
Give the Robolectric choice a reason that is still true
2026-08-24 23:12:48 -05:00
JMR-devandClaude Opus 5 4aba3bbd2e Stop swallowing coroutine errors nobody asserted on
`drainEscapedCoroutineErrors()` cleared the collector at rule-construction time
with `runCatching { runTest {} }`, and discarding what it found was the whole
mechanism: it could not tell the one known deposit from an escaped error nobody
had asserted on. That traded a loud, misleading failure for a silent one, which
was acceptable only while exactly one depositor existed and the seam to remove it
did not.

The seam exists now, so the depositor is gone: the OOM is consumed by the test
that raises it. Every Compose class takes the v2 `createComposeRule()` directly,
and a future escaped error fails a test again instead of disappearing.

The two findings the drain's KDoc carried that outlive it: the v2 rule and the
non-v2 `StateRestorationTester` do interoperate -- the note now sits at the two
declarations that pair them -- and a drain could never have been a `@Before`
(the rule's `runTest` wraps it) or a `@BeforeClass` (Robolectric runs that
outside the sandbox classloader, where the collector is a different object).

Full JVM suite run twice in a row with the drain deleted: 373 tests, 0 failures
both times.

Closes #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 23:10:04 -05:00
JMR-devandClaude Opus 5 dbba213c51 Give the pick a dispatcher, so an escaped error fails the test that caused it
`onInputPicked` hops to a hard-coded `Dispatchers.IO` inside a `launch` with no
exception handler -- deliberate, because a real OutOfMemoryError should reach the
thread's default handler and take the process down. On the JVM there is no such
handler: kotlinx-coroutines-test installs a process-wide collector, once per
classloader and never removed, which keeps the error and rethrows it at whichever
`runTest` starts next. Every Compose rule is a `runTest`, so the OOM raised by
`ConversionViewModelProbeFailureTest` failed some *other* Compose class, and which
one moved between runs of identical, green code.

Naming the dispatcher gives the throw somewhere to land. With the pick inline
inside a `runTest`, the collector's callback belongs to the test that caused the
error, so it is handed over and consumed rather than stored for a stranger.

Both hops of a pick rather than only the probe, which is where this differs from
the seam issue #66 sketched: leaving the metadata query on a real IO thread makes
the coroutine resume on a main looper Robolectric leaves paused, and that bounce
is exactly the asynchrony that made delivery unpredictable.

That buys the assertion the test could not make before -- the real OutOfMemoryError
instance, not an inference from a card that never filled in, which is also what a
probe returning null looks like. Reverting the hop to `Dispatchers.IO` turns it
red: "expected java.lang.OutOfMemoryError to be thrown, but nothing was thrown".

Refs #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 23:07:38 -05:00
JMR-devandClaude Opus 5 8ac6e2b1c2 Name the format in the image-demuxer failures
Bare assertTrue/assertFalse report java.lang.AssertionError and nothing else,
so the mutation that proves this test bites -- relaxing the _pipe suffix to a
substring -- went red saying only that a line failed. The format name is the
one thing a reader needs, exactly as the MIME is in the sibling test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:46:12 -05:00
JMR-dev 4ff44be1d7 Give the Robolectric choice a reason that is still true
Two test classes justified using Robolectric by asserting that the alternative does not
exist:

  AppRootRestorationTest       "The instrumented tests cannot run on the development
                                host at all (see CLAUDE.md)"
  OutputPublisherStagingTest   "The instrumented suite cannot run on the development
                                host, so this is the only place [it] can be caught"

Both were true when written and stopped being true on 2026-08-22, when the segfault was
traced to SwiftShader's Reactor JIT against SELinux execheap rather than to the machine.
tools/local-emulator/run-e2e.sh has run API 33-36 here since.

The first one cites CLAUDE.md as its authority, and PR #73 corrected CLAUDE.md to say the
opposite. So it was no longer merely stale: a reader who followed the reference found the
contradiction, with the citation making the wrong half look verified. That is the worst
version of this -- R14, R15, R20 and R25 were all the same defect, and this is the fifth.

The choice itself was never wrong, which is why the fix is not to move these tests. Both
belong on the JVM, and the honest reason is cost rather than impossibility: neither needs
anything a device supplies, and both run inside the same ./gradlew invocation as every
other unit test instead of booting an emulator. That argument survives the correction; the
premise did not.

The old line also has a second failure mode worth naming. "Nobody can execute this" invites
a reader to skip the local run and let CI decide, which is the opposite of what the
definition-of-done in #51 asks for.

Verified: the string appears nowhere in app/src now, and testDebugUnitTest, ktlintCheck and
detekt are green.

Closes #46.
2026-08-24 22:45:02 -05:00
JMR-devandClaude Opus 5 7f951baf8f Make the two codec tables answer for each other, and stop describeAudio printing a NUL
The FFprobe codec vocabulary is written out in at least four places and none of them
had a test. Two had already drifted apart. `x264`, `hev1`, `x265` and `vp09` resolved
in `CodecNames.videoFromName` and returned null from
`AndroidDeviceCodecs.mimeForCodecName`, so the app identified the codec for the source
card and for routing and then ran the device capability check blind on the same string;
`mpeg4` ran the other way and rendered as a raw name. Nothing could notice, and the
reason is structural: a `when` cannot be enumerated, so no test can ask one table what
the other one knows.

Both are maps now, for that reason alone, and `CodecVocabularyTest` walks the two key
sets. A name added to -- or removed from -- one side alone fails the build. The one
legitimate asymmetry is listed rather than implied: `mpeg4` is decodable input with no
`VideoCodec` to name it, so `CodecNames` is right not to carry it. That list is itself
checked, because otherwise it is an escape hatch -- any future divergence could be waved
through by adding the name to it, and adding `x265` to it now fails.

THIS CHANGES BEHAVIOUR for `x264`, `hev1`, `x265` and `vp09`. A null from
`mimeForCodecName` means "unknown to us: assume the platform can handle it and let a
failed export trigger the FFmpeg fallback", which is the right policy for a name nobody
recognises and the wrong one for a name recognised one file over. A device without the
matching decoder now sends those four to FFmpeg up front instead of spending a doomed
hardware attempt to discover it. No input loses hardware it could have used: each alias
resolves to the MIME its canonical spelling already resolved to, so a device that has
the decoder still answers true. `ConversionRouterTest` still passes and that is not
evidence either way -- every `canDecode` in it is a hand-written stub that never reaches
this table.

#74 is the same family one level down. `describeVideo` answered "Unrecognised" for
`InputProbe.UNPARSEABLE` and `describeAudio` had no such arm, so an unparseable audio
codec would have fallen through to `?: name` -- and the sentinel opens with a NUL, so
the source-info card would have rendered a `Text` beginning with U+0000. The two now
share one body, which is what stops the next arm being added to one side only.

Two corrections to that ticket, taken from the file rather than from the ticket, since
it warns about exactly this:

  - It quotes `audioFromName` as opening with `null, InputProbe.UNPARSEABLE -> null`.
    It did not; it opened with `null -> null` and the sentinel reached `else`. Naming
    the sentinel in the shared lookup therefore changes no answer and is documentation,
    not the fix.
  - It says `describeVideo`'s arm has no test of its own. It did -- `descriptions stay
    readable for unknown and missing codecs` asserts it -- so deleting the shared arm
    now reddens three tests across both sides, not one.

Mutations run, each on the full 386-test suite:

  add "avc3" to CodecNames only    -> CodecVocabularyTest red on two counts,
                                      CodecNamesTest green: 8 tests, 0 failures, which
                                      is the ticket's point about per-table arm tests
  delete the UNPARSEABLE arm       -> CodecNamesTest red on three, one of them quoting
                                      the NUL back
  add "x265" to DECODE_ONLY_NAMES  -> CodecVocabularyTest red on the escape hatch
  delete "vp09" from the MIME map  -> CodecVocabularyTest red on three, which is the
                                      state this commit is fixing

Audio is not cross-checked, and that is a gap rather than a decision: the device
capability check is video-only, so this module has no second audio table to compare
`AUDIO_ALIASES` against. `Media3Engine.audioMimeTypeFor` is the other half and belongs
to #85. `MediaProbe.shortName` (#84) is the fourth table and is untouched here for the
same reason.

Closes #87.
Closes #74.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:44:55 -05:00
JMR-devandClaude Opus 5 5ec2bba64b Check the MIME types Media3Engine hands Transformer, and the claim above them
Both tables decide what codec ends up in the user's file, and neither was
exercised. Point H265 at VIDEO_H264 and every hardware HEVC export writes
H.264 into a file the user asked to be H.265: Transformer does as told, the
export succeeds, and the only symptom is a codec nobody chose.

One arm carried an assertion rather than a value -- "Never reached: only an
Encode plan consults this, and COPY/NONE are not Encode" -- which is a claim
about callers parked in a branch of a callee. It is true, and nothing checked
it, so it would have gone on reading as true after it stopped being. Proved
instead: CopyPlanner answers both codecs before the Encode branch and its
fallback draws from ContainerCapabilities.encodableVideo, which contains
neither, so a sweep over every spec the planner can be handed asserts no
Encode plan carries COPY or NONE. Counters guard the sweep, because
`as? Encode ?: let` asserts nothing at all for a Drop or Copy plan.

The audio sibling claim did not survive intact. "MP3 and FLAC have no Android
encoder; the router routes them to FFmpeg" is true and incomplete: one rule,
`audioEncode !in MEDIA3_AUDIO`, diverts Vorbis by identical logic, so three of
the six encodable codecs never reach the table. VORBIS -> AUDIO_VORBIS is a
correct mapping for a request Transformer is never given. The arm stays -- a
right answer in unreachable code costs nothing -- and the comment now says so.

The tables are asked of the router's decisions rather than of its codec sets,
because the comments claim behaviour and a set can be right while the rule
reading it is wrong. Both move to an internal companion object so a JVM test
can reach them without constructing an engine, which would start a real
HandlerThread to answer an enum lookup; #57's precedent, and the JVM test
source set is a friend of main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:43:05 -05:00
JMR-devandClaude Opus 5 fd2bb1d889 Test the three MediaProbe helpers nothing else would catch
MediaProbe's MIME table, its image-demuxer rule and its Int reader are pure
functions with no test at all, and each fails silently rather than loudly.
shortName falls through to substringAfter('/') and reports a plausible-looking
string that CodecNames may or may not still recognise, so a dropped arm turns a
stream-copyable file into a re-encode. isImageFormat is checked before anything
else in classify, so a wrong answer overrides both probes. intOr's runCatching
is the only thing standing between a Float frame rate and losing every other
track property the loop had read.

Widen the three to internal, as #57 did, and say in each KDoc why the shape is
what it is -- the _pipe suffix is not a substring test because yuv4mpegpipe is
raw video, and getInteger casts rather than coerces.

Every format name asserted came from ffprobe rather than from memory: a picked
.png reports png_pipe, a .jpg reports jpeg_pipe, a .y4m reports yuv4mpegpipe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:36:17 -05:00
Jason Ross ad28293b72 Merge pull request #82 from JMR-dev/docs/api37-advisory-counts
Say three where a third test joined, and stop the name claiming to be exact
2026-08-24 22:06:10 -05:00
JMR-dev b9abe85580 Say three where a third test joined, and stop the name claiming to be exact
#80 added SafPickerRoundTripTest's rotation case to @FailsOnEmulatorApi37, because a
real rotation aborts the framework on android-37.0. Three tests carry the marker now --
two in Media3EngineTest, one in SafPickerRoundTripTest -- and five statements still
described two.

Four were counts, and wrong:

  status_check.yml  "notAnnotation removes the two tests that do not pass"
  status_check.yml  "The two API 37 tests the gating row above excludes"
  CLAUDE.md         "the gating leg runs the other 55"          (59 - 3 = 56)
  CLAUDE.md         "do not read a green run as evidence those two tests pass"

The fifth was worse, because it was not a count. The advisory job's header justified its
name with an invariant:

  "It is named for WHAT IT RUNS, deliberately. Both tests drive a full H.264 -> H.265
   hardware transcode through Media3Engine"

The rotation case drives no transcode. So the comment did not merely miscount -- it
asserted a property of the job's contents that had stopped being true, and that property
was the entire argument for the name.

The name is unchanged, deliberately, and the header now says so instead of implying the
question never arose. This is not a required context, it is red on every PR by design, and
it is one people have learned to look for; renaming a check costs more than the imprecision
does. What replaced the invariant is the honest rule: THE MARKER IS THE DEFINITION, NOT THE
NAME -- this job holds the tests that cannot pass on the API 37 emulator image, whatever
their subject.

Two things stay as they were because they are still true. "the two Media3EngineTest cases
that pass here" is correct: that class has four tests and two carry the marker. And the
decoder theory is still a claim about the Media3 pair alone, so it now says so rather than
being read as covering a rotation failure it has nothing to do with.

Nothing about the job's behaviour changes: same name, same continue-on-error, same marker,
same selection on both rows. Verified: yaml parses, five jobs, matrix still 33/34/35/36/37.

The check to re-run when a test next joins or leaves the marker, which is the event that
broke this twice:

  grep -rn "@FailsOnEmulatorApi37" app/src/androidTest --include='*.kt' | grep -v import | grep -c FailsOn

It must equal the number every corrected comment states. It is 3.

Closes #81.
2026-08-24 21:58:33 -05:00
Jason Ross 4375a377bc Merge pull request #80 from JMR-dev/test/r38-8-saf-e2e
Pick a file through the real system picker, then rotate the phone
2026-08-24 21:23:36 -05:00
JMR-devandClaude Opus 5 3925f1aa9f Re-find the picker node when it goes stale, and re-measure API 37
CI found a flake this workstation could not, and fixing it overturned half of what
the previous commit recorded about API 37.

THE FLAKE. UiObject2 caches the AccessibilityNodeInfo it was found with, and
DocumentsUI is still settling when a node first appears -- its list rebinds, the
roots strip lays out, a window animates. If the node is replaced in that gap,
click() throws against the handle rather than missing the target:

  androidx.test.uiautomator.StaleObjectException
    at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1042)
    at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
    at SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:223)

It is not intermittent on a COLD emulator -- CI hit it on API 33, 34 and 35, every
one of them, on the first run. It never appeared here because the local emulator had
been warm for an hour. tapPickerNode now re-finds the node and taps again, three
attempts. That retries acquiring a handle to a node that has to be there anyway:
every attempt still goes through awaitPickerNode, which fails outright if it is
absent, so the MIME mutation's bite is untouched. Verified with `pm clear
com.google.android.documentsui` between runs, five for five green on API 34.

AND THE CORRECTION IT FORCED. The previous commit marked the whole class
@FailsOnEmulatorApi37 on the strength of two measured failures. One of them was
this bug. Re-measured with the fix, one method per fresh android-37.0 emulator:

  thePickedInputSurvivesARealRotation             INSTRUMENTATION_ABORTED:
                                                  System has crashed.
  pickingAFileThroughTheSystemPickerFillsInTheFileCard              PASSED

So a rotation, which rebuilds every surface at once, is what the gralloc mapper does
not survive; starting another app's activity is not. The marker moves to the one
method that earned it, and the picker test runs on the gating API 37 leg like
anything else. The workflow comment, run-e2e.sh and the doc all say that now.

The lesson is worth more than the measurement, and the doc keeps it: an annotation
is a claim about an IMAGE, and a broken test makes every image look broken. Both a
framework abort and a stale node read as "the run fell over". Re-measure after
fixing a test before deciding what the platform did.

Also measured rather than assumed, since it is what keeps the gating leg green: the
runner's annotation filter honours a class-level marker, expanding it to every
method. On API 34, `annotation=` selected exactly 4 tests (2 Media3EngineTest + 2
here) and `notAnnotation=` selected 55 with neither of these in it. CI's own gating
API 37 leg then reported 55 / 0 on the previous push. That is why moving the marker
to a single method is a narrowing rather than a repair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 21:14:07 -05:00
JMR-devandClaude Opus 5 a3c835b7c9 Keep the picker test off the API 37 gating leg, having measured why
The API 37 emulator images abort surfaceflinger inside the guest's Gralloc5 mapper,
init SIGKILLs zygote with it, and the framework restarts under the run. run-e2e.sh
and the CI leg disable SystemUI to remove the trigger -- but that removes the IDLE
one, RegionSamplingThread's nav-bar luma sampling. Driving DocumentsUI and rotating
the display are not idle. They are the first things in this suite that generate
surface traffic of their own.

Both tests were measured on android-37.0 under swangle_indirect with SystemUI
disabled and verified quiet, and measured SEPARATELY -- inferring the second from
the first is the mistake docs/api-37-emulator-crash.md opens by correcting. They
fail in the two shapes a framework restart produces:

  thePickedInputSurvivesARealRotation
    INSTRUMENTATION_ABORTED: System has crashed.
    Expected 59 tests, received 50
    (5 hasReadColorBufferDma aborts; the framework dies DURING the test, so six
     later tests never run and the XML carries a failure with no text at all)

  pickingAFileThroughTheSystemPickerFillsInTheFileCard
    androidx.test.uiautomator.StaleObjectException
      at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
    (3 aborts; the picker's root node was rebuilt between finding it and tapping it)

Both pass on API 33 and API 36 locally -- whole suite, 59/0/0/2 on each -- which is
the same evidence pattern that made the Media3EngineTest pair the image rather than
the app.

So the class carries @FailsOnEmulatorApi37 and runs on the advisory leg.

THREE PLACES SAID "nothing in this suite touches system UI", and that is what makes
the SystemUI-disable deviation defensible. It is no longer true of the suite, and all
three are corrected rather than left to rot -- the workflow comment, run-e2e.sh's
header, and the doc. The rule they state is being APPLIED, not broken: the thing that
depends on system UI is excluded from the leg that cannot be trusted for it.

Two consequences stated rather than left to be discovered:

- run-e2e.sh applies no annotation filter, unlike CI, so a local `run-e2e.sh 37`
  reports these two on top of the Media3 pair AND DOES NOT FINISH. Its totals come
  back short and which later tests ran is arbitrary. The summary row now says so;
  it previously promised "exactly two failures", which would have read as a
  regression in someone else's diff.
- The advisory job is still named "E2E API 37 Media3 hardware transcode", and half
  of what it now runs is neither. Renaming a check touches branch protection, so it
  is deliberately not done here; the doc records the staleness and the revisit
  trigger now says the marker covers two unrelated bugs that can go green apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:58:34 -05:00
JMR-devandClaude Opus 5 650ca8fca3 Pick a file the way a user does, then rotate the phone
Two things nothing in this repo asserted, and they are one test class because
separately the second one asserts nothing new.

THE PICKER. ConverterScreen opens SAF with a MIME filter, and a filter is a thing
that can hide the user's file. Narrow it and the app still builds, still renders,
and still passes every JVM test -- the user taps "Choose file" and gets an empty
picker. The round trip now runs for real: DocumentsUI is driven with UiAutomator to
a fixture root, and the app is asserted to come back with the file.

The file card's name is not the only assertion, because a name proves less than it
looks: it comes from a metadata query, which a URI with no read grant answers just
as well. The "Container: MP4" detail row only appears once something has opened the
file and read its header, so it is what says the picker handed back a URI the app
can USE.

THE ROTATION. MainActivity declares no configChanges, and ConversionViewModel holds
the picked file in a plain MutableStateFlow with NO SavedStateHandle behind it.
Nothing persists it. The only thing that carries it across a rotation is the
ViewModelStore the Activity retains -- which no test anywhere asserted.

Two guards run before that assertion, because both ways it could pass while proving
nothing are silent: the display rotation really changed, and MainActivity really was
a different instance afterwards. Without the second one this is a recomposition test
wearing a rotation's name.

MUTATIONS, RUN RATHER THAN ASSERTED, on a local API 34 emulator.

Narrowing the filter to arrayOf("application/x-lmc-no-such-type") takes the fixture
root out of the picker entirely -- DocumentsUI matches the request against
Root.COLUMN_MIME_TYPES and drops roots that cannot answer -- and both tests fail:

  java.lang.IllegalArgumentException: the system picker never showed
      BySelector [TEXT='\QLMC R38 fixtures\E']

Making the ViewModel composition-scoped fails ONLY the rotation test:

  androidx.compose.ui.test.ComposeTimeoutException: Condition (a node tagged
      converter.fileCard.name exists) still not satisfied after 30000 ms

and :app:testDebugUnitTest stays BUILD SUCCESSFUL under it. That divergence is what
#64 exists to establish and what its own comment doubted; the PR body has the
verdict and why the doubt was reasonable.

THE PROVIDER HAD TO BE JAVA. It is the only Java file in the module. A
manifest-declared provider is a component of the instrumentation PACKAGE, so the
system starts a plain org.libremediaconverter.test process for it with only the test
APK on its dex path -- and the test APK is built without the Kotlin stdlib, because
the app APK has it and duplicating it is what checkDebugAndroidTestDuplicateClasses
prevents. The Kotlin draft died on its first query:

  java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics;
      at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument

The compiler emits that reference for the null checks on nearly every function, so
no Kotlin dialect avoids it. Same reason nothing in that file imports androidx.

No new test tags: CHOOSE_FILE, FILE_CARD_NAME and detailRow already named both ends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:36:31 -05:00
JMR-devandClaude Opus 5 b18f45def7 Give the system file picker something to pick
Nothing in either source set drives SAF as a picker. The only SAF coverage is the
publish side, in OutputPublisherPublishTest, against hand-written ContentProvider
fakes -- so the launcher wiring in ConverterScreen, the MIME filter it passes, and
the grant that comes back have never been executed by a test.

Driving the real picker needs three things this repo did not have.

UiAutomator, because DocumentsUI is another process. Compose's matchers stop at this
process's composition and Espresso's stop at its view hierarchy; neither can see or
tap a window belonging to another package.

It FLOATS, at "2.+", which is the same argument the catalog already makes for work
and lifecycle rather than a new one: androidx.test.uiautomator is inside
floatedGroupPrefixes, so the componentSelection guard makes "+" mean "newest
RELEASED", and that is load-bearing here -- this library publishes 2.4.0-alphas above
its stable, so without the guard the float would be a pin to a prerelease. Resolved
to 2.4.0 (released) on debugAndroidTestRuntimeClasspath, checked rather than assumed.
It is deliberately NOT pinned alongside ktlint/detekt/JaCoCo/Robolectric: those are
pinned because a new rule or a new runtime changes the verdict on files nobody
touched. UiAutomator has no verdict -- it taps what a selector names, and a selector
that stops matching is this repo's test to fix, in a diff that explains itself. The
"2." rather than a bare "+" is the one thing held back: a major is where the selector
API would be free to change under exactly that assumption.

A DocumentsProvider, because DocumentsUI does not browse a filesystem -- it lists what
providers offer it. Writing a file into Downloads would have worked and tested less:
the fixture root declares Root.COLUMN_MIME_TYPES, and DocumentsUI filters the drawer
by it, which is what gives the MIME filter a mutation with a shape rather than "one
file among the hundreds in Downloads was not listed". Its contents are also exactly
one file, where a shared directory accumulates whatever earlier runs left behind.

And the first AndroidManifest.xml this source set has ever had, to declare it -- a
ContentProvider is instantiated by the system and cannot be registered from test
code. In androidTest rather than src/debug so it is installed by the instrumentation
APK only, and never appears in a developer's own file picker.

Two things worth knowing before editing either file. XML comments cannot contain "--",
which the manifest's first draft failed the build on; and "*/" inside a KDoc closes
the comment, which the provider's did. Both are silent in review and loud in the
build.

No test yet, and no new test tag: TestTags.Converter.CHOOSE_FILE and FILE_CARD_NAME
already name both ends of the round trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:15:33 -05:00
Jason Ross d674bc4848 Merge pull request #79 from JMR-dev/test/r38-7-join-states
Ask each join state what it lets the user do next
2026-08-24 19:58:26 -05:00
JMR-devandClaude Opus 5 02555ceb91 Ask each join state what it lets the user do next
`JoinScreenContent` decides the whole join UI in one `when`, and until R38.5 gave it a state
parameter nothing could ask it anything: `Waiting` follows a denied foreground start and `Joined`
follows a finished `ConcatWorker` run, so neither is reachable by driving a real `JoinViewModel`.
`JoinScreenContentTest` used that seam to prove it exists, on one state. This is the matrix behind
it -- seven states, each pinned to the affordance it offers and the callback that affordance is
wired to, asserting on the value handed back rather than on something merely having fired.

Two of the thirteen assert things nothing else in the suite has ever asked.

The rows are read back sorted by their position on screen and compared as an ordered list. A join
is the one flow where the order of the inputs is the content of the output -- the empty state
promises "in the order you want them" -- and `JoinLeafTagsTest` proves only that a row tags itself
with the file it shows, which a reversed list would satisfy just as well.

The progress bar is asserted to be indeterminate, not merely present. It carries no percentage on
purpose, because FFmpeg reports progress against one input's duration and that means nothing across
a concatenation; the converter screen's bar is determinate, so "there is a bar" is exactly the
assertion that would let a fabricated percentage land here unnoticed.

Three mutations, each reverted after:

- `Text(s.message)` -> `Text("")` in `Failed`: "a failed join renders the message it carries" fails
  with `could not find any node that satisfies: (Text + InputText + EditableText contains 'The
  second file has no audio track, so joining stopped.')`.
- `when (s.strategy)` -> `when (ConcatStrategy.STREAM_COPY)` in `Joined`: "a re-encoded join says
  the files differed" fails on the copy for the branch that no longer runs.
- `s.inputs.forEach` -> `s.inputs.reversed().forEach` in `Ready`: the ordering test fails
  `expected:<[join.fileRow:intro.mp4, join.fileRow:middle.mp4, join.fileRow:outro.mp4]> but
  was:<[join.fileRow:outro.mp4, join.fileRow:middle.mp4, join.fileRow:intro.mp4]>`.

Test-only: no file under `app/src/main` changes, and no tag is added to `TestTags`, because every
string these states render is either already tagged or unambiguous as text. The typographic
characters in the asserted copy -- U+2026 in "Joining N files...", U+2014 in the `Joined` and
Paused lines -- were checked byte-for-byte against `JoinScreen.kt` rather than retyped; an ASCII
lookalike compiles and then quietly matches nothing.

Closes #63.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:49:48 -05:00
Jason Ross 1b1d5c6d04 Merge pull request #78 from JMR-dev/test/r38-6-conversion-states
R38.6 — Every ConversionState renders its own affordances
2026-08-24 19:49:05 -05:00
JMR-devandClaude Opus 5 2f3f461cc1 Say what each conversion state puts on screen, and what it withholds
The screen's state machine had a seam and no matrix behind it. Every arm of
the `when` returns `Unit`, so an arm can render anything at all and still
compile -- a button offered where it cannot work, a state's own data never
reaching the node meant to show it, an affordance wired to the wrong
callback. The leaf tests cannot see any of that: they compose `FileCard`,
`AdvancedPicker` and the three pickers directly and never hold a
`ConversionState`.

The arm worth guarding most is `Ready`'s `enabled = validation.isValid`. The
Advanced picker deliberately lets an impossible container / codec pair be
selected, so that one expression is all that stands between an invalid spec
and a job that cannot succeed. `enabled = true` compiles, renders an
identical screen apart from one colour, and passed the whole suite before
this.

Callbacks are asserted over the complete log rather than one at a time, so a
case reads "this one fired and nothing else". A bare "the callback ran" check
stays green on an arm that fires the right callback for the wrong reason.

The routing chip needed a tag to be locatable at all: its text comes from the
finished job, so a text matcher would have to name a routing explanation the
screen does not own. That is the only production change here.

Not asserted, deliberately: `Failed`'s error colour, which Compose publishes
nowhere in the semantics tree; and the three absent `FileCard`s, which are
compile-guarded -- `Idle`, `Saved` and `Failed` carry no `input` -- so those
lines state the intent without being what enforces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:35:21 -05:00
Jason Ross 6166763f24 Merge pull request #77 from JMR-dev/test/r38-5-state-seam
Extract the state seam both screens lack
2026-08-24 19:20:50 -05:00
JMR-dev 46ad95350b Give both screens somewhere for a state to come from
`ConverterScreen` and `JoinScreen` each inlined their whole `when (state)`
inside the public entry point, and state arrived only as `viewModel.state`.
That left four of the twelve state branches across the two screens with no
test that could ever reach them: driving a real ViewModel needs a WorkManager
and a media probe in the constructor, and even then `Waiting` follows a denied
foreground start and `Converted`/`Joined` follow a worker run that has already
succeeded.

So the `when` moves into `ConverterScreenContent` and `JoinScreenContent`,
which take the state, the settings, the validation and an actions holder. The
entry points keep the three launchers and `collectAsStateWithLifecycle` and
nothing else.

The callbacks travel in `ConverterActions` / `JoinActions` rather than as loose
parameters because detekt's `LongParameterList` sits at its default threshold
of six and `config/detekt/detekt.yml` does not relax it for `@Composable` --
`AdvancedPicker` already sits exactly on it. Twelve flat parameters would turn
a clean detekt run red; data classes are exempt from the rule.

Nothing else changed. The body was cut and pasted rather than retyped, so the
U+2026, U+2014 and U+00B7 characters the leaf tests match on are the same
bytes, and `is Idle -> Unit` in the nested `when` -- permanently unreachable,
and deliberately kept -- survives the move. The diff stops above `FormatPicker`
in one file and above `FileRow` in the other, which is why the leaf suites
#57-#60 landed pass unedited: every one of them composes a leaf directly and
none references either entry point.

The two new tests are the bite, one per screen and one per direction of the
seam: a `Converted` / `Joined` state renders Save, and tapping Save hands back
the name the finished job chose. The state matrix itself is #62 and #63.
2026-08-24 19:06:26 -05:00
Jason Ross e968deb5a2 Merge pull request #76 from JMR-dev/fix/jacoco-robolectric-coverage
Count the Robolectric tests, which JaCoCo has never counted
2026-08-24 18:53:42 -05:00
JMR-dev 3d55004286 Count the Robolectric tests, which JaCoCo has never counted
The three #52 test PRs landed 56 new tests and the coverage figure moved 29.8% -> 29.7%.
That looked like the tests being worthless. It was the measurement.

Robolectric loads every class it touches through its own sandbox classloader, and those
classes arrive with no source location. JaCoCo skips no-location classes unless told
otherwise, and nothing here told it. So not one Robolectric test has ever contributed
coverage in this repo -- and Robolectric is what exercises the framework edge: both
workers, the publisher, both ViewModels, every Compose screen.

Same commit, same 335 tests, same 0 failures, only the block below added:

  LINE    652/2194  29.7%  ->  1519/2194  69.2%
  BRANCH  425/1424  29.8%  ->   758/1424  53.2%

  OutputPublisher       0.0% -> 97.5%      MainActivityKt     6.8% -> 86.4%
  ConversionViewModel   0.0% -> 85.4%      ConverterScreenKt  6.6% -> 62.8%

The discriminator, so this is not cargo cult: inside ConverterScreenKt, `describe` is the
one non-Composable and is exercised by a plain JVM test. It reported 8/8 covered while
every @Composable in the same class reported 0 -- including ones whose mutations
demonstrably failed the build when reverted. Across files the split is exactly
Robolectric-vs-not: StagingSweep, tested purely, 100%; OutputPublisher, ConversionViewModel
and FailureOutcome, tested under Robolectric, 0%.

`excludes = listOf("jdk.internal.*")` is not decoration. Without it JaCoCo walks
JDK-internal classes Robolectric has no location for either and the test JVM dies rather
than reporting a number.

CLAUDE.md's coverage bullet is rewritten, because it was wrong twice over. The figure was
an artifact, and the explanation attached to it -- that coverage fell as the suite grew
from 11 test files to 43 because the denominator outran the numerator on framework-edge
code "the JVM cannot reach" -- described a cause that does not exist. The JVM reaches that
code fine. The new tests were disproportionately Robolectric, so each one added denominator
and no numerator: the measurement was punishing precisely the tests that were hardest to
write, and the conclusion drawn from it was that writing them had not helped.

Mutation, run both ways on this branch: remove the block and jacocoTestReport collapses
back to 29.7% / 29.8%; restore it and it returns to 69.2% / 53.2%.

Two things that were true stay true. There is still no coverage gate, and a floor still
needs a settled baseline -- this one just moved 39 points in one build change. And
"re-measure before quoting it" was already written down; following it is the only reason
this was found.
2026-08-24 18:45:30 -05:00
Jason Ross e06b0826a0 Merge pull request #73 from JMR-dev/docs/instrumented-tests-correction
Say where instrumented tests run, instead of where they used to not run
2026-08-24 17:17:18 -05:00
JMR-dev 7b578c1ccf Merge branch 'main' into docs/instrumented-tests-correction 2026-08-24 17:10:27 -05:00
Jason Ross 9e7f80feaa Merge pull request #72 from JMR-dev/test/r38-2-filecard
Say in tests what the file card says when it does not know
2026-08-24 17:10:06 -05:00
JMR-dev 3c5a37fd3c Merge branch 'main' into test/r38-2-filecard 2026-08-24 17:02:19 -05:00
Jason Ross af13155c27 Merge pull request #71 from JMR-dev/test/r38-4-advanced-picker
Hold the Advanced panel's gate, and the error card outside it
2026-08-24 17:01:33 -05:00
JMR-dev 4ea5afefe1 Merge branch 'main' into test/r38-4-advanced-picker 2026-08-24 16:54:10 -05:00
Jason Ross 7e4f22322b Merge pull request #69 from JMR-dev/tools/file-issue-script
Check the shell, and stop one-off issues falling off the board
2026-08-24 16:52:16 -05:00
Jason Ross 2ee97e30b7 Merge branch 'main' into tools/file-issue-script 2026-08-24 16:43:41 -05:00
Jason Ross d8f1590d2b Merge pull request #67 from JMR-dev/test/r38-3-pickers
Hold the three pickers to the constant they hand back
2026-08-24 16:43:16 -05:00
JMR-dev 3d51fefeff Say where instrumented tests run, instead of where they used to not run
CLAUDE.md carried three claims about instrumented tests. All three were false, one of
them contradicted a paragraph forty lines below it in the same file, and a subagent
working on #58 hit the contradiction and had to stop and flag it rather than trust the
project's own instructions. That is the cost being paid here: this file is what every
contributor and every agent reads first.

  "Instrumented tests do not run locally"  -- they do, API 33-36, since 22c7914.
  "Emulators segfault on this host"        -- solved 2026-08-22; it was SwiftShader's
                                              Reactor JIT meeting SELinux execheap, not a
                                              broken machine, and another renderer avoids
                                              it. docs/local-emulator.md is titled
                                              "Emulators do run on this host".
  "CI's matrix therefore stops at API 36"  -- the matrix has been 33/34/35/36/37 since
                                              #56 merged, with a gating API 37 leg.

The contradiction was the worst of it. The testing-norm section added in #51 says "E2E is
runnable locally now", so the file simultaneously told you the emulator works and that it
segfaults on every AVD. A reader has no way to tell which half is current, and the wrong
half is the one that stops work: an agent that believes emulators are impossible here does
not try, and the local e2e half of the definition-of-done in #51 quietly stops being
enforceable.

The replacement says what is true now and names what is still true and why -- API 37 still
needs the manual Pixel check before a release, because the two advisory tests are the one
thing CI cannot answer for. It also states plainly that the advisory job is red on every
PR by design, which is the other thing agents keep rediscovering the hard way: three
separate subagents have now flagged that failure as possibly theirs.

The norm bullet now points at the section rather than re-arguing it, so there is one place
to correct next time rather than two that can drift apart again.

Same defect class as R14, R15, R20 and R25, all of which were documentation claims this
repo's own review falsified. The pattern is not that the docs were careless; it is that
they were written at a moment and the moment moved.
2026-08-24 16:26:47 -05:00
JMR-dev 6cd17f25aa Pin shellcheck, because the unpinned one disagreed with the local run
The step added in the previous commit went red on its own PR, and the reason is the one
CLAUDE.md already gives for pinning ktlint, detekt and JaCoCo: "a new rule in a linter
makes files nobody touched stop passing, so CI goes red on a PR whose diff cannot explain
it." Here it was not even a new rule, just a different version of the same tool.

The runner's ambient shellcheck is 0.9.0. The container used to check locally was 0.11.0.
They disagree about how to report `on_signal`, which is installed as the INT and TERM trap
eleven lines below its declaration and so is never called by name:

  0.11.0  SC2329, once, on the function declaration -- "never invoked"
  0.9.0   SC2317, seven times, one per command in the body -- "appears to be unreachable"

The disable directive named SC2329, so 0.11.0 was silent and 0.9.0 reported seven findings.
Nothing about the script was wrong; the local check simply was not the check CI ran.

Two changes, because either alone still leaves a way to be surprised:

  - CI runs shellcheck from an image pinned by digest, so an upgrade is a line in this
    file that someone chose, not something that arrives on a Tuesday. The version is
    still printed, so a finding out of nowhere can be tied to that line.

  - The directive names SC2317 and SC2329 both, so a contributor whose distro ships 0.9.0
    gets the same answer locally as CI gives. Verified against both images: clean under
    0.9.0 and clean under 0.11.0.

CLAUDE.md now says to check with the pinned digest rather than with whatever is installed,
which is what would have caught this before the push.
2026-08-24 16:13:05 -05:00
JMR-devandClaude Opus 5 fea88a281f Say in tests what the file card says when it does not know
"Size unknown" is the line a stream fixing D5 reported as untestable. It is two
assertTextEquals calls, and it needed two rather than one: the size line renders
independently of the probe, so it is asserted with a probe and without one. That
independence is the contract, and a test of the probed case alone would leave the
branch a user hits first -- the card is on screen before the probe finishes --
unguarded.

The rest of the card degrades in words the same way, and none of it was covered:
the four InputKind branches, "No video track", "No audio track", describeVideo's
"Unknown", and the two `> 0` guards that drop the dimension and length rows
rather than printing 0 and 0:00. Each guard gets a case on both sides, because
the present side alone stays green when the guard is deleted -- what deleting it
produces is "Size: 0x0" and "Length: 0:00", the same invented-measurement defect
as "0 B".

The four pure helpers go in a plain JVM class beside it, with formatBytes pinned
at each threshold and one byte below it. A `>=` quietly becoming a `>` is only
visible from a value sitting exactly on the boundary.

Two things the issue could not have known:

- Its second acceptance criterion, "delete the return@Column and watch the
  Reading... test go red", cannot happen -- it does not compile. The early return
  is what smart-casts `probe` non-null, so ten uses below it fail with "Only safe
  (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver". The
  exit is enforced by the compiler, not by a test. Both compilable regressions
  someone would land instead are covered and were run red.
- CodecNames.describeAudio has no UNPARSEABLE arm, unlike describeVideo, so it
  answers the raw sentinel rather than "Unrecognised". Unreachable today, because
  the UNPARSEABLE kind renders the explanatory line instead of rows. Left alone;
  recorded on the PR for R38.5.

The divider's absence is not asserted and cannot be: Material 3 renders it as a
Box with no semantics modifier, so it contributes no node. What is asserted is
everything it precedes, plus the card's child count. The class KDoc says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:10:38 -05:00
JMR-devandClaude Opus 5 7c69d0699a Hold the Advanced panel's gate, and the error card outside it
`AdvancedPicker` is the one leaf on the converter screen that carries its own
state, and `ValidationError` is deliberately invoked after the
`AnimatedVisibility` that gates the chip rows -- so an invalid spec explains
itself and offers one-tap fixes while the section is collapsed. That is the
only route out of an invalid spec for a user who never opened Advanced, it was
completely untested, and folding the two `if` blocks into one is a plausible
tidy-up that compiles.

`AdvancedPickerTest` covers the gate in both directions, clicks each of the
four colliding chip labels through its own row tag, and does every assertion
about the error card with the toggle untouched.

`AdvancedPanelSavedStateTest` is the `DestinationSaverTest` split for
`expanded`: `StateRestorationTester` saves into an in-memory map, so it proves
`rememberSaveable` is in use and nothing about the representation. Driving a
real `SaveableStateRegistry` shows the picker saves the `MutableState` itself
rather than the `Boolean`, which only survives a rotation because
`mutableStateOf` on Android returns a `Parcelable` one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:06:00 -05:00
JMR-dev baaaa934e0 Check the shell, and stop one-off issues falling off the board
Two gaps, both found the same way -- by something going wrong quietly.

`gh issue create` does not touch the project board. The issue is created, carries its
labels, and is invisible in the Kanban, which looks exactly like a ticket nobody filed.
On 2026-08-24 eight issues filed as a scripted batch all reached the board and one filed
as a one-off minutes later did not; it surfaced only because someone went looking for it.
A batch carries the board step inside its loop. One-offs are where it slips, so
tools/github/file-issue.sh is for one-offs.

Three things it does that a two-command shell snippet would not:

  - Resolves the project, Status field and option ids BY NAME, every run. Caching them
    is the obvious optimisation and the wrong one -- a renamed or reordered column would
    then have this writing a stale id into the board with no error anywhere.

  - Reads the item back. A mutation returning 200 says the request was accepted, not that
    the board shows what was asked for; the read-back is the only step that checks the
    claim this script exists to make. It is a GraphQL query because REST cannot do it --
    the `fields` array REST returns on a project item carries Title and nothing else, so
    a REST-only check reports every item's Status as unset.

  - Exits 3, loudly, with the issue number on a line of its own, when the issue was
    created but the board step failed. That exact combination is the failure being
    prevented; it must never be the quiet path.

Shell was the other language here with nothing checking it -- four scripts, one of them
the CI entry point. shellcheck now runs in the Static analysis job over
`git ls-files '*.sh'`, so a script added later is covered without editing the workflow,
and it runs at full severity with `info` included.

That raises two findings today and both are the tool being wrong, so both are answered
with a targeted `disable` carrying its reason rather than by lowering the severity:
run-e2e.sh's `on_signal` is reported as never invoked when it is installed as the INT and
TERM trap eleven lines below it, and the `$names` inside file-issue.sh's queries are
GraphQL variables that must not expand -- expanding them would send the shell's idea of
$owner to the API instead of declaring a parameter. A blanket --severity=warning would
have hidden both, and the next real finding with them.

The gradle step gains `if: !cancelled()` so a shellcheck failure cannot cost the
ktlint/detekt/lint lists -- the same reason that step already passes --continue.

Not covered, deliberately: shellcheck here reads .sh files, not the inline `run:` blocks
in the workflows, where a good deal of this repo's bash actually lives. actionlint does
read them, and finds one pre-existing info-level issue in build.yml. Wiring it in means
pinning a container digest, because every action here is pinned by SHA and actionlint's
usual installer is a curl-pipe-bash off a moving branch. Its own ticket, not this commit.
2026-08-24 16:03:14 -05:00
JMR-devandClaude Opus 5 2bed40d080 Hold the three pickers to the constant they hand back
The format, quality and engine pickers are the same dozen lines with a
different enum substituted, and both ways they can go wrong are silent.
An onClick that closes over the picker's `selected` parameter instead of
the chip's own entry returns one constant for every chip; an inverted
`entry == selected` lights every chip but the right one. Neither throws,
neither changes the labels on screen, and a test that only asserted the
callback ran would pass over the first of them.

So each click test presses every chip in the row and compares the whole
recorded list against `entries`, which makes the constant load-bearing
rather than the click count, and each selection test asserts over every
chip rather than only the one that should be lit.

Verified by mutation, not by the suite going green:

- `onSelect(format)` -> `onSelect(OutputFormat.MP4_H264)` fails with
  `expected:<[MP4_H264, MP4_H265, WEBM_VP9, ...]> but was:<[MP4_H264,
  MP4_H264, MP4_H264, ...]>`
- `format == selected` -> `format != selected` fails both format
  selection tests on `Selected = 'true'` for a chip that should not be
- `onSelect(preference)` -> `{}` fails with `expected:<[AUTO,
  PREFER_HARDWARE, FORCE_SOFTWARE]> but was:<[]>`
- `selected.description` -> `QualityTier.FAST.description` fails the
  quality prose test on the missing BEST line

Labels are read off the enums so a reword cannot redden this file for
the wrong reason. `EnginePreference` has no label of its own, so the
screen's own `label()` supplies that set. The one display literal with
no symbol behind it, the custom-spec line, was copied out of the source
byte for byte because it holds a U+2014 that would fail silently if
retyped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:58:48 -05:00
Jason Ross 175472ae88 Merge pull request #65 from JMR-dev/test/r38-1-screen-test-seam
Make the screen leaves nameable from a test, and give tests a tag vocabulary
2026-08-24 15:53:38 -05:00
JMR-devandClaude Opus 5 df2e42a2b3 Name the screen leaves, and give the tests a tag vocabulary
Kotlin `private` on a top-level declaration is file-scoped, so every leaf
composable in the two screens was invisible even to the JVM test source set,
which is a friend of main. The only three declarations src/test could name were
ConverterScreen, JoinScreen and AppRoot -- there was nothing to write a test
against, which is why #52 could not be started as filed.

`internal` is the same choice MainActivity already documents for Destination:
the unit tests can name it, and it stays invisible to anything outside the
module. Eleven declarations in ConverterScreen and FileRow in JoinScreen.

The tag table is the other half. Tests reference a symbol rather than a literal,
which is what keeps the five children that follow independent: "Cancel", "Start
over" and "Save file" are each rendered by both screens and by more than one
state branch, so rewording one would otherwise redden several PRs at once and no
diff would explain why. There were zero testTag, semantics or contentDescription
calls anywhere in main before this.

Every tag is applied inside main. A tag a test hands down as a Modifier proves
only that the test set it -- it would survive the affordance losing its own tag
entirely, which is the vacuous shape CLAUDE.md records nine of in one review.
That is why FileRow and DetailRow derive theirs from data they already hold
rather than taking an index from the call site.

TestTags is public rather than internal, and R38.8 is the reason. It reads the
table from androidTest, and whether that is a friend source set of main under
AGP 9 had no in-tree answer -- nothing referenced a main internal from there.
Settled by compiling one: it is a friend, so internal would work today. Public
anyway, because that friendship is AGP wiring rather than something this project
states, and the KDoc now carries the measurement so nobody has to repeat it.

Smoke tests cover each leaf: it renders, and its tag resolves to exactly one
node. Counting rather than asserting existence is deliberate, since a duplicated
tag fails differently depending on which finder a later test happens to use. The
state-branch tags -- Convert, Cancel, Save file, Start over, the progress bars --
have no bite yet: reaching a branch needs the state seam R38.5 extracts, and the
state matrix is R38.6/R38.7 by design.

Five mutations, all red on the named test alone: FORMAT_CHIPS deleted,
FILE_CARD_NAME deleted, detailRow's tag no longer derived from its label,
FileRow's no longer derived from its name, and JOIN_MORE given JOIN's value --
the last caught only by the uniqueness check, which is what it is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:30:52 -05:00
JMR-devandClaude Opus 5 64c1a60a97 Drain the escaped coroutine error before the next test starts
`ConversionViewModelProbeFailureTest.an OutOfMemoryError is not swallowed`
deliberately lets a real error escape `viewModelScope.launch`, which has no
exception handler by design -- the ViewModel's KDoc says an OOM raised in the
probe should reach the thread's handler and take the process down.

On the JVM something else takes it. kotlinx-coroutines-test installs a
process-wide collector for uncaught coroutine errors, keeps whatever it catches,
and hands the backlog to the next `runTest` that starts, which throws
UncaughtExceptionsBeforeTest. Every Compose test is a `runTest`: that is how
`createComposeRule` runs a composition. So the error lands on an unrelated test
in an unrelated file, and the message names neither the test that caused it nor
the error's origin.

Nothing has hit it yet only because the sole Compose test in the repo happens to
run before the ViewModel one. R38 adds six more Compose classes in exactly the
two packages that surround it, and the first two of them made the suite fail in
two different files on two consecutive runs of identical, green code -- the
throw is on a real Dispatchers.IO thread, delivered after the state assertion
that ends the test responsible, so which class catches it is a race.

Drain it where a Compose rule is built. A @Before cannot: the rule's `runTest`
wraps the statement that calls it, so it has already thrown. @BeforeClass cannot
either, because Robolectric runs it outside the sandbox classloader, where the
collector is a different object. Constructing the rule is early enough, since
JUnit builds a fresh test-class instance -- and every @get:Rule field on it --
before evaluating any rule.

This is containment, not the cure. The cure is a seam: give the probe hop an
injectable dispatcher the way the constructor already does for
cleanupDispatcher, so the error has somewhere to land. That is a production
change and deserves its own commit.

kotlinx-coroutines-test was already on the unit-test classpath through
compose-ui-test-junit4; it is declared now because a file imports it. Pinned,
like robolectric and the linters: org.jetbrains.kotlinx is not one of the groups
the prerelease guard covers, so a float here would be free to take a milestone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:30:32 -05:00
Jason Ross 1779f20a03 Merge pull request #56 from JMR-dev/ci/api37-split
Split the API 37 CI leg so the part that works can gate
2026-08-23 17:35:52 -05:00
JMR-devandClaude Opus 5 225ecdd7e6 Split the API 37 leg so the part that works can gate
CI has never run the API level this app targets. The reason it did not was
never "API 37 is untestable" -- it was that two tests fail on the emulator
image, so one row would be permanently red or permanently allow-listed. This
splits that row instead of choosing between those two.

E2E API 37 gates. It runs 55 of the suite's 57 instrumented tests and must be
green. E2E API 37 Media3 hardware transcode runs the other two, reports, and
never blocks (continue-on-error). Both are driven off ONE marker,
@FailsOnEmulatorApi37: the gating job passes notAnnotation, the advisory job
passes annotation. Two lists would drift, and drift is silent in both
directions -- a test that ends up in neither job reads as green. Excluding by
class was not an option either: Media3EngineTest has four tests and two of
them pass here, so notClass would have thrown away real coverage.

The advisory job is named for what it runs, not for what we think is wrong.
Both its tests drive a full H.264 -> H.265 hardware transcode, which is what
distinguishes them from the two Media3EngineTest cases that pass -- those
never decode video. The goldfish-decoder theory sits in a comment inside the
job, where it can be corrected without renaming a check people have learned to
look for; docs/api-37-emulator-crash.md keeps measurement and inference apart.

The SystemUI disable moves into .github/scripts/e2e-run.sh behind
E2E_DISABLE_SYSTEM_UI, unset everywhere but the two API 37 jobs, so the other
four legs run byte-identical commands -- the same shape as
E2E_EXTRA_GRADLE_ARGS. It runs BEFORE the streamed logcat starts, deliberately:
`adb shell stop` would end that logcat and nothing restarts it, so a disable
placed after it would cost the leg its diagnostics for the part of the run that
matters. The body is probe v2 from api37-debug.yml -- the version measured 4/4
-- not the older one-round form: three rounds, waits for system_server to
actually be gone, verifies against `pm list packages -d`, and requires a 45 s
window with zero new aborts. The weaker probe reported success on a run that
then started SystemUI eight more times.

The caveat is written next to the row rather than left implicit: this leg runs
with SystemUI disabled and the framework restarted under it, a device
configuration no other leg and no Pixel run uses. Anything that touches system
UI must not trust it, and the Pixel check before each release is still the only
API 37 run with SystemUI intact.

docs/api-37-emulator-crash.md's "So should CI take API 37?" said no on three
reasons. Two were claims about CI that had never been measured; the section now
carries the eight runs that measured them, and the third reason is what the
split answers. docs/local-emulator.md and api37-debug.yml's header carried the
same "the matrix stops at 36" claim and are corrected with it.

CLAUDE.md is left alone deliberately -- its "CI's matrix therefore stops at API
36" clause is now false, and that correction is parked in the doc's existing
"Correction owed to CLAUDE.md" section, where two others are already waiting.

Making E2E API 37 an actually-required check is a repository-settings change
and must come after this is on main: adding a required context that does not
exist on the default branch blocks every PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:20:49 -05:00
JMR-devandClaude Opus 5 b3a705e3da Measure the API 36 control and record what CI cannot measure
Three additions to docs/api-37-emulator-crash.md, all from a CI investigation
run through .github/workflows/api37-debug.yml.

A third measured bullet: API 36 against API 37, back to back, same two tests,
same renderer, same SystemUI-disable path. 37.0 fails both on
c2.goldfish.h264.decoder (32660148155); 36 passes both in 4.603 s with the
same decoder in its logcat (32660152961). That falsifies "the stripped
configuration is what breaks these tests" -- a reading the other measurements
never addressed, because they all compare against a device that still had
SystemUI. It carries its two uncontrolled variables rather than dropping them:
API 36's framework restart happened with zero aborts logged where API 37's had
two, so a restart under an active abort loop is still uncontrolled; and the
images differ on the encoder side, which is a second reason "broken h264
decoder" is the wrong shape of claim.

The decoder-mechanism bullet is unchanged and still labelled inference. This
adds a measurement next to it; it does not retract anything.

The intact-SystemUI counterfactual is unmeasurable on a GitHub runner, and now
says why. Seven dispatches, zero verdicts, with a mechanism rather than bad
luck: while the framework crash-loops the guest cannot reliably create per-user
private directories, so an app installed during the loop has no cache dir and
the fixture copy dies in @Before before any codec exists. googlesdksetup and
nexuslauncher hit the same thing. The result XML masks it behind an
UninitializedPropertyAccessException in tearDown, which reads as a defect in
this repository and is not one.

Abort cadence corrected. "Roughly every 20 s" was the watchdog's sampling
interval, not the cadence: measured gaps are 20-90 s, median 60-70 s, three to
five per run, with sys.boot_completed held at 1 throughout. The wrong figure
lived in api37-debug.yml's own comments, so that line is corrected too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:20:40 -05:00
Jason Ross 577dae998b Merge pull request #55 from JMR-dev/ci/api37-debug
Verify the SystemUI disable instead of trusting what pm reported
2026-08-23 09:49:15 -05:00
JMR-devandClaude Opus 5 acc71bcaee Verify the SystemUI disable instead of trusting what pm reported
Four dispatches of one configuration -- API 37.0, swiftshader_indirect,
SystemUI disabled -- came back three green and one not, and the odd one out
was not a different failure so much as the same run without the fix applied.
In 32646029143 `pm disable-user` reported `new state: disabled-user` and
SystemUI then started eight more times:

  14:41:24 ActivityManager: Start proc 6412:com.android.systemui ... GradientColorWallpaper
  14:45:05 ActivityManager: Start proc 17299:com.android.systemui ... GradientColorWallpaper

with ten more RegionSampling aborts and a surfaceflinger pid that never sat
still (489, 1570, 3524, 4396, 6038, 7987, 9732, 11520, 13208, 15048). The
framework is being SIGKILLed every twenty seconds while this runs, so a
package-state change can go down with the system_server that accepted it.

Two things were wrong, and the second is why the first went unnoticed:

  - one disable attempt was treated as sufficient
  - the wait after `adb shell stop` was not a wait. It asked `service check`
    0.3 s later and got `found` from the system_server that was still on its
    way out, so it never waited for anything. Both the good and the bad run
    printed `services back after 5 s`, which is how a broken fix looked
    identical to a working one.

Now: up to three rounds of disable -> take the framework down and confirm
system_server is actually gone -> bring it back -> verify the package is in
`pm list packages -d` -> require a 45 s window with zero new aborts. Nothing
is believed because a command said so.

Also adds measure_baseline, default true. The 45 s pre-measurement is what
makes the rate comparable with the local figures, but it is 45 s of
crash-looping before the disable has to land, which is a worse starting
point than a real leg would have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:48:53 -05:00
Jason Ross b97d7c36a3 Merge pull request #54 from JMR-dev/ci/api37-debug
Resolve adb by path in the API 37 watchdog
2026-08-23 09:28:19 -05:00
JMR-devandClaude Opus 5 93398c4616 Resolve adb by path in the API 37 watchdog
The first three dispatches came back with every watchdog sample reading
`boot=? surfaceflinger=none zygote64=none dma_aborts=0`, on runs where the
device demonstrably booted and the action's own adb was working two steps
away. The watchdog was not measuring anything.

The emulator action puts platform-tools on PATH with core.addPath, which
writes GITHUB_PATH and therefore only affects LATER steps. The watchdog is
started before the action -- that is the whole point of it -- so it inherits
the runner's own PATH, where a bare `adb` is not necessarily anything. Every
call failed into `2>/dev/null` and the sampler dutifully recorded the silence
as zero.

It now resolves adb by path, preferring ANDROID_HOME, re-resolving on every
iteration in case platform-tools arrives later, and echoing the path it
settled on. The launch step prints ANDROID_HOME and `command -v adb` for the
same reason: a repeat of this failure should be one line to spot, not three
runs of quiet zeros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:27:57 -05:00
Jason Ross 19a1e66277 Merge pull request #53 from JMR-dev/ci/api37-debug
Add a dispatch-only workflow for the API 37 CI question
2026-08-23 09:15:49 -05:00
JMR-devandClaude Opus 5 8fdad6e20b Add a dispatch-only workflow for the API 37 CI question
status_check.yml stops its E2E matrix at 36 and says the android-37.0 image is
why. That is established locally under -gpu host and under ANGLE, and it is not
established for CI: runners use -gpu swiftshader_indirect, and the one local
measurement of that mode was void for a local reason -- Fedora denies execheap
to SwiftShader's JIT, so the emulator died before the guest mattered. What CI
does at API 37 has therefore never actually been measured.

This is that E2E job with the matrix replaced by workflow_dispatch inputs, so a
hypothesis costs a dispatch rather than a commit: renderer, API level, image
target, channel, SystemUI disable, boot timeout, whether the suite runs at all,
and free-form emulator and Gradle arguments. It triggers on nothing else and
gates nothing.

It calls .github/scripts/e2e-run.sh rather than forking it, and pins the same
disk-size, ram-size, action SHAs and KVM setup as the job it copies, so a run
here measures the renderer and not a different device.

The watchdog is load-bearing rather than decorative. The emulator action calls
killEmulator() from its own catch block, so a run whose emulator never boots is
torn down before any script: line executes and leaves nothing behind -- which is
the exact failure shape API 37 is suspected of. It starts before the action,
samples sys.boot_completed, the surfaceflinger and zygote pids and the
hasReadColorBufferDma abort count every 20 s, and keeps a rolling copy of the
crash buffer so the last read survives the teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:15:08 -05:00
Jason Ross 742703d360 Merge pull request #51 from JMR-dev/docs/definition-of-done
Write down that testable code is not done until it is tested
2026-08-23 08:52:00 -05:00
JMR-devandClaude Opus 5 6c34fad17c Write down that testable code is not done until it is tested
Stated as a project norm: if a piece is unit testable it gets unit tests, and
if it is e2e testable it gets e2e tests, before it counts as done. Both clauses,
not either/or.

Recorded here rather than left as a habit because the recent review measured
what happens without it. Forty-six mutations were run against a 257-test suite;
thirty-six bit and NINE were vacuous, five of those passing the entire suite
while a reattachment code path sat completely unguarded. That code had shipped,
been reviewed, and looked tested. "The suite is green" was true and meant
nothing.

The convention also names the two things that make it enforceable rather than
aspirational. Unit-testable is broader than it looks, because the pure-seam
pattern converts device-bound logic into a testable function plus a thin edge,
and Robolectric now covers the rest including Compose. And e2e is genuinely
runnable locally since the emulator renderer cause was found -- until last
night, "run the instrumented suite" was not a request anyone could act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 08:50:15 -05:00
Jason Ross 9c4f14202f Merge pull request #50 from JMR-dev/docs/coverage-figure
Measure the coverage figure instead of carrying it forward
2026-08-23 00:51:23 -05:00
JMR-devandClaude Opus 5 2747bb8627 Measure the coverage figure instead of carrying it forward
CLAUDE.md has said "~31% of lines" since the lint/format work landed. Measured
on main today it is 29.8% (629/2113 lines, 408/1424 branches).

The number went DOWN, which is worth stating rather than quietly correcting.
The JVM suite went from 11 test files to 43 over the same period, so the
intuition -- and the review finding that prompted this, which called the
direction certain -- was that coverage must have risen. It did not: main source
grew from 4,114 to 5,715 lines as the fixes added Reattachment, JobSnapshots,
JobTags, InputQuery, StagingNames, StagingSweep, NativeLoadFailure and an
Application class. The denominator outran the numerator.

That is not an argument against the tests. It is an argument against quoting a
coverage percentage from memory, which is exactly how the stale figure survived.
The line now carries the measurement, its date, and the instruction to re-measure.

R30 / #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 00:22:53 -05:00
Jason Ross 6d6d2189ca Merge pull request #47 from JMR-dev/tools/api-37-emulator
Re-derive the API 37 emulator failure, and harden the local sweep
2026-08-23 00:21:26 -05:00
JMR-dev f8e6bfa2a3 Merge remote-tracking branch 'origin/main' into tools/api-37-emulator 2026-08-22 23:52:58 -05:00
Jason Ross 5a1b8832d3 Merge pull request #48 from JMR-dev/fix/review-app-gaps
Close eleven review findings in app code
2026-08-22 23:52:18 -05:00
JMR-dev 0896cef758 Merge remote-tracking branch 'origin/main' into fix/review-app-gaps 2026-08-22 23:26:03 -05:00
JMR-devandClaude Opus 5 d3975617a8 Put a gate on the three pieces of wiring that had none
Three separate mutations passed the full 257-test suite, all for the same reason: the tool
was tested and the thing that calls it was not.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

R6 / #15, R23 / #32

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

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

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

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

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

R22 / #31, R10 / #19

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 22:09:19 -05:00
79 changed files with 10318 additions and 506 deletions
+294
View File
@@ -0,0 +1,294 @@
#!/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>]
#
# 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.
set -uo pipefail
LABEL="${1:-unknown}"
LOG="${2:-}"
BASELINE_FILE="${3:-}"
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"
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"
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)"
# The #81 check, verbatim: 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.
if [ -d "$REPO_ROOT/app/src/androidTest" ]; then
marked="$(grep -rn "@FailsOnEmulatorApi37" "$REPO_ROOT/app/src/androidTest" --include='*.kt' \
| grep -v import | grep -c FailsOn || 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
if [ "$failed" != "unknown" ] && [ "$failed" != "$baseline" ]; then
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
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"
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
echo " baseline: matches ($baseline expected, $baseline failed)"
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 |"
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
+134 -1
View File
@@ -34,12 +34,117 @@ 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
# enough under the job's 60-min cap that a genuine wedge still leaves time to capture it.
WEDGE_TIMEOUT=1200
# ---------------------------------------------------------------------------
# API 37 only, and nothing else sets it, so this is inert everywhere it is not wanted --
# 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.
#
# 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
# `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.
#
# 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.
# ---------------------------------------------------------------------------
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'; }
disable_region_sampling() {
local round=1 i out before after
while [ "$round" -le 3 ]; do
echo "--- SystemUI disable, 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"
case "$out" in *"new state: disabled"*) break ;; esac
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"
else
echo " NOT DISABLED after the restart -- the package state did not survive"
round=$((round + 1))
continue
fi
before="$(count_aborts)"
sleep 45
after="$(count_aborts)"
echo " abort rate, SystemUI disabled: $((after - before)) new in 45 s (total ${after:-0})"
[ "$((after - before))" -eq 0 ] && break
echo " still aborting after round $round"
round=$((round + 1))
done
# 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"
else
echo "::warning::E2E api${LABEL}: SystemUI is still enabled -- expect INSTRUMENTATION_ABORTED"
fi
return 0
}
if [ "${E2E_DISABLE_SYSTEM_UI:-}" = "1" ]; then
echo "::group::E2E api${LABEL} -- removing the region-sampling listener"
disable_region_sampling
echo "::endgroup::"
fi
# Stream logcat from now until the step ends, into a file that survives to the artifact upload.
# Without this, a failure that happens on-device leaves nothing behind: `adb logcat -d` at the
# end only has whatever is still in the ring buffer, and a chatty test run evicts the cause.
@@ -116,12 +221,40 @@ 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::"
# 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.
#
# 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
bash "$SCRIPT_DIR/e2e-report-shape.sh" "$LABEL" "$GRADLE_LOG" \
"$REPO_ROOT/app/src/androidTest/java/org/libremediaconverter/FailsOnEmulatorApi37.kt" || true
else
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
+413
View File
@@ -0,0 +1,413 @@
name: API 37 debug
# ---------------------------------------------------------------------------
# WHAT THIS IS FOR, AND WHY IT IS SEPARATE
#
# The android-37.x emulator images abort surfaceflinger inside their own gralloc
# mapper. That was established locally, under -gpu host and under ANGLE
# (docs/api-37-emulator-crash.md), but NOT on a GitHub runner: CI runs
# -gpu swiftshader_indirect, and the one local measurement of that mode was void for a
# purely local reason (Fedora's SELinux denies execheap to SwiftShader's Reactor JIT --
# docs/local-emulator.md). What CI actually does at API 37 was an open question, and
# this workflow is the instrument that answered it.
#
# IT IS STILL THE INSTRUMENT. status_check.yml now carries API 37 -- a gating leg that
# disables SystemUI first, and an advisory one for the two @FailsOnEmulatorApi37 tests
# -- so this file's job is no longer to decide that, but to test a change to it for one
# dispatch instead of one commit. The next questions it exists for are written down
# under "When to revisit" in docs/api-37-emulator-crash.md: a new API 37.x image, or an
# ATD image for 37, either of which could retire the whole workaround.
#
# It is a copy of that E2E job with the matrix replaced by workflow_dispatch inputs,
# so one hypothesis costs one dispatch rather than one commit. It triggers on nothing
# else: no push, no pull_request, no schedule. Nothing depends on it and it gates
# nothing.
#
# TWO THINGS THIS DELIBERATELY DOES NOT DO:
#
# - 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.
# - It does not change status_check.yml. If a configuration here turns out to work,
# the change to the real matrix is proposed separately.
#
# THE WATCHDOG IS THE POINT, not a nicety. reactivecircus/android-emulator-runner calls
# killEmulator() from its own catch block, so a run whose emulator never boots is torn
# down before a single `script:` line executes -- no probe, no e2e-run.sh, no artifacts,
# nothing to read afterwards. That is precisely the failure shape API 37 is suspected of.
# The watchdog therefore starts BEFORE the action, from outside it, and samples the device
# on its own clock.
# ---------------------------------------------------------------------------
on:
workflow_dispatch:
inputs:
api_level:
description: 'API level, as the SDK spells it. 37.0, 37.1, 37.2-beta3, 36 ... A bare 37 does not exist and fails during SDK setup.'
type: string
default: '37.0'
target:
description: 'System image target. android-37.1 and 37.2-beta* ship ONLY as google_apis_ps16k -- there is no plain google_apis above 37.0.'
type: string
default: 'google_apis'
channel:
description: 'SDK channel. beta is required for any 37.2-beta* image.'
type: choice
options: ['stable', 'beta', 'dev', 'canary']
default: 'stable'
gpu_mode:
description: 'The -gpu argument. swiftshader_indirect is what status_check.yml uses today; swangle_indirect is what works locally on API 37.'
type: choice
options:
- swiftshader_indirect
- swangle_indirect
- angle_indirect
- host
- auto
- guest
- 'off'
default: 'swiftshader_indirect'
disable_system_ui:
description: 'Take SystemUI out before the suite runs, which is what stops SurfaceFlinger RegionSamplingThread reaching the mapper bug. Restarts the framework.'
type: boolean
default: false
run_tests:
description: 'Run the instrumented suite. false boots, probes and stops -- the cheap loop when the question is only whether it boots and at what abort rate.'
type: boolean
default: true
emulator_boot_timeout:
description: 'Seconds the action waits for sys.boot_completed. Do not lower this for a software renderer: a slow boot would be misreported as a failed one.'
type: string
default: '600'
emulator_extra_options:
description: 'Appended verbatim to the emulator command line -- e.g. "-verbose", or "-feature -GLDMA,-GLDMA2". The action interpolates it into a sh -c, so "| tee $RUNNER_TEMP/emulator.log" also works and is uploaded.'
type: string
default: ''
disable_animations:
description: 'The action settings-puts three animation scales after boot. Each is an adb call that throws if the framework is mid-restart, which would kill the run before the probe. false removes three of those calls.'
type: boolean
default: true
gradle_extra_args:
description: 'Passed to e2e-run.sh as E2E_EXTRA_GRADLE_ARGS, its existing hook -- e.g. "--rerun", or -Pandroid.testInstrumentationRunnerArguments.class=... to run one class instead of the suite.'
type: string
default: ''
measure_baseline:
description: 'Measure the abort rate for 45 s BEFORE disabling SystemUI. Answers "how fast is it aborting"; costs 45 s of crash-looping first, which is a worse starting point for the disable.'
type: boolean
default: true
run-name: >-
api ${{ inputs.api_level }}/${{ inputs.target }} · gpu ${{ inputs.gpu_mode }} ·
systemui ${{ inputs.disable_system_ui && 'disabled' || 'running' }} ·
tests ${{ inputs.run_tests && 'yes' || 'no' }}
# No `concurrency` block, unlike status_check.yml. Every dispatch here runs on the same
# ref (main), so a group keyed on github.ref with cancel-in-progress would make two
# parallel experiments cancel each other -- which is the opposite of what this is for.
permissions:
contents: read
env:
GRADLE_CACHE_PATHS: |
~/.gradle/caches
~/.gradle/wrapper
jobs:
e2e-api37:
name: E2E API ${{ inputs.api_level }} (${{ inputs.gpu_mode }})
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '25' # Matches the daemon JVM pinned in gradle/gradle-daemon-jvm.properties
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.GRADLE_CACHE_PATHS }}
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
# Without this the emulator falls back to software rendering and takes minutes
# longer to boot, when it boots at all.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# Both helpers live in RUNNER_TEMP rather than in the repository: they are debug
# instrumentation for this workflow only, and writing them here keeps the whole
# experiment in one file that can be read top to bottom.
- name: Write the watchdog and the probe
env:
LABEL: ${{ inputs.api_level }}
run: |
cat > "$RUNNER_TEMP/watchdog.sh" <<'WATCHDOG'
#!/usr/bin/env bash
# Samples the device from outside the emulator action, because the action tears the
# emulator down on a boot timeout before any script: line runs. Everything here is
# `timeout`-wrapped: a wedged adb must not stall the sampler, and no probe may fail.
SERIAL="emulator-5554"
# adb is resolved by path, not by name. The emulator action puts platform-tools on
# PATH with core.addPath, which only affects LATER steps -- this one already exists
# by then, so a bare `adb` here is not the runner's adb and may be nothing at all.
# The first version of this file assumed otherwise and every sample came back
# boot=? dma_aborts=0 while the action's own adb was working fine two steps away.
# Re-resolved every iteration because platform-tools may be installed after this
# starts, and echoed to stdout so a repeat of that failure is visible immediately.
ADB=""
resolve_adb() {
for c in "$ADB" "${ANDROID_HOME:-}/platform-tools/adb" "${ANDROID_SDK_ROOT:-}/platform-tools/adb" "$(command -v adb 2> /dev/null)"; do
if [ -n "$c" ] && [ -x "$c" ]; then
[ "$c" = "$ADB" ] || echo "watchdog: adb resolved to $c"
ADB="$c"
return 0
fi
done
return 1
}
OUT="$RUNNER_TEMP/watchdog-api$LABEL.txt"
CRASH="$RUNNER_TEMP/crash-buffer-api$LABEL.txt"
GUESTLOG="$RUNNER_TEMP/watchdog-logcat-api$LABEL.txt"
STOP="$RUNNER_TEMP/watchdog.stop"
# A continuous guest logcat, restarted whenever the device goes away. During a
# surfaceflinger crash loop the framework restarts every few seconds and adb goes
# with it, so a single `adb logcat` would end at the first restart.
(
while [ ! -f "$STOP" ]; do
if resolve_adb; then
timeout 120 "$ADB" -s "$SERIAL" wait-for-device > /dev/null 2>&1 \
&& timeout 3000 "$ADB" -s "$SERIAL" logcat -v time >> "$GUESTLOG" 2>&1
fi
sleep 3
done
) &
echo "watchdog started $(date -u +%FT%TZ) -- serial $SERIAL" >> "$OUT"
i=0
while [ "$i" -lt 300 ]; do
i=$((i + 1))
[ -f "$STOP" ] && break
if ! resolve_adb; then
echo "$(date -u +%T) no adb yet" >> "$OUT"
sleep 20
continue
fi
boot="$(timeout 20 "$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2> /dev/null | tr -d '\r\n')"
sf="$(timeout 20 "$ADB" -s "$SERIAL" shell pidof surfaceflinger 2> /dev/null | tr -d '\r\n')"
zy="$(timeout 20 "$ADB" -s "$SERIAL" shell pidof zygote64 2> /dev/null | tr -d '\r\n')"
# Kept as a file rather than a variable so the last successful read survives the
# action killing the emulator -- which is when it is most worth having.
if timeout 30 "$ADB" -s "$SERIAL" logcat -d -b crash > "$CRASH.new" 2> /dev/null; then
mv "$CRASH.new" "$CRASH"
fi
dma="$(grep -c 'hasReadColorBufferDma' "$CRASH" 2> /dev/null || true)"
sigabrt="$(grep -c 'signal 6' "$CRASH" 2> /dev/null || true)"
printf '%s boot=%-4s surfaceflinger=%-8s zygote64=%-8s dma_aborts=%-5s sigabrt=%s\n' \
"$(date -u +%T)" "${boot:-?}" "${sf:-none}" "${zy:-none}" "${dma:-0}" "${sigabrt:-0}" >> "$OUT"
# One shot, the first time the device is up: which GLES implementation the guest
# actually got. This is the guest-side answer to the same question the emulator's
# own gles_mode_selected line answers host-side.
if [ "$boot" = "1" ] && [ ! -f "$RUNNER_TEMP/renderer-api$LABEL.txt" ]; then
{
echo "=== booted at $(date -u +%FT%TZ), watchdog sample $i ==="
timeout 30 "$ADB" -s "$SERIAL" shell dumpsys SurfaceFlinger 2>&1 | head -40
echo "--- getprop ---"
timeout 20 "$ADB" -s "$SERIAL" shell getprop 2>&1 | grep -Ei 'egl|gles|gpu|ranchu|gfxstream' || true
} > "$RUNNER_TEMP/renderer-api$LABEL.txt" 2>&1
fi
sleep 20
done
echo "watchdog finished $(date -u +%FT%TZ) after $i samples" >> "$OUT"
WATCHDOG
cat > "$RUNNER_TEMP/probe.sh" <<'PROBE'
#!/usr/bin/env bash
# Runs on the booted device, before the suite. Two jobs: record what the guest got,
# and measure the gralloc abort RATE -- which is the number that decides whether a
# five-minute test run can survive, and the one comparable with the local figures in
# docs/api-37-emulator-crash.md (10-11 per 150 s idle under ANGLE with SystemUI up).
#
# Never exits non-zero. The action runs script: lines in one try/catch, so a failing
# probe would skip e2e-run.sh entirely and the run would measure nothing.
exec > >(tee -a "$RUNNER_TEMP/probe-api$LABEL.txt") 2>&1
echo "===== probe api$LABEL -- $(date -u +%FT%TZ) ====="
adb shell getprop sys.boot_completed
adb shell getprop ro.build.fingerprint
adb shell getprop ro.build.version.sdk
echo "--- SurfaceFlinger (the GLES line names the renderer the guest is on) ---"
adb shell dumpsys SurfaceFlinger 2>&1 | head -30
echo "--- binder services ---"
for s in package activity window; do adb shell service check "$s" 2>&1; done
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'; }
if [ "${MEASURE_BASELINE:-true}" = "true" ]; then
before="$(count_aborts)"
sleep 45
after="$(count_aborts)"
echo "--- abort rate, SystemUI running: $((after - before)) new in 45 s (total ${after:-0}) ---"
else
# Skipped on purpose when the question is reliability rather than rate: every
# second spent measuring is a second of crash-looping, and the disable is what
# has to land. A real CI leg would disable as early as it can, so measure that.
echo "--- baseline window skipped (MEASURE_BASELINE=false) ---"
fi
if [ "${DISABLE_SYSTEM_UI:-false}" = "true" ]; then
# Three rounds, because ONE round is not reliable and the failure is silent.
# Measured: of four runs of the same configuration, three came back with the
# suite running and one (32646029143) had SystemUI restarting throughout --
# `ActivityManager: Start proc N:com.android.systemui ... GradientColorWallpaper`
# eight more times after a `pm disable-user` that had reported
# `new state: disabled-user`, and ten more RegionSampling aborts with it. The
# framework is being SIGKILLed under this loop, so a package-state change can be
# lost with the system_server that accepted it. (An earlier version of this comment
# said "every ~20 s". That was the watchdog's SAMPLING interval, not the cadence.
# Measured: 20-90 s between aborts, median 60-70 s, 3-5 in a four-minute window --
# docs/api-37-emulator-crash.md, "Abort cadence, corrected".)
#
# Nothing here trusts a command's own report. Each round: disable, take the
# framework down and confirm it is DOWN before bringing it back (the previous
# version 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), then verify
# the package is really disabled and that no abort lands in a quiet window.
round=1
while [ "$round" -le 3 ]; do
echo "--- disable 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"
case "$out" in *"new state: disabled"*) break ;; esac
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
if systemui_disabled; then
echo " verified: com.android.systemui is in pm list packages -d"
else
echo " NOT DISABLED after the restart -- the package state did not survive"
round=$((round + 1))
continue
fi
before="$(count_aborts)"
sleep 45
after="$(count_aborts)"
echo "--- abort rate, SystemUI disabled: $((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"
fi
echo "--- crash buffer (tail 60) ---"
adb logcat -d -b crash 2>&1 | tail -60
echo "===== probe done ====="
exit 0
PROBE
chmod +x "$RUNNER_TEMP/watchdog.sh" "$RUNNER_TEMP/probe.sh"
echo "helpers written to $RUNNER_TEMP"
- name: Start the watchdog
env:
LABEL: ${{ inputs.api_level }}
run: |
echo "ANDROID_HOME=${ANDROID_HOME:-<unset>} ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT:-<unset>}"
echo "adb on PATH: $(command -v adb || echo '<none -- the watchdog will fall back to ANDROID_HOME>')"
nohup bash "$RUNNER_TEMP/watchdog.sh" > "$RUNNER_TEMP/watchdog-stdout.txt" 2>&1 < /dev/null &
disown
echo "watchdog pid $!"
- name: Instrumented tests
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
env:
LABEL: ${{ inputs.api_level }}
DISABLE_SYSTEM_UI: ${{ inputs.disable_system_ui }}
MEASURE_BASELINE: ${{ inputs.measure_baseline }}
# e2e-run.sh's own hook, unset in CI's real workflow and therefore inert there.
E2E_EXTRA_GRADLE_ARGS: ${{ inputs.gradle_extra_args }}
with:
api-level: ${{ inputs.api_level }}
target: ${{ inputs.target }}
channel: ${{ inputs.channel }}
arch: x86_64
profile: pixel_6
emulator-boot-timeout: ${{ inputs.emulator_boot_timeout }}
emulator-options: -no-window -gpu ${{ inputs.gpu_mode }} -noaudio -no-boot-anim -camera-back none ${{ inputs.emulator_extra_options }}
disable-animations: ${{ inputs.disable_animations }}
# disk-size, ram-size: kept exactly as status_check.yml pins them, so this
# measures the renderer and not a different device. 8G because the APK plus
# FFmpeg does not fit the default userdata partition; 2560M because the
# emulator's own RAM floor varies by API level and 2560M is the highest of them.
disk-size: 8G
ram-size: 2560M
# Two lines, because the action splits script: on newlines and runs each as its
# own `sh -c`. The first is this workflow's own probe; the second is CI's real
# harness, invoked unmodified. run_tests: false replaces it with an echo rather
# than a second copy of the job.
script: |
bash ${{ runner.temp }}/probe.sh
${{ inputs.run_tests && format('bash .github/scripts/e2e-run.sh {0}', inputs.api_level) || 'echo "run_tests=false -- suite skipped, boot and probe only"' }}
- name: Stop the watchdog
if: always()
run: |
touch "$RUNNER_TEMP/watchdog.stop"
echo "----- watchdog samples -----"
cat "$RUNNER_TEMP/watchdog-api${{ inputs.api_level }}.txt" 2>/dev/null || echo "(no watchdog output)"
echo "----- renderer -----"
cat "$RUNNER_TEMP/renderer-api${{ inputs.api_level }}.txt" 2>/dev/null || echo "(never booted, or dumpsys unavailable)"
echo "----- crash buffer, last read before teardown (tail 80) -----"
tail -80 "$RUNNER_TEMP/crash-buffer-api${{ inputs.api_level }}.txt" 2>/dev/null || echo "(none)"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: api37-debug-run${{ github.run_number }}
path: |
${{ runner.temp }}/watchdog-api*.txt
${{ runner.temp }}/watchdog-logcat-api*.txt
${{ runner.temp }}/watchdog-stdout.txt
${{ runner.temp }}/crash-buffer-api*.txt
${{ runner.temp }}/renderer-api*.txt
${{ runner.temp }}/probe-api*.txt
${{ runner.temp }}/emulator*.log
${{ runner.temp }}/logcat-api*.txt
${{ runner.temp }}/diagnostics-api*.txt
${{ runner.temp }}/wedge-diagnostics-api*.txt
app/build/reports/androidTests/
app/build/outputs/androidTest-results/
if-no-files-found: warn
+16 -1
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
@@ -81,7 +92,11 @@ jobs:
- name: Verify the released artifacts
run: |
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
# A glob, not `ls | head`: the glob is already here, and parsing ls is what
# SC2012 is about. Gradle's names have no spaces today, which is exactly the
# kind of assumption that holds until it does not.
apks=(app/build/outputs/apk/release/*.apk)
APK="${apks[0]}"
# A release that shipped one ABI, or lost 16 KB alignment, would install
# fine on a test device and fail for users or at Play submission. Both are
# cheap to check and expensive to discover later.
+227 -9
View File
@@ -156,7 +156,54 @@ jobs:
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
# Shell is the other language in this repo -- four scripts, one of them the CI
# entry point itself -- and nothing was checking it. `git ls-files` rather than a
# fixed list, so a script added later is covered without editing this workflow.
#
# Full severity, `info` included. The findings it raises today are answered with
# targeted `disable` directives carrying their reason, the same way
# config/detekt/detekt.yml carries only the rules this codebase legitimately
# breaks. A blanket --severity=warning would have hidden them and the next real
# one alike.
#
# PINNED BY DIGEST, for the reason CLAUDE.md already gives for pinning ktlint,
# detekt and JaCoCo: a new rule in a linter makes files nobody touched stop
# passing, so CI goes red on a PR whose diff cannot explain it. That is not
# hypothetical here. The first cut of this step used the runner's ambient
# shellcheck, which is 0.9.0, and 0.9.0 reports a trap handler as seven
# unreachable commands (SC2317) where 0.11.0 reports it once on the declaration
# (SC2329) -- same script, same directive, different answer, and a red build on
# the PR that introduced the step. The version is printed so a finding that
# appears out of nowhere can be tied to a bump of this line.
- name: shellcheck
env:
SHELLCHECK: koalaman/shellcheck@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d
run: |
docker run --rm "$SHELLCHECK" --version
git ls-files -z '*.sh' | xargs -0 -r docker run --rm -v "$PWD:/mnt" "$SHELLCHECK"
# actionlint closes the half shellcheck cannot see. The step above reads .sh files;
# a good deal of this repo's bash lives in inline `run:` blocks instead -- the release
# verification here, the emulator setup and teardown in this file and in
# api37-debug.yml. actionlint parses each workflow and runs shellcheck over every
# `run:`, on top of its own checks for expression syntax, `needs:` references, matrix
# keys and action input names.
#
# Pinned by digest for the same reason shellcheck is, and with a second reason of its
# own: actionlint's documented install is `bash <(curl -s .../download-actionlint.bash)`
# off a moving branch, which would sit badly in a repo that pins every action by SHA.
- name: actionlint
env:
ACTIONLINT: rhysd/actionlint@sha256:9d36088643581e728c969f35141f88139fec77280b2be23c1f66f8e40e1025e7
run: |
docker run --rm "$ACTIONLINT" -version
docker run --rm -v "$PWD:/repo" -w /repo "$ACTIONLINT" -color
# `!cancelled()` rather than a plain sequence: a shellcheck failure above must not
# cost the ktlint/detekt/lint lists. Same reason this step passes --continue -- one
# round trip should produce every list, not stop at the first.
- name: ktlint, detekt and Android lint
if: '!cancelled()'
run: ./gradlew :app:ktlintCheck :app:detekt :app:lintDebug --continue --stacktrace
# The XML matters as much as the HTML: it is the one that can be diffed between
@@ -178,8 +225,9 @@ jobs:
# across it -- none below 34, dataSync at 34, mediaProcessing from 35. Testing a
# single level would leave two thirds of that branch unexercised.
#
# It stops at 36 rather than targetSdk 37 because the android-37.0 emulator image
# is broken, not because 37 does not matter. See docs/api-37-emulator-crash.md.
# It reaches targetSdk 37, but the API 37 row is not like the other four and the
# comment on it says how. Two tests are excluded there and run in their own
# advisory job below. See docs/api-37-emulator-crash.md.
#
# FFmpeg is not built here. The AAR is committed under bin/, so a red run means the
# code is broken rather than that a cross-compile hiccuped.
@@ -204,13 +252,49 @@ jobs:
api-level: "35"
- label: "36"
api-level: "36"
# No API 37 row. targetSdk is 37, but the android-37.0 emulator image
# crash-loops surfaceflinger inside its own gralloc mapper, so every test
# fails there no matter what this app does. Ruling that in took four CI
# rounds, so the evidence and the ruled-out fixes are written down rather
# than left to be rediscovered: docs/api-37-emulator-crash.md. That file
# also records what to try first when re-adding it -- note that the row
# needs api-level "37.0", since a bare 37 fails during SDK setup.
# 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.
#
# "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.
#
# 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.
#
# 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
# 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.
- label: "37"
api-level: "37.0"
disable-system-ui: "1"
gradle-args: "-Pandroid.testInstrumentationRunnerArguments.notAnnotation=org.libremediaconverter.FailsOnEmulatorApi37"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -236,6 +320,12 @@ jobs:
- name: Instrumented tests
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
# Both of these are empty on every row but 37, and both are read with a
# `:-` default in e2e-run.sh, so the four legs below 37 run the identical
# gradle command they always have.
env:
E2E_DISABLE_SYSTEM_UI: ${{ matrix.disable-system-ui }}
E2E_EXTRA_GRADLE_ARGS: ${{ matrix.gradle-args }}
with:
api-level: ${{ matrix.api-level }}
target: google_apis
@@ -287,6 +377,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
@@ -297,3 +388,130 @@ jobs:
name: e2e-wedge-api${{ matrix.label }}
path: ${{ runner.temp }}/wedge-diagnostics-api${{ matrix.label }}.txt
if-no-files-found: ignore
# ---------------------------------------------------------------------------
# The three API 37 tests the gating row above excludes, run on their own so they
# stay visible instead of disappearing behind a notAnnotation.
#
# continue-on-error: it reports, it never blocks. That is the whole reason it is
# a separate job rather than a sixth matrix row: a row would share the gating
# job's `E2E API <label>` name, and a check cannot be both required and advisory
# under one name.
#
# It was named for WHAT IT RUNS, and that is now APPROXIMATE rather than exact.
# When this job was created it held two tests, both driving a full H.264 -> H.265
# hardware transcode through Media3Engine -- which is exactly what separated them
# from the two Media3EngineTest cases that pass here, since those two never decode
# video. Since 2026-08-25 it also holds SafPickerRoundTripTest's rotation case,
# which drives no transcode at all: a real rotation rebuilds every surface at once
# and takes the framework down on this image (INSTRUMENTATION_ABORTED), which is a
# different failure from the decoder one below.
#
# The name is kept anyway, and that is a decision rather than an oversight. This is
# not a required context, it is red on every PR by design, and it is one people
# have learned to look for -- renaming a check costs more than the imprecision
# does. **The marker is the definition, not the name:** what this job holds is the
# tests that cannot pass on the API 37 emulator image, whatever their subject. The
# theory about the Media3 pair is in the next paragraph, where it can be corrected
# without touching the name.
#
# THEORY, NOT SETTLED: the exception surfaces at `dequeueOutputBuffer` on
# `c2.goldfish.h264.decoder`, the emulator's own codec, which gets its frames out
# of a host-side colour buffer -- the same readback machinery that aborts
# surfaceflinger on this image. It is about the MEDIA3 PAIR only; the rotation
# case above fails for its own reason. What is MEASURED is narrower: those two
# fail on
# the API 37 emulator image; pass at API 36 on this runner under the same renderer
# AND the same SystemUI-disable path; pass at API 33-36 without that path at all,
# since nothing below 37 needs it; and pass on a physical Pixel 10 Pro XL at 37. That the decoder is the culprit rather than something else
# the decode path touches is inference. docs/api-37-emulator-crash.md separates
# the two, and the images also differ on the encoder side, which is why "broken
# h264 decoder" is not written into this job's name.
#
# WHEN THIS GOES GREEN, delete the annotation rather than this job: the gating
# row picks the tests back up automatically, and this job goes empty and can go
# with it.
# ---------------------------------------------------------------------------
e2e-api37-advisory:
name: E2E API 37 Media3 hardware transcode (advisory)
runs-on: ubuntu-latest
needs: ffmpeg
timeout-minutes: 60
continue-on-error: true
env:
E2E_LABEL: "37-media3-transcode"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '25'
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.GRADLE_CACHE_PATHS }}
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Instrumented tests
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
env:
E2E_DISABLE_SYSTEM_UI: "1"
# 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.
api-level: "37.0"
target: google_apis
arch: x86_64
profile: pixel_6
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
disk-size: 8G
ram-size: 2560M
script: bash .github/scripts/e2e-run.sh ${{ env.E2E_LABEL }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-report-api${{ env.E2E_LABEL }}
path: |
app/build/reports/androidTests/
app/build/outputs/androidTest-results/
if-no-files-found: warn
# Uploaded always, and here it matters more than anywhere else in this file:
# this job is EXPECTED to be red, so the logcat is the only thing that says
# whether it is red for the known reason or for a new one.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-diagnostics-api${{ env.E2E_LABEL }}
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
if: always()
with:
name: e2e-wedge-api${{ env.E2E_LABEL }}
path: ${{ runner.temp }}/wedge-diagnostics-api${{ env.E2E_LABEL }}.txt
if-no-files-found: ignore
+111 -10
View File
@@ -64,16 +64,43 @@ of the first one that fails.
on this machine (below), so without it an androidTest compile error is not discovered until CI.
ktlint and detekt also cover the `test`/`androidTest` source sets that `lintDebug` skips.
## Instrumented tests do not run locally
## Instrumented tests: where they actually run
Two independent reasons, so do not spend time on either:
This section said the opposite until 2026-08-24, and both of its claims had been false for two
days. Read it as the current answer, and see the git history if you need the old one.
- **Emulators segfault on this host.** qemu dies on every AVD. Instrumented tests run on CI or on
the physical Pixel, never in a local emulator.
- **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its own gralloc
mapper, so every test fails there regardless of this app. `docs/api-37-emulator-crash.md` records
the evidence and the ruled-out fixes; CI's matrix therefore stops at API 36 even though targetSdk
is 37. **API 37 needs a manual check on the Pixel 10 Pro XL before each release.**
- **Local emulators work, for API 33-36.** `tools/local-emulator/run-e2e.sh` runs them on this
host. The segfault that made this look impossible was not a broken machine: SwiftShader's Reactor
JIT writes generated shader code onto the heap and executes it, Fedora's SELinux policy denies
`execheap`, and qemu dies. Choosing a different renderer avoids it entirely — `-gpu host`,
`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 60 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 57.
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.
`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.
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
for.
On a device or emulator, build only the ABI it can execute:
@@ -96,10 +123,84 @@ 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** — currently ~31% of lines. A floor needs a baseline that has
settled first.
- **Coverage is reported, not gated** — **69.2% of lines (1519/2194), 53.2% of branches**,
measured 2026-08-24 with `./gradlew :app:jacocoTestReport`.
**Every figure this file carried before that date 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
`isIncludeNoLocationClasses` block in `app/build.gradle.kts` is what fixes it; **do not delete
it as stray config**, and re-run the numbers if you ever touch it. Same commit, same 335 tests:
29.7% -> 69.2% with that block alone.
The old entry also explained the wrong thing. It said coverage **fell** as the suite grew from 11
test files to 43 because "the denominator outran the numerator" on framework-edge code "the JVM
cannot reach". The JVM reaches that code fine. What actually happened is that the new tests were
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.
- **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.
Three things make that a real bar rather than a slogan here:
- **Unit-testable is broader than it looks.** The pure-seam pattern — `work/FailureOutcome.kt`
documents the reasoning — turns "needs a device" into "a pure function plus a thin edge".
Robolectric is in the JVM source set, `compose-ui-test-junit4` with it, so Compose screens are
unit testable too. Reach for the seam before concluding something cannot be unit tested.
- **E2E is runnable locally**, API 33-36, via `tools/local-emulator/run-e2e.sh` — see
"Instrumented tests: where they actually run" above. That was believed impossible until the
SELinux/renderer cause was found, and it is what makes the e2e half of this norm enforceable.
- **A test has to bite.** Revert the line it covers, confirm it goes red, restore. A review of
this codebase ran 46 mutations against a 257-test suite and **9 were vacuous** — five of them
passing the whole suite over a completely unguarded code path. Green is not evidence.
Name what you did not cover and why. Genuine exemptions exist; implied coverage is the problem.
- `kotlin.code.style=official`. Gradle stays Kotlin DSL.
- **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:
eight issues filed as a scripted batch all reached the board; one filed as a one-off minutes
later did not. A batch carries the board step in its loop; **one-offs are where it slips**, which
is what the script is for. It resolves the project and Status ids by name rather than caching
them, and it **reads the item back** — a mutation returning 200 is not evidence the board shows
what was asked for. Exit 3 means the issue was created but did not reach the board, and prints
the number so it cannot be lost quietly.
`above-cut` and `backlog` are **labels from the 2026-08-22 triage pass** — "worked autonomously
overnight" and "held for manual review". They are not board columns. Status carries board state;
do not put a cut label on a newly filed ticket.
- **shellcheck runs in CI**, inside the Static analysis job, over `git ls-files '*.sh'` so a new
script is covered without editing the workflow. It runs at full severity, `info` included: the
two findings that raises today are answered with targeted `disable` directives carrying their
reason, exactly as `config/detekt/detekt.yml` carries only the rules this codebase legitimately
breaks. Do not silence it with `--severity=warning` — that hides the next real finding too.
**It is pinned by image digest, and joins ktlint/detekt/JaCoCo in the "Dependency versions"
rule above** — for exactly the reason stated there, demonstrated the day it was added. The first
cut used the runner's ambient shellcheck. That is **0.9.0**, while the container used to check
locally was 0.11.0, and the two disagree about how to report a trap handler: 0.11.0 says
`SC2329` once on the declaration, 0.9.0 says `SC2317` on each of seven lines in the body. Same
script, same directive, one green and one red. Directives that must survive both name both codes.
Locally, use the same pin rather than whatever is installed:
`podman run --rm -v "$PWD:/mnt:z" docker.io/koalaman/shellcheck@sha256:61862eba... <files>`
(the digest is in `status_check.yml`; there is no shellcheck system package on this host).
**`actionlint` covers the half shellcheck cannot see** — the inline `run:` blocks, where a good
deal of this repo's bash lives. It runs shellcheck over each `run:` plus its own checks on
expression syntax, `needs:` references, matrix keys and action inputs. It sits in the same job,
**pinned by digest** for the reason above and one of its own: its documented installer is a
`curl | bash` off a moving branch, which does not belong in a repo that pins every action by SHA.
Locally: `podman run --rm -v "$PWD:/repo:z" -w /repo docker.io/rhysd/actionlint@sha256:9d360886... -color`.
## Dependency versions
Libraries **float on minor + patch** (`coreKtx = "1.+"`). Three groups deliberately do not:
+8 -1
View File
@@ -113,7 +113,14 @@ container × codec matrix — including combinations that cannot work, which it
offers alternatives for rather than hiding.
Conversions run as durable background work, so they survive leaving the app and are
restored after a restart.
restored when you reopen it.
One case is not restored, and it is worth knowing about. If Android refuses to let a job
restart in the background, it is retried on an exponential backoff for about eight and a
half hours and then given up on — and a job that has been given up on does not come back
when you reopen the app. Reopening the app is what grants permission to run, so a
conversion that has stalled this way is best started again from the app rather than waited
on.
## Building
+50 -2
View File
@@ -1,3 +1,4 @@
import org.gradle.api.tasks.PathSensitivity
import org.gradle.testing.jacoco.tasks.JacocoReport
plugins {
@@ -188,6 +189,40 @@ detekt {
}
// Pin the coverage agent rather than inheriting whatever Gradle bundles.
// Robolectric loads every class it touches through its own sandbox classloader, and those
// classes arrive with no source location. JaCoCo skips no-location classes by default, so
// without this block **not one Robolectric test counts** -- and Robolectric is what exercises
// the framework edge here: the workers, the publisher, both ViewModels, every Compose screen.
//
// Measured on e06b082, same 335 tests, same 0 failures, only this block added:
//
// LINE 29.7% -> 69.2% OutputPublisher 0.0% -> 97.5%
// BRANCH 29.8% -> 53.2% ConversionViewModel 0.0% -> 85.4%
//
// The discriminator, if this ever looks like superstition: inside ConverterScreenKt, `describe`
// is the one non-Composable and is exercised by a plain JVM test -- it reported 8/8 covered while
// every @Composable in the same class reported 0, including ones whose mutations demonstrably
// failed the build when reverted.
//
// `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)
extensions.configure<JacocoTaskExtension> {
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
}
}
jacoco {
toolVersion = libs.versions.jacoco.get()
}
@@ -211,8 +246,11 @@ val jacocoGeneratedExcludes = listOf(
)
// AGP 9 compiles Kotlin through its built-in compiler, which writes here rather than to the
// classic `tmp/kotlin-classes/debug`. All hand-written code in this module is Kotlin, so the
// javac output (BuildConfig and R only) is not read at all.
// classic `tmp/kotlin-classes/debug`. All hand-written code in the MAIN source set is Kotlin, so
// the javac output (BuildConfig and R only) is not read at all. There is now one hand-written
// Java file in the module -- androidTest's FixtureDocumentsProvider, which cannot be Kotlin
// because the process it runs in has no Kotlin stdlib; its own header explains why. It is in
// androidTest, so it is not in this task's classDirectories and this stays accurate.
val jacocoDebugKotlinClasses = layout.buildDirectory.dir(
"intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes",
)
@@ -306,11 +344,21 @@ dependencies {
// the tests stay green.
testImplementation(platform(libs.compose.bom))
testImplementation(libs.compose.ui.test.junit4)
// For `runTest` alone, in ConversionViewModelProbeFailureTest. It arrives transitively
// with the rule above anyway; declared because a test file imports it directly, and an
// import of something nobody asked for breaks the day the library that pulled it in stops.
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.work.testing)
// androidTest only, and it has to be: UiAutomator drives the whole device, including
// windows belonging to other packages. The system file picker is one -- DocumentsUI runs
// in its own process, so Compose's matchers cannot see it and Espresso's cannot either
// (both are scoped to this process's view hierarchy). Nothing on the JVM has a device to
// drive, so there is no unit-test counterpart to add it to.
androidTestImplementation(libs.androidx.uiautomator)
debugImplementation(libs.compose.ui.test.manifest)
}
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The first manifest this source set has ever had, and it exists for one component.
SafPickerRoundTripTest drives the real system file picker. DocumentsUI only shows what a
DocumentsProvider offers it, so a test that picks a file needs a provider to pick from, and
that provider has to be declared: a ContentProvider is instantiated by the system from a
manifest entry and cannot be registered from test code.
It is declared HERE rather than in src/debug on purpose. src/debug would put a fake storage
root inside the shipped debug APK, where it would show up in every developer's own file
picker and in every other app's; this way it is installed only by the instrumentation APK,
alongside the test that needs it, and is gone the moment that APK is uninstalled.
The four attributes are not decoration. Each one is required for the picker to see it:
exported DocumentsUI is another app; an unexported provider is invisible to it.
permission MANAGE_DOCUMENTS is held by DocumentsUI and essentially nothing else,
so this is what stops any installed app from reading the fixture. The
provider is exported to the *picker*, not to the world.
grantUriPermissions How the app under test ends up able to read the URI it was handed. The
picker returns the document URI with FLAG_GRANT_READ_URI_PERMISSION,
and that flag does nothing unless the provider allows grants. Without
it the pick "succeeds" and every read of the result fails.
DOCUMENTS_PROVIDER The action DocumentsUI queries the package manager for. No filter, no
root in the drawer.
The authority carries the .test suffix because this component belongs to the instrumentation
package (org.libremediaconverter.test), not to the app. Authorities are global to the device:
reusing the app's would collide with the app on any device where both are installed.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="org.libremediaconverter.saf.FixtureDocumentsProvider"
android:authorities="org.libremediaconverter.test.fixtures"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>
@@ -0,0 +1,55 @@
package org.libremediaconverter
/**
* Marks an instrumented test that does not pass 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
* one — so a test carrying it runs in exactly one of the two and can never fall through both.
* 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).
*
* 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
* emulator image cannot currently answer this one". `docs/api-37-emulator-crash.md` has the
* measurements and the one bullet in them that is still inference.
*
* 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.
*
* **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 means.** A test carrying it cannot pass
* on this image, so the count is 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.
*
* 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 = 3
@@ -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)
@@ -11,14 +11,26 @@ 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.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
@@ -57,6 +69,7 @@ class Media3EngineTest {
}
@Test
@FailsOnEmulatorApi37
fun transcodesH264ToH265AndReportsProgress(): Unit = runBlocking {
val seen = mutableListOf<Int>()
@@ -119,6 +132,7 @@ class Media3EngineTest {
* HandlerThread indirection holds before any of that lands in Phase 2.
*/
@Test
@FailsOnEmulatorApi37
fun runsFromAThreadWithNoLooper() {
val pool = Executors.newSingleThreadExecutor()
try {
@@ -167,6 +181,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 {
@@ -208,5 +279,12 @@ class Media3EngineTest {
private companion object {
const val TIMEOUT_SECONDS = 120L
/**
* 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
}
}
@@ -0,0 +1,234 @@
package org.libremediaconverter.saf;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Root;
import android.provider.DocumentsProvider;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/**
* One file, offered to the system file picker, so that picking one can be tested at all.
*
* <p>DocumentsUI does not browse a filesystem: it lists what {@link DocumentsProvider}s hand it.
* So a test that drives the real picker has to supply the thing being picked, and it has to
* supply it as a manifest-declared component, because a {@code ContentProvider} is instantiated
* by the system and cannot be registered from test code. {@code
* app/src/androidTest/AndroidManifest.xml} is that declaration and says why each of its
* attributes is load-bearing.
*
* <h2>The only Java file in this module, and it has to be</h2>
*
* <p>Everything else here is Kotlin. This cannot be: <b>the Kotlin standard library is not on
* this class's classpath at runtime.</b>
*
* <p>Instrumentation code normally never notices. The test APK's dex is loaded into the app's
* process, where the app APK supplies {@code kotlin.jvm.internal.Intrinsics} — so the test APK is
* built without it, deliberately, since packaging a second copy is what {@code
* checkDebugAndroidTestDuplicateClasses} exists to prevent. A provider is different. It is a
* component of the instrumentation <i>package</i>, so when DocumentsUI queries it the system
* starts a plain {@code org.libremediaconverter.test} process with only the test APK on its dex
* path, and no app APK anywhere. The Kotlin version of this file crashed there on its first
* query, before returning a single row:
*
* <pre>
* FATAL EXCEPTION: binder:6369_2
* Process: org.libremediaconverter.test
* java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics;
* at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument
* </pre>
*
* <p>The compiler emits that reference for the null checks on almost every function, so there is
* no Kotlin dialect that avoids it. For the same reason nothing here imports {@code androidx.*}:
* those classes are absent from this process for exactly the same reason. Framework and JDK only.
*
* <h2>Why a provider rather than a file in Downloads</h2>
*
* <p>That would have worked, and it would have tested less. Two properties are what {@code
* SafPickerRoundTripTest} actually needs:
*
* <ul>
* <li><b>The root declares {@link Root#COLUMN_MIME_TYPES}, and DocumentsUI filters by it.</b>
* That is what gives the screen's MIME filter a mutation with a shape: ask for a type this
* root does not offer and the root itself is not in the picker, so the failure reads as
* "the fixture root is not there" rather than "one file among the hundreds in Downloads was
* not listed".
* <li><b>The contents are exactly this and nothing else.</b> A shared directory accumulates
* whatever earlier runs and other tests left in it, and a picker test that finds the wrong
* file passes.
* </ul>
*
* <p>The descriptor is opened on a real file rather than served through a pipe, deliberately.
* {@code InputQuery.sizeOf} falls back to {@code ParcelFileDescriptor.statSize} when a provider
* omits {@code OpenableColumns.SIZE}, and a pipe's {@code statSize} is {@code -1} — an unknown
* size, which is a different case with a screen of its own. This fixture is meant to be an
* ordinary, fully described file, so that the one thing under test is the round trip.
*/
public final class FixtureDocumentsProvider extends DocumentsProvider {
/**
* What the picker calls this root.
*
* <p>Deliberately not a word any other root uses. The picker's own landing screen already
* offers "Images", "Audio", "Videos" and "Documents", and a UiAutomator selector that could
* match two things is not a selector.
*/
public static final String ROOT_TITLE = "LMC R38 fixtures";
/**
* What the file card has to end up showing.
*
* <p>The same string reaches the assertion two ways — as the picker row UiAutomator taps, and
* as {@code OpenableColumns.DISPLAY_NAME} on the URI the app is handed — which is exactly the
* round trip under test.
*/
public static final String FIXTURE_DISPLAY_NAME = "lmc-r38-fixture.mp4";
/**
* The type the root advertises, and the one the MIME mutation has to stop matching.
*
* <p>A real type rather than something invented, so the wildcard filter the screen passes
* today is not the only filter under which this test could pass.
*/
public static final String FIXTURE_MIME_TYPE = "video/mp4";
private static final String ROOT_ID = "lmc-r38-root";
private static final String ROOT_DOCUMENT_ID = "root";
private static final String FIXTURE_DOCUMENT_ID = "root/" + FIXTURE_DISPLAY_NAME;
/** 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";
private static final String[] DEFAULT_ROOT_PROJECTION = {
Root.COLUMN_ROOT_ID,
Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY,
Root.COLUMN_MIME_TYPES,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
};
private static final String[] DEFAULT_DOCUMENT_PROJECTION = {
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED,
};
@Override
public boolean onCreate() {
return true;
}
/**
* The single root.
*
* <p>{@link Root#COLUMN_MIME_TYPES} is the important column. Left null it would mean "this
* root supports everything", the picker would list it whatever was asked for, and the MIME
* mutation would have nothing to bite on.
*/
@Override
public Cursor queryRoots(String[] projection) {
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION);
cursor.newRow()
.add(Root.COLUMN_ROOT_ID, ROOT_ID)
.add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
.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_ICON, android.R.drawable.ic_menu_gallery);
return cursor;
}
@Override
public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
if (ROOT_DOCUMENT_ID.equals(documentId)) {
addDirectoryRow(cursor);
} else if (FIXTURE_DOCUMENT_ID.equals(documentId)) {
addFixtureRow(cursor);
} else {
throw new FileNotFoundException("no such document: " + documentId);
}
return cursor;
}
@Override
public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder)
throws FileNotFoundException {
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
if (ROOT_DOCUMENT_ID.equals(parentDocumentId)) {
addFixtureRow(cursor);
}
return cursor;
}
@Override
public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal)
throws FileNotFoundException {
if (!FIXTURE_DOCUMENT_ID.equals(documentId)) {
throw new FileNotFoundException("no such document: " + documentId);
}
return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY);
}
private void addDirectoryRow(MatrixCursor cursor) {
cursor.newRow()
.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_SIZE, null);
}
private void addFixtureRow(MatrixCursor cursor) throws FileNotFoundException {
File file = fixtureFile();
cursor.newRow()
.add(Document.COLUMN_DOCUMENT_ID, FIXTURE_DOCUMENT_ID)
.add(Document.COLUMN_DISPLAY_NAME, FIXTURE_DISPLAY_NAME)
.add(Document.COLUMN_MIME_TYPE, FIXTURE_MIME_TYPE)
.add(Document.COLUMN_FLAGS, 0)
.add(Document.COLUMN_SIZE, file.length())
.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
}
/**
* The fixture on disk, unpacked from this APK's own assets the first time anything asks.
*
* <p>On demand rather than seeded once in {@link #onCreate()}, because this process is started
* by whoever queries the provider and can be killed between two queries of the same test.
*
* <p>A failure here is reported as {@link FileNotFoundException} rather than swallowed. A
* provider that answers with a zero-byte file would put the test on the "Size unknown" screen
* with nothing saying why.
*/
private File fixtureFile() throws FileNotFoundException {
File file = new File(getContext().getFilesDir(), FIXTURE_DISPLAY_NAME);
if (file.length() > 0L) {
return file;
}
try (InputStream source = getContext().getAssets().open(FIXTURE_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 " + FIXTURE_ASSET + ": " + e);
}
return file;
}
}
@@ -0,0 +1,786 @@
package org.libremediaconverter.saf
import android.app.UiAutomation
import androidx.compose.ui.test.ComposeTimeoutException
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.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
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 org.junit.After
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.FailsOnEmulatorApi37
import org.libremediaconverter.MainActivity
import org.libremediaconverter.ui.TestTags
/**
* Choosing a file, through the real system picker, and still having it after a rotation.
*
* Two defects, and neither is reachable from anywhere else in this repo.
*
* **The picker is opened with a filter, and a filter can hide the user's file.** `ConverterScreen`
* launches `ActivityResultContracts.OpenDocument` with a MIME array; DocumentsUI hides every root
* and every document that array does not match. Narrow it and the app still compiles, still
* renders, still passes every JVM test — and the user taps "Choose file" and is shown an empty
* picker. Nothing in either source set drove SAF **as a picker** before this: the only SAF coverage
* is the publish side, in `OutputPublisherPublishTest`, against hand-written `ContentProvider`
* fakes. The launcher wiring, the filter, and the read grant that comes back had never been
* executed by a test.
*
* **The picked file has to survive a rotation.** `MainActivity` declares no `configChanges`, so
* every rotation destroys and recreates it, and `ConversionViewModel` holds the picked file in a
* plain `MutableStateFlow` with no `SavedStateHandle` behind it. The only thing that carries it
* across is the retained `ViewModelStore` the Activity gets from resolving the ViewModel through
* `LocalViewModelStoreOwner`. Scope it to the composition instead and the file is gone.
*
* ### Why these two are one test class
*
* A rotation test alone has no bite of its own. `AppRootRestorationTest` already catches
* `rememberSaveable` -> `remember` on the JVM, and a second test whose only mutation is one an
* existing test catches is the vacuous test this whole decomposition exists to prevent. So the
* rotation here runs **from a real picked input**, which is a state no JVM test can produce:
* `AppRootRestorationTest` injects a stub `content` lambda specifically to avoid standing up
* either ViewModel, and `StateRestorationTester` saves into an in-memory map rather than a
* `Bundle`.
*
* ### #93: what actually failed was reading the screen, not the picker
*
* Ninety minutes after this class landed it started failing on gating legs at API 33, 34, 35 and
* 37 — on diffs that were two KDoc comments, a MIME lookup table and a README paragraph (#93).
* Every failure named the fixture root, so it read as a root-discovery race, and the ticket was
* filed on that reading. It was not one, and it was not the `StaleObjectException` #80 had fixed
* an hour earlier either.
*
* **DocumentsUI was fine.** On the API 34 leg of run 32806342548 its own
* `ProvidersAccess: Matched roots` names
* `content://org.libremediaconverter.test.fixtures/root/lmc-r38-root` five times inside the sixty
* seconds the test spent failing, `ActivityTaskManager` logged the `PickActivity` as `Displayed`,
* and the provider process started on cue.
*
* **This process could not read any window at all.** Two counts settle it. Across that whole leg
* UiAutomator logged `Retrieving node with selector` 1095 times and `Node not found with selector`
* 1095 times — not one selector ever matched, from the first query of the run. The green leg of
* the same job asked 7 times and found 5. `UiDevice.getWindowRoots` builds its search set from
* `UiAutomation.getWindows()` and, on API 21 and up, from nothing else; an empty list there makes
* every selector unfindable and says nothing whatever about SAF. The corroborating detail is that
* `By.desc("Show roots")` — the toolbar button, present on that screen whether the roots list is
* stale or not — was also not found, 28 s after the picker was displayed.
*
* **A fresh picker is not the repair, and this was measured rather than assumed.** The same leg
* opened a *second* `PickActivity` for the second test, in the same DocumentsUI process
* (pid 3299), and read exactly as little from it. So whatever was broken outlived one window.
* [requireAReadableScreen] is the part aimed at that: it asks whether this process can see the
* app's own window *before* the picker is opened, and [rebuildUiAutomation] tears the connection
* down and builds another if it cannot.
*
* **The check has since caught the real thing, in CI, and the connection rebuild did not repair
* it.** Run 32811493607, API 35 and API 37 legs, both tests, 12 s each instead of 60:
*
* ```
* java.lang.AssertionError: UiAutomator cannot see this app's own window, so it could not have
* seen the picker's either. This is not a SAF failure.
* at SafPickerRoundTripTest.requireAReadableScreen
* ```
*
* That is the diagnosis this class could not previously give, and it moves the question off SAF
* for good.
*
* ### What the window list said, and why nothing here can fix it
*
* [describeWindows] was added to that failure so the next occurrence would close the question
* rather than reopen it. It did — on the API 34 leg of run 32812248131 and again, character for
* character, on the API 33 leg of run 32812892103:
*
* ```
* ... Waking the device, dismissing the keyguard and rebuilding the UiAutomation connection all
* failed to make it readable. What it could see: com.android.systemui[type=3], android[type=3]
* ```
*
* `type=3` is `AccessibilityWindowInfo.TYPE_SYSTEM`. The list is **not** empty — it holds the
* system windows and **not one `TYPE_APPLICATION` window**, on a device where the framework had
* already logged `Displayed org.libremediaconverter/.MainActivity`. So the application layer
* never reaches accessibility on those boots, and every selector in this class, the picker's and
* the app's alike, is unfindable for the whole instrumentation run.
*
* Three CI runs on this branch caught the fault, at API 33, 34, 35 and 37, and every one of them
* printed that same list. It is not one level's quirk.
*
* ### And that list is what identified the occluder
*
* `android[type=3]` is `system_server`, and what it was holding is in the same logcat, minutes
* before this class ever ran:
*
* ```
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
* Reason: Input dispatching timed out (Application does not have a focused window)
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
* ```
*
* **The launcher ANRs on a loaded runner emulator, and the dialog it leaves behind never goes
* away.** It is opaque and fullscreen, so `AccessibilityWindowManager` drops every application
* window beneath it — which is how the app can be `Displayed` and unreadable at once, the
* contradiction that made #93 look like a SAF bug for six PRs. It is present on both legs
* examined, at API 33 and 34, at the failure timestamp.
*
* So [dismissASystemErrorDialog] is tried first, and it is the remedy with a mechanism behind it.
* The other two are kept behind it and are **measured as not the cause**: [unlockTheDevice] (the
* keyguard theory, from `KeyguardViewMediator` reporting an unprovisioned device — dismissing it
* changed nothing) and [rebuildUiAutomation]. A second `PickActivity` is not a remedy for this
* either, and that was measured too: the first failing leg opened one and read as little from it.
*
* **What is honest about the dialog remedy: it has been shown to do no harm, not to work.** It
* was forced on with no dialog present and the suite stayed green, which is the way a blind
* `click()` could have broken a healthy run. Dismissing a real ANR dialog has not been observed,
* because the fault has never been reproduced locally — not on six warm runs, not on cold
* full-suite runs at API 34 and 35 on freshly created AVDs under `swangle_indirect` at two cores,
* not under host load. If it recurs, the message now names the dialog and the window list, so the
* next step is a measurement rather than another theory.
*
* ### The whole pick is retried, which is a separate and smaller claim
*
* [pickTheFixture] also backs out and asks for another picker when the walk comes up short. That
* is not the answer to the paragraph above; it is the answer to a picker whose *lists* were built
* before their data arrived, which is a real thing DocumentsUI does and which
* [tapPickerNode]'s re-find cannot reach either — it re-acquires a handle inside the one picker.
*
* One API 37 run failed a step deeper than the rest: the root appeared and
* `[TEXT='\Qlmc-r38-fixture.mp4\E']` did not. **That shape has not been reproduced or
* diagnosed.** It is covered here only because a fresh pick re-walks from Recent, and that is
* worth writing down rather than letting the retry read as a fix for something nobody measured.
*
* ### The mutations, and what they printed
*
* Both were run, not asserted. Narrowing the wildcard array `ConverterScreen.kt` passes to
* `pickInput.launch` — to `arrayOf("application/x-lmc-no-such-type")` — empties the picker of the
* fixture root entirely, and both tests fail on the assertion that names it. **Re-run after the
* #93 retry landed**, because a retry that tolerated an absent root would have made this mutation
* vacuous, which is the one thing that must not happen here:
*
* ```
* java.lang.AssertionError: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E'],
* in 3 separate pickers (the last one left org.libremediaconverter in front)
* at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:268)
* ```
*
* The root is absent from all three pickers, so all three report it, and the cost of saying so is
* bounded: 126 s and 127 s for the two tests, against the 1200 s wrapper timeout in
* `.github/scripts/e2e-run.sh`. The clause about what was left in front is not decoration either
* — it is what says the retry really did get back to the app between attempts rather than tapping
* behind a picker that never closed.
*
* **That mutation only shows the retry failing correctly.** Showing it *recovering* needs a
* failure that goes away, so one was injected: a field making the first
* [walkThePickerToTheFixture] of each test return a selector nothing matches. Both tests then
* passed, with `ActivityTaskManager` logging four `OPEN_DOCUMENT` starts for the two of them —
* two pickers each. That is the run which says the reopened pick completes: that
* `pickInput.launch` is not refused from the re-resumed Activity, and that the second test's
* reopen, which lands in the last-accessed stack rather than on Recent, still walks to the file.
* Making the ViewModel composition-scoped leaves the picker test alone and fails
* [thePickedInputSurvivesARealRotation], with `:app:testDebugUnitTest` still BUILD SUCCESSFUL —
* which is the divergence this ticket was filed to establish, and which was doubted on it. It is
* `viewModel()` -> `viewModel(viewModelStoreOwner = remember { <a plain ViewModelStoreOwner> })`,
* **plus** `factory = ViewModelProvider.AndroidViewModelFactory()` and a `MutableCreationExtras`
* carrying `APPLICATION_KEY`. The factory half is not decoration: an owner that is not a
* `HasDefaultViewModelProviderFactory` contributes no creation extras, and the default factory
* cannot construct an `AndroidViewModel` without them — so the owner swap alone crashes on
* construction instead of demonstrating the scope. The PR body quotes both failures verbatim.
*
* ### It has to be an unlocked emulator
*
* The Pixel 10 Pro XL is secure-locked and cannot be unlocked from a shell, so the picker cannot be
* 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.
*
* ### Why only the rotation test carries [FailsOnEmulatorApi37]
*
* This class is the first thing in the suite that touches system UI, and the android-37.x images
* are where that stops being free: surfaceflinger aborts inside the guest's Gralloc5 mapper, init
* SIGKILLs zygote with it, and the framework restarts underneath the run. Disabling SystemUI --
* the deviation the API 37 leg already makes -- removes the *idle* trigger, not this one.
*
* The marker is on one method and not on the class, because that is what was measured, one method
* per fresh emulator, on `android-37.0` under `swangle_indirect`:
*
* ```
* thePickedInputSurvivesARealRotation INSTRUMENTATION_ABORTED: System has crashed.
* Expected 1 tests, received 0
* pickingAFileThroughTheSystemPickerFillsInTheFileCard PASSED
* ```
*
* A rotation rebuilds every surface on screen at once, which the mapper does not survive; merely
* starting DocumentsUI does not.
*
* **The first version of this said the class, and it was wrong.** The picker test had failed at
* API 37 too -- with a `StaleObjectException` that turned out to be this file's own bug rather
* than the image's, and which CI then reproduced deterministically at API 33, 34 and 35. Fixing
* it ([tapPickerNode]) and re-measuring is what separated the two. An annotation is a claim about
* an image, and a broken test makes every image look broken; **re-measure after fixing a test
* before deciding what the platform did.**
*
* The annotation says only that, and CI reads it twice, so the rotation test runs on the advisory
* API 37 leg and not the gating one. **Do not read it as "a rotation is allowed to lose the
* file".** That is what API 33 through 36 are for, and they answer it.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class SafPickerRoundTripTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
private val device: UiDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
/** The app under test, whose own window is what [requireAReadableScreen] asks for. */
private val appPackage: String =
InstrumentationRegistry.getInstrumentation().targetContext.packageName
/** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */
private var rotated = false
/**
* Leave the device the way it was found — and only if this test moved it.
*
* Two things are deliberate here, and both are about the *other* tests on the device rather
* than about these two.
*
* The flag, because this runs after every test in the class, not only the one that rotated. An
* unconditional restore issues a WindowManager rotation request after the picker test as well,
* which has nothing to undo; JUnit does not promise method order, so that is an interaction
* between two tests that no single-class run would ever show. Tracked as a flag rather than
* read back off `isNaturalOrientation`, because a device whose *natural* orientation is
* landscape would answer that question the wrong way round.
*
* And `unfreezeRotation`, because `setOrientationNatural` does not merely rotate: it freezes
* the rotation there. A run that stopped after it would hand the next test a device that
* cannot rotate at all.
*/
@After
fun restoreOrientation() {
if (!rotated) return
device.setOrientationNatural()
device.unfreezeRotation()
device.waitForIdle()
}
@Test
fun pickingAFileThroughTheSystemPickerFillsInTheFileCard() {
pickTheFixture()
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME)
.assertTextEquals(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
// Not the same assertion twice. The name above comes from a metadata query, which a URI
// with no read grant answers just as well; this line only appears once something has
// opened the file and read its header. It is what says the picker handed back a URI the
// app can actually USE -- delete grantUriPermissions from the fixture's manifest entry and
// the name still arrives while this goes red.
//
// The whole "Container: MP4" and not "MP4": DetailRow renders the label and the value as
// one semantics node.
awaitNode(TestTags.Converter.detailRow(CONTAINER_LABEL))
composeRule.onNodeWithTag(TestTags.Converter.detailRow(CONTAINER_LABEL))
.assertTextEquals("$CONTAINER_LABEL: MP4")
}
@Test
@FailsOnEmulatorApi37
fun thePickedInputSurvivesARealRotation() {
pickTheFixture()
// 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)
device.setOrientationLandscape()
rotated = true
composeRule.waitForIdle()
// Two guards before the assertion that matters, because both of the ways this test could
// pass while proving nothing are silent ones.
//
// A device that ignored the rotation request would leave the app exactly as it was, and
// "the file is still there" would then be a statement about a screen nothing happened to.
assertNotEquals(
"the device did not actually rotate, so nothing below is about a rotation",
NATURAL_ROTATION,
device.displayRotation,
)
// And a rotation that did NOT recreate the Activity -- a configChanges attribute added to
// the manifest, an aspect-ratio or orientation lock -- would make this a recomposition
// test. The retained ViewModelStore is only interesting because the Activity around it
// really was destroyed and rebuilt.
assertNotEquals(
"the rotation did not recreate MainActivity, so the retained ViewModelStore was never used",
before,
System.identityHashCode(composeRule.activity),
)
awaitNode(TestTags.Converter.FILE_CARD_NAME)
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME)
.assertTextEquals(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
}
// --- driving the picker ---------------------------------------------------------------
/**
* Taps "Choose file", walks the system picker to the fixture, and returns once the app has it.
*
* Everything between the first tap and the last belongs to `com.google.android.documentsui`,
* which is why UiAutomator is here at all: Compose's matchers stop at this process's
* composition and Espresso's at its view hierarchy, and the picker is neither.
*
* **What is retried here is the whole pick.** [tapPickerNode]'s re-find re-acquires a handle
* to a node inside the picker that is already open, so it cannot reach a list that was built
* before its data arrived. Backing out and tapping "Choose file" again gets a *second*
* `PickActivity`, which rebuilds every list in it — and is what a user does when a picker
* comes up wrong. It is **not** the answer to the unreadable-screen failure in the class
* KDoc; [requireAReadableScreen], one line above, is the part aimed at that.
*
* The first attempt keeps the full [PICKER_TIMEOUT_MS]; the later ones use
* [REOPENED_TIMEOUT_MS], because by then the picker's process, its provider and its root cache
* 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.
*/
private fun pickTheFixture() {
var missing: BySelector? = null
repeat(PICK_ATTEMPTS) { attempt ->
requireAReadableScreen()
openThePicker()
missing = walkThePickerToTheFixture(
if (attempt == 0) PICKER_TIMEOUT_MS else REOPENED_TIMEOUT_MS,
)
if (missing == null) {
awaitNode(TestTags.Converter.FILE_CARD_NAME)
return
}
dismissThePicker()
}
throw AssertionError(
"the system picker never showed $missing, in $PICK_ATTEMPTS separate pickers " +
"(the last one left ${device.currentPackageName} in front)",
)
}
/**
* Refuses to go near the picker until this process can read a window it already knows is there.
*
* **This is the check that would have answered #93 outright**, instead of leaving six PRs to
* infer a SAF fault from a picker that was never the problem. It is here because of what the
* failing logcat counts. Across the whole API 34 leg UiAutomator
* asked for a node 1095 times and logged `Node not found` 1095 times — it never read anything,
* from the first query of the run onwards. The green leg of the same job asked 7 times and
* found 5. So the window list `UiDevice` searches, `UiAutomation.getWindows()`, was empty for
* that entire instrumentation run; on API 21 and up that list is the *only* place
* `getWindowRoots` looks, so an empty one makes every selector unfindable and says nothing
* about the app, the picker or the fixture.
*
* The probe is deliberately the app's **own** window, asked while the app is in front and
* before anything is tapped. It is the one window that must be readable for any of the rest to
* mean anything, so a failure here is unambiguous — where "the picker never showed the root"
* was not, and is what sent #93 looking at package installation and root caches.
*
* The repair is [rebuildUiAutomation]. It has been forced on and measured — a rebuilt
* connection still reads windows, which is the way it could have been worse than nothing —
* but it has **never been run against the real fault**, because the fault has never been
* reproduced on demand. See the class KDoc. What is certain is that a fresh picker is *not*
* the repair: the failing leg opened a second `PickActivity` for the second test, in the
* same DocumentsUI process, and read exactly as little from it.
*/
private fun requireAReadableScreen() {
val app = By.pkg(appPackage)
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
dismissASystemErrorDialog()
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
unlockTheDevice()
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
rebuildUiAutomation()
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) != true) {
throw AssertionError(
"UiAutomator cannot see this app's own window, so it could not have seen the " +
"picker's either. This is not a SAF failure. Closing a system error dialog, " +
"waking the device, dismissing the keyguard and rebuilding the UiAutomation " +
"connection all failed to make it readable. What it could see: " +
describeWindows(),
)
}
}
/**
* Closes a system "isn't responding" dialog, if that is what is on top of the app.
*
* **This is the occluder #93 turned out to have**, and it took the window list in the failure
* message to find it. `AppNotRespondingDialog` belongs to `system_server`, so it is the
* `android[type=3]` in `com.android.systemui[type=3], android[type=3]` — and it is opaque and
* fullscreen, so `AccessibilityWindowManager` drops every application window beneath it. The
* app is `Displayed` and unreadable at the same time, which is exactly the contradiction this
* class spent #93 failing to explain. It is not even this app's dialog:
*
* ```
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
* Reason: Input dispatching timed out (Application does not have a focused window)
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
* ```
*
* The launcher ANRs on a loaded runner emulator minutes before this class runs, and the dialog
* it leaves behind never goes away on its own.
*
* Dismissed by resource id rather than by button text, because the text is localised and the
* ids are not, and by id rather than by "the first button in the system window", because that
* would click whatever system window happened to be there. `aerr_wait` first: it dismisses the
* dialog and leaves the offending app alone, which is the polite answer when the app is not
* ours. Back is not tried — `BaseErrorDialog` swallows key events.
*/
private fun dismissASystemErrorDialog() {
for (id in ERROR_DIALOG_BUTTONS) {
val button = device.findObject(By.res(id)) ?: continue
button.click()
device.waitForIdle()
return
}
}
/**
* Wakes the display and asks the keyguard to go away.
*
* The cheapest explanation for "this process cannot see the app's own window" is that
* something is in front of it, and on a runner emulator that something is the lock screen:
* these images come up unprovisioned, and `KeyguardViewMediator` says so in as many words --
* `we need to show the keyguard since the device isn't provisioned yet`. An occluded window is
* not in the accessibility window list, which is the same symptom as a broken connection and
* has a far more ordinary cause.
*
* `wm dismiss-keyguard` rather than a swipe, because it is a request to the window manager
* rather than a gesture that has to land somewhere this process cannot see. It is only
* attempted on the failure path -- a device that was readable never reaches here -- so a run
* where the keyguard was never up pays nothing and is not altered.
*/
private fun unlockTheDevice() {
device.wakeUp()
device.executeShellCommand("wm dismiss-keyguard")
device.waitForIdle()
}
/** The accessibility window list, for a failure message that says what was actually there. */
private fun describeWindows(): String {
val windows = InstrumentationRegistry.getInstrumentation().uiAutomation.windows
if (windows.isEmpty()) return "no windows at all (UiAutomation.getWindows() is empty)"
return windows.joinToString(", ") { "${it.root?.packageName ?: "?"}[type=${it.type}]" }
}
/**
* Tears down this run's `UiAutomation` connection and establishes a new one.
*
* `Instrumentation.getUiAutomation` hands back the existing connection unless the flags differ
* from the ones it was created with, in which case it destroys it and builds another — so
* asking for different flags and then for the original ones back is how a test reaches the
* connection at all. `UiDevice` re-reads the flags from `Configurator` on every call rather
* than caching an instance, so the next selector goes through the new connection.
*
* `FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES` is toggled rather than chosen: it is only being
* used as a value that differs from whatever is configured, and it is put back.
*
* **Forced on and measured, because the obvious way for this to be worse than nothing is
* silent.** `UiDevice` puts `FLAG_RETRIEVE_INTERACTIVE_WINDOWS` on the service info during its
* own initialisation, and `getWindows()` is empty without it — so a rebuilt connection that
* did not get the flag back would cause exactly the emptiness this is meant to cure, on the
* one path where it is the last hope. Run unconditionally on every attempt, on a cold API 34
* emulator, both tests passed, and logcat shows the connection really being replaced rather
* than handed back: `Init UiAutomation[id=2, flags=0]`, then `id=4, flags=1`, then
* `id=6, flags=0`, with `Registering UiTestAutomationService` between each.
*/
private fun rebuildUiAutomation() {
val configurator = Configurator.getInstance()
val flags = configurator.uiAutomationFlags
val instrumentation = InstrumentationRegistry.getInstrumentation()
configurator.uiAutomationFlags = flags xor UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES
instrumentation.getUiAutomation(configurator.uiAutomationFlags)
configurator.uiAutomationFlags = flags
instrumentation.getUiAutomation(flags)
}
/** Waits for the app to be showing its own screen again, then asks for a picker. */
private fun openThePicker() {
awaitNode(TestTags.Converter.CHOOSE_FILE)
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
}
/**
* Null once the fixture URI is with the app, or the selector whose list never carried it.
*
* Three things have to be there, in order, and the `when` names them in that order so that a
* failure says which one was missing rather than "the picker did not work".
*
* **The first branch is what tells an unreadable picker from an absent root.** In #93 neither
* the root *nor the toolbar's "Show roots" button* could be found for sixty seconds, and a
* stale roots list would have left the toolbar findable. Both arrived as one message. Asking
* for the picker's package on its own separates them: `never showed BySelector [PKG=...]`
* means the picker was not readable, and the root selector means the root was not offered.
*
* The second is the line the MIME filter mutation fails on: DocumentsUI matches the requested
* types against `Root.COLUMN_MIME_TYPES` and drops the roots that cannot answer, so a filter
* the fixture root does not satisfy takes the root out of the picker altogether — along with
* "Images", "Audio", "Videos" and "Documents", measured on API 34.
*
* **The third takes no recovery action of its own, and that is deliberate rather than an
* oversight.** [openTheRootsDrawer] exists because a root has a *second* place it can be
* shown; a document in a directory listing has no second place, so there is nothing an
* in-picker action could do. Its recovery is the outer loop: a fresh picker re-walks from
* Recent into the root, which rebuilds the directory listing as well as the roots strip.
*/
private fun walkThePickerToTheFixture(timeoutMs: Long): BySelector? {
val picker = By.pkg(DOCUMENTS_UI_PACKAGE)
val root = By.text(FixtureDocumentsProvider.ROOT_TITLE)
val fixture = By.text(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
return when {
device.wait(Until.hasObject(picker), timeoutMs) != true -> picker
!tapPickerNode(root, timeoutMs, ifAbsent = ::openTheRootsDrawer) -> root
!tapPickerNode(fixture, timeoutMs) -> fixture
else -> null
}
}
/**
* The picker's own drawer, opened only when the root was not on the screen it landed on.
*
* **In practice it never runs, and #80 was right to say so.** A hierarchy dump taken on a
* cold API 34 emulator while this test was passing has the fixture root on the landing
* screen — `text="LMC R38 fixtures"` at `android:id/title`, under a `BROWSE FILES IN OTHER
* APPS` header — with the drawer shut (`Show roots` present, `Hide roots` absent). So the
* roots strip is the normal path and the drawer is a widening, kept because a device with a
* populated Recent may push the strip off screen. Looking in a second place widens where the
* root is searched for; it does not weaken what has to be found, which is still this root.
*/
private fun openTheRootsDrawer() {
device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click()
}
/**
* Backs out of the picker until the app has the window focus again.
*
* **The focus is asked of the Activity, not of UiAutomator, and that is not a stylistic
* choice.** The failure this retry exists for is a picker window UiAutomator cannot see, so a
* probe that went through the same accessibility window list would cheerfully report "the
* picker is gone" about the window that is still in front — and the reopened pick would then
* tap "Choose file" behind it. `Activity.hasWindowFocus` comes from the framework instead, and
* answers about the app rather than about the picker.
*
* 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.
*/
private fun dismissThePicker() {
repeat(BACK_PRESSES) {
if (awaitAppFocus()) return
// Before the back press, not instead of it: an app-error dialog swallows key events,
// so a back aimed at the picker lands on the dialog and nothing moves. Measured --
// API 34 of run 32813885120 exhausted all four presses with `android` in front, which
// is that dialog, while the launcher it belonged to went on ANRing behind everything.
dismissASystemErrorDialog()
device.pressBack()
}
// 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()) {
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(),
)
}
}
/** 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) {
composeRule.activity.hasWindowFocus()
}
true
} catch (_: ComposeTimeoutException) {
false
}
/**
* Finds the picker node [selector] names and taps it, re-finding it if it goes stale.
*
* **The re-finding is not padding, and this is not a retry of the assertion.** A `UiObject2`
* holds an `AccessibilityNodeInfo` captured when it was found, and DocumentsUI is still
* settling when the node first appears — its list rebinds, the roots strip lays out, a window
* animates. If the node is replaced in that gap, `click()` throws `StaleObjectException`
* against the handle rather than missing the target. Measured on a cold API 34 emulator:
*
* ```
* androidx.test.uiautomator.StaleObjectException
* at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1042)
* at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
* ```
*
* So what is retried is *acquiring a handle to a node that has to be there anyway*. **A node
* that is simply not in this picker is reported rather than retried here** — it comes back as
* `false`, and [pickTheFixture] answers it with a whole new picker, which is the only thing
* that rebuilds a list or a window. The MIME mutation's bite is untouched either way: a root
* that is not in the picker is not found on any attempt or in any picker, and the failure is
* still "the system picker never showed" rather than a stale one.
*/
private fun tapPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit = {}): Boolean {
var stale: StaleObjectException? = null
repeat(TAP_ATTEMPTS) { attempt ->
// ifAbsent only on the first attempt: it navigates, and re-navigating from a screen it
// already reached would walk away from the node.
val node = awaitPickerNode(selector, timeoutMs, if (attempt == 0) ifAbsent else ({}))
?: return false
device.waitForIdle()
try {
node.click()
return true
} catch (e: StaleObjectException) {
stale = e
}
}
throw AssertionError("$selector kept going stale between finding it and tapping it", stale)
}
/**
* The picker node [selector] names, or null if this picker never showed it.
*
* [ifAbsent] runs once, after the first wait comes up empty, and then the wait is repeated. A
* null return from `findObject` is deliberately not an error there: it is the "already on the
* right screen" case.
*/
private fun awaitPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit) =
device.wait(Until.findObject(selector), timeoutMs)
?: run {
ifAbsent()
device.wait(Until.findObject(selector), timeoutMs)
}
/**
* Blocks until [tag] is in the composition, so an assertion cannot race the picker's result.
*
* The described overload of `waitUntil`, not the bare one. A timeout is how both of this
* class's mutations report themselves, and the bare overload's message is
* `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()
}
}
private companion object {
/**
* Generous on purpose. This waits on another app being started, and on FFprobe spawning a
* native process over a `content://` URI; a timeout that merely usually passes is a flaky
* gating leg on five API levels, which costs far more than the seconds it saves.
*/
const val PICKER_TIMEOUT_MS = 30_000L
const val APP_TIMEOUT_MS = 30_000L
/**
* The same wait once a picker has already come and gone, and shorter for a reason.
*
* What [PICKER_TIMEOUT_MS] is generous about is a cold start: DocumentsUI's process, the
* fixture's provider process, the root cache. By the second attempt all three are warm and
* the only thing left to wait on is one screen being laid out — measured at 2.7 to 3.4 s
* from the picker starting, on cold CI emulators at API 33, 34 and 35. Ten seconds is
* three times the worst of those, and it is what keeps a genuinely absent root — the MIME
* mutation — from costing three full-length attempts.
*/
const val REOPENED_TIMEOUT_MS = 10_000L
/**
* How long the app is given to take the window focus back after a back press.
*
* Short, because this is asked once per back press and the first one is always asked while
* the picker is still in front, where it is *expected* to time out.
*/
const val FOCUS_TIMEOUT_MS = 3_000L
/**
* How long this process is given to be able to read the screen at all.
*
* Short, and it is not waiting on anything being drawn: the app is already in front
* when this is asked. It is waiting only on the accessibility window list existing,
* which either does within a poll or two or -- as in #93 -- not at all.
*/
const val READABLE_TIMEOUT_MS = 5_000L
/** `Surface.ROTATION_0`, named rather than `0` so the comparison reads. */
const val NATURAL_ROTATION = 0
/**
* How many pickers the fixture may fail to appear in before that is the finding.
*
* Three. Each one is a fresh `PickActivity` -- a fresh window, a fresh accessibility
* registration, a fresh roots query and a fresh directory load -- so this bounds the thing
* #93 measured, which is a picker that came up unreadable *once*. A root that is genuinely
* not offered is absent from all three, which is what keeps #64's MIME mutation red.
*/
const val PICK_ATTEMPTS = 3
/**
* How many back presses may be spent getting out of a picker.
*
* One is enough from Recent, two from inside the fixture's own directory. Four leaves room
* for a picker that has been navigated deeper than this test ever navigates it, and stops
* well short of the count that would start finishing `MainActivity` instead.
*/
const val BACK_PRESSES = 4
/**
* The package the system picker runs in.
*
* Named rather than resolved: `PackageManager.resolveActivity` is deprecated from API 33
* and its replacement is a lint argument this test does not need to have. A wrong value
* here cannot pass silently -- it is the first thing [walkThePickerToTheFixture] looks
* for, so the failure would read `never showed BySelector [PKG='...']` on every device.
* It is `com.google.android.documentsui` on every `google_apis` emulator image the CI
* matrix uses and on the Pixel 10 Pro XL.
*/
const val DOCUMENTS_UI_PACKAGE = "com.google.android.documentsui"
/**
* How many times a picker node may be re-found before its staleness is the finding.
*
* Three, not "until the timeout". Each attempt already waits up to [PICKER_TIMEOUT_MS] for
* the node to exist, so this bounds only the settling window after it does; a node that is
* still being replaced after three of those is telling you something about the device, and
* a loop that hid it would be the flake rather than the fix.
*/
const val TAP_ATTEMPTS = 3
/**
* The buttons on the framework's app-error dialogs, by resource id.
*
* `aerr_wait` is first because it dismisses the dialog without killing the app under it,
* and the app under it is usually the launcher rather than anything this suite owns.
* `button1` catches the plainer `BaseErrorDialog` shapes that have no `aerr_` ids.
*/
val ERROR_DIALOG_BUTTONS = listOf(
"android:id/aerr_wait",
"android:id/aerr_close",
"android:id/button1",
)
/** DocumentsUI's drawer button. It carries no text, only this description. */
const val SHOW_ROOTS_DESCRIPTION = "Show roots"
/** The detail row `MediaProbe` fills in for anything it could open and identify. */
const val CONTAINER_LABEL = "Container"
}
}
@@ -75,7 +75,13 @@ class AndroidDeviceCodecs private constructor(
return AndroidDeviceCodecs(encoders, decoders)
}
private fun mimeFor(codec: VideoCodec): String? = when (codec) {
/**
* `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.
* The JVM test source set is a friend of `main`, so this stays invisible outside the
* module — the precedent is `MainActivity`'s `Destination`.
*/
internal fun mimeFor(codec: VideoCodec): String? = when (codec) {
VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC
VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC
VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8
@@ -87,20 +93,62 @@ class AndroidDeviceCodecs private constructor(
VideoCodec.COPY, VideoCodec.NONE -> null
}
/** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */
private fun mimeForCodecName(name: String): String? = when (name.lowercase()) {
"h264", "avc", "avc1" -> MediaFormat.MIMETYPE_VIDEO_AVC
"hevc", "h265", "hvc1" -> MediaFormat.MIMETYPE_VIDEO_HEVC
"vp8" -> MediaFormat.MIMETYPE_VIDEO_VP8
"vp9" -> MediaFormat.MIMETYPE_VIDEO_VP9
"av1", "av01" -> MediaFormat.MIMETYPE_VIDEO_AV1
"mpeg4" -> MediaFormat.MIMETYPE_VIDEO_MPEG4
// Unknown to us: assume the platform can handle it and let a failed export
// trigger the FFmpeg fallback, rather than pre-emptively refusing hardware.
else -> null
}
/**
* FFprobe-style codec names, and the MediaFormat MIME type each one asks about.
*
* This is the same vocabulary `CodecNames.VIDEO_ALIASES` holds, written out a second time
* because this side has to answer in platform MIME types and `model` does not depend on
* Android. Two copies of one vocabulary drift, and these had: `x264`, `hev1`, `x265` and
* `vp09` resolved for display and routing and fell through to null here, so the app ran
* the capability check blind on inputs it had already identified (#87). They are listed
* now, which **changes behaviour** for those four names — see [mimeForCodecName].
*
* A map rather than a `when` because a `when` cannot be enumerated, and `CodecVocabularyTest`
* has to walk both key sets to notice the next divergence.
*/
internal val NAME_TO_MIME: Map<String, String> = mapOf(
"h264" to MediaFormat.MIMETYPE_VIDEO_AVC,
"avc" to MediaFormat.MIMETYPE_VIDEO_AVC,
"avc1" to MediaFormat.MIMETYPE_VIDEO_AVC,
"x264" to MediaFormat.MIMETYPE_VIDEO_AVC,
"hevc" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"h265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"hvc1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"hev1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"x265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"vp8" to MediaFormat.MIMETYPE_VIDEO_VP8,
"vp9" to MediaFormat.MIMETYPE_VIDEO_VP9,
"vp09" to MediaFormat.MIMETYPE_VIDEO_VP9,
"av1" to MediaFormat.MIMETYPE_VIDEO_AV1,
"av01" to MediaFormat.MIMETYPE_VIDEO_AV1,
"mpeg4" to MediaFormat.MIMETYPE_VIDEO_MPEG4,
)
/** Test seam: lets instrumented tests build a probe from explicit sets. */
/**
* The names in [NAME_TO_MIME] that no [VideoCodec] member spells, and why.
*
* MPEG-4 Part 2 is decodable input the app never targets, so there is no enum for it and
* `CodecNames` is right not to carry it. That makes it the one place the two tables
* legitimately differ. It is listed rather than implied so the cross-check can tell a
* documented asymmetry from a fresh drift — and so the list itself is checked: a name here
* that `CodecNames` does resolve is a divergence being waved through, and the test fails on
* it.
*/
internal val DECODE_ONLY_NAMES: Set<String> = setOf("mpeg4")
/**
* Maps an FFprobe-style codec name onto a MediaFormat MIME type.
*
* Null keeps its documented meaning — unknown to us: assume the platform can handle it and
* let a failed export trigger the FFmpeg fallback, rather than pre-emptively refusing
* hardware. What changed with #87 is which names are unknown. Four that FFmpeg genuinely
* emits used to land here and be treated as unknown while the rest of the app knew exactly
* what they were; a device without the matching decoder now routes them to FFmpeg up front
* instead of spending a doomed hardware attempt to find out.
*/
internal fun mimeForCodecName(name: String): String? = NAME_TO_MIME[name.lowercase()]
/** Test seam: lets a test build a probe from explicit sets, on a device or on the JVM. */
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
}
}
@@ -101,7 +101,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
@@ -120,6 +155,29 @@ class ConversionViewModel @JvmOverloads constructor(
* the first screen.
*/
private val cleanupDispatcher: CoroutineDispatcher = Dispatchers.IO,
/**
* Where the two blocking hops behind a pick run — the metadata query and the probe.
*
* A seam for the probe above all, because that is the one call in this class that throws
* on purpose. [probeOrUnreadable] rethrows anything that is not a native load failure, and
* the `launch` it runs in has no exception handler by design: on a device the error reaches
* the thread's default handler and takes the process down, which is what an
* [OutOfMemoryError] should do.
*
* On the JVM there is no such handler. kotlinx-coroutines-test installs a process-wide
* collector, once and for the life of the classloader, that keeps an escaped error and
* hands it to whichever `runTest` starts next — so it failed a Compose test class that had
* nothing to do with it, and *which* class moved between runs of identical code. Naming the
* dispatcher is what lets a test keep the throw inside its own window, where it fails the
* test that caused it and is consumed rather than collected.
*
* Both hops rather than the probe alone, which is where this differs from the seam issue #66
* proposed: leaving the metadata query on a real [Dispatchers.IO] makes the coroutine resume
* on a main looper that Robolectric leaves paused, and that bounce is precisely the
* asynchrony that made delivery unpredictable. One dispatcher covers a whole pick, and
* leaves nothing about it to timing.
*/
private val pickDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : AndroidViewModel(app) {
private val workManager = WorkManager.getInstance(app)
@@ -138,10 +196,12 @@ class ConversionViewModel @JvmOverloads constructor(
/**
* 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
@@ -241,13 +301,13 @@ class ConversionViewModel @JvmOverloads constructor(
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(Dispatchers.IO) { InputQuery.describe(getApplication(), uri) }
val file = withContext(pickDispatcher) { InputQuery.describe(getApplication(), uri) }
// 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(Dispatchers.IO) { probeOrUnreadable(uri) }
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) {
@@ -400,25 +460,53 @@ class ConversionViewModel @JvmOverloads constructor(
activeWorkId?.let(workManager::cancelWorkById)
}
/**
* 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. 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
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 ?: "Could not save the file.", pending)
}
}
}
@@ -432,6 +520,12 @@ 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() {
observer?.cancel()
@@ -33,6 +33,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -51,6 +52,7 @@ import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
import org.libremediaconverter.ui.TestTags
import java.util.Locale
@UnstableApi
@@ -71,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) }
@@ -85,6 +90,89 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
ActivityResultContracts.RequestPermission(),
) { viewModel.convert() }
ConverterScreenContent(
state = state,
settings = settings,
validation = validation,
actions = ConverterActions(
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,
)
}
/**
* Everything [ConverterScreenContent] can ask for, in one value.
*
* A holder rather than twelve parameters because detekt's `LongParameterList` sits at its default
* threshold of six and `config/detekt/detekt.yml` does not relax it for `@Composable` the way it
* relaxes `LongMethod` and `CyclomaticComplexMethod` -- `AdvancedPicker` already sits exactly on
* that threshold. The rule exempts data classes, so the callbacks travel together.
*
* In production every one of these is a launcher or a `ConversionViewModel` call. Naming them here
* instead of handing the content a ViewModel is the whole point of the seam: a test can render a
* [ConversionState] no ViewModel can be driven into, since `Waiting` needs a denied foreground
* start and `Converted` needs a worker run that has already succeeded.
*/
internal data class ConverterActions(
/** Open the document picker. The `Idle` and `Ready` branches both offer it. */
val onPickInput: () -> Unit,
val onPreset: (OutputFormat) -> Unit,
val onContainer: (Container) -> Unit,
val onVideoCodec: (VideoCodec) -> Unit,
val onAudioCodec: (AudioCodec) -> Unit,
val onSuggestion: (OutputSpec) -> Unit,
val onQuality: (QualityTier) -> Unit,
val onEnginePreference: (EnginePreference) -> Unit,
/**
* Start the job. It asks for the notification permission first, which is why the screen never
* calls `convert` directly -- the launcher's result callback does, whichever way it went.
*/
val onConvert: () -> Unit,
val onCancel: () -> Unit,
/**
* Open the save dialog for the finished output.
*
* Takes the suggested name rather than reading it back off the state, because the name comes
* from the job -- see `ConversionWorker.KEY_SUGGESTED_NAME` -- and the branch that renders the
* button is the only place that has it.
*/
val onSave: (suggestedName: String) -> Unit,
val onReset: () -> Unit,
)
/**
* The converter screen, with its state handed in.
*
* Split from [ConverterScreen] so that state has somewhere to come from other than a live
* `ConversionViewModel`. Driving the screen through a real one needs a `WorkManager` and a media
* probe in the constructor, and even then two of the six states are unreachable: `Waiting` follows
* a denied foreground start and `Converted` follows a completed worker.
*
* `internal` rather than private, because `src/test` is a friend of `main` and this is what the
* state tests compose. The leaves below stay exactly where they were -- this function is a move,
* not a redesign, and the tests that already pin those leaves are what says so.
*/
@UnstableApi
@Composable
internal fun ConverterScreenContent(
state: ConversionState,
settings: ConversionSettings,
validation: Validation,
actions: ConverterActions,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
@@ -113,10 +201,11 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
modifier = Modifier.padding(bottom = 16.dp),
)
Button(
onClick = { pickInput.launch(arrayOf("*/*")) },
onClick = actions.onPickInput,
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
.height(PrimaryButtonHeight)
.testTag(TestTags.Converter.CHOOSE_FILE),
) { Text("Choose file") }
}
@@ -129,29 +218,32 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
is ConversionState.Ready -> {
FileCard(s.input)
FormatPicker(settings.matchingPreset, viewModel::setPreset)
FormatPicker(settings.matchingPreset, actions.onPreset)
AdvancedPicker(
spec = settings.spec,
validation = validation,
onContainer = viewModel::setContainer,
onVideoCodec = viewModel::setVideoCodec,
onAudioCodec = viewModel::setAudioCodec,
onSuggestion = viewModel::applySuggestion,
onContainer = actions.onContainer,
onVideoCodec = actions.onVideoCodec,
onAudioCodec = actions.onAudioCodec,
onSuggestion = actions.onSuggestion,
)
QualityPicker(settings.quality, viewModel::setQuality)
EnginePicker(settings.enginePreference, viewModel::setEnginePreference)
QualityPicker(settings.quality, actions.onQuality)
EnginePicker(settings.enginePreference, actions.onEnginePreference)
Button(
onClick = {
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
},
onClick = actions.onConvert,
// The Advanced picker lets an impossible combination be selected on
// purpose, so this is what stops it from being run.
enabled = validation.isValid,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.Converter.CONVERT),
) { Text("Convert") }
OutlinedButton(
onClick = { pickInput.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth(),
onClick = actions.onPickInput,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE),
) { Text("Choose a different file") }
}
@@ -160,11 +252,13 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
Text("Converting… ${s.percent}%")
LinearProgressIndicator(
progress = { s.percent / 100f },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Converter.PROGRESS),
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
onClick = actions.onCancel,
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
) { Text("Cancel") }
}
@@ -182,8 +276,8 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
onClick = actions.onCancel,
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
) { Text("Cancel") }
}
@@ -198,23 +292,33 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
// explains why a job was slow, makes the software fallback
// visible, and is how the user learns a remux happened rather
// than a re-encode.
AssistChip(onClick = {}, label = { Text(s.routeReason) })
AssistChip(
onClick = {},
label = { Text(s.routeReason) },
modifier = Modifier.testTag(TestTags.Converter.ROUTE_REASON),
)
}
Button(
onClick = { chooseDestination.launch(s.suggestedName) },
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
onClick = { actions.onSave(s.suggestedName) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.SAVE_FILE),
) { Text("Save file") }
OutlinedButton(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth(),
onClick = actions.onReset,
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
) { Text("Start over") }
}
is ConversionState.Saved -> {
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
onClick = actions.onReset,
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.Converter.CONVERT_ANOTHER),
) { Text("Convert another") }
}
@@ -224,10 +328,36 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { 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") }
}
}
}
}
@@ -237,9 +367,12 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Unit) {
internal fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Unit) {
Text("Output format", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.FORMAT_CHIPS),
) {
OutputFormat.entries.forEach { format ->
FilterChip(
selected = format == selected,
@@ -267,7 +400,7 @@ private fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Un
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AdvancedPicker(
internal fun AdvancedPicker(
spec: OutputSpec,
validation: Validation,
onContainer: (Container) -> Unit,
@@ -277,14 +410,23 @@ private fun AdvancedPicker(
) {
var expanded by rememberSaveable { mutableStateOf(false) }
TextButton(onClick = { expanded = !expanded }) {
TextButton(
onClick = { expanded = !expanded },
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_TOGGLE),
) {
Text(if (expanded) "Hide advanced" else "Advanced")
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_PANEL),
) {
Text("Container", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_CONTAINER_CHIPS),
) {
Container.entries.forEach { container ->
FilterChip(
selected = container == spec.container,
@@ -295,7 +437,10 @@ private fun AdvancedPicker(
}
Text("Video", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_VIDEO_CHIPS),
) {
VideoCodec.entries.forEach { codec ->
FilterChip(
selected = codec == spec.videoCodec,
@@ -306,7 +451,10 @@ private fun AdvancedPicker(
}
Text("Audio", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_AUDIO_CHIPS),
) {
AudioCodec.entries.forEach { codec ->
FilterChip(
selected = codec == spec.audioCodec,
@@ -331,9 +479,11 @@ private fun AdvancedPicker(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSpec) -> Unit) {
internal fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSpec) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Converter.VALIDATION_ERROR),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
@@ -347,10 +497,11 @@ private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSp
if (invalid.suggestions.isNotEmpty()) {
Text("Try instead:", style = MaterialTheme.typography.labelMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
invalid.suggestions.forEach { suggestion ->
invalid.suggestions.forEachIndexed { index, suggestion ->
AssistChip(
onClick = { onSuggestion(suggestion) },
label = { Text(describe(suggestion)) },
modifier = Modifier.testTag(TestTags.Converter.suggestion(index)),
)
}
}
@@ -359,7 +510,7 @@ private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSp
}
}
private fun describe(spec: OutputSpec): String {
internal fun describe(spec: OutputSpec): String {
val video = when (spec.videoCodec) {
VideoCodec.NONE -> null
else -> spec.videoCodec.label
@@ -374,9 +525,12 @@ private fun describe(spec: OutputSpec): String {
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
internal fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
Text("Quality", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.QUALITY_CHIPS),
) {
QualityTier.entries.forEach { tier ->
FilterChip(
selected = tier == selected,
@@ -390,9 +544,12 @@ private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
internal fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
Text("Engine", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag(TestTags.Converter.ENGINE_CHIPS),
) {
EnginePreference.entries.forEach { preference ->
FilterChip(
selected = preference == selected,
@@ -403,7 +560,7 @@ private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference
}
}
private fun EnginePreference.label(): String = when (this) {
internal fun EnginePreference.label(): String = when (this) {
EnginePreference.AUTO -> "Automatic"
EnginePreference.PREFER_HARDWARE -> "Prefer hardware"
EnginePreference.FORCE_SOFTWARE -> "Force software"
@@ -418,21 +575,34 @@ private fun EnginePreference.label(): String = when (this) {
* pretending it has an unknown codec.
*/
@Composable
private fun FileCard(input: InputFile) {
Card(modifier = Modifier.fillMaxWidth()) {
internal fun FileCard(input: InputFile) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Converter.FILE_CARD),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(input.displayName, style = MaterialTheme.typography.titleMedium)
Text(
input.displayName,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NAME),
)
// The null is handled here rather than inside formatBytes, because "no provider would
// say" is not a number and a formatter that invented one -- "0 B" -- is the defect
// this card would be showing. It degrades in words, like the codec rows below it.
Text(
input.sizeBytes?.let(::formatBytes) ?: "Size unknown",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_BYTES),
)
val probe = input.probe
if (probe == null) {
Text("Reading…", style = MaterialTheme.typography.bodySmall)
Text(
"Reading…",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NOTE),
)
return@Column
}
@@ -442,6 +612,7 @@ private fun FileCard(input: InputFile) {
InputKind.UNPARSEABLE -> Text(
"Could not identify this file. It will be converted with FFmpeg.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NOTE),
)
InputKind.IMAGE -> {
@@ -481,22 +652,23 @@ private fun FileCard(input: InputFile) {
}
@Composable
private fun DetailRow(label: String, value: String) {
internal fun DetailRow(label: String, value: String) {
Text(
"$label: $value",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestTags.Converter.detailRow(label)),
)
}
private fun formatDuration(ms: Long): String {
internal fun formatDuration(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format(Locale.US, "%d:%02d", minutes, seconds)
}
private fun formatBytes(bytes: Long): String = when {
internal fun formatBytes(bytes: Long): String = when {
bytes >= 1_000_000_000 -> String.format(Locale.US, "%.1f GB", bytes / 1e9)
bytes >= 1_000_000 -> String.format(Locale.US, "%.1f MB", bytes / 1e6)
bytes >= 1_000 -> String.format(Locale.US, "%.0f kB", bytes / 1e3)
@@ -64,9 +64,18 @@ object InputQuery {
* A join's total is only as good as its worst-known part. Adding up the ones that answered
* would produce a lower bound that reads exactly like a real total, and the space check has
* no way to tell the two apart — which is the same conflation this whole file exists to end.
*
* The sum saturates rather than wrapping. Sizes reach here non-negative — both of the ways one
* is found reject a negative answer — but nothing bounds their *sum*, and a total that wrapped
* negative would not be harmless nonsense: `OutputPublisher.hasSpaceFor` compares it against
* free space, so the largest join representable would come back as the one with the most room.
*/
fun total(sizes: List<Long?>): Long? = sizes.fold(0L as Long?) { running, size ->
if (running == null || size == null) null else running + size
when {
running == null || size == null -> null
size > Long.MAX_VALUE - running -> Long.MAX_VALUE
else -> running + size
}
}
/**
@@ -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]
@@ -138,31 +160,6 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
.build()
}
/**
* Media3 encodes only H.264 and H.265 of the codecs this app offers.
*
* VP8/VP9/AV1 targets never reach here — the router sends them to FFmpeg because
* `Transformer.setVideoMimeType` rejects them — so anything unexpected returns null and lets
* Transformer pick, rather than silently substituting H.265 the way the old mapping did.
*/
private fun videoMimeTypeFor(codec: VideoCodec): String? = when (codec) {
VideoCodec.H264 -> MimeTypes.VIDEO_H264
VideoCodec.H265 -> MimeTypes.VIDEO_H265
// Never reached: only an Encode plan consults this, and COPY/NONE are not Encode.
VideoCodec.COPY, VideoCodec.NONE -> null
VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1 -> null
}
private fun audioMimeTypeFor(codec: AudioCodec): String? = when (codec) {
AudioCodec.AAC -> MimeTypes.AUDIO_AAC
AudioCodec.OPUS -> MimeTypes.AUDIO_OPUS
AudioCodec.VORBIS -> MimeTypes.AUDIO_VORBIS
AudioCodec.PCM -> MimeTypes.AUDIO_RAW
AudioCodec.COPY, AudioCodec.NONE -> null
// MP3 and FLAC have no Android encoder; the router routes them to FFmpeg.
AudioCodec.MP3, AudioCodec.FLAC -> null
}
/**
* Polls export progress on the Transformer's own thread.
*
@@ -192,7 +189,57 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
thread.quitSafely()
}
private companion object {
/**
* The progress interval, and the two enum-to-MIME tables.
*
* The tables are pure functions of a codec enum, so they sit here rather than on the instance:
* a JVM test can then exercise every arm without constructing an engine, which would start a
* real [HandlerThread] to answer a lookup. `internal` rather than `private` for the reason
* `MainActivity`'s `Destination` records — the JVM test source set is a friend of `main`, so
* these stay invisible to anything outside the module.
*/
internal companion object {
const val PROGRESS_INTERVAL_MS = 250L
/**
* Media3 encodes only H.264 and H.265 of the codecs this app offers.
*
* VP8/VP9/AV1 targets never reach here — the router sends them to FFmpeg because
* `Transformer.setVideoMimeType` rejects them — so anything unexpected returns null and
* lets Transformer pick, rather than silently substituting H.265 as the old mapping did.
*/
internal fun videoMimeTypeFor(codec: VideoCodec): String? = when (codec) {
VideoCodec.H264 -> MimeTypes.VIDEO_H264
VideoCodec.H265 -> MimeTypes.VIDEO_H265
// Never reached, and no longer only asserted: `Media3EngineMimeTypesTest` drives
// `CopyPlanner` over every spec it can be handed and shows that no Encode plan carries
// either, which is what turns "COPY/NONE are not Encode" into a checked claim.
VideoCodec.COPY, VideoCodec.NONE -> null
VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1 -> null
}
/**
* Media3 encodes AAC, Opus and PCM. Three arms below are dead, not two.
*
* The comment this replaces named MP3 and FLAC as the exceptions, which reads as though
* every other arm were live. **Vorbis is not.** A single router rule diverts every audio
* codec outside {AAC, Opus, PCM} to FFmpeg, and Vorbis is outside it, so
* `VORBIS -> AUDIO_VORBIS` names a MIME type Transformer is never actually asked for.
*
* The arm stays because the mapping is correct — deleting a right answer out of
* unreachable code buys nothing — but it is an entry waiting on a routing change rather
* than a live one. `Media3EngineMimeTypesTest` routes all six encodable codecs and asserts
* which three arrive, so if that set moves, the disagreement fails rather than surprises.
*/
internal fun audioMimeTypeFor(codec: AudioCodec): String? = when (codec) {
AudioCodec.AAC -> MimeTypes.AUDIO_AAC
AudioCodec.OPUS -> MimeTypes.AUDIO_OPUS
AudioCodec.VORBIS -> MimeTypes.AUDIO_VORBIS
AudioCodec.PCM -> MimeTypes.AUDIO_RAW
AudioCodec.COPY, AudioCodec.NONE -> null
// MP3 and FLAC have no Android encoder at any API level, so the router sends them to
// FFmpeg before an encoder is ever asked for.
AudioCodec.MP3, AudioCodec.FLAC -> null
}
}
}
@@ -242,8 +242,20 @@ object MediaProbe {
else -> Container.MKV
}
/** FFprobe describes still images through the image demuxers rather than a media container. */
private fun isImageFormat(formatName: String): Boolean {
/**
* FFprobe describes still images through the image demuxers rather than a media container.
*
* The two halves of the rule are not interchangeable. `image2` is a whole name — what FFprobe
* reports for a numbered image sequence — while `_pipe` has to be a *suffix* test, because the
* piped demuxers are named one per image codec: `png_pipe`, `jpeg_pipe`, `webp_pipe`, and
* thirty more. Relaxing that suffix to a substring would swallow `yuv4mpegpipe`, which is raw
* video, and `classify` checks this before anything else — so a false positive makes the
* source-info card describe a video as an image.
*
* `internal` so the unit tests can name both halves; the JVM test source set is a friend of
* `main`, so this stays invisible outside the module.
*/
internal fun isImageFormat(formatName: String): Boolean {
val names = formatName.split(',').map { it.trim().lowercase() }
return names.any { it == "image2" || it.endsWith("_pipe") }
}
@@ -284,11 +296,35 @@ object MediaProbe {
}
}
private fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
/**
* One track property as an Int, or [fallback] when the format has no Int to give.
*
* `containsKey` alone is not enough, because `MediaFormat` is a heterogeneous map: a key it
* holds as a Float answers `getInteger` with a `ClassCastException` rather than a coercion, and
* `KEY_FRAME_RATE` — which [probeForConcat] reads — is legitimately set either way. The
* `runCatching` is therefore load-bearing rather than defensive. Without it a single
* oddly-typed field throws past the whole track loop, and the catch there answers with an empty
* [ConcatInput], discarding the codec and dimensions that had already been read.
*
* `internal` for the unit tests, as [shortName].
*/
internal fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
if (containsKey(key)) runCatching { getInteger(key) }.getOrDefault(fallback) else fallback
/** MediaFormat MIME -> the short codec names the router and FFmpeg both speak. */
private fun shortName(mime: String): String = when (mime) {
/**
* MediaFormat MIME -> the short codec names the router and FFmpeg both speak.
*
* A lookup table over platform constants is the shape that rots quietly. Most of these arms are
* translations rather than trimming — `video/avc` is `h264`, `audio/mp4a-latm` is `aac`,
* `video/x-vnd.on2.vp9` is `vp9` — so a dropped arm does not fail. It falls through to
* `substringAfter('/')` and reports a different, plausible-looking string that
* `CodecNames` may or may not still recognise, and an unrecognised codec is how a
* stream-copyable file quietly becomes a re-encode.
*
* `internal` so the unit tests can name every arm; the JVM test source set is a friend of
* `main`, so this stays invisible outside the module.
*/
internal fun shortName(mime: String): String = when (mime) {
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
MediaFormat.MIMETYPE_VIDEO_HEVC -> "hevc"
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
@@ -6,6 +6,41 @@ import android.provider.DocumentsContract
import android.provider.OpenableColumns
import java.io.File
/**
* What a save has to say when the staged file is not there any more.
*
* Reachable without anything going wrong: staging lives in `cacheDir`, which the OS reclaims
* whenever it wants the space, and [sweepStaging] collects anything a day old. A result offered by
* reattachment is the likeliest to meet it — the check that decided the file existed ran during a
* tag query that can be hours old by the time the Save button is tapped.
*
* A written sentence rather than the exception's message, which is what used to reach the screen:
* `/data/user/0/org.libremediaconverter/cache/conversions/4b4882….mp4: open failed: ENOENT (No such
* file or directory)` is a true statement about a path the user has never seen and cannot act on.
* Kept next to [OutputPublisher] because both ViewModels need it and staging is what it is about.
*/
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."
/**
* 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.
*
@@ -50,8 +85,18 @@ open class OutputPublisher(private val context: Context) {
* through its engine, with a message of its own.
*
* Open so a test can force a full disk; see `FakeFailures` in the instrumented source set.
*
* Written as `free - headroom > required` rather than the equivalent-looking
* `free > required + headroom`. The second overflows: a request within 128 MiB of
* [Long.MAX_VALUE] wraps the sum negative, every free-space measurement beats a negative
* number, and the check answers "plenty of room" to the largest request it can be given. That
* is reachable rather than theoretical — [InputQuery.total] sums a join's inputs, so the number
* arriving here is not bounded by any single file. Both operands are clamped at zero first, so
* the subtraction cannot underflow and a nonsense negative size decides exactly as zero does
* instead of buying slack.
*/
open fun hasSpaceFor(bytes: Long): Boolean = stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
open fun hasSpaceFor(bytes: Long): Boolean =
stagingDir.usableSpace.coerceAtLeast(0L) - SPACE_HEADROOM_BYTES > bytes.coerceAtLeast(0L)
/**
* The same check for a job whose input size nobody could determine — see [InputQuery].
@@ -111,14 +156,22 @@ open class OutputPublisher(private val context: Context) {
* which is the right way round, since a flush that failed means the bytes are not
* durably there to begin with.
*
* A failure from `openOutputStream` itself is deliberately outside the guard. Nothing
* has been written at that point, so there is nothing of ours to remove.
* `openOutputStream` is inside the guard as well, and the reasoning that used to keep it
* out -- "nothing has been written at that point, so there is nothing of ours to remove" --
* was wrong about what exists. SAF's `CreateDocument` contract creates the document *before*
* this is called, which is why every fixture in `OutputPublisherPublishTest` starts as an
* existing empty file. So a provider that hands out no stream at all -- gone between the
* picker and the write, or simply returning null -- left a zero-byte file at the name the
* user chose while the UI said "Could not save the file". The two bounds above are what make
* removing it safe, and they apply to this case exactly as they do to a failed copy. A
* provider that will not open its own empty document may well refuse to delete it too, which
* is already [deletePartialOutput]'s documented no-op path.
*/
open fun publish(staged: File, destination: Uri) {
val destinationWasEmpty = destinationIsKnownEmpty(destination)
val out = context.contentResolver.openOutputStream(destination)
?: error("Could not open destination for writing: $destination")
try {
val out = context.contentResolver.openOutputStream(destination)
?: error("Could not open destination for writing: $destination")
out.use { sink -> staged.inputStream().use { source -> source.copyTo(sink) } }
} catch (failure: Throwable) {
if (destinationWasEmpty) deletePartialOutput(destination, failure)
@@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -31,6 +32,7 @@ import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
import org.libremediaconverter.ui.TestTags
import org.libremediaconverter.work.ConcatWorker
@UnstableApi
@@ -44,13 +46,54 @@ 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(
onPickInputs = { pickInputs.launch(arrayOf("video/*")) },
onJoin = viewModel::join,
onCancel = viewModel::cancel,
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
onReset = viewModel::reset,
),
modifier = modifier,
)
}
/**
* Everything [JoinScreenContent] can ask for, in one value.
*
* Five callbacks would fit under detekt's `LongParameterList` threshold, unlike the converter's
* twelve. It is a holder anyway, so both screens present the same shape to the state tests and
* neither one has to be reworked the first time a branch grows a button.
*/
internal data class JoinActions(
/** Open the multi-document picker. The `Idle` and `Ready` branches both offer it. */
val onPickInputs: () -> Unit,
val onJoin: () -> Unit,
val onCancel: () -> Unit,
/** Open the save dialog. Takes the name the job chose -- see `ConcatWorker.KEY_SUGGESTED_NAME`. */
val onSave: (suggestedName: String) -> Unit,
val onReset: () -> Unit,
)
/**
* The join screen, with its state handed in.
*
* The same split as [org.libremediaconverter.convert.ConverterScreenContent], for the same reason:
* `JoinState.Waiting` follows a denied foreground start and `JoinState.Joined` follows a completed
* concatenation, so neither is reachable by driving a real `JoinViewModel`.
*/
@UnstableApi
@Composable
internal fun JoinScreenContent(state: JoinState, actions: JoinActions, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
@@ -79,10 +122,11 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
modifier = Modifier.padding(bottom = 16.dp),
)
Button(
onClick = { pickInputs.launch(arrayOf("video/*")) },
onClick = actions.onPickInputs,
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
.height(PrimaryButtonHeight)
.testTag(TestTags.Join.CHOOSE_FILES),
) { Text("Choose files") }
}
@@ -96,12 +140,17 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
is JoinState.Ready -> {
s.inputs.forEach { FileRow(it) }
Button(
onClick = viewModel::join,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
onClick = actions.onJoin,
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.Join.JOIN),
) { Text("Join ${s.inputs.size} files") }
OutlinedButton(
onClick = { pickInputs.launch(arrayOf("video/*")) },
modifier = Modifier.fillMaxWidth(),
onClick = actions.onPickInputs,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Join.CHOOSE_DIFFERENT_FILES),
) { Text("Choose different files") }
}
@@ -110,10 +159,14 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
// Indeterminate on purpose: FFmpeg reports progress against a
// single input's duration, which means nothing across a
// concatenation. A fabricated percentage would be worse than none.
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Join.PROGRESS),
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
onClick = actions.onCancel,
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
) { Text("Cancel") }
}
@@ -126,8 +179,8 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
onClick = actions.onCancel,
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
) { Text("Cancel") }
}
@@ -145,20 +198,26 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { chooseDestination.launch(s.suggestedName) },
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
onClick = { actions.onSave(s.suggestedName) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.SAVE_FILE),
) { Text("Save file") }
OutlinedButton(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth(),
onClick = actions.onReset,
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
) { Text("Start over") }
}
is JoinState.Saved -> {
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
onClick = actions.onReset,
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight)
.testTag(TestTags.Join.JOIN_MORE),
) { Text("Join more") }
}
@@ -168,10 +227,32 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { 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") }
}
}
}
}
@@ -180,8 +261,12 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
}
@Composable
private fun FileRow(input: InputFile) {
Card(modifier = Modifier.fillMaxWidth()) {
internal fun FileRow(input: InputFile) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.Join.fileRow(input.displayName)),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(input.displayName, style = MaterialTheme.typography.bodyMedium)
}
@@ -18,6 +18,8 @@ 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.STAGED_FILE_GONE_MESSAGE
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.JobTags
@@ -43,7 +45,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
@@ -69,9 +94,10 @@ class JoinViewModel @JvmOverloads constructor(
* 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
@@ -221,24 +247,45 @@ class JoinViewModel @JvmOverloads constructor(
activeWorkId?.let(workManager::cancelWorkById)
}
/**
* Copies the staged result out to the destination the user picked.
*
* The existence check is the same one `ConversionViewModel.save` makes, for the same reason: a
* 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
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 ?: "Could not save the file.", pending)
}
}
}
@@ -248,6 +295,10 @@ 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() {
observer?.cancel()
@@ -15,36 +15,97 @@ package org.libremediaconverter.model
*/
object CodecNames {
fun videoFromName(name: String?): VideoCodec? = when (name?.lowercase()) {
null, InputProbe.UNPARSEABLE -> null
"h264", "avc", "avc1", "x264" -> VideoCodec.H264
"hevc", "h265", "hvc1", "hev1", "x265" -> VideoCodec.H265
"vp8" -> VideoCodec.VP8
"vp9", "vp09" -> VideoCodec.VP9
"av1", "av01" -> VideoCodec.AV1
else -> null
}
/**
* The video vocabulary, as data rather than a `when`.
*
* This is not the only place the app spells these names. `AndroidDeviceCodecs` reads the same
* FFprobe strings to decide what the device can decode, and answers in platform MIME types,
* which `model` cannot name without depending on Android. The two copies drifted apart:
* `x264`, `hev1`, `x265` and `vp09` resolved here and returned null there, so the app
* identified the codec for display and routing and then ran the device check blind, attempting
* a hardware path it had enough information to skip (#87).
*
* The reason this is a map is that **a `when` cannot be enumerated**, so nothing could compare
* the two tables. `CodecVocabularyTest` walks both key sets, so a name added to or removed
* from one side alone now fails the build rather than waiting for a wasted transcode to show
* it.
*
* Keys are lowercase; [videoFromName] lowercases before looking one up.
*/
internal val VIDEO_ALIASES: Map<String, VideoCodec> = mapOf(
"h264" to VideoCodec.H264,
"avc" to VideoCodec.H264,
"avc1" to VideoCodec.H264,
"x264" to VideoCodec.H264,
"hevc" to VideoCodec.H265,
"h265" to VideoCodec.H265,
"hvc1" to VideoCodec.H265,
"hev1" to VideoCodec.H265,
"x265" to VideoCodec.H265,
"vp8" to VideoCodec.VP8,
"vp9" to VideoCodec.VP9,
"vp09" to VideoCodec.VP9,
"av1" to VideoCodec.AV1,
"av01" to VideoCodec.AV1,
)
fun audioFromName(name: String?): AudioCodec? = when (name?.lowercase()) {
null -> null
"aac", "mp4a", "aac_latm" -> AudioCodec.AAC
"opus" -> AudioCodec.OPUS
"vorbis" -> AudioCodec.VORBIS
"mp3", "mp3float", "mpga" -> AudioCodec.MP3
"flac" -> AudioCodec.FLAC
"pcm", "raw", "pcm_s16le", "pcm_s24le", "pcm_f32le" -> AudioCodec.PCM
else -> null
}
/**
* The audio vocabulary, data for the same reason.
*
* Nothing cross-checks this one yet, and that is a gap rather than a decision: the device
* capability check is video-only, so this module holds no second audio table to compare it
* against. `Media3Engine.audioMimeTypeFor` is the other half, and #85 owns that file.
*/
internal val AUDIO_ALIASES: Map<String, AudioCodec> = mapOf(
"aac" to AudioCodec.AAC,
"mp4a" to AudioCodec.AAC,
"aac_latm" to AudioCodec.AAC,
"opus" to AudioCodec.OPUS,
"vorbis" to AudioCodec.VORBIS,
"mp3" to AudioCodec.MP3,
"mp3float" to AudioCodec.MP3,
"mpga" to AudioCodec.MP3,
"flac" to AudioCodec.FLAC,
"pcm" to AudioCodec.PCM,
"raw" to AudioCodec.PCM,
"pcm_s16le" to AudioCodec.PCM,
"pcm_s24le" to AudioCodec.PCM,
"pcm_f32le" to AudioCodec.PCM,
)
fun videoFromName(name: String?): VideoCodec? = asCodecName(name)?.let(VIDEO_ALIASES::get)
fun audioFromName(name: String?): AudioCodec? = asCodecName(name)?.let(AUDIO_ALIASES::get)
/** Human-readable name for the source-info card. Falls back to the raw probe string. */
fun describeVideo(name: String?): String = when {
fun describeVideo(name: String?): String = describe(name) { videoFromName(it)?.label }
fun describeAudio(name: String?): String = describe(name) { audioFromName(it)?.label }
/**
* Lowercases a probe string, and answers null for the two inputs that are not codec names at
* all: absent, and the [InputProbe.UNPARSEABLE] sentinel.
*
* The sentinel would miss every key anyway, so naming it changes no answer. Naming it is still
* the point: `videoFromName` excluded it explicitly and `audioFromName` did not, which read as
* though the two disagreed about what the sentinel means — the same asymmetry as #74 one
* function further up.
*/
private fun asCodecName(name: String?): String? =
if (name == null || name == InputProbe.UNPARSEABLE) null else name.lowercase()
/**
* The shared body of [describeVideo] and [describeAudio].
*
* They are one function apiece over one vocabulary, and they had stopped matching:
* `describeVideo` answered "Unrecognised" for [InputProbe.UNPARSEABLE] and `describeAudio` fell
* through to `?: name` instead. The sentinel opens with a NUL, so that fallback would have put
* a U+0000 into a `Text` on the source-info card (#74). Sharing the arms is what stops the next
* one being added to one side only.
*/
private fun describe(name: String?, label: (String) -> String?): String = when {
name == null -> "Unknown"
name == InputProbe.UNPARSEABLE -> "Unrecognised"
else -> videoFromName(name)?.label ?: name
}
fun describeAudio(name: String?): String = when {
name == null -> "Unknown"
else -> audioFromName(name)?.label ?: name
else -> label(name) ?: name
}
}
@@ -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.
@@ -284,8 +300,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 {
@@ -0,0 +1,162 @@
package org.libremediaconverter.ui
/**
* Where a test finds each affordance on the two screens.
*
* Every button, picker and card in `ConverterScreen` and `JoinScreen` carries one of these through
* `Modifier.testTag`, so a test names a symbol and never a literal. That is the whole reason the
* table exists: `"Cancel"`, `"Start over"` and `"Save file"` are each rendered by both screens and
* by more than one state branch, so rewording one of them would otherwise redden several
* independent test files at once, and none of those diffs would explain why.
*
* Tags are applied inside `main`, never handed in by the caller. A tag a test passes down as a
* `Modifier` proves only that the test set it -- it would stay green with the affordance's own tag
* deleted, which is exactly the vacuous test `CLAUDE.md` records nine of.
*
* ### Public rather than `internal`, deliberately
*
* `androidTest` **is** a friend source set of `main` here: an `androidTest` file referencing the
* `internal` `Destination.CONVERT` compiles clean through `:app:compileDebugAndroidTestKotlin`
* under AGP 9.3.1 (measured 2026-08-24 -- nothing in the repo referenced a main `internal` from
* `androidTest`, so the question had no in-tree answer until then). `internal` would compile today.
*
* It is public anyway. That friendship is AGP wiring rather than something this project states, and
* this table is a contract read from three source sets: `main` applies the tags, `src/test` and
* `src/androidTest` name them. Public buys no external exposure in an application module -- nothing
* consumes it from outside -- so the durable answer costs nothing here.
*
* ### Invariants
*
* Values are distinct, which `TagTableUniquenessTest` asserts. Two affordances sharing a tag would
* break the "resolves to exactly one node" assertion in a file nobody had touched.
*/
object TestTags {
/**
* Affordances both screens render, under one name each.
*
* Shared rather than per-screen because only one screen is composed at a time -- the shell
* swaps them -- so a tag can only ever resolve within the screen under test.
*/
const val CANCEL: String = "action.cancel"
/** Rendered by `Converted`/`Joined` and again by `Failed` on both screens. */
const val START_OVER: String = "action.startOver"
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"
/** `ConverterScreen`. */
object Converter {
const val CHOOSE_FILE: String = "converter.chooseFile"
const val CONVERT: String = "converter.convert"
const val CHOOSE_DIFFERENT_FILE: String = "converter.chooseDifferentFile"
const val CONVERT_ANOTHER: String = "converter.convertAnother"
/** The determinate bar in `Converting`. It carries no text, so nothing else can find it. */
const val PROGRESS: String = "converter.progress"
/**
* The chip on `Converted` that says which engine ran the job and why.
*
* Conditional on `routeReason` being non-blank, and that condition is what the tag is for:
* its text comes from the finished job, so a text matcher looking for it would have to
* name a routing explanation the screen does not own.
*/
const val ROUTE_REASON: String = "converter.routeReason"
const val FILE_CARD: String = "converter.fileCard"
const val FILE_CARD_NAME: String = "converter.fileCard.name"
/**
* The byte size, or `"Size unknown"`.
*
* Named for bytes rather than "size" because the `IMAGE` branch also renders a row labelled
* `Size` -- pixel dimensions -- through [detailRow], and the two mean different things.
*/
const val FILE_CARD_BYTES: String = "converter.fileCard.bytes"
/**
* The one-line explanation that stands in for the detail rows: `"Reading…"` while the probe
* is still running, or the unreadable-file line once it has finished and found nothing.
* The two are mutually exclusive, so one tag covers both.
*/
const val FILE_CARD_NOTE: String = "converter.fileCard.note"
/**
* The chip rows, not the pickers around them.
*
* Each tag sits on the `FlowRow` of chips, so the prose a picker renders beside it -- the
* `"Custom — set below."` line under the formats, the tier description under the quality
* chips -- is outside the tagged node. Tagging the picker as a whole would mean wrapping
* three sibling emissions in a layout that does not exist today.
*/
const val FORMAT_CHIPS: String = "converter.formatChips"
const val QUALITY_CHIPS: String = "converter.qualityChips"
const val ENGINE_CHIPS: String = "converter.engineChips"
/** The `Advanced` / `Hide advanced` toggle. Present whether or not the panel is open. */
const val ADVANCED_TOGGLE: String = "converter.advanced.toggle"
/** The panel the toggle gates. Absent from the tree while collapsed. */
const val ADVANCED_PANEL: String = "converter.advanced.panel"
/**
* The three chip rows inside the panel, separately.
*
* Separately because their labels collide: `"Copy"` and `"None"` are both a `VideoCodec`
* and an `AudioCodec`, and `"MP3"` and `"FLAC"` are both a `Container` and an `AudioCodec`,
* so a text matcher over the open panel is ambiguous for four of the chips.
*/
const val ADVANCED_CONTAINER_CHIPS: String = "converter.advanced.containerChips"
const val ADVANCED_VIDEO_CHIPS: String = "converter.advanced.videoChips"
const val ADVANCED_AUDIO_CHIPS: String = "converter.advanced.audioChips"
/** The error card. Rendered outside the panel, so it is reachable while collapsed. */
const val VALIDATION_ERROR: String = "converter.validationError"
/** One detail line of the file card, by the label it renders: `Container`, `Video`, ... */
fun detailRow(label: String): String = "converter.fileCard.row:$label"
/**
* One suggested output on the validation card, by position.
*
* By position rather than by the text of the suggestion, because that text comes from
* `describe`, which is itself under test -- a tag derived from it would move whenever the
* thing it is meant to locate changed.
*/
fun suggestion(index: Int): String = "converter.validationError.suggestion:$index"
}
/** `JoinScreen`. */
object Join {
const val CHOOSE_FILES: String = "join.chooseFiles"
const val JOIN: String = "join.join"
const val CHOOSE_DIFFERENT_FILES: String = "join.chooseDifferentFiles"
const val JOIN_MORE: String = "join.joinMore"
/** The indeterminate bar in `Joining`. */
const val PROGRESS: String = "join.progress"
/**
* One picked input, by the name it displays.
*
* By name rather than by position, so the tag is derived from data the row already holds
* and can stay inside `FileRow`. Passing an index down would mean the call site owned the
* tag, and a test that supplies its own tag asserts nothing about the screen.
*/
fun fileRow(displayName: String): String = "join.fileRow:$displayName"
}
}
@@ -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(
@@ -46,9 +46,12 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
val declaredTotal = inputData
.takeIf { it.hasKeyWithValueOfType<Long>(KEY_TOTAL_BYTES) }
?.getLong(KEY_TOTAL_BYTES, 0L)
val format = OutputFormat.valueOf(
inputData.getString(KEY_FORMAT) ?: DEFAULT_FORMAT.name,
)
// 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: FAILED with no error in the output Data, and no staged.delete().
val format = inputData.getString(KEY_FORMAT)
?.let { name -> OutputFormat.entries.firstOrNull { it.name == name } }
?: DEFAULT_FORMAT
if (!hasRoomFor(declaredTotal, uris)) {
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to join these files."))
@@ -67,12 +67,18 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
.takeIf { it.hasKeyWithValueOfType<Long>(KEY_SIZE_BYTES) }
?.getLong(KEY_SIZE_BYTES, 0L)
val spec = readSpec()
val quality = QualityTier.valueOf(
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name,
)
val preference = EnginePreference.valueOf(
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name,
)
// Looked up rather than `valueOf`, for the reason [readSpec] gives twelve lines below and
// for one more: these three reads sit ABOVE the try. A name this build does not define --
// which is what a downgrade with work still queued produces, the same previous-version case
// JobTags is written for -- threw IllegalArgumentException straight past the catch, taking
// the retry, the error message and the staged file's delete with it. That is the escape
// shape setForeground was moved inside the try to end.
val quality = inputData.getString(KEY_QUALITY)
?.let { name -> QualityTier.entries.firstOrNull { it.name == name } }
?: QualityTier.FAST
val preference = inputData.getString(KEY_ENGINE_PREFERENCE)
?.let { name -> EnginePreference.entries.firstOrNull { it.name == name } }
?: EnginePreference.AUTO
if (!hasRoomFor(declaredSize, inputUri)) {
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to convert."))
@@ -104,10 +104,13 @@ sealed interface Reattachment {
* result still worth offering from one already dealt with, which is why that check
* carries the weight here.
*
* It is also the seam for a neighbouring defect: a result the user dismissed with "Start
* over" currently keeps its staged file, so today it can be offered again on the next
* launch. Nothing here changes when that is fixed — the file stops existing and the job
* stops qualifying.
* It is also the seam a neighbouring fix acts through. "Start over" deletes the staged
* file, so a result the user dismissed stops qualifying here without this rule needing to
* know that happened — the file stops existing and the job falls out. What survives is the
* narrower gap that delete cannot close: `reset()` dispatches it to
* [kotlinx.coroutines.Dispatchers.IO] and it is cancelled with the Activity, so a
* dismissal on the way out of the app can leave the file behind. That is what
* `OutputPublisher.sweepStaging` is for, and its own KDoc names this case.
*
* Ranked, when more than one qualifies:
*
@@ -34,14 +34,20 @@ import org.robolectric.RobolectricTestRunner
* representation survives a `Bundle` round trip. A JVM round-trip test on the
* saver covers the representation.
*
* Robolectric rather than the instrumented suite, deliberately. The instrumented tests
* cannot run on the development host at all (see CLAUDE.md), and a red test nobody can
* execute is not a loop anyone can work in.
* Robolectric rather than the instrumented suite, deliberately -- but not because the
* instrumented suite is unavailable. It runs on this host for API 33-36
* (`tools/local-emulator/run-e2e.sh`), and CI runs 33-37. The reason is cost: this test
* needs a composition and a saved-state round trip, nothing a device supplies, and it runs
* in the same `./gradlew` invocation as every other JVM test instead of booting an
* emulator. A loop measured in seconds is a loop people stay inside.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class AppRootRestorationTest {
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
// deliberate: the v2 package has no tester of its own and the two do interoperate.
@get:Rule
val composeRule = createComposeRule()
@@ -0,0 +1,98 @@
package org.libremediaconverter
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.StagingSweep
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.concurrent.TimeUnit
/**
* That process start actually sweeps.
*
* [StagingSweepTest][org.libremediaconverter.convert.StagingSweepTest] pins the age rule and
* `OutputPublisherStagingTest` pins the sweep against a real filesystem; neither says anything
* about whether anything calls it, and deleting the one line that does left the whole suite green.
* That line is the only reason this Application class exists, and it is the backstop for every leak
* `discardStaged` cannot reach — a process reclaimed before a save, a worker that failed before its
* 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.
*/
@RunWith(RobolectricTestRunner::class)
class AppStartSweepTest {
private lateinit var app: LibreMediaConverterApp
private lateinit var stagingDir: File
@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() }
}
@Test
fun `the application the manifest starts is the one that sweeps`() {
assertEquals(LibreMediaConverterApp::class.java, RuntimeEnvironment.getApplication().javaClass)
}
@Test
fun `process start collects an abandoned staged file and leaves a live one alone`() {
val abandoned = stagedFile("abandoned.mp4")
val live = stagedFile("live.mp4")
// Set explicitly. Relying on a file being written "long enough ago" is not something a test
// can arrange, and the grace period is a day.
assertTrue(
abandoned.setLastModified(System.currentTimeMillis() - StagingSweep.GRACE_PERIOD_MS - ONE_MINUTE_MS),
)
// Both files are still here on the way in. The Application was already constructed once
// before this test ran, so without this the sweep that call started could be the one that
// collected the file, and the assertion below would be about the wrong process start.
assertTrue(abandoned.exists() && live.exists())
app.onCreate()
awaitGone(abandoned)
// 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,106 @@
package org.libremediaconverter
import android.content.res.XmlResourceParser
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.xmlpull.v1.XmlPullParser
/**
* What the app lets leave the device.
*
* `data_extraction_rules.xml` is a resource rather than code, so nothing was checking it: reverting
* the whole file to the template's boilerplate left the unit tests green AND `lintDebug` green, and
* a future edit dropping the excludes would ship in silence. The failure it would cause is one
* nobody meets in development — a cloud restore or a device-to-device transfer.
*
* What is at stake is written in the file itself. WorkManager's queue is the app's entire backup
* payload, and every row in it references a `content://` URI granted to one install on one device
* and an output path under that install's `cacheDir`. Neither survives the transfer, and the rows
* are not inert when they arrive: reattachment queries WorkManager by tag on launch, so a fresh
* install would come up attached to a job the user never ran on it.
*
* Read out of the compiled resource table rather than off `src/main/res`, so what is asserted is
* what the APK actually carries. Note the limit of that: this pins the rules' content, not the
* `android:dataExtractionRules` attribute that points the system at them.
*/
@RunWith(RobolectricTestRunner::class)
class BackupExclusionsTest {
@Test
fun `the work queue is excluded from cloud backup and from device transfer alike`() {
// Both sections, because they are separately honoured: `allowBackup` stays true and the
// exclusion is per-file, so an edit that dropped either half would leave the other looking
// like the whole answer.
assertEquals(
mapOf(
"cloud-backup" to WORK_MANAGER_STATE,
"device-transfer" to WORK_MANAGER_STATE,
),
excludesBySection(),
)
}
/**
* Every `<exclude>` in the rules, as `domain:path`, grouped by the section it sits in.
*
* Both halves of each entry, because an `<exclude>` carrying no path is skipped unchecked by
* lint's own detector — so that spelling could protect nothing while still looking like a rule.
*/
private fun excludesBySection(): Map<String, Set<String>> {
// Both sections start present and empty, so a section deleted outright fails as an empty
// set rather than as a missing key -- the same finding either way, said the same way.
val found = SECTIONS.associateWith { mutableSetOf<String>() }
var section: String? = null
RuntimeEnvironment.getApplication().resources.getXml(R.xml.data_extraction_rules).use { parser ->
while (parser.next() != XmlPullParser.END_DOCUMENT) {
section = parser.sectionAfter(section, found)
}
}
return found
}
/** Folds one parse event into [found], and answers which section the parser is now inside. */
private fun XmlResourceParser.sectionAfter(section: String?, found: Map<String, MutableSet<String>>): String? =
when {
eventType == XmlPullParser.START_TAG && name in SECTIONS -> name
eventType == XmlPullParser.END_TAG && name == section -> null
eventType == XmlPullParser.START_TAG && name == "exclude" && section != null ->
section.also { found.getValue(it) += entry() }
else -> section
}
private fun XmlResourceParser.entry(): String = "${attribute("domain")}:${attribute("path")}"
/**
* The value of the attribute called [name] on the current tag.
*
* Walked by index rather than looked up by namespace. These attributes carry the `android`
* namespace in the source file, but a parser over the *compiled* resource reports them with
* none, so `getAttributeValue(namespace, name)` answers null for every one of them.
*/
private fun XmlResourceParser.attribute(name: String): String? =
(0 until attributeCount).firstOrNull { getAttributeName(it) == name }?.let { getAttributeValue(it) }
private companion object {
/** The two ways data leaves a device, both of which these rules have to answer. */
val SECTIONS = setOf("cloud-backup", "device-transfer")
/**
* WorkManager's own storage, spelled the way WorkManager spells it.
*
* The database is Room-backed and therefore in WAL mode, hence the two sidecars. Pinning
* the spelling is the point rather than a cost: a WorkManager release renaming its database
* would silently un-exclude the queue, and this failing is how anyone would find out.
*/
val WORK_MANAGER_STATE = setOf(
"database:androidx.work.workdb",
"database:androidx.work.workdb-wal",
"database:androidx.work.workdb-shm",
"sharedpref:androidx.work.util.preferences.xml",
)
}
}
@@ -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,173 @@
package org.libremediaconverter.codec
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.Test
import org.libremediaconverter.model.CodecNames
import org.libremediaconverter.model.VideoCodec
/**
* Bites on #87: two tables read one codec vocabulary and had stopped agreeing.
*
* `CodecNames.VIDEO_ALIASES` answers "which enum is this FFprobe name", for the source-info card
* and for routing. `AndroidDeviceCodecs.NAME_TO_MIME` answers "which MIME do I ask this device
* about", for the capability check. On `ad28293` five names lived in one and not the other: `x264`,
* `hev1`, `x265` and `vp09` were identified for display and then fell through the device check as
* unknown, so the app attempted a hardware path it had enough information to skip; `mpeg4` ran the
* other way and rendered as a raw name on the card.
*
* Per-table arm tests would have passed on both tables and encoded the disagreement, which is why
* these walk the key sets instead. A name added to — or removed from — one side alone fails here.
*/
class CodecVocabularyTest {
private val aliases = CodecNames.VIDEO_ALIASES
private val mimes = AndroidDeviceCodecs.NAME_TO_MIME
private val decodeOnly = AndroidDeviceCodecs.DECODE_ONLY_NAMES
@Test
fun `no video codec name resolves for display without also resolving for the device check`() {
assertEquals(
"resolve in CodecNames but return null from mimeForCodecName, so the device check runs blind",
emptySet<String>(),
aliases.keys - mimes.keys,
)
}
@Test
fun `no video codec name resolves for the device check without being a name the app can label`() {
assertEquals(
"resolve in AndroidDeviceCodecs but not in CodecNames, and are not listed as decode-only",
emptySet<String>(),
mimes.keys - aliases.keys - decodeOnly,
)
}
/**
* Membership is not enough: `"x265" to MIMETYPE_VIDEO_AVC` would satisfy both key sets and
* still ask the device about the wrong codec.
*/
@Test
fun `the two tables agree on what each name means, not merely that they know it`() {
aliases.forEach { (name, codec) ->
val expected = AndroidDeviceCodecs.mimeFor(codec)
assertNotNull("$name maps to $codec, which has no MIME to ask about", expected)
assertEquals("$name is $codec in CodecNames", expected, mimes[name])
}
}
/**
* The exception list is the escape hatch: any future divergence could be waved through by
* adding the name to it. Guard both directions so it cannot be.
*/
@Test
fun `the decode-only names are genuinely decode-only`() {
decodeOnly.forEach { name ->
assertNotNull("$name is listed as decode-only but the device check cannot resolve it", mimes[name])
assertNull(
"$name is listed as decode-only, but CodecNames does resolve it — that is a divergence " +
"being waved through rather than a documented exception",
CodecNames.videoFromName(name),
)
}
}
/**
* The five names #87 measured, pinned by name so the specific regression cannot come back
* quietly even if someone rewrites the tables above.
*/
@Test
fun `the names that used to resolve on one side only resolve on both`() {
mapOf(
"x264" to VideoCodec.H264,
"hev1" to VideoCodec.H265,
"x265" to VideoCodec.H265,
"vp09" to VideoCodec.VP9,
).forEach { (name, codec) ->
assertEquals("$name is a name FFmpeg emits", codec, CodecNames.videoFromName(name))
assertEquals(
"$name has to reach the device check too, or the app identifies it and then asks blind",
AndroidDeviceCodecs.mimeFor(codec),
AndroidDeviceCodecs.mimeForCodecName(name),
)
}
// The one that runs the other way: decodable input with no enum to name it.
assertNull("mpeg4 is not an output the app can target", CodecNames.videoFromName("mpeg4"))
assertNotNull("mpeg4 is still decodable input", AndroidDeviceCodecs.mimeForCodecName("mpeg4"))
}
/**
* Without this the agreement test above could pass on two nulls.
*
* `MediaFormat.MIMETYPE_VIDEO_AVC` is a Java compile-time constant, so it is inlined and the
* unit-test classpath's stubbed `android.jar` never has to supply it. If that ever stops being
* true, every MIME comparison here would be `null == null` and green — the vacuous-mutation
* failure this repo has counted before. Assert one literal so the stub fails loudly instead.
*/
@Test
fun `the MIME constants are real strings rather than stubs`() {
assertEquals("video/avc", AndroidDeviceCodecs.mimeForCodecName("h264"))
assertEquals("video/hevc", AndroidDeviceCodecs.mimeForCodecName("hevc"))
assertEquals("video/avc", AndroidDeviceCodecs.mimeFor(VideoCodec.H264))
}
@Test
fun `codec names are matched case-insensitively on both sides`() {
assertEquals(VideoCodec.H265, CodecNames.videoFromName("HEV1"))
assertEquals("video/hevc", AndroidDeviceCodecs.mimeForCodecName("HEV1"))
}
@Test
fun `a name neither table knows still resolves to nothing`() {
assertNull(CodecNames.videoFromName("cinepak"))
assertNull(AndroidDeviceCodecs.mimeForCodecName("cinepak"))
}
/**
* The behaviour #87 actually changes, at the seam that uses it.
*
* `canDecode` treats an unresolved name as "assume the platform copes". Before the alias
* 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.
*/
@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"))
assertTrue("x265 is HEVC by another name", hevcOnly.canDecode("x265"))
assertFalse("this device has no AVC decoder, and x264 is AVC", hevcOnly.canDecode("x264"))
assertTrue("a name nobody knows keeps the permissive answer", hevcOnly.canDecode("cinepak"))
}
/**
* The other half of the null policy, at the seam it exists for — #86.
*
* `mimeFor`'s `COPY, NONE -> null` arm carries its consequence in a comment: "Returning null
* makes canEncode answer true, which is the right answer: a copied or absent track places no
* demand on the hardware." That is a product decision, and until this test nothing held it. A
* MIME appearing in that arm would make a device with no matching encoder refuse a stream copy
* — a job that never encodes anything — and the router would send it to FFmpeg to re-mux what
* Media3 could have re-muxed.
*
* The `H264` line is what makes the other two mean something: without it, a `canEncode` that
* simply returned `true` would satisfy this test. `NONE` is asserted separately from `COPY`
* because they are one arm today and two answers, and splitting the arm must not silently
* halve the coverage.
*/
@Test
fun `a device with no video encoder at all still permits a copied or absent track`() {
val noEncoders = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/avc"))
assertTrue(
"a copied track is re-muxed, not encoded, so no encoder is required",
noEncoders.canEncode(VideoCodec.COPY),
)
assertTrue("an absent track places no demand on the hardware", noEncoders.canEncode(VideoCodec.NONE))
assertFalse(
"this device has no AVC encoder, so an H.264 target has to be refused — without this, " +
"a canEncode that always answered true would satisfy the two assertions above",
noEncoders.canEncode(VideoCodec.H264),
)
}
}
@@ -0,0 +1,162 @@
package org.libremediaconverter.codec
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.libremediaconverter.convert.Media3Engine
import org.libremediaconverter.model.VideoCodec
/**
* Bites on #86: a fifth `VideoCodec -> MIME` table, and nothing checking it agrees with the fourth.
*
* [AndroidDeviceCodecs.mimeFor] and [Media3Engine.videoMimeTypeFor] take the same enum and return a
* MIME string, from opposite ends of one export. The first asks the device *"have you an encoder
* for this?"*; the second tells Transformer *"produce this."* If they name different MIME types for
* the same codec, the app checks for one encoder and then requests another — the check passes, the
* export succeeds, and the user's H.265 file contains H.264. Both were `private` until #85 and #87
* widened them, so this assertion could not be written before; each table had per-arm tests that
* pinned its own answers and could not see the other side.
*
* **They do not agree everywhere, and must not be forced to.** Three buckets, all pinned below:
*
* - **H.264 and H.265** — both tables name a MIME, and it has to be the same one. This is the
* bucket the defect lives in.
* - **VP8, VP9 and AV1** — the device table names a real MIME, Transformer's returns null. That is
* correct, not drift: `Transformer.setVideoMimeType` will not accept them, so the router sends
* them to FFmpeg before Media3 is asked anything, while a device may still genuinely own a VP9
* encoder and `canEncode` has to give a truthful answer about it. Flattening `mimeFor` to null
* here to "make the tables agree" would make `canEncode(VP9)` answer true on hardware that has
* no VP9 encoder. The routing half of that claim is proved in
* `Media3EngineMimeTypesTest.the router sends exactly H264 and H265 video encodes to Media3`,
* which drives the real router; it is not repeated here.
* - **COPY and NONE** — neither names a MIME, because neither is encoded at all.
*
* The fourth bucket is asserted empty: a codec Transformer names and the device check cannot ask
* about would mean `canEncode` waving through a target the app then really does encode.
*
* **Audio has no partner, and that is a gap rather than a decision.** [Media3Engine.audioMimeTypeFor]
* is the same shape one enum over — `AudioCodec -> MIME` — but [AndroidDeviceCodecs] enumerates
* `video/` MIME types only, so there is no device-side audio table to cross-check it against. An
* audio encoder this device lacks is therefore not caught up front the way a video one is; the job
* reaches Media3 and falls back after failing. Named here so the asymmetry reads as unfinished
* rather than intended.
*/
@UnstableApi
class VideoCodecMimeAgreementTest {
/** Both tables name a MIME. The pair has to match; this is the whole point of the file. */
private val bothNameAMime = setOf(VideoCodec.H264, VideoCodec.H265)
/** Only the device table names one, because Transformer is never asked for these. */
private val deviceOnly = setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1)
/** Neither names one: nothing is encoded, so there is no encoder to name. */
private val neitherNamesOne = setOf(VideoCodec.COPY, VideoCodec.NONE)
/**
* Sorts every [VideoCodec] by what the two tables actually answer, then compares the sorting
* with the buckets documented above.
*
* This is what makes the agreement test below non-vacuous, and it is deliberately an exact
* comparison in all four directions. A codec added to the enum lands in some bucket and fails
* here rather than arriving unclassified. A table that starts returning null for everything —
* the shape a filtered loop would pass on — empties two buckets and fails here. And a
* *convergence* fails too: giving `videoMimeTypeFor(VP9)` a real MIME moves VP9 out of
* `deviceOnly`, which is the point. The divergence should be deliberate and visible, so
* changing it should require saying so in this file.
*/
@Test
fun `each video codec is in the bucket the two tables actually put it in`() {
assertEquals(
"codecs both tables name a MIME for",
bothNameAMime,
VideoCodec.entries.filter { device(it) != null && transformer(it) != null }.toSet(),
)
assertEquals(
"codecs only the device check names a MIME for, because Transformer will not encode them",
deviceOnly,
VideoCodec.entries.filter { device(it) != null && transformer(it) == null }.toSet(),
)
assertEquals(
"codecs neither table names a MIME for, because nothing is encoded",
neitherNamesOne,
VideoCodec.entries.filter { device(it) == null && transformer(it) == null }.toSet(),
)
assertEquals(
"codecs Transformer names a MIME for that the device check cannot ask about — canEncode " +
"would answer true without looking, for a codec Media3 really is told to produce",
emptySet<VideoCodec>(),
VideoCodec.entries.filter { device(it) == null && transformer(it) != null }.toSet(),
)
}
/**
* The cross-check itself.
*
* Per-arm tests in either file cannot catch this: each pins its own table's answers, so a pair
* changed in lockstep with its own expectations stays green on both sides while the two tables
* describe different codecs.
*/
@Test
fun `where both tables name a MIME they name the same one`() {
bothNameAMime.forEach { codec ->
val asked = device(codec)
val requested = transformer(codec)
assertNotNull("AndroidDeviceCodecs has no MIME to ask the device about for ${codec.label}", asked)
assertNotNull("Media3Engine has no MIME to give Transformer for ${codec.label}", requested)
assertEquals(
"${codec.label}: the device is asked about $asked and Transformer is then told to " +
"produce $requested, so the capability check answers about a codec that is not the output",
asked,
requested,
)
}
}
/**
* The documented divergence, asserted rather than described.
*
* Both halves matter. The null side is Media3's refusal; the non-null side is the device
* check's genuine question, and it is the half a reader "tidying up" the disagreement would
* delete.
*/
@Test
fun `the codecs Transformer will not encode are still codecs this device may or may not have`() {
deviceOnly.forEach { codec ->
assertNotNull(
"${codec.label} goes to FFmpeg, but canEncode still has to answer truthfully about " +
"this device's encoder — a null here makes it answer true without looking",
device(codec),
)
assertNull(
"Transformer rejects ${codec.label}, so naming a MIME for it would request an export " +
"Media3 cannot perform",
transformer(codec),
)
}
}
/**
* Guards every comparison above against passing as `null == null`.
*
* `MediaFormat`'s MIME types are Java compile-time constants and are inlined, so the unit-test
* classpath's stubbed `android.jar` never supplies them; `MimeTypes`' come from a real
* `media3-common` jar. If either stopped holding, the buckets would collapse and this fails
* first, with the reason. Same guard, and the same reason, as
* `CodecVocabularyTest.the MIME constants are real strings rather than stubs`.
*/
@Test
fun `both tables return real MIME strings rather than stubs`() {
assertEquals("video/avc", AndroidDeviceCodecs.mimeFor(VideoCodec.H264))
assertEquals("video/hevc", AndroidDeviceCodecs.mimeFor(VideoCodec.H265))
assertEquals("video/x-vnd.on2.vp9", AndroidDeviceCodecs.mimeFor(VideoCodec.VP9))
assertEquals("video/avc", Media3Engine.videoMimeTypeFor(VideoCodec.H264))
assertEquals("video/hevc", Media3Engine.videoMimeTypeFor(VideoCodec.H265))
}
private fun device(codec: VideoCodec): String? = AndroidDeviceCodecs.mimeFor(codec)
private fun transformer(codec: VideoCodec): String? = Media3Engine.videoMimeTypeFor(codec)
}
@@ -0,0 +1,158 @@
package org.libremediaconverter.convert
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
import androidx.compose.runtime.saveable.SaveableStateRegistry
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* What `AdvancedPickerTest`'s restoration test cannot see.
*
* `StateRestorationTester` saves into an **in-memory map**, never a `Bundle`. That is enough to
* discriminate `rememberSaveable` from `remember`, and it is where it stops: the map holds object
* references, so a value the platform could never parcel goes in and comes back out looking green.
* `AppRootRestorationTest` has the same blind spot and `DestinationSaverTest` is the split it
* prompted; this is that split for `expanded`, the only `rememberSaveable` on either screen's
* leaves.
*
* ### The saved representation is not the Boolean
*
* `var expanded by rememberSaveable { mutableStateOf(false) }` passes no `stateSaver`, so
* `autoSaver` saves **the `MutableState` itself**, not the `false` inside it. That works only
* because `mutableStateOf` on Android returns a `Parcelable` implementation -- the same call on a
* plain JVM returns one that is not. So what stands between an open panel and a rotation that
* closes it is a platform-specific detail of a factory function nothing here names directly, and
* an in-memory map cannot tell the two apart.
*
* Pinning it is the move `DestinationSaverTest` makes about names versus ordinals. Passing an
* explicit `stateSaver` would save a bare `Boolean` instead and is a perfectly reasonable edit --
* it is just not the one in the tree, and it should be made on purpose rather than discovered
* after a rotation.
*
* ### Shared bite, stated rather than implied
*
* `rememberSaveable` -> `remember` empties the registry, so it reddens this file *and* the
* restoration test in `AdvancedPickerTest`. Both failures belong in any report of that mutation.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class AdvancedPanelSavedStateTest {
@get:Rule
val composeRule = createComposeRule()
/**
* `canBeSaved = { true }` deliberately.
*
* A predicate mirroring what a `Bundle` accepts would be a hand-written copy of the thing
* under test, and a `false` from it *drops* the entry silently -- so the test would fail by
* finding nothing saved, which is also how a `remember` regression fails. Two causes, one
* symptom, is not a test. The type is checked on the way out instead.
*/
private val registry = SaveableStateRegistry(restoredValues = null, canBeSaved = { true })
@Test
fun `the panel registers its open state with the registry, and nothing else`() {
setPicker()
// Collapsed is a saved value, not an absent one: `rememberSaveable` registers its provider
// on first composition, whatever the state happens to be. Exactly one, because `expanded`
// is the only saveable in the subtree -- a second would mean something else began saving.
assertEquals(1, savedValues().size)
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
val saved = theOneSavedValue()
assertTrue("saved as ${saved?.javaClass?.name}", saved is MutableState<*>)
assertEquals(true, (saved as MutableState<*>).value)
}
@Test
fun `the open panel survives a real Parcel, not just an in-memory map`() {
setPicker()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
val saved = theOneSavedValue()
// The claim the restoration test cannot make. A `MutableState` that was not `Parcelable`
// would satisfy `StateRestorationTester` and then be dropped by the platform.
assertTrue("saved as ${saved?.javaClass?.name}", saved is Parcelable)
val restored = throughARealBundle(saved as Parcelable)
assertTrue("restored as ${restored.javaClass.name}", restored is MutableState<*>)
assertEquals(true, (restored as MutableState<*>).value)
}
private fun setPicker() {
composeRule.setContent {
CompositionLocalProvider(LocalSaveableStateRegistry provides registry) {
AdvancedPicker(
spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC),
validation = Validation.Valid,
onContainer = {},
onVideoCodec = {},
onAudioCodec = {},
onSuggestion = {},
)
}
}
}
/** Every value the picker hands the host to persist, keys dropped -- they are positional. */
private fun savedValues(): List<Any?> = composeRule.runOnIdle { registry.performSave().values.flatten() }
/**
* The single saved value, asserted rather than assumed.
*
* `single()` on an empty list throws `NoSuchElementException: List is empty`, which names
* neither the panel nor the registry -- and an empty registry is exactly how the
* `rememberSaveable` -> `remember` regression shows up here.
*/
private fun theOneSavedValue(): Any? {
val values = savedValues()
assertEquals("the panel should register exactly one saved value", 1, values.size)
return values.first()
}
/** A write and a read through a real `Parcel`, which is what the tester's map stands in for. */
private fun throughARealBundle(value: Parcelable): Parcelable {
val bundle = Bundle().apply { putParcelable(KEY, value) }
val parcel = Parcel.obtain()
return try {
parcel.writeBundle(bundle)
parcel.setDataPosition(0)
val restored = requireNotNull(parcel.readBundle(javaClass.classLoader)) {
"the Bundle did not survive the Parcel"
}
requireNotNull(restored.getParcelable(KEY, Parcelable::class.java)) {
"the saved state did not survive the Parcel"
}
} finally {
parcel.recycle()
}
}
private companion object {
const val KEY = "expanded"
}
}
@@ -0,0 +1,323 @@
package org.libremediaconverter.convert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
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 org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* The gate over the Advanced chips, and the error card that deliberately sits outside it.
*
* Two defects, and they pull in opposite directions.
*
* The first is the chips escaping the gate, or never being reachable through it. `AdvancedPicker`
* is the one leaf on this screen that is not stateless -- `expanded` is its own `rememberSaveable`
* -- and Container, Video and Audio live inside `AnimatedVisibility(visible = expanded)`. Nothing
* else on the screen hides anything, so a refactor that flattened the panel, or wired the toggle to
* a state nobody reads, would render an app that looks reasonable in a screenshot and is wrong.
*
* The second is the opposite mistake, and it is the one this file exists for: **moving the
* `ValidationError` call inside the `AnimatedVisibility`**. It is invoked after that block, so an
* invalid spec explains itself and offers one-tap fixes *while the section is collapsed*. That is
* the only route out of an invalid spec for a user who never opened Advanced -- and since the only
* way to reach an invalid spec is through Advanced, hiding the way out behind the same toggle looks
* locally sensible and is a trap. Tidying the two `if` blocks into one is a plausible edit, it
* compiles, and until this file existed nothing went red. Every assertion about the error card here
* therefore runs with the toggle untouched, and asserts the panel is absent in the same test, so a
* future `expanded = true` default cannot quietly satisfy it either.
*
* The invalid specs come from [ContainerCapabilities.validate] rather than from a hand-built
* [Validation.Invalid], so the messages and the suggestions are the real pairing. A hand-built one
* would keep passing after `validate` stopped producing anything like it.
*
* Node location is by the three separate chip-row tags, never by text. `"Copy"` and `"None"` are
* each both a [VideoCodec] and an [AudioCodec], and `"MP3"` and `"FLAC"` are each both a
* [Container] and an [AudioCodec], so a text matcher over the open panel is ambiguous for four
* chips -- which is what the separate tags are for.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class AdvancedPickerTest {
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
// deliberate: the v2 package has no tester of its own and the two do interoperate.
@get:Rule
val composeRule = createComposeRule()
private val restoration = StateRestorationTester(composeRule)
private val containers = mutableListOf<Container>()
private val videoCodecs = mutableListOf<VideoCodec>()
private val audioCodecs = mutableListOf<AudioCodec>()
private val applied = mutableListOf<OutputSpec>()
// --- the expand gate ----------------------------------------------------
@Test
fun `the three chip rows appear only while the panel is expanded`() {
setPicker()
assertPanelHidden()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertExists()
ROW_TAGS.forEach { composeRule.onNodeWithTag(it).assertExists() }
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
// The exit transition outlives the click, so absence has to be waited for rather than
// asserted straight away -- unlike the initial collapsed state, which has no animation
// in flight.
composeRule.waitUntil { nodeCount(TestTags.Converter.ADVANCED_PANEL) == 0 }
assertPanelHidden()
}
/** The toggle is the only affordance the collapsed picker offers, so it has to say so. */
@Test
fun `the toggle names the direction it will move in`() {
setPicker()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).assertTextEquals("Advanced")
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE)
.assertTextEquals("Hide advanced")
}
/**
* The four colliding labels, one per row.
*
* `"Copy"` is a video codec *and* an audio codec; `"MP3"` is a container *and* an audio codec.
* Clicking each through its own row is what proves the rows are wired to different callbacks
* -- a picker that handed every chip to `onAudioCodec` would look identical on screen.
*/
@Test
fun `each chip row reports to its own callback, including the labels that collide`() {
setPicker()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
chipIn(TestTags.Converter.ADVANCED_VIDEO_CHIPS, "Copy").performClick()
assertEquals(listOf(VideoCodec.COPY), videoCodecs)
assertEquals(emptyList<AudioCodec>(), audioCodecs)
chipIn(TestTags.Converter.ADVANCED_AUDIO_CHIPS, "Copy").performClick()
assertEquals(listOf(AudioCodec.COPY), audioCodecs)
chipIn(TestTags.Converter.ADVANCED_CONTAINER_CHIPS, "MP3").performClick()
assertEquals(listOf(Container.MP3), containers)
// Still only the one audio click. `MP3` is an AudioCodec label too, and the container row
// must not be reporting through that callback.
assertEquals(listOf(AudioCodec.COPY), audioCodecs)
}
// --- the error card, which is outside the gate --------------------------
/**
* The headline case. Dropping both tracks is reachable from the collapsed screen -- the
* `None`/`None` pair is set inside Advanced, but the user can close it again -- and the
* explanation has to still be there.
*/
@Test
fun `an empty output explains itself while the section is collapsed`() {
val spec = OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE)
val invalid = invalidFor(spec)
assertEquals("This would produce an empty file — keep at least one track.", invalid.message)
setPicker(spec, invalid)
assertPanelHidden()
composeRule.onNodeWithTag(TestTags.Converter.VALIDATION_ERROR).assertExists()
composeRule.onNodeWithText(invalid.message).assertIsDisplayed()
}
@Test
fun `a codec the container cannot hold explains itself while the section is collapsed`() {
val spec = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.OPUS)
val invalid = invalidFor(spec)
assertEquals("WebM cannot hold H.264 video.", invalid.message)
setPicker(spec, invalid)
assertPanelHidden()
composeRule.onNodeWithText(invalid.message).assertIsDisplayed()
}
/**
* Clicking a suggestion, with the toggle never touched.
*
* The second suggestion rather than the first, and its count pinned first: with one suggestion
* a picker that handed every chip `suggestions[0]` would pass, and `onNodeWithTag` on a
* suggestion index that no longer exists reports an unhelpful matcher failure rather than
* saying the list shrank.
*/
@Test
fun `a suggestion chip applies its own spec without the section ever being opened`() {
val spec = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.OPUS)
val invalid = invalidFor(spec)
assertEquals(2, invalid.suggestions.size)
val second = invalid.suggestions[1]
setPicker(spec, invalid)
assertPanelHidden()
composeRule.onNodeWithTag(TestTags.Converter.suggestion(1)).assertTextEquals(describe(second))
composeRule.onNodeWithTag(TestTags.Converter.suggestion(1)).performClick()
assertEquals(listOf(second), applied)
// What the chips offer is what `validate` said would work, not a repair of the test's own.
assertTrue(
"suggestion $second should itself validate",
ContainerCapabilities.validate(second, PROBE).isValid,
)
}
/** A valid spec has nothing to say, collapsed or not. */
@Test
fun `a valid spec renders no error card`() {
setPicker()
composeRule.onNodeWithTag(TestTags.Converter.VALIDATION_ERROR).assertDoesNotExist()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
composeRule.onNodeWithTag(TestTags.Converter.VALIDATION_ERROR).assertDoesNotExist()
}
// --- recreation ---------------------------------------------------------
/**
* `expanded` is the only `rememberSaveable` on either screen's leaves.
*
* `MainActivity` declares no `configChanges`, so a rotation destroys and rebuilds the whole
* composition. A panel the user opened, set three chips in, and left open must not close
* itself on the way back. `remember` would.
*
* What this cannot see is the saved *representation* -- `StateRestorationTester` saves into an
* in-memory map rather than a `Bundle`. `AdvancedPanelSavedStateTest` covers that half.
*/
@Test
fun `an open panel is still open after recreation`() {
restoration.setContent {
AdvancedPicker(
spec = VALID_SPEC,
validation = Validation.Valid,
onContainer = {},
onVideoCodec = {},
onAudioCodec = {},
onSuggestion = {},
)
}
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertExists()
restoration.emulateSavedInstanceStateRestore()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertExists()
ROW_TAGS.forEach { composeRule.onNodeWithTag(it).assertExists() }
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE)
.assertTextEquals("Hide advanced")
}
/** The default has to survive too, or the panel would spring open on every rotation. */
@Test
fun `a collapsed panel is still collapsed after recreation`() {
restoration.setContent {
AdvancedPicker(
spec = VALID_SPEC,
validation = Validation.Valid,
onContainer = {},
onVideoCodec = {},
onAudioCodec = {},
onSuggestion = {},
)
}
assertPanelHidden()
restoration.emulateSavedInstanceStateRestore()
assertPanelHidden()
}
// --- helpers ------------------------------------------------------------
private fun setPicker(spec: OutputSpec = VALID_SPEC, validation: Validation = Validation.Valid) {
composeRule.setContent {
AdvancedPicker(
spec = spec,
validation = validation,
onContainer = { containers += it },
onVideoCodec = { videoCodecs += it },
onAudioCodec = { audioCodecs += it },
onSuggestion = { applied += it },
)
}
}
/** The whole panel, by every tag it owns, so a partial escape counts as a failure. */
private fun assertPanelHidden() {
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertDoesNotExist()
ROW_TAGS.forEach { composeRule.onNodeWithTag(it).assertDoesNotExist() }
}
private fun nodeCount(tag: String) = composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().size
private fun chipIn(rowTag: String, label: String) =
composeRule.onNode(hasText(label) and hasAnyAncestor(hasTestTag(rowTag)))
private fun invalidFor(spec: OutputSpec): Validation.Invalid {
val validation = ContainerCapabilities.validate(spec, PROBE)
return validation as? Validation.Invalid
?: throw AssertionError("$spec was expected to be invalid, but validate said $validation")
}
private companion object {
val ROW_TAGS = listOf(
TestTags.Converter.ADVANCED_CONTAINER_CHIPS,
TestTags.Converter.ADVANCED_VIDEO_CHIPS,
TestTags.Converter.ADVANCED_AUDIO_CHIPS,
)
val VALID_SPEC = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC)
/** An ordinary H.264/AAC MP4, so the suggestions have a real source to repair towards. */
val PROBE = InputProbe(
videoCodec = "h264",
audioCodec = "aac",
durationMs = 90_000,
container = Container.MP4,
)
}
}
@@ -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)
@@ -2,14 +2,15 @@ package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import android.os.Looper
import androidx.media3.common.util.UnstableApi
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -18,8 +19,6 @@ import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import java.util.concurrent.TimeUnit
/**
* That a probe which throws leaves a screen the user can act on, not a dead coroutine.
@@ -87,19 +86,35 @@ class ConversionViewModelProbeFailureTest {
* is out of memory" into "this video looks unreadable" and let the app carry on in a
* state it cannot honour — which is the regression a blanket `catch (Throwable)` would
* have introduced, and the reason this defect was left open rather than fixed carelessly.
*
* **The error itself is what is asserted here, and that is what the `pickDispatcher` seam
* bought.** With the hop hard-coded to `Dispatchers.IO` this was impossible: the throw
* happened on a pool thread some time after this method had returned, so all a test could do
* was infer it from a card that never filled in — which is also what a probe returning null
* would look like. Worse, the escaped error went into kotlinx-coroutines-test's process-wide
* collector and was rethrown at whichever `runTest` started next, which is a *different*
* Compose class between runs of identical code. Putting the pick on [Dispatchers.Unconfined]
* runs it inline, inside a `runTest` whose scope owns the collector's callback: the error is
* handed to this test and consumed, rather than stored for a stranger.
*
* Note where it surfaces — at the end of `runTest`, not inside `onInputPicked`. `launch`
* gives an escaped error to the handler chain and never to its caller, so nothing can catch
* it at the call itself. This is as close as the coroutine machinery allows, and unlike the
* old assertion it is the real [OutOfMemoryError] instance.
*/
@Test
fun `an OutOfMemoryError is not swallowed`() {
ConversionDependencies.probe = { _, _ -> throw OutOfMemoryError("Failed to allocate 512 MB") }
// Unconfined for the pick, so the whole of onInputPicked runs inline on this thread and
// has thrown before runTest can leave the scope that has to receive the error.
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined, Dispatchers.Unconfined)
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(INPUT)
val escaped = assertThrows(OutOfMemoryError::class.java) { runTest { viewModel.onInputPicked(INPUT) } }
// The observable difference, and the reason this is asserted on state rather than on a
// caught throwable: the probe hop is on Dispatchers.IO, so an error that escapes lands
// on that thread's handler rather than at this call. What must not happen is the card
// filling in with an "unreadable" verdict the app would then act on.
val settled = settle(viewModel)
assertEquals("Failed to allocate 512 MB", escaped.message)
// The other half of the contract, unchanged: an OOM is about the process, so the card is
// left as it was rather than filled in with a verdict the app would then act on.
val settled = viewModel.state.value
// `sizeBytes = null`, not `0L`: no provider is registered for this authority, so the
// metadata query returns nothing and the descriptor cannot be opened either. That is the
// unknown, and it stopped being spelled the same way as "empty" -- see [InputQuery].
@@ -125,30 +140,20 @@ 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
}
/**
* Pumps the looper the way [awaitState] does, but for a fixed span and without requiring
* anything to happen — here "the pick never came back" is the expected outcome, so there
* is no predicate to wait on.
*/
private fun settle(viewModel: ConversionViewModel): ConversionState {
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SETTLE_MS)
while (System.nanoTime() < deadline) {
shadowOf(Looper.getMainLooper()).idle()
Thread.sleep(POLL_MS)
}
return viewModel.state.value
}
private companion object {
val INPUT: Uri = Uri.parse("content://test/holiday.mp4")
const val SETTLE_MS = 500L
const val POLL_MS = 5L
}
}
@@ -0,0 +1,152 @@
package org.libremediaconverter.convert
import org.junit.Assert.assertEquals
import org.junit.Test
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
/**
* The four pure helpers behind the converter screen's prose, pinned at the points where they
* change what they say.
*
* No Compose rule and no Robolectric: these are `String` in, `String` out, and running them under a
* device sandbox would buy nothing while hiding the boundaries in a rendered tree.
*
* The defect each group bites on:
*
* - **[formatBytes] picks a unit by comparing against three thresholds.** Every one of them is a
* `>=`, and a `>` would move a file sitting exactly on a boundary into the unit below -- `1 GB`
* shown as `1000.0 MB`. Only a value *on* the threshold can tell the two apart, so each of the
* three is asserted at the boundary and one below it. The unit prefixes are decimal, matching
* what the file manager and the provider report, not powers of two.
* - **[formatDuration] has no hours field.** An hour-long recording reads `60:00`, and that is the
* contract rather than an oversight -- the row is a length, not a clock. Pinned so that adding
* hours is a deliberate change with a red test in front of it instead of a silent reformat.
* - **[describe] builds the suggestion-chip label out of up to three parts**, and the parts are
* conditional: [VideoCodec.NONE] and [AudioCodec.NONE] drop out entirely, so an image output
* with neither track has to render as the container alone rather than as a container followed
* by a dangling separator.
* - **[EnginePreference] carries no `label` property**, unlike every other enum the screen
* renders; its three display strings live in a `when` in the screen file. Adding a constant is
* caught by the compiler because that `when` is exhaustive, but nothing stops two constants
* being given the same string, which is what the distinctness assertion is for.
*/
class ConverterFormattersTest {
@Test
fun `bytes below a kilobyte are counted exactly`() {
assertEquals("0 B", formatBytes(0))
assertEquals("1 B", formatBytes(1))
assertEquals("999 B", formatBytes(999))
}
@Test
fun `each unit starts exactly on its threshold rather than one byte past it`() {
assertEquals("1 kB", formatBytes(1_000))
assertEquals("1.0 MB", formatBytes(1_000_000))
assertEquals("1.0 GB", formatBytes(1_000_000_000))
}
/**
* One byte below each threshold, which is the half a `>=` to `>` change leaves alone. Both
* halves are needed: the boundary values alone would still pass if the comparison let
* everything through.
*/
@Test
fun `a value just below a threshold stays in the smaller unit`() {
assertEquals("999 B", formatBytes(999))
assertEquals("1000 kB", formatBytes(999_999))
assertEquals("1000.0 MB", formatBytes(999_999_999))
}
@Test
fun `a real file size reads as one decimal place`() {
assertEquals("12.3 MB", formatBytes(12_345_678))
assertEquals("1.5 GB", formatBytes(1_500_000_000))
}
@Test
fun `a duration is minutes and zero-padded seconds`() {
assertEquals("0:00", formatDuration(0))
assertEquals("0:01", formatDuration(1_000))
assertEquals("0:59", formatDuration(59_000))
assertEquals("1:00", formatDuration(60_000))
assertEquals("1:30", formatDuration(90_000))
}
/** Sub-second remainders are dropped rather than rounded up into the next second. */
@Test
fun `a partial second does not become a whole one`() {
assertEquals("0:00", formatDuration(999))
assertEquals("0:59", formatDuration(59_999))
}
/** No hours field, deliberately: an hour is `60:00` and two hours are `120:00`. */
@Test
fun `an hour and beyond keeps counting in minutes`() {
assertEquals("60:00", formatDuration(3_600_000))
assertEquals("61:01", formatDuration(3_661_000))
assertEquals("120:00", formatDuration(7_200_000))
}
@Test
fun `a spec with both tracks names the container and joins the two codecs`() {
assertEquals(
"MP4 · H.264 + AAC",
describe(OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC)),
)
}
@Test
fun `a track set to none is left out instead of being named none`() {
assertEquals(
"MP3 · MP3",
describe(OutputSpec(Container.MP3, VideoCodec.NONE, AudioCodec.MP3)),
)
assertEquals(
"MP4 · H.264",
describe(OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.NONE)),
)
}
/** An image output has neither track, so there is nothing for the separator to separate. */
@Test
fun `a spec with no tracks at all is the container alone, with no trailing separator`() {
assertEquals("GIF", describe(OutputSpec(Container.GIF, VideoCodec.NONE, AudioCodec.NONE)))
assertEquals(
"PNG frames",
describe(OutputSpec(Container.IMAGE_SEQUENCE, VideoCodec.NONE, AudioCodec.NONE)),
)
}
/** `Copy` is a codec here, not the absence of one, so a remux describes both tracks. */
@Test
fun `a remux names copy on both tracks rather than dropping them`() {
assertEquals(
"Matroska · Copy + Copy",
describe(OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY)),
)
}
@Test
fun `each engine preference has the wording the chips show`() {
assertEquals("Automatic", EnginePreference.AUTO.label())
assertEquals("Prefer hardware", EnginePreference.PREFER_HARDWARE.label())
assertEquals("Force software", EnginePreference.FORCE_SOFTWARE.label())
}
/**
* Two constants sharing a label would render as two identical chips, one of which the user
* could not choose deliberately. The exhaustive `when` cannot catch that; this does.
*/
@Test
fun `no two engine preferences render the same chip`() {
val labels = EnginePreference.entries.map { it.label() }
assertEquals(EnginePreference.entries.size, labels.toSet().size)
assertEquals(emptyList<String>(), labels.filter { it.isBlank() })
}
}
@@ -0,0 +1,196 @@
package org.libremediaconverter.convert
import android.net.Uri
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.v2.createComposeRule
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 org.junit.Rule
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.InputKind
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* Each leaf of the converter screen renders, and each tag it claims resolves to exactly one node.
*
* The defect this bites on is a tag that is not where the table says it is: dropped by a refactor
* that rewrote a `Modifier` chain, applied to the wrong one of two siblings, or duplicated onto a
* leaf that is rendered twice. None of that is visible at compile time -- a `testTag` is a string
* handed to a modifier -- and none of it shows up in the app either, because nothing but a test
* ever reads one.
*
* It has to be caught here rather than by the children that consume the tags. R38.2, R38.3 and
* R38.4 all *begin* by locating a node through one of these, so a tag that had quietly moved would
* surface as three unrelated PRs failing on a line their own diffs do not touch. Counting the nodes
* rather than asserting existence is deliberate: `onNodeWithTag` on two matches throws about
* ambiguity in one place and passes in another, so "exactly one" is the property worth pinning.
*
* Deliberately *not* the state matrix. Which affordances each `ConversionState` renders is R38.6,
* and it needs the state seam R38.5 extracts -- the branch buttons tagged in this change (Convert,
* Cancel, Save file, Start over, ...) therefore have no bite yet, which the PR body records.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConverterLeafTagsTest {
@get:Rule
val composeRule = createComposeRule()
private fun assertResolvesToOneNode(tag: String) {
composeRule.onAllNodesWithTag(tag).assertCountEquals(1)
}
private fun input(sizeBytes: Long? = 12_345_678L, probe: InputProbe? = VIDEO_PROBE) = InputFile(
uri = Uri.parse("content://test/clip.mkv"),
displayName = "clip.mkv",
sizeBytes = sizeBytes,
probe = probe,
)
@Test
fun `the format picker tags its chip row`() {
composeRule.setContent { FormatPicker(OutputFormat.MP4_H264) {} }
assertResolvesToOneNode(TestTags.Converter.FORMAT_CHIPS)
}
@Test
fun `the quality picker tags its chip row`() {
composeRule.setContent { QualityPicker(QualityTier.FAST) {} }
assertResolvesToOneNode(TestTags.Converter.QUALITY_CHIPS)
}
@Test
fun `the engine picker tags its chip row`() {
composeRule.setContent { EnginePicker(EnginePreference.AUTO) {} }
assertResolvesToOneNode(TestTags.Converter.ENGINE_CHIPS)
}
@Test
fun `the advanced picker tags its toggle, which is all it renders while collapsed`() {
setAdvancedPicker()
assertResolvesToOneNode(TestTags.Converter.ADVANCED_TOGGLE)
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertDoesNotExist()
}
/**
* The panel and its three rows only exist once the toggle has been clicked, which is R38.4's
* subject. Expanding is the only way to reach the tags at all, so the smoke test has to do it.
*/
@Test
fun `expanding the advanced picker tags the panel and each of its three chip rows`() {
setAdvancedPicker()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
assertResolvesToOneNode(TestTags.Converter.ADVANCED_PANEL)
assertResolvesToOneNode(TestTags.Converter.ADVANCED_CONTAINER_CHIPS)
assertResolvesToOneNode(TestTags.Converter.ADVANCED_VIDEO_CHIPS)
assertResolvesToOneNode(TestTags.Converter.ADVANCED_AUDIO_CHIPS)
}
@Test
fun `the validation card tags itself and every suggestion on it`() {
composeRule.setContent {
ValidationError(
Validation.Invalid(
message = "WebM cannot hold H.264 video.",
suggestions = listOf(
OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC),
OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS),
),
),
) {}
}
assertResolvesToOneNode(TestTags.Converter.VALIDATION_ERROR)
assertResolvesToOneNode(TestTags.Converter.suggestion(0))
assertResolvesToOneNode(TestTags.Converter.suggestion(1))
}
@Test
fun `the file card tags itself, its name and its size line`() {
composeRule.setContent { FileCard(input()) }
assertResolvesToOneNode(TestTags.Converter.FILE_CARD)
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_NAME)
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_BYTES)
}
/**
* Both writers of the note line get their own case. They are two separate `Text` calls in two
* branches that share one tag, so a test of either alone would leave the other unguarded.
*/
@Test
fun `the file card tags the note it shows while the probe is still running`() {
composeRule.setContent { FileCard(input(probe = null)) }
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_NOTE)
}
@Test
fun `the file card tags the note it shows when nothing could read the file`() {
composeRule.setContent { FileCard(input(probe = InputProbe(kind = InputKind.UNPARSEABLE))) }
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_NOTE)
}
@Test
fun `a detail row tags itself with the label it renders`() {
composeRule.setContent { DetailRow("Container", "Matroska") }
assertResolvesToOneNode(TestTags.Converter.detailRow("Container"))
}
/** The rows the file card builds carry the same per-label tags, one per row it renders. */
@Test
fun `the file card's detail rows are each tagged by their own label`() {
composeRule.setContent { FileCard(input()) }
assertResolvesToOneNode(TestTags.Converter.detailRow("Container"))
assertResolvesToOneNode(TestTags.Converter.detailRow("Video"))
assertResolvesToOneNode(TestTags.Converter.detailRow("Audio"))
assertResolvesToOneNode(TestTags.Converter.detailRow("Length"))
}
private fun setAdvancedPicker() {
composeRule.setContent {
AdvancedPicker(
spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC),
validation = Validation.Valid,
onContainer = {},
onVideoCodec = {},
onAudioCodec = {},
onSuggestion = {},
)
}
}
private companion object {
val VIDEO_PROBE = InputProbe(
videoCodec = "video/avc",
audioCodec = "audio/mp4a-latm",
durationMs = 90_000,
kind = InputKind.VIDEO,
container = Container.MKV,
width = 1920,
height = 1080,
)
}
}
@@ -0,0 +1,169 @@
package org.libremediaconverter.convert
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* Each picker lights the chip it was handed and reports the constant that was pressed.
*
* The defect this bites on is a picker that renders perfectly and answers wrongly. All three are
* the same dozen lines with a different enum substituted, so the failure mode is a copy-paste that
* survives review: an `onClick` that closes over the picker's `selected` parameter instead of the
* chip's own entry hands back one constant no matter which chip was tapped, and an inverted
* `entry == selected` lights every chip except the right one. Neither throws, neither changes the
* set of labels on screen, and a test that only asserted "the callback ran" would pass over both.
*
* Clicking every chip in turn and comparing the whole recorded list against `entries` is what makes
* the constant load-bearing rather than the click count -- a hardcoded `onSelect` fires the same
* number of times as a correct one. Selection is asserted over every chip for the same reason: the
* one that should be lit proves nothing on its own, because `!=` lights it too whenever the enum
* has exactly one entry, and lights all its siblings whenever it has more.
*
* Labels come from `OutputFormat.label` and `QualityTier.label`; [label], which the screen owns
* because `EnginePreference` carries no label of its own, supplies the third set. Retyping any of
* them here would turn a rename into a red test that named the wrong cause.
*
* Not covered, deliberately: the `"Output format"`, `"Quality"` and `"Engine"` headings, which are
* untagged `Text` calls with no enum behind them and no behaviour to bite on.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConverterPickerSelectionTest {
@get:Rule
val composeRule = createComposeRule()
/**
* The chip carrying [label] inside the row tagged [rowTag].
*
* By ancestor rather than by direct child: how many semantics nodes Material 3 puts between a
* `FlowRow` and its chips is that library's business, and a matcher that assumed "one" would
* break on an upgrade that changed nothing this test is about.
*/
private fun chipIn(rowTag: String, label: String): SemanticsNodeInteraction =
composeRule.onNode(hasAnyAncestor(hasTestTag(rowTag)) and hasText(label))
private fun assertOnlySelected(rowTag: String, labels: List<String>, selected: String?) {
labels.forEach { label ->
val chip = chipIn(rowTag, label)
if (label == selected) chip.assertIsSelected() else chip.assertIsNotSelected()
}
}
@Test
fun `the format picker lights the selected format and no other`() {
composeRule.setContent { FormatPicker(OutputFormat.WEBM_VP9) {} }
assertOnlySelected(
rowTag = TestTags.Converter.FORMAT_CHIPS,
labels = OutputFormat.entries.map { it.label },
selected = OutputFormat.WEBM_VP9.label,
)
}
/** A spec no preset can express lights nothing, which is what the custom line stands in for. */
@Test
fun `the format picker lights nothing when the spec is custom`() {
composeRule.setContent { FormatPicker(null) {} }
assertOnlySelected(
rowTag = TestTags.Converter.FORMAT_CHIPS,
labels = OutputFormat.entries.map { it.label },
selected = null,
)
composeRule.onNodeWithText(CUSTOM_SPEC_NOTE).assertExists()
}
@Test
fun `a selected format hides the custom line`() {
composeRule.setContent { FormatPicker(OutputFormat.MP3) {} }
composeRule.onNodeWithText(CUSTOM_SPEC_NOTE).assertDoesNotExist()
}
@Test
fun `clicking a format chip reports that format`() {
val picked = mutableListOf<OutputFormat>()
composeRule.setContent { FormatPicker(null) { picked += it } }
OutputFormat.entries.forEach { chipIn(TestTags.Converter.FORMAT_CHIPS, it.label).performClick() }
assertEquals(OutputFormat.entries.toList(), picked)
}
@Test
fun `the quality picker lights the selected tier and no other`() {
composeRule.setContent { QualityPicker(QualityTier.BEST) {} }
assertOnlySelected(
rowTag = TestTags.Converter.QUALITY_CHIPS,
labels = QualityTier.entries.map { it.label },
selected = QualityTier.BEST.label,
)
}
/** The line under the chips describes what was chosen, not whichever tier was written first. */
@Test
fun `the quality picker explains the tier that is selected`() {
composeRule.setContent { QualityPicker(QualityTier.BEST) {} }
composeRule.onNodeWithText(QualityTier.BEST.description).assertExists()
composeRule.onNodeWithText(QualityTier.FAST.description).assertDoesNotExist()
}
@Test
fun `clicking a quality chip reports that tier`() {
val picked = mutableListOf<QualityTier>()
composeRule.setContent { QualityPicker(QualityTier.FAST) { picked += it } }
QualityTier.entries.forEach { chipIn(TestTags.Converter.QUALITY_CHIPS, it.label).performClick() }
assertEquals(QualityTier.entries.toList(), picked)
}
@Test
fun `the engine picker lights the selected preference and no other`() {
composeRule.setContent { EnginePicker(EnginePreference.FORCE_SOFTWARE) {} }
assertOnlySelected(
rowTag = TestTags.Converter.ENGINE_CHIPS,
labels = EnginePreference.entries.map { it.label() },
selected = EnginePreference.FORCE_SOFTWARE.label(),
)
}
@Test
fun `clicking an engine chip reports that preference`() {
val picked = mutableListOf<EnginePreference>()
composeRule.setContent { EnginePicker(EnginePreference.AUTO) { picked += it } }
EnginePreference.entries.forEach { chipIn(TestTags.Converter.ENGINE_CHIPS, it.label()).performClick() }
assertEquals(EnginePreference.entries.toList(), picked)
}
private companion object {
/**
* Copied byte for byte out of `ConverterScreen.kt` -- it holds a U+2014 em dash, which
* retyped as ASCII would match nothing and fail as "no node found" rather than as a reword.
*/
const val CUSTOM_SPEC_NOTE: String = "Custom — set below."
}
}
@@ -0,0 +1,114 @@
package org.libremediaconverter.convert
import android.net.Uri
import androidx.compose.ui.test.junit4.v2.createComposeRule
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 org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.Validation
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
import java.io.File
/**
* The seam carries a `ConversionState` in and an action back out.
*
* The defect this bites on is the extraction having quietly stopped being an extraction: a
* `ConverterScreenContent` that ignores the `state` it was handed, or renders the finished job's
* affordances without wiring them to the callbacks the entry point supplies. Neither shows up at
* compile time -- an unread parameter compiles, and a `Button` whose `onClick` does nothing is a
* valid `Button` -- and neither is visible from the leaf tests, which compose `FileCard`,
* `AdvancedPicker` and the pickers directly and never see a state at all.
*
* **Both assertions were unreachable before R38.5**, which is the point of the ticket rather than
* a remark about it. `ConversionState.Converted` is produced only by a `ConversionWorker` run that
* has already succeeded, so no test can drive a real `ConversionViewModel` into it: it would need
* a `WorkManager`, a media probe, a staged output file and a completed job. Handing the state in
* is the only way to ask what the screen does with it.
*
* Deliberately not the state matrix. Which affordances each of the six `ConversionState`s renders
* is R38.6 (#62); this file asserts only that the injection point exists and works in both
* directions, so the two PRs cannot collide over the same cases.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConverterScreenContentTest {
@get:Rule
val composeRule = createComposeRule()
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
private val savedAs = mutableListOf<String>()
@Test
fun `a converted job renders the save button`() {
setContent(converted())
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
}
/**
* The direction that did not exist before this change.
*
* Asserting the *name* rather than just that something was called: the suggested name comes
* from the job -- `ConversionWorker.KEY_SUGGESTED_NAME` -- and is what the save dialog opens
* with, so a Save button wired to the wrong branch's state would hand over the wrong one and
* a bare "was called" check would stay green.
*/
@Test
fun `tapping save hands back the name the finished job chose`() {
setContent(converted())
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
assertEquals(listOf("holiday.mp4"), savedAs)
}
/**
* `staged` names a file that does not exist, on purpose.
*
* The branch renders `formatBytes(s.staged.length())`, and `length()` answers `0L` for a
* missing path rather than throwing, so the size line reads `0 B` and no temporary folder is
* needed. `routeReason` stays blank, which is what keeps the routing chip out of the tree --
* that chip is R38.6's case, not this file's.
*/
private fun converted() = ConversionState.Converted(
input = InputFile(
uri = Uri.parse("content://test/holiday.mkv"),
displayName = "holiday.mkv",
sizeBytes = 12_345_678L,
),
staged = File("no-such-staged-output.mp4"),
suggestedName = "holiday.mp4",
mimeType = "video/mp4",
)
private fun setContent(state: ConversionState) {
composeRule.setContent {
ConverterScreenContent(
state = state,
settings = ConversionSettings(),
validation = Validation.Valid,
actions = ConverterActions(
onPickInput = {},
onPreset = {},
onContainer = {},
onVideoCodec = {},
onAudioCodec = {},
onSuggestion = {},
onQuality = {},
onEnginePreference = {},
onConvert = {},
onCancel = {},
onSave = { suggestedName -> savedAs += suggestedName },
onReset = {},
),
)
}
}
}
@@ -0,0 +1,478 @@
package org.libremediaconverter.convert
import android.net.Uri
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertRangeInfoEquals
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
import java.io.File
/**
* Every `ConversionState` renders its own affordances, and only its own.
*
* The defect this bites on is a `when` arm that has drifted from the state it names: a button
* offered in a state where it cannot work, a state's own data never reaching the node that is
* supposed to display it, or an affordance wired to the wrong callback. None of that is a compile
* error -- every arm of the `when` returns `Unit`, so an arm can render anything at all -- and none
* of it is visible from the leaf tests, which compose `FileCard`, `AdvancedPicker` and the three
* pickers directly and never see a `ConversionState`.
*
* The arm most worth guarding is `Ready`'s `enabled = validation.isValid`. The Advanced picker
* deliberately lets an impossible container / codec combination be selected -- `AdvancedPicker`'s
* KDoc says teaching the constraint beats hiding it -- so that single expression is the only thing
* standing between an invalid spec and a job that cannot succeed. `enabled = true` compiles, renders
* an identical screen apart from one colour, and passes every other test in this suite.
*
* Callbacks are asserted by **identity, over the whole log**: [fired] records all twelve of them and
* each assertion compares the complete list against one expected entry. A bare "the callback ran"
* check stays green when an arm fires the right callback for the wrong reason, and a check on one
* callback alone stays green when an arm fires two.
*
* ### Not asserted here, so that each is a decision rather than an omission
*
* - **`Failed`'s error colour.** #62's table asks for the message "in the error colour". Compose
* publishes no text colour to the semantics tree -- there is no `SemanticsProperties` entry for
* 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`, `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.
* - **The suggested name `Converted` hands to the save dialog** is pinned by
* `ConverterScreenContentTest`; repeating it here would be a second copy of one assertion.
* - **`ConverterScreen`'s permission dance.** `requestNotifications` calls `convert()` on both grant
* and deny, deliberately -- the KDoc explains that the foreground service runs either way -- and
* it lives in the entry point, above the seam this file composes.
* - **`is ConversionState.Idle -> Unit` in the nested `when`.** The outer `when` peels `Idle` off
* first, so that arm is permanently unreachable and no test can reach it.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConverterStateAffordancesTest {
@get:Rule
val composeRule = createComposeRule()
/**
* Every callback the screen fired, in order, tagged with the value it carried.
*
* All twelve are recorded rather than only the one under test, so an assertion can be
* `assertEquals(listOf("cancel"), fired)` -- which says "this one and nothing else".
*/
private val fired = mutableListOf<String>()
// -------------------------------------------------------------------- Idle
@Test
fun `an idle screen offers the prompt and the picker, and nothing to act on yet`() {
setContent(ConversionState.Idle)
composeRule.onNodeWithText("Pick a file to convert.").assertExists()
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertExists()
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertDoesNotExist()
composeRule.onNodeWithTag(TestTags.CANCEL).assertDoesNotExist()
// Compile-guarded rather than guarded here -- `Idle` has no `input`. See the class KDoc.
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
}
/**
* No [performScrollTo] on this one, unlike every other click below. `Idle` is the centred
* branch outside the `verticalScroll` column, so it has no scrollable ancestor to scroll in.
*/
@Test
fun `tapping choose file on an idle screen asks for a file and does nothing else`() {
setContent(ConversionState.Idle)
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
assertEquals(listOf("pickInput"), fired)
}
// ------------------------------------------------------------------- Ready
/**
* All four pickers, the card above them and both buttons below, in one assertion each.
*
* A superset of #62's "all five pickers": which four or five of these count as a picker is not
* worth arguing about, so the case names everything the arm emits.
*/
@Test
fun `a picked file offers its card, all four pickers and both buttons`() {
setContent(ConversionState.Ready(input()))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertExists()
composeRule.onNodeWithTag(TestTags.Converter.FORMAT_CHIPS).assertExists()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).assertExists()
composeRule.onNodeWithTag(TestTags.Converter.QUALITY_CHIPS).assertExists()
composeRule.onNodeWithTag(TestTags.Converter.ENGINE_CHIPS).assertExists()
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertExists()
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE).assertExists()
}
/** The card is handed `s.input`, so the name on it is how the state is shown to have arrived. */
@Test
fun `the file card on a picked file names the file that was picked`() {
setContent(ConversionState.Ready(input()))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertTextEquals("holiday.mkv")
}
@Test
fun `convert is offered for a spec that can be produced`() {
setContent(ConversionState.Ready(input()), validation = Validation.Valid)
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertIsEnabled()
}
@Test
fun `tapping convert starts the job and does nothing else`() {
setContent(ConversionState.Ready(input()), validation = Validation.Valid)
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).performScrollTo().performClick()
assertEquals(listOf("convert"), fired)
}
/**
* The bite named in #62. Reverting `enabled = validation.isValid` to `enabled = true` reddens
* exactly this case, and nothing else in the repository.
*/
@Test
fun `convert is withheld for a spec that cannot be produced`() {
setContent(ConversionState.Ready(input()), validation = INVALID)
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertIsNotEnabled()
}
/** The other button on the arm goes back to the picker rather than starting anything. */
@Test
fun `tapping choose a different file asks for a file rather than converting`() {
setContent(ConversionState.Ready(input()))
composeRule
.onNodeWithTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE)
.performScrollTo()
.performClick()
assertEquals(listOf("pickInput"), fired)
}
// -------------------------------------------------------------- Converting
/**
* Two independent readings of the same `percent`, on purpose.
*
* The heading is a string and the bar is a float, and the arm computes them from the state
* separately -- `"${s.percent}%"` against `s.percent / 100f`. A hardcoded bar and a hardcoded
* heading are different mistakes, so neither assertion covers the other.
*/
@Test
fun `a running job reports how far it has got, in words and on the bar`() {
setContent(ConversionState.Converting(input(), percent = 42))
composeRule.onNodeWithText("Converting… 42%").assertExists()
composeRule
.onNodeWithTag(TestTags.Converter.PROGRESS)
.assertRangeInfoEquals(ProgressBarRangeInfo(0.42f, 0f..1f))
}
@Test
fun `a running job offers cancel and not start over`() {
setContent(ConversionState.Converting(input(), percent = 42))
composeRule.onNodeWithTag(TestTags.CANCEL).assertExists()
composeRule.onNodeWithTag(TestTags.START_OVER).assertDoesNotExist()
}
@Test
fun `tapping cancel on a running job cancels it and does nothing else`() {
setContent(ConversionState.Converting(input(), percent = 42))
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
assertEquals(listOf("cancel"), fired)
}
// ----------------------------------------------------------------- Waiting
/**
* The second bite named in #62. Deleting the `Cancel` button from the `Waiting` arm reddens
* this case and the one below it.
*
* The paragraph is asserted in full rather than by a fragment because it is the only thing the
* arm renders besides the card and the button, and because its wording is the arm's whole
* job -- `FailureOutcome` records that two different causes land here and the state cannot tell
* them apart, so the text has to cover both. A reword should redden one test, and this is it.
*/
@Test
fun `a paused job explains why and still offers cancel`() {
setContent(ConversionState.Waiting(input()))
composeRule.onNodeWithText(PAUSED_PARAGRAPH).assertExists()
composeRule.onNodeWithTag(TestTags.CANCEL).assertExists()
}
@Test
fun `tapping cancel on a paused job cancels it and does nothing else`() {
setContent(ConversionState.Waiting(input()))
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
assertEquals(listOf("cancel"), fired)
}
// --------------------------------------------------------------- Converted
@Test
fun `a finished job offers save and start over, and no longer offers cancel`() {
setContent(converted())
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
composeRule.onNodeWithTag(TestTags.CANCEL).assertDoesNotExist()
}
@Test
fun `tapping start over on a finished job resets and does not save`() {
setContent(converted())
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
assertEquals(listOf("reset"), fired)
}
/**
* The chip carries the job's own explanation, so its text is the assertion rather than its
* presence: a chip showing the engine name, or the previous job's reason, would still exist.
*/
@Test
fun `a finished job shows the routing decision the job reported`() {
setContent(converted(routeReason = "Software — the MKV input needed a re-encode"))
composeRule
.onNodeWithTag(TestTags.Converter.ROUTE_REASON)
.assertTextEquals("Software — the MKV input needed a re-encode")
}
/** The other side of the `isNotBlank` guard, which is unguarded without a case of its own. */
@Test
fun `a finished job that reported no routing decision shows no chip`() {
setContent(converted(routeReason = ""))
composeRule.onNodeWithTag(TestTags.Converter.ROUTE_REASON).assertDoesNotExist()
}
// ------------------------------------------------------------------- Saved
@Test
fun `a saved file names itself and offers another conversion`() {
setContent(ConversionState.Saved(displayName = "holiday.mp4"))
composeRule.onNodeWithText("Saved holiday.mp4.").assertExists()
composeRule.onNodeWithTag(TestTags.Converter.CONVERT_ANOTHER).assertExists()
// Compile-guarded rather than guarded here -- `Saved` has no `input`. See the class KDoc.
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
}
@Test
fun `tapping convert another after a save resets and does nothing else`() {
setContent(ConversionState.Saved(displayName = "holiday.mp4"))
composeRule
.onNodeWithTag(TestTags.Converter.CONVERT_ANOTHER)
.performScrollTo()
.performClick()
assertEquals(listOf("reset"), fired)
}
// ------------------------------------------------------------------ Failed
/**
* The message is the arm's only output that carries information, and it comes from the state.
* An arm rendering a fixed apology would look right and say nothing, which is why the assertion
* is on the text handed in rather than on a node existing.
*/
@Test
fun `a failed job renders the reason it was given and offers a restart`() {
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
composeRule.onNodeWithText("Ran out of space while writing the output.").assertExists()
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
// Compile-guarded rather than guarded here -- `Failed` has no `input`. See the class KDoc.
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."))
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
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(
uri = Uri.parse("content://test/holiday.mkv"),
displayName = "holiday.mkv",
sizeBytes = 12_345_678L,
)
/**
* `staged` names a path that does not exist, deliberately: `File.length()` answers `0L` for a
* 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"),
routeReason = routeReason,
suggestedName = "holiday.mp4",
mimeType = "video/mp4",
)
private fun setContent(state: ConversionState, validation: Validation = Validation.Valid) {
composeRule.setContent {
ConverterScreenContent(
state = state,
settings = ConversionSettings(),
validation = validation,
actions = ConverterActions(
onPickInput = { fired += "pickInput" },
onPreset = { fired += "preset:$it" },
onContainer = { fired += "container:$it" },
onVideoCodec = { fired += "videoCodec:$it" },
onAudioCodec = { fired += "audioCodec:$it" },
onSuggestion = { fired += "suggestion:$it" },
onQuality = { fired += "quality:$it" },
onEnginePreference = { fired += "engine:$it" },
onConvert = { fired += "convert" },
onCancel = { fired += "cancel" },
onSave = { fired += "save:$it" },
onReset = { fired += "reset" },
),
)
}
}
private companion object {
/**
* A spec no container can hold, with somewhere to go instead.
*
* Built here rather than run through `ContainerCapabilities` because what makes a spec
* invalid is that class's subject; all this arm needs is a `Validation` that answers
* `isValid == false`.
*/
val INVALID = Validation.Invalid(
message = "WebM cannot hold H.264 video.",
suggestions = listOf(OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC)),
)
/** Copied from the `Waiting` arm, where it is written as two concatenated fragments. */
const val PAUSED_PARAGRAPH =
"Paused. Android limits background media processing, so this will " +
"resume automatically — keeping the app open helps it along."
}
}
@@ -0,0 +1,370 @@
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.** It lives in the entry point, above the
* `ScreenContent` seam, and reaching it needs a real ViewModel inside a composition. What it
* reads -- `pendingSave()?.mimeType` -- is asserted directly instead, which is why that
* derivation was moved out of the entry point in the first place.
* - **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,254 @@
package org.libremediaconverter.convert
import android.net.Uri
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithTag
import androidx.media3.common.util.UnstableApi
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* What the source-info card says when it does not know something.
*
* The defect is a card that invents an answer instead of admitting it has none. Two of them are
* live here and neither had a test before this file:
*
* - **`InputFile.sizeBytes` is nullable and the card is the reader that has to say so in words.**
* `sizeBytes` used to be `0L` for "nobody told me", and [UnknownInputSizeTest] records what that
* cost at the space check. The card is the other reader, and its failure mode is the mirror
* image: hand the null to `formatBytes` and it renders `"0 B"` -- a measurement, shown to the
* user, that no provider ever made. It renders **independently of the probe**, which is why the
* same assertion appears twice below, with the probe present and absent. That independence is
* the contract; a test covering only the probed case would leave the branch a user actually hits
* first -- the card is on screen before the probe finishes -- unguarded.
* - **The codec rows degrade in words too.** `CodecNames.describeVideo`/`describeAudio` answer
* `"Unknown"` for a codec nothing named, the `VIDEO` branch answers `"No audio track"` for a file
* with no audio, and the two `> 0` guards drop the dimension and length rows rather than printing
* `0` and `0:00`. Each of those has a case below on **both** sides of the guard, because a test
* of the present side alone stays green with the guard deleted.
*
* ### What cannot be asserted here, so that it is a decision rather than an omission
*
* The `probe == null` branch exits before `HorizontalDivider`, and **the divider's absence is not
* observable from a test**: Material 3 renders it as a `Box` with no semantics modifier, so it
* contributes no node to the semantics tree at all. What is asserted instead is everything the
* divider precedes -- no detail row for any label the four kind branches can emit -- plus the
* card's child count, which pins "these three texts and nothing else" without having to enumerate.
*
* The early exit itself is enforced by the compiler rather than by this file, which the PR body
* records: deleting `return@Column` un-smart-casts `probe`, and the `probe.kind` below it stops
* compiling. The mutation that reddens the test here is the compilable form of that regression --
* defaulting the null away with `?: InputProbe()` and letting the kind rows render.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class FileCardTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun `a file no provider could measure says so in words rather than showing a zero`() {
setFileCard(input(sizeBytes = null, probe = VIDEO_PROBE))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_BYTES)
.assertTextEquals("Size unknown")
}
/**
* The same line, with no probe at all. Separate from the case above rather than folded into
* it because `setContent` may only be called once per rule, and because two independent reds
* are the evidence that the size line does not depend on the probe.
*/
@Test
fun `the size line says the same thing while the probe is still running`() {
setFileCard(input(sizeBytes = null, probe = null))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_BYTES)
.assertTextEquals("Size unknown")
}
@Test
fun `a size that was reported is formatted rather than replaced by the unknown line`() {
setFileCard(input(sizeBytes = 12_345_678L, probe = VIDEO_PROBE))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertTextEquals("clip.mkv")
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_BYTES).assertTextEquals("12.3 MB")
}
/**
* The note and the emptiness are one behaviour, so they are one test: a regression that keeps
* the note but renders the rows anyway would leave a note-only test green.
*/
@Test
fun `while the probe is still running the card shows the reading note and nothing else`() {
setFileCard(input(probe = null))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NOTE)
.assertTextEquals("Reading…")
assertNoDetailRows()
// Name, size, note. Catches a row whose label is not in EVERY_ROW_LABEL as well.
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).onChildren().assertCountEquals(3)
}
@Test
fun `a file nothing could read gets the explanatory line instead of unknown codecs`() {
setFileCard(input(probe = InputProbe(kind = InputKind.UNPARSEABLE)))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NOTE)
.assertTextEquals("Could not identify this file. It will be converted with FFmpeg.")
assertNoDetailRows()
}
@Test
fun `an image gets its type and its pixel dimensions`() {
setFileCard(input(probe = InputProbe(kind = InputKind.IMAGE, width = 1920, height = 1080)))
assertRow("Type", "Image")
assertRow("Size", "1920×1080")
}
/** The `width > 0` guard, from the side that would print `0×0` if it were dropped. */
@Test
fun `an image whose dimensions nothing reported gets the type row alone`() {
setFileCard(input(probe = InputProbe(kind = InputKind.IMAGE)))
assertRow("Type", "Image")
assertNoRow("Size")
}
@Test
fun `an audio-only file says it has no video track rather than leaving the row blank`() {
setFileCard(
input(
probe = InputProbe(
audioCodec = "aac",
hasVideo = false,
durationMs = 90_000,
kind = InputKind.AUDIO_ONLY,
container = Container.MP3,
),
),
)
assertRow("Container", Container.MP3.label)
assertRow("Video", "No video track")
assertRow("Audio", AudioCodec.AAC.label)
assertRow("Length", "1:30")
assertNoRow("Type")
assertNoRow("Size")
}
/**
* Everything the audio branch can fail to know, at once: no container, no codec name, no
* duration. Each degrades in its own words, and the length row disappears rather than
* claiming `0:00`.
*/
@Test
fun `an audio-only file nothing else could describe degrades one row at a time`() {
setFileCard(input(probe = InputProbe(hasVideo = false, kind = InputKind.AUDIO_ONLY)))
assertRow("Container", "Unknown")
assertRow("Video", "No video track")
assertRow("Audio", "Unknown")
assertNoRow("Length")
}
@Test
fun `a video file composes its codec with its dimensions on one row`() {
setFileCard(input(probe = VIDEO_PROBE))
assertRow("Container", Container.MP4.label)
assertRow("Video", "${VideoCodec.H264.label} · 1920×1080")
assertRow("Audio", AudioCodec.AAC.label)
assertRow("Length", "1:30")
}
/**
* `"No audio track"` rather than `describeAudio(null)`'s `"Unknown"`. The video branch knows
* the difference between a track it could not name and a track that is not there; the audio
* branch above cannot, because a file with no audio is not audio-only.
*/
@Test
fun `a video file with no audio track says so instead of naming an unknown codec`() {
setFileCard(input(probe = VIDEO_PROBE.copy(audioCodec = null)))
assertRow("Audio", "No audio track")
}
/** Both `> 0` guards on the video branch, plus the codec name nothing supplied. */
@Test
fun `a video file missing its codec, dimensions and duration omits them rather than faking them`() {
setFileCard(
input(
probe = VIDEO_PROBE.copy(
videoCodec = null,
width = 0,
height = 0,
durationMs = 0,
),
),
)
assertRow("Video", "Unknown")
assertNoRow("Length")
}
/**
* The row is one node, not a label node beside a value node. A test matching on `"Container"`
* alone would pass against either shape.
*/
@Test
fun `a detail row renders its label and its value as a single node`() {
composeRule.setContent { DetailRow("Container", "Matroska") }
composeRule.onNodeWithTag(TestTags.Converter.detailRow("Container"))
.assertTextEquals("Container: Matroska")
}
private fun setFileCard(input: InputFile) = composeRule.setContent { FileCard(input) }
private fun input(sizeBytes: Long? = 12_345_678L, probe: InputProbe? = VIDEO_PROBE) = InputFile(
uri = Uri.parse("content://test/clip.mkv"),
displayName = "clip.mkv",
sizeBytes = sizeBytes,
probe = probe,
)
private fun assertRow(label: String, value: String) {
composeRule.onNodeWithTag(TestTags.Converter.detailRow(label))
.assertTextEquals("$label: $value")
}
private fun assertNoRow(label: String) {
composeRule.onNodeWithTag(TestTags.Converter.detailRow(label)).assertDoesNotExist()
}
private fun assertNoDetailRows() = EVERY_ROW_LABEL.forEach(::assertNoRow)
private companion object {
/** Every label the four kind branches can emit, so absence can be asserted exhaustively. */
val EVERY_ROW_LABEL = listOf("Container", "Video", "Audio", "Length", "Type", "Size")
val VIDEO_PROBE = InputProbe(
videoCodec = "h264",
audioCodec = "aac",
durationMs = 90_000,
kind = InputKind.VIDEO,
container = Container.MP4,
width = 1920,
height = 1080,
)
}
}
@@ -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,287 @@
package org.libremediaconverter.convert
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.AudioPlan
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionPlan
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.ConversionRouter
import org.libremediaconverter.model.CopyPlanner
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.VideoPlan
/**
* Guards [Media3Engine]'s two enum-to-MIME tables and the claims written above them.
*
* The defect: neither table was exercised at all, so nothing stood between a wrong entry and the
* user's file. Point `H265` at `VIDEO_H264` and every hardware HEVC export writes H.264 into a
* file the user asked to be H.265 — Transformer does exactly as told, the export succeeds, and
* the only symptom is a codec nobody chose.
*
* Worse, one arm carried an assertion instead of a value:
*
* ```
* // Never reached: only an Encode plan consults this, and COPY/NONE are not Encode.
* ```
*
* That is a claim about *callers* parked in a branch of a callee. It happens to be true, and
* nothing whatsoever checked it, so it would have gone on reading as true after it stopped being.
*
* Three kinds of test, because arm-by-arm equality alone would only pin today's answers:
*
* 1. Every arm of both tables, nulls included.
* 2. The "never reached" claim, proved over every plan [CopyPlanner] can produce.
* 3. The tables against [ConversionRouter]'s actual decisions rather than against its codec sets —
* the comments claim behaviour ("the router routes them to FFmpeg"), and a set can be right
* while the rule that reads it is wrong.
*
* A JVM test rather than an instrumented one: both tables take an enum and return a constant.
*/
@UnstableApi
class Media3EngineMimeTypesTest {
@Test
fun `every video codec maps to the MIME type Transformer will be given`() {
assertEquals(
"EXPECTED_VIDEO_MIME must name every VideoCodec, so a new one cannot arrive untested",
VideoCodec.entries.toSet(),
EXPECTED_VIDEO_MIME.keys,
)
VideoCodec.entries.forEach { codec ->
assertEquals(
"videoMimeTypeFor(${codec.label})",
EXPECTED_VIDEO_MIME.getValue(codec),
Media3Engine.videoMimeTypeFor(codec),
)
}
}
@Test
fun `every audio codec maps to the MIME type Transformer will be given`() {
assertEquals(
"EXPECTED_AUDIO_MIME must name every AudioCodec, so a new one cannot arrive untested",
AudioCodec.entries.toSet(),
EXPECTED_AUDIO_MIME.keys,
)
AudioCodec.entries.forEach { codec ->
assertEquals(
"audioMimeTypeFor(${codec.label})",
EXPECTED_AUDIO_MIME.getValue(codec),
Media3Engine.audioMimeTypeFor(codec),
)
}
}
/**
* The "never reached" claim, proved rather than repeated.
*
* [Media3Engine] asks these tables only for `plan.video as? VideoPlan.Encode`, and every plan
* it sees comes from [CopyPlanner]. So the claim reduces to a property of the planner: over
* every spec it can be handed, an `Encode` never carries `COPY` or `NONE`. That holds because
* both codecs are answered before the `Encode` branch, and the fallback draws from
* `ContainerCapabilities.encodableVideo`, which contains neither — but this asserts it instead
* of trusting the reading.
*
* The counters are not decoration. `(plan.video as? VideoPlan.Encode)?.let { ... }` asserts
* nothing at all for a `Drop` or `Copy` plan, so a sweep that stopped producing `Encode` plans
* would stay green while checking nothing.
*/
@Test
fun `no plan CopyPlanner can produce carries COPY or NONE inside an Encode`() {
var videoEncodes = 0
var audioEncodes = 0
everyPlan().forEach { (spec, probe, plan) ->
(plan.video as? VideoPlan.Encode)?.let {
videoEncodes++
assertTrue(
"CopyPlanner produced VideoPlan.Encode(${it.codec}) for $spec against $probe",
it.codec != VideoCodec.COPY && it.codec != VideoCodec.NONE,
)
}
(plan.audio as? AudioPlan.Encode)?.let {
audioEncodes++
assertTrue(
"CopyPlanner produced AudioPlan.Encode(${it.codec}) for $spec against $probe",
it.codec != AudioCodec.COPY && it.codec != AudioCodec.NONE,
)
}
}
assertTrue("the sweep produced no video Encode plan, so it asserted nothing", videoEncodes > 0)
assertTrue("the sweep produced no audio Encode plan, so it asserted nothing", audioEncodes > 0)
}
/**
* The video table's other claim: VP8, VP9 and AV1 targets "never reach here".
*
* Asked of the router rather than of its private codec set, so the rule is what is under test.
*/
@Test
fun `the router sends exactly H264 and H265 video encodes to Media3`() {
val onMedia3 = REAL_VIDEO_CODECS.filter { engineForVideoEncode(it) == Engine.MEDIA3 }
assertEquals(listOf(VideoCodec.H264, VideoCodec.H265), onMedia3)
}
/**
* The audio table's sibling claim, and where it turned out to be incomplete.
*
* The comment named MP3 and FLAC. One rule — `audioEncode !in MEDIA3_AUDIO` — diverts Vorbis
* by exactly the same logic, so three of the six encodable codecs never reach the table, not
* two. Asserted as the whole set rather than as two memberships, which is what makes the
* omission visible.
*/
@Test
fun `the router keeps MP3 FLAC and Vorbis audio encodes off Media3`() {
val onMedia3 = REAL_AUDIO_CODECS.filter { engineForAudioEncode(it) == Engine.MEDIA3 }
assertEquals(listOf(AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.PCM), onMedia3)
}
/**
* The binding that makes the two halves above one test rather than two coincidences.
*
* A codec the router starts sending to Media3 must have a MIME type here, or Transformer is
* left to pick its own and the user gets a codec they did not choose.
*/
@Test
fun `every codec the router sends to Media3 has a MIME type`() {
REAL_VIDEO_CODECS.filter { engineForVideoEncode(it) == Engine.MEDIA3 }.forEach { codec ->
assertNotNull(
"${codec.label} is routed to Media3 but videoMimeTypeFor returns null",
Media3Engine.videoMimeTypeFor(codec),
)
}
REAL_AUDIO_CODECS.filter { engineForAudioEncode(it) == Engine.MEDIA3 }.forEach { codec ->
assertNotNull(
"${codec.label} is routed to Media3 but audioMimeTypeFor returns null",
Media3Engine.audioMimeTypeFor(codec),
)
}
}
/**
* The reverse direction, which holds for video and not for audio.
*
* Every video codec the router withholds has a null entry, so that table is exactly the set of
* codecs Media3 is asked to encode. Audio has one entry more than the router will ever use:
* `VORBIS -> AUDIO_VORBIS` is correct and unreachable. Pinned deliberately — if a routing
* change makes Vorbis live, this is the test that says the arm above stopped being dead.
*/
@Test
fun `Vorbis is the one MIME type the router never asks for`() {
REAL_VIDEO_CODECS.filter { engineForVideoEncode(it) == Engine.FFMPEG }.forEach { codec ->
assertEquals(
"${codec.label} never reaches Media3, so it must not name a MIME type",
null,
Media3Engine.videoMimeTypeFor(codec),
)
}
val namedButUnrouted = REAL_AUDIO_CODECS
.filter { Media3Engine.audioMimeTypeFor(it) != null }
.filter { engineForAudioEncode(it) == Engine.FFMPEG }
assertEquals(listOf(AudioCodec.VORBIS), namedButUnrouted)
assertEquals(MimeTypes.AUDIO_VORBIS, Media3Engine.audioMimeTypeFor(AudioCodec.VORBIS))
}
/**
* Routes a video-only re-encode to [codec] and reports the engine chosen.
*
* `mpeg2video` is the load-bearing detail: [CopyPlanner] upgrades a request to a stream copy
* when the source codec matches, and a `Copy` plan would answer a different question. A name
* `CodecNames` cannot resolve forces an `Encode` for every codec, which the assertion pins so
* that a planner change cannot quietly turn this sweep into a sweep of `Copy` plans.
*/
private fun engineForVideoEncode(codec: VideoCodec): Engine {
val request = ConversionRequest(
spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE),
probe = InputProbe(videoCodec = "mpeg2video", container = Container.MKV),
)
assertEquals(
"this request no longer plans a video Encode, so its engine says nothing about $codec",
VideoPlan.Encode(codec),
CopyPlanner.plan(request.spec, request.probe).video,
)
return ConversionRouter.route(request, DeviceCodecs.PERMISSIVE).engine
}
/** The audio counterpart. `ac3` is unresolvable for the same reason `mpeg2video` is. */
private fun engineForAudioEncode(codec: AudioCodec): Engine {
val request = ConversionRequest(
spec = OutputSpec(Container.MP4, VideoCodec.NONE, codec),
probe = InputProbe(audioCodec = "ac3", hasVideo = false, container = Container.MKV),
)
assertEquals(
"this request no longer plans an audio Encode, so its engine says nothing about $codec",
AudioPlan.Encode(codec),
CopyPlanner.plan(request.spec, request.probe).audio,
)
return ConversionRouter.route(request, DeviceCodecs.PERMISSIVE).engine
}
private fun everyPlan(): List<Triple<OutputSpec, InputProbe, ConversionPlan>> =
ALL_SPECS.flatMap { spec -> PROBES.map { Triple(spec, it, CopyPlanner.plan(spec, it)) } }
private companion object {
/** Every arm of `videoMimeTypeFor`, including the ones the tests above prove unreachable. */
val EXPECTED_VIDEO_MIME: Map<VideoCodec, String?> = mapOf(
VideoCodec.H264 to MimeTypes.VIDEO_H264,
VideoCodec.H265 to MimeTypes.VIDEO_H265,
VideoCodec.VP8 to null,
VideoCodec.VP9 to null,
VideoCodec.AV1 to null,
// Unreachable, and asserted anyway: the proof lives in another test, and a reader
// deleting these would leave the arms themselves unexercised.
VideoCodec.COPY to null,
VideoCodec.NONE to null,
)
val EXPECTED_AUDIO_MIME: Map<AudioCodec, String?> = mapOf(
AudioCodec.AAC to MimeTypes.AUDIO_AAC,
AudioCodec.OPUS to MimeTypes.AUDIO_OPUS,
AudioCodec.VORBIS to MimeTypes.AUDIO_VORBIS,
AudioCodec.PCM to MimeTypes.AUDIO_RAW,
AudioCodec.MP3 to null,
AudioCodec.FLAC to null,
AudioCodec.COPY to null,
AudioCodec.NONE to null,
)
/** Codecs a user can actually ask to be produced: `COPY` and `NONE` are instructions. */
val REAL_VIDEO_CODECS = VideoCodec.entries - VideoCodec.COPY - VideoCodec.NONE
val REAL_AUDIO_CODECS = AudioCodec.entries - AudioCodec.COPY - AudioCodec.NONE
/** Every output a spec can name — 15 containers by 7 video codecs by 8 audio codecs. */
val ALL_SPECS: List<OutputSpec> = Container.entries.flatMap { container ->
VideoCodec.entries.flatMap { video ->
AudioCodec.entries.map { audio -> OutputSpec(container, video, audio) }
}
}
/** Inputs chosen to reach each of [CopyPlanner]'s branches. */
val PROBES = listOf(
// Nothing known about the source at all.
InputProbe(),
// Identified, and the container changes: the copy upgrade applies.
InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV),
// Identified, container unchanged: the copy upgrade deliberately does not apply.
InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MP4),
// Copyable but not encodable by either engine — the fallback's reason for existing.
InputProbe(videoCodec = "av1", audioCodec = "flac", container = Container.MKV),
// Real codecs this app cannot name, so a copy is never proven safe.
InputProbe(videoCodec = "mpeg2video", audioCodec = "ac3", container = Container.AVI),
// The platform extractor could not open it.
InputProbe(videoCodec = InputProbe.UNPARSEABLE),
// Audio only.
InputProbe(videoCodec = null, audioCodec = "opus", hasVideo = false, container = Container.OGG),
)
}
}
@@ -0,0 +1,91 @@
package org.libremediaconverter.convert
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The image-demuxer rule, which looks arbitrary until it is read as a suffix.
*
* `MediaProbe.classify` asks [MediaProbe.isImageFormat] before anything else, so this one boolean
* overrides everything both probes found: true and the source-info card says "Image" and a size,
* false and it says container, codec and length. Neither mistake fails loudly.
*
* The rule has two halves and they are not the same shape. `image2` is a whole format name —
* FFprobe reports it for a numbered image sequence — while the piped demuxers are named one per
* image codec, so `_pipe` has to be matched as a *suffix*: `png_pipe`, `jpeg_pipe`, `webp_pipe`
* and some thirty more. Widening that suffix to a substring is the tempting simplification and it
* is wrong, because `yuv4mpegpipe` is raw video.
*
* The image names were measured rather than recalled. `ffprobe -show_entries format=format_name`
* reports `png_pipe` for a `.png`, `jpeg_pipe` for a `.jpg`, `yuv4mpegpipe` for a `.y4m`, and
* `image2` only when that demuxer is named explicitly. The container names come from
* [MediaProbeFormatTest], and the case and spacing variants are synthetic — those exercise the
* normalisation rather than anything FFprobe emits.
*
* One real format name is deliberately not asserted either way. `image2pipe` gets a false answer
* here, being neither `image2` nor a `_pipe` suffix, and that is inert rather than a latent bug:
* FFprobe only selects it when the demuxer is named with `-f image2pipe`, while `probeWithFFprobe`
* forces no format at all, so a picked image arrives as `png_pipe` or its own codec's equivalent.
* Pinning today's answer for a name this app cannot receive would be a test about FFmpeg's command
* line rather than about this rule.
*/
class MediaProbeImageFormatTest {
@Test
fun `a numbered image sequence is an image`() {
assertIsImage("image2")
}
/** What a picked PNG or JPEG actually reports, and the reason the suffix rule exists. */
@Test
fun `the per-codec piped demuxers are images`() {
assertIsImage("png_pipe")
assertIsImage("jpeg_pipe")
assertIsImage("webp_pipe")
}
/**
* The half that a substring match would break.
*
* `yuv4mpegpipe` contains `pipe` and is not an image: it is raw uncompressed video, and
* describing it as an image would hide its codec, its size and its length from the card while
* leaving the file perfectly convertible.
*/
@Test
fun `a format that merely contains pipe is not an image`() {
assertNotImage("yuv4mpegpipe")
}
/** The ordinary media containers, which is what the false answer is mostly for. */
@Test
fun `a real container is not an image`() {
assertNotImage("mov,mp4,m4a,3gp,3g2,mj2")
assertNotImage("matroska,webm")
assertNotImage("mp3")
}
/**
* FFprobe names every format sharing the demuxer, so the entry that matters can be anywhere in
* the list — and the padding and case are normalised the same way [MediaProbe.containerFrom]
* normalises them.
*/
@Test
fun `an image entry is found anywhere in the list, whatever its spacing or case`() {
assertIsImage("PNG_PIPE")
assertIsImage(" image2 ")
assertIsImage("something_else, tiff_pipe")
}
/** Nothing to go on is not an image; the card falls back to describing an unknown container. */
@Test
fun `an empty format name is not an image`() {
assertNotImage("")
}
private fun assertIsImage(formatName: String) =
assertTrue("isImageFormat(\"$formatName\")", MediaProbe.isImageFormat(formatName))
private fun assertNotImage(formatName: String) =
assertFalse("isImageFormat(\"$formatName\")", MediaProbe.isImageFormat(formatName))
}
@@ -0,0 +1,108 @@
package org.libremediaconverter.convert
import android.media.MediaFormat
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The MIME -> short codec name table, which nothing downstream would notice going wrong.
*
* `MediaExtractor` answers in platform MIME spellings; the router, the copy planner and the
* source-info card all speak FFmpeg's short names. [MediaProbe.shortName] is the one place those
* two vocabularies meet, and most of its arms are translations rather than trimming — `video/avc`
* is `h264`, `audio/mp4a-latm` is `aac`, `video/x-vnd.on2.vp9` is `vp9`.
*
* So a dropped or mistyped arm does not throw. It falls through to `substringAfter('/')` and
* reports a different, entirely plausible-looking string. `CodecNames` carries alias lists that
* happen to rescue some of those (`avc`, `av01`, `raw`) and not others (`mp4a-latm`,
* `x-vnd.on2.vp9`), which is exactly why leaning on the rescue is not a plan: an unrecognised
* codec is how a stream-copyable file quietly becomes a re-encode, and how the card ends up naming
* a codec no user has heard of. This table is the only place those arms are pinned.
*
* A plain JVM test rather than Robolectric: `MediaFormat.MIMETYPE_*` are Java compile-time String
* constants, so this test and `MediaProbe` alike carry the literals in their own bytecode and the
* framework class is never loaded.
*
* Every case names its MIME in the failure message, because the MIME is the thing that has to be
* looked up when one of these goes red.
*/
class MediaProbeMimeNamesTest {
@Test
fun `an AVC track is reported as h264, which is what everything downstream calls it`() {
assertShortName("h264", MediaFormat.MIMETYPE_VIDEO_AVC)
}
/** On2's vendor MIME looks nothing like the codec name FFmpeg and the router use. */
@Test
fun `the VP8 and VP9 vendor MIMEs are reported without their vendor prefix`() {
assertShortName("vp8", MediaFormat.MIMETYPE_VIDEO_VP8)
assertShortName("vp9", MediaFormat.MIMETYPE_VIDEO_VP9)
}
@Test
fun `AV1 and MPEG-4 are reported by codec name rather than by MIME spelling`() {
assertShortName("av1", MediaFormat.MIMETYPE_VIDEO_AV1)
assertShortName("mpeg4", MediaFormat.MIMETYPE_VIDEO_MPEG4)
}
@Test
fun `an AAC track is reported as aac, not as the mp4a-latm its MIME says`() {
assertShortName("aac", MediaFormat.MIMETYPE_AUDIO_AAC)
}
@Test
fun `uncompressed audio is reported as pcm, which is not what its MIME says either`() {
assertShortName("pcm", MediaFormat.MIMETYPE_AUDIO_RAW)
}
/**
* Four arms produce exactly what the fallback would produce anyway.
*
* `video/hevc` -> `hevc`, `audio/opus` -> `opus`, `audio/flac` -> `flac`,
* `audio/vorbis` -> `vorbis`: for these the `when` arm and `substringAfter('/')` agree, so
* deleting the arm changes no observable behaviour and no test can catch it. That is a
* property of the code rather than a gap here, and it is reported as such rather than dressed
* up as coverage. The assertions still earn their place — they pin the promise the router is
* given (`hevc`, whatever the MIME happens to spell) against a later edit that changes the
* mapping rather than deleting it.
*/
@Test
fun `the arms whose MIME subtype already is the short name still map to it`() {
assertShortName("hevc", MediaFormat.MIMETYPE_VIDEO_HEVC)
assertShortName("opus", MediaFormat.MIMETYPE_AUDIO_OPUS)
assertShortName("flac", MediaFormat.MIMETYPE_AUDIO_FLAC)
assertShortName("vorbis", MediaFormat.MIMETYPE_AUDIO_VORBIS)
}
/**
* The fallback, which is what makes an unlisted codec describable at all.
*
* These are real `MediaFormat` MIMEs with no arm of their own. Dropping the subtype is the
* right guess far more often than reporting the whole MIME would be — FFprobe calls the first
* of these `ac3` too.
*/
@Test
fun `a MIME with no arm of its own falls back to its subtype`() {
assertShortName("ac3", MediaFormat.MIMETYPE_AUDIO_AC3)
assertShortName("mpeg2", MediaFormat.MIMETYPE_VIDEO_MPEG2)
assertShortName("dolby-vision", MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION)
}
/**
* The surprising half of `substringAfter`'s contract, pinned deliberately.
*
* With no `/` in the string it returns the whole input rather than the empty string. Today's
* callers gate on a `video/` or `audio/` prefix so they cannot reach this, but "report what
* you were given" rather than "report nothing" is what would keep a malformed MIME visible on
* the card instead of blank.
*/
@Test
fun `a MIME with no subtype separator is reported unchanged`() {
assertShortName("weird", "weird")
assertShortName("", "")
}
private fun assertShortName(expected: String, mime: String) =
assertEquals("shortName(\"$mime\")", expected, MediaProbe.shortName(mime))
}
@@ -0,0 +1,79 @@
package org.libremediaconverter.convert
import android.media.MediaFormat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Reading Int track properties out of a `MediaFormat`, which is a heterogeneous map.
*
* [MediaProbe.intOr] guards two different failures with one expression, and only one of them is
* obvious. A key the format does not carry is the easy half. The other is a key it *does* carry
* with a value of another type: `getInteger` casts rather than coerces, so a frame rate stored as
* a Float answers with a `ClassCastException`. `probeForConcat` reads `KEY_FRAME_RATE`, which the
* platform accepts either way, and its `catch` sits outside the track loop — so without the
* `runCatching` one oddly-typed field would discard the codec and dimensions already read from
* that file and the join would re-encode for no reason.
*
* Robolectric rather than a plain JVM test, unlike the two sibling `MediaProbe` helper tests: this
* one needs a real `MediaFormat` instance, not just its compile-time String constants.
*/
@RunWith(RobolectricTestRunner::class)
class MediaProbeTrackFieldsTest {
@Test
fun `a property the format carries as an Int is read`() {
val format = videoFormat()
assertEquals(1920, with(MediaProbe) { format.intOr(MediaFormat.KEY_WIDTH) })
assertEquals(1080, with(MediaProbe) { format.intOr(MediaFormat.KEY_HEIGHT) })
}
/**
* A track that simply does not say. `MediaExtractor` omits `KEY_FRAME_RATE` for plenty of real
* files, and 0 is what `ConcatPlanner` reads as "cannot prove a match".
*/
@Test
fun `a key the format does not carry gives the fallback`() {
val format = videoFormat()
assertEquals(0, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE) })
assertEquals(-1, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE, -1) })
}
/**
* The premise of the `runCatching`, pinned against the platform rather than assumed.
*
* If `getInteger` coerced a Float instead of throwing, the guard below would be testing
* nothing at all — so the throw is asserted directly first.
*/
@Test
fun `getInteger refuses a Float rather than coercing it`() {
val format = videoFormat()
format.setFloat(MediaFormat.KEY_FRAME_RATE, NON_INTEGRAL_FRAME_RATE)
val thrown = runCatching { format.getInteger(MediaFormat.KEY_FRAME_RATE) }.exceptionOrNull()
assertTrue("expected getInteger to refuse a Float, got $thrown", thrown is ClassCastException)
}
/** And that refusal is answered with the fallback, not passed on to the caller. */
@Test
fun `a frame rate the format carries as a Float gives the fallback rather than throwing`() {
val format = videoFormat()
format.setFloat(MediaFormat.KEY_FRAME_RATE, NON_INTEGRAL_FRAME_RATE)
assertEquals(0, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE) })
assertEquals(-1, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE, -1) })
}
private fun videoFormat(): MediaFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 1920, 1080)
private companion object {
/** NTSC's 30000/1001, the frame rate that cannot be stored as an Int in the first place. */
const val NON_INTEGRAL_FRAME_RATE = 29.97f
}
}
@@ -0,0 +1,107 @@
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.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.InputProbe
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import java.io.ByteArrayOutputStream
import java.io.File
/**
* What the user is told when the file went away between being offered and being saved.
*
* Not a corner: staging is `cacheDir`, which is what the OS empties when it wants space, and the
* sweep collects anything a day old. Reattachment is where the two are furthest apart — the check
* that decided the file existed ran inside a tag query on launch, and the Save button may not be
* tapped for hours.
*
* The real [OutputPublisher] rather than the recording stub, because the defect is what the *real*
* publish does with a staged file that is not there: `staged.inputStream()` throws, and `save()`
* put `e.message` on screen — a `/data/user/0/…/4b4882….mp4: open failed: ENOENT` path the user has
* never seen and can do nothing with.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class MissingStagedFileTest {
private lateinit var app: Application
private lateinit var publisher: OutputPublisher
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = OutputPublisher(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,
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
),
)
// A destination that really opens, so the save gets far enough to reach the staged file.
// Without this the failure would be about the destination and the test would pass while
// saying nothing.
shadowOf(app.contentResolver).registerOutputStreamSupplier(DESTINATION) { ByteArrayOutputStream() }
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `saving a conversion whose staged file has gone says so in a sentence`() {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.parse("content://test/holiday.mp4"))
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
viewModel.convert()
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
assertTrue("the fixture must start with a real staged file", staged.delete())
viewModel.save(DESTINATION)
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed }
assertEquals(STAGED_FILE_GONE_MESSAGE, (failed as ConversionState.Failed).message)
}
@Test
fun `saving a join whose staged file has gone says so in a sentence`() {
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 }
assertTrue("the fixture must start with a real staged file", staged.delete())
viewModel.save(DESTINATION)
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed }
assertEquals(STAGED_FILE_GONE_MESSAGE, (failed as JoinState.Failed).message)
}
private companion object {
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
}
}
@@ -265,10 +265,34 @@ class OutputPublisherPublishTest {
)
}
@Test
fun `a destination the provider will not open does not stay behind as an empty file`() {
// No stream supplier is registered for this URI and the fake provider does not implement
// openFile, which is a provider that has gone away between the picker and the write.
//
// The document exists all the same: SAF's CreateDocument contract created it before
// publish() was ever called, so "nothing has been written yet" was never the same claim as
// "there is nothing of ours here". Leaving it means a zero-byte file at the name the user
// chose, while the screen says the save failed.
val destination = FakeSafProvider.backingFile(documentUri)
assertEquals("the fixture starts as the empty document SAF hands back", 0L, destination.length())
val failure = runCatching { publisher.publish(staged, documentUri) }.exceptionOrNull()
assertTrue("a destination that will not open must not appear to succeed, got $failure", failure != null)
assertEquals(listOf(documentUri), FakeSafProvider.deleteRequests)
assertFalse(
"a zero-byte file must not be left at the name the user picked",
destination.exists(),
)
}
@Test
fun `a destination that cannot be opened at all fails without any cleanup`() {
// The JVM twin of UnopenableUriTest's unwritable-destination case. Nothing was
// written, so there is nothing of ours to remove.
// The JVM twin of UnopenableUriTest's unwritable-destination case. The open sits inside
// the guarded region now, so what keeps this one untouched is the guard rather than the
// placement: nothing answers for that authority, so no size can be read, and "I could not
// tell" must never authorise a delete.
val failure = runCatching { publisher.publish(staged, deadUri) }.exceptionOrNull()
assertTrue("publishing to a dead provider must not appear to succeed, got $failure", failure != null)
@@ -18,8 +18,10 @@ import java.util.UUID
* the actual filesystem — the same calls `reset()` makes, without needing a ViewModel (both
* of those construct a `WorkManager`, which is not initialised on the JVM classpath).
*
* The instrumented suite cannot run on the development host, so this is the only place the
* "Start over leaks a full-size copy" defect can be caught before CI.
* The instrumented suite could also catch the "Start over leaks a full-size copy" defect --
* it runs on this host for API 33-36 (`tools/local-emulator/run-e2e.sh`) and on CI for
* 33-37. Here rather than there because a real `cacheDir` is all the defect needs, and
* finding it costs an emulator boot there and a few seconds here.
*/
@RunWith(RobolectricTestRunner::class)
class OutputPublisherStagingTest {
@@ -0,0 +1,214 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import android.os.Looper
import android.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.work.Configuration
import androidx.work.WorkManager
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
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 org.robolectric.Shadows.shadowOf
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
/**
* The two decisions `reattach()` makes that [org.libremediaconverter.work.Reattachment] cannot.
*
* `Reattachment.choose` answers "which job", and twenty tests pin it. What it does not decide is
* whether the answer may still be used by the time it arrives, or how much of it the card is
* allowed to believe — and both of those live in the ViewModel, where nothing was asserting them.
* Deleting either guard left the whole suite green.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ReattachGuardsTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var workManager: WorkManager
private lateinit var staged: File
private lateinit var queries: HoldableTaskExecutor
@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)) }
queries = HoldableTaskExecutor()
WorkManagerTestInitHelper.initializeTestWorkManager(
app,
Configuration.Builder()
.setMinimumLoggingLevel(Log.ASSERT)
.setExecutor(SynchronousExecutor())
.setTaskExecutor(queries)
.setWorkerFactory(
SucceedingWorkerFactory(workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath)),
)
.build(),
)
workManager = WorkManager.getInstance(app)
}
@After
fun tearDown() {
queries.release()
ConversionDependencies.reset()
}
/**
* The race the guard exists for: the tag query suspends, and while it is away the user picks a
* file of their own. Reattaching over that would throw away what they just did — and, worse,
* point the Save button at yesterday's file while the card named today's.
*
* Made deterministic by holding WorkManager's task executor rather than by hoping the pick wins:
* the query cannot complete until this test lets it, so the pick has landed before the guard is
* ever reached.
*/
@Test
fun `a file picked while the query was in flight is not reattached over`() {
finishAConversionWithNobodyWatching()
val picked = File(app.cacheDir, "beach.mp4").apply { writeBytes(ByteArray(2048)) }
queries.hold()
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.fromFile(picked))
val ready = awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
// The URI, because that is what tells the two inputs apart: a reattached job's is
// Uri.EMPTY -- WorkManager never hands back the Data a request was enqueued with -- while a
// picked file's is the one the picker returned.
assertEquals(Uri.fromFile(picked), (ready as ConversionState.Ready).input.uri)
queries.release()
settle()
// The query really did run and really did reach the guard -- without this the assertion
// below would pass just as well against a reattachment that never arrived.
assertTrue("the reattach query should have been held, then run", queries.heldTasks > 0)
val current = viewModel.state.value
assertTrue("the user's pick must survive a late reattachment, got $current", current is ConversionState.Ready)
assertEquals(Uri.fromFile(picked), (current as ConversionState.Ready).input.uri)
assertEquals(2_048L, current.input.sizeBytes)
}
/**
* Two finished jobs naming one staged file, which is exactly what the device produced before
* staging was keyed on the job id.
*
* The file is the user's either way, so it is still offered. Which job wrote it is not
* knowable, so the card must not borrow either job's input name: a card labelled with the other
* conversion's file is a confident lie, where a neutral label is merely thin.
*/
@Test
fun `a result two jobs both claim is offered without being attributed to either`() {
finishAConversionWithNobodyWatching(displayName = "holiday.mp4")
finishAConversionWithNobodyWatching(displayName = "beach.mp4")
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
val converted = awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
converted as ConversionState.Converted
assertEquals(
"the bytes on disk are what the user gets back",
staged.absolutePath,
converted.staged.absolutePath,
)
assertEquals(
"neither job's name may be claimed for the other's file",
"Media file",
converted.input.displayName,
)
// The size travels in the same tags as the name, so it goes the same way rather than being
// reported as one job's number against the other job's file.
assertEquals(null, converted.input.sizeBytes)
}
/** Pumps the main looper for long enough that anything already dispatched has run. */
private fun settle() {
repeat(SETTLE_PUMPS) {
shadowOf(Looper.getMainLooper()).idle()
Thread.sleep(SETTLE_INTERVAL_MS)
}
}
private fun finishAConversionWithNobodyWatching(displayName: String = "holiday.mp4") {
workManager.enqueue(
ConversionWorker.request(
inputUri = Uri.parse("content://test/$displayName"),
displayName = displayName,
sizeBytes = 4_096L,
),
).result.get()
}
private companion object {
const val SETTLE_PUMPS = 60
const val SETTLE_INTERVAL_MS = 5L
}
}
/**
* WorkManager's task executor, with a brake the test can apply.
*
* The reattachment query is a suspending call the ViewModel makes in `init`, so a test that wants
* to act "while it is in flight" has to be able to stop it finishing. Holding the executor it runs
* on is the only seam for that: `jobSnapshots` takes no dispatcher, and racing it would make the
* assertion depend on which of two IO hops happened to return first.
*
* Never applied on the main thread. The test releases the brake from there, so a wait taken on that
* thread would deadlock the loop that was going to end it. The wait is bounded for the same class of
* reason: a wiring mistake should turn the test red, not hang the build.
*/
private class HoldableTaskExecutor : Executor {
private val released = CountDownLatch(1)
@Volatile
private var holding = false
/** How many tasks were actually held. Zero means the brake never gripped anything. */
@Volatile
var heldTasks = 0
private set
fun hold() {
holding = true
}
fun release() {
holding = false
released.countDown()
}
override fun execute(command: Runnable) {
if (holding && Looper.myLooper() != Looper.getMainLooper()) {
heldTasks++
check(released.await(HOLD_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
"a held WorkManager task was never released"
}
}
command.run()
}
private companion object {
const val HOLD_TIMEOUT_SECONDS = 10L
}
}
@@ -0,0 +1,79 @@
package org.libremediaconverter.convert
import android.content.Context
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.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* The two sums the space check is made of, at the sizes where addition stops working.
*
* `SpaceCheckTest` pins which *question* each worker asks; this pins what the answer is once the
* number is large. Both halves were live on main: `hasSpaceFor` added the headroom to the request
* before comparing, and [InputQuery.total] folded a join's inputs with nothing stopping the sum
* from wrapping. A wrapped total is not merely nonsense — it is negative, and every free-space
* measurement beats a negative number, so the check that exists to refuse impossible jobs approved
* the most impossible one it can be handed.
*
* Nothing here is about the *allocatable-versus-usable* question, which is a separate decision
* still parked. This is the arithmetic on whichever number that decision ends up producing.
*/
@RunWith(RobolectricTestRunner::class)
class SpaceArithmeticTest {
private lateinit var context: Context
private lateinit var publisher: OutputPublisher
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
publisher = OutputPublisher(context)
}
@Test
fun `a request no disk could hold is refused rather than wrapping into plenty of room`() {
assertFalse("eight exabytes do not fit anywhere", publisher.hasSpaceFor(Long.MAX_VALUE))
// Just inside the headroom of the maximum, which is the arithmetic's actual edge: this is
// the range where `bytes + headroom` goes negative while `bytes` alone still looks huge.
assertFalse(publisher.hasSpaceFor(Long.MAX_VALUE - ONE_HUNDRED_MIB))
}
// The clamp on a negative size is deliberately NOT asserted here. It only changes the answer
// when free space is below the headroom, which this test cannot arrange -- the publisher reads
// the host's real cache volume -- so any assertion available would pass against the unclamped
// arithmetic too, and a test that cannot fail is worse than the gap it appears to close.
@Test
fun `an ordinary request is still allowed, so the refusals above are not vacuous`() {
val free = File(context.cacheDir, "conversions").usableSpace
assertTrue(
"a one-byte conversion must fit; the volume under the cache reports $free bytes free",
publisher.hasSpaceFor(1L),
)
}
@Test
fun `a join total too large to represent saturates instead of turning negative`() {
val enormous = listOf(FOUR_EXABYTES, FOUR_EXABYTES, FOUR_EXABYTES)
val total = InputQuery.total(enormous)
assertEquals(Long.MAX_VALUE, total)
// The whole point, in the shape the defect had: this total is handed straight to the space
// check by ConcatWorker, and before the clamp it arrived negative and was approved.
assertFalse("a join of three four-exabyte files does not fit", publisher.hasSpaceFor(total!!))
}
private companion object {
const val ONE_HUNDRED_MIB = 100L * 1024 * 1024
/** Big enough that three of them overflow, small enough to be a plausible `statSize`. */
const val FOUR_EXABYTES = 4_000_000_000_000_000_000L
}
}
@@ -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,53 @@
package org.libremediaconverter.join
import android.net.Uri
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.media3.common.util.UnstableApi
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* The join screen's one leaf renders, and tags itself with the file it is showing.
*
* `FileRow` is the only place on either screen where the same leaf is rendered more than once at a
* time -- one row per picked input -- so it is the only tag that cannot be a constant. It is
* derived from `displayName`, inside `FileRow` itself, and that is the part worth a test: a row
* that took its tag from the call site would let R38.7 pass a tag in and assert nothing, which is
* the vacuous shape `CLAUDE.md` records nine of in one review.
*
* Two rows are rendered here rather than one, because a tag derived from the wrong thing -- a
* constant, an index the row does not have -- would still resolve to one node with a single input
* on screen.
*
* Deliberately not the state matrix: which affordances each `JoinState` renders is R38.7.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JoinLeafTagsTest {
@get:Rule
val composeRule = createComposeRule()
private fun input(displayName: String) = InputFile(
uri = Uri.parse("content://test/$displayName"),
displayName = displayName,
sizeBytes = 4_000_000L,
)
@Test
fun `each file row is tagged with the name it displays`() {
composeRule.setContent {
FileRow(input("first.mp4"))
FileRow(input("second.mp4"))
}
composeRule.onAllNodesWithTag(TestTags.Join.fileRow("first.mp4")).assertCountEquals(1)
composeRule.onAllNodesWithTag(TestTags.Join.fileRow("second.mp4")).assertCountEquals(1)
}
}
@@ -0,0 +1,82 @@
package org.libremediaconverter.join
import androidx.compose.ui.test.junit4.v2.createComposeRule
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 org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
import java.io.File
/**
* The join screen's half of the same seam, and the same two directions.
*
* The defect is the one `ConverterScreenContentTest` describes -- a content composable that
* ignores the state handed to it, or renders the finished job's affordances unwired -- and it has
* to be asked separately here because the two screens share no code. `JoinScreen` and
* `ConverterScreen` were extracted in the same commit by the same hand, which is exactly the
* circumstance in which one of them gets the wiring right and the other does not.
*
* `JoinState.Joined` is unreachable through a real `JoinViewModel` for the same reason
* `ConversionState.Converted` is: only a `ConcatWorker` run that has already succeeded produces
* one, carrying the strategy it chose and the name it picked.
*
* `JoinScreenKt` is the honest remaining coverage gap on this repo, and closing it is R38.7 (#63),
* not this file. Which affordances each `JoinState` renders belongs there; this asserts only that
* the injection point exists.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JoinScreenContentTest {
@get:Rule
val composeRule = createComposeRule()
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
private val savedAs = mutableListOf<String>()
@Test
fun `a finished join renders the save button`() {
setContent(joined())
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
}
@Test
fun `tapping save hands back the name the finished join chose`() {
setContent(joined())
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
assertEquals(listOf("joined.mp4"), savedAs)
}
/** `staged` names a missing file deliberately -- see the same helper on the converter side. */
private fun joined() = JoinState.Joined(
staged = File("no-such-staged-output.mp4"),
strategy = ConcatStrategy.STREAM_COPY,
suggestedName = "joined.mp4",
mimeType = "video/mp4",
)
private fun setContent(state: JoinState) {
composeRule.setContent {
JoinScreenContent(
state = state,
actions = JoinActions(
onPickInputs = {},
onJoin = {},
onCancel = {},
onSave = { suggestedName -> savedAs += suggestedName },
onReset = {},
),
)
}
}
}
@@ -0,0 +1,329 @@
package org.libremediaconverter.join
import android.net.Uri
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertRangeInfoEquals
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
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
import java.io.File
/**
* Every `JoinState` renders its own affordances, wired to its own callback.
*
* The defect is a branch of `JoinScreenContent`'s `when` that reads the wrong thing: a count taken
* from a literal rather than from `inputs`, a strategy line that describes the other strategy, a
* button wired to the neighbouring branch's callback, a `Failed` that drops the message it carries.
* None of that is visible at compile time -- every branch of the `when` type-checks against the
* same `JoinScreenContent` signature -- and none of it is visible from the leaf tests either, which
* compose `FileRow` on its own and never see a state.
*
* `JoinScreenContentTest` deliberately asks only whether the seam exists, using `Joined`. This is
* the matrix behind it: seven states, each pinned to what it lets the user do next.
*
* ### Two assertions here that nothing else in the suite makes
*
* **Order.** A join is the one flow where the order of the inputs is the content of the output --
* the empty state promises "in the order you want them" -- so the rows are read back sorted by
* their position on screen and compared as a list, not as a set. `JoinLeafTagsTest` proves a row
* tags itself with the file it shows; nothing proved the rows come out in the order they went in.
*
* **Indeterminate.** The join progress bar carries no percentage, on purpose: FFmpeg reports
* progress against one input's duration, which means nothing across a concatenation. The converter
* screen's bar is determinate, so "it has a progress bar" is the assertion that would not notice a
* fabricated percentage arriving here.
*
* ### Not asserted here, deliberately
*
* `JoinState.Joined.mimeType` is not rendered by this composable at all -- it is read by the entry
* point, to open the save dialog with a type that matches the finished job. The colour of the
* `Failed` message is `MaterialTheme.colorScheme.error`, which is theme lookup rather than state
* logic, so it is left to the eye. The `is JoinState.Idle -> Unit` arm inside the scrolling branch
* is unreachable by construction: the outer `when` peels `Idle` off first.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JoinStateAffordancesTest {
@get:Rule
val composeRule = createComposeRule()
/** Which callback the screen invoked, in order, with what it passed. Empty until one fires. */
private val events = mutableListOf<String>()
@Test
fun `the empty state asks for files in order and offers the picker`() {
setContent(JoinState.Idle)
composeRule.onNodeWithText("Pick two or more files to join, in the order you want them.").assertExists()
// No `performScrollTo` on this one: `Idle` is the centred branch, outside the scrolling
// column every other state renders into, so there is nothing to scroll.
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).performClick()
assertEquals(listOf("pickInputs"), events)
}
/**
* The rows come out in the order the inputs went in.
*
* Sorted by position rather than trusting the order `fetchSemanticsNodes` happens to return, so
* the assertion is about what the user sees down the screen. Three inputs, with names whose
* alphabetical order is not their picked order, so a list that had been sorted anywhere on the
* way through would not be able to pass this.
*/
@Test
fun `the picked inputs are listed in the order they were picked`() {
val picked = listOf("intro.mp4", "middle.mp4", "outro.mp4")
setContent(JoinState.Ready(inputs = picked.map(::input)))
val topToBottom = composeRule.onAllNodes(isFileRow)
.fetchSemanticsNodes()
.sortedBy { it.positionInRoot.y }
.map { it.config[SemanticsProperties.TestTag] }
assertEquals(picked.map(TestTags.Join::fileRow), topToBottom)
}
/**
* Three inputs, not two: two is the minimum a join accepts, so a button that had been
* hardcoded to the smallest legal join would still read correctly with two on screen.
*/
@Test
fun `the join button counts the files it will join`() {
setContent(JoinState.Ready(inputs = listOf(input("intro.mp4"), input("middle.mp4"), input("outro.mp4"))))
composeRule.onNodeWithTag(TestTags.Join.JOIN).assertTextEquals("Join 3 files")
composeRule.onNodeWithTag(TestTags.Join.JOIN).performScrollTo().performClick()
assertEquals(listOf("join"), events)
}
/** `Ready` is the one working state that still offers the picker, to replace the selection. */
@Test
fun `a ready join can be repicked`() {
setContent(JoinState.Ready(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_DIFFERENT_FILES).performScrollTo().performClick()
assertEquals(listOf("pickInputs"), events)
}
@Test
fun `a running join names the count and shows a bar with no percentage`() {
setContent(JoinState.Joining(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
composeRule.onNodeWithText("Joining 2 files…").assertExists()
composeRule.onNodeWithTag(TestTags.Join.PROGRESS).assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
assertEquals(listOf("cancel"), events)
}
/**
* The paragraph is byte-identical to the converter screen's, which is the point of asserting
* the whole of it rather than a fragment: the two branches were worded together, and a reword
* that lands on one screen only is the failure this notices.
*/
@Test
fun `a paused join explains itself and still offers cancel`() {
setContent(JoinState.Waiting(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
composeRule.onNodeWithText(PAUSED_PARAGRAPH).assertExists()
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
assertEquals(listOf("cancel"), events)
}
@Test
fun `a stream copied join says nothing was re-encoded`() {
setContent(joined(ConcatStrategy.STREAM_COPY))
composeRule.onNodeWithText(STREAM_COPY_EXPLANATION).assertExists()
composeRule.onNodeWithText(REENCODE_EXPLANATION).assertDoesNotExist()
}
/**
* The other half of the pair. Asserting the absence of the stream-copy line as well, because a
* branch that had collapsed to one answer would still render *an* explanation.
*/
@Test
fun `a re-encoded join says the files differed`() {
setContent(joined(ConcatStrategy.REENCODE))
composeRule.onNodeWithText(REENCODE_EXPLANATION).assertExists()
composeRule.onNodeWithText(STREAM_COPY_EXPLANATION).assertDoesNotExist()
}
/** The size comes from the staged file, which is missing here, so `length()` answers `0L`. */
@Test
fun `a finished join reports the size of what it produced`() {
setContent(joined(ConcatStrategy.STREAM_COPY))
composeRule.onNodeWithText("Joined — 0 MB.").assertExists()
}
@Test
fun `a finished join offers save and start over, and they are not the same button`() {
setContent(joined(ConcatStrategy.STREAM_COPY))
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
assertEquals(listOf("save:joined.mp4", "reset"), events)
}
@Test
fun `a saved join names the file and offers to join more`() {
setContent(JoinState.Saved(displayName = "holiday-joined.mp4"))
composeRule.onNodeWithText("Saved holiday-joined.mp4.").assertExists()
composeRule.onNodeWithTag(TestTags.Join.JOIN_MORE).assertTextEquals("Join more")
composeRule.onNodeWithTag(TestTags.Join.JOIN_MORE).performScrollTo().performClick()
assertEquals(listOf("reset"), events)
}
/**
* The message is the whole content of this state -- it is the only thing that says why the job
* stopped -- and it arrives as a string the failure produced, so a branch that rendered a fixed
* apology instead would look correct on screen.
*/
@Test
fun `a failed join renders the message it carries`() {
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
composeRule.onNodeWithText("The second file has no audio track, so joining stopped.").assertExists()
}
@Test
fun `a failed join offers start over`() {
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
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
}
private fun input(displayName: String) = InputFile(
uri = Uri.parse("content://test/$displayName"),
displayName = displayName,
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"),
strategy = strategy,
suggestedName = "joined.mp4",
mimeType = "video/mp4",
)
private fun setContent(state: JoinState) {
composeRule.setContent {
JoinScreenContent(
state = state,
actions = JoinActions(
onPickInputs = { events += "pickInputs" },
onJoin = { events += "join" },
onCancel = { events += "cancel" },
onSave = { suggestedName -> events += "save:$suggestedName" },
onReset = { events += "reset" },
),
)
}
}
private companion object {
/** Byte-identical to the converter screen's, and split the same way `main` splits it. */
const val PAUSED_PARAGRAPH =
"Paused. Android limits background media processing, so this will " +
"resume automatically — keeping the app open helps it along."
const val STREAM_COPY_EXPLANATION =
"Files matched, so they were joined without " +
"re-encoding — no quality loss."
const val REENCODE_EXPLANATION =
"Files differed in format, so they were re-encoded " +
"to match."
}
}
@@ -1,6 +1,7 @@
package org.libremediaconverter.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
@@ -10,6 +11,16 @@ import org.junit.Test
* Three vocabularies meet: `MediaExtractor` MIME types, FFprobe `codec_name` strings, and the
* enums. Stream copy depends on the round trip, so a missing alias here shows up as "we could not
* identify the source codec" and silently costs the user a re-encode.
*
* Also bites on #74: `describeVideo` and `describeAudio` are one function apiece over one
* vocabulary and had stopped matching. Only the video side special-cased
* [InputProbe.UNPARSEABLE]; the audio side fell through to the raw name, and that sentinel opens
* with a NUL, so the source-info card would have rendered a control character. The arms are shared
* now, and the tests below assert both sides so the symmetric bug cannot reappear on the other one.
*
* The tables these read are cross-checked against the device capability check by
* `CodecVocabularyTest` (#87). Deliberately not repeated here: this file is what each name means,
* that one is whether the app's two copies of the vocabulary still agree.
*/
class CodecNamesTest {
@@ -48,4 +59,52 @@ class CodecNamesTest {
// An unrecognised but real codec name is more useful shown than hidden.
assertEquals("cinepak", CodecNames.describeVideo("cinepak"))
}
/** The audio row of the same card, which had none of the above. */
@Test
fun `audio descriptions degrade exactly the way video ones do`() {
assertEquals("AAC", CodecNames.describeAudio("mp4a"))
assertEquals("Unknown", CodecNames.describeAudio(null))
assertEquals("Unrecognised", CodecNames.describeAudio(InputProbe.UNPARSEABLE))
assertEquals("qdm2", CodecNames.describeAudio("qdm2"))
}
/**
* #74's actual failure mode, stated as the thing the user would have seen.
*
* `InputProbe.UNPARSEABLE` is `"\u0000unparseable"`. Falling through to `?: name` does not
* mislabel the track, it puts U+0000 into a `Text`.
*/
@Test
fun `no description can put a control character on the card`() {
listOf(CodecNames.describeAudio(InputProbe.UNPARSEABLE), CodecNames.describeVideo(InputProbe.UNPARSEABLE))
.forEach { assertFalse("$it leaks the sentinel", it.contains('\u0000')) }
}
/**
* Every alias, pinned one at a time.
*
* The tables became maps so `CodecVocabularyTest` could enumerate them; this is what catches a
* key mistyped or a value pointing at the wrong enum while that rewrite happened.
*/
@Test
fun `every name in the tables resolves to the codec it spells`() {
CodecNames.VIDEO_ALIASES.forEach { (name, codec) ->
assertEquals(name, codec, CodecNames.videoFromName(name))
}
CodecNames.AUDIO_ALIASES.forEach { (name, codec) ->
assertEquals(name, codec, CodecNames.audioFromName(name))
}
assertEquals(VideoCodec.H264, CodecNames.videoFromName("x264"))
assertEquals(VideoCodec.VP9, CodecNames.videoFromName("vp09"))
assertEquals(AudioCodec.MP3, CodecNames.audioFromName("mpga"))
assertEquals(AudioCodec.OPUS, CodecNames.audioFromName("opus"))
}
/** The audio lookup reads the sentinel the same way the video one does. */
@Test
fun `the unparseable sentinel resolves to nothing on the audio side too`() {
assertNull(CodecNames.audioFromName(InputProbe.UNPARSEABLE))
assertNull(CodecNames.audioFromName(null))
}
}
@@ -20,6 +20,21 @@ 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,
)
// --- copy and encode are different questions ----------------------------
/**
@@ -85,17 +100,27 @@ class ContainerCapabilitiesTest {
/** A suggestion that is itself invalid is worse than no suggestion. */
@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,
)
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", invalid.suggestions.isNotEmpty())
invalid.suggestions.forEach { suggestion ->
assertTrue(
"suggested $suggestion for $spec is itself invalid",
ContainerCapabilities.validate(suggestion, probe).isValid,
)
}
}
}
@@ -127,6 +152,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)
@@ -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(
@@ -0,0 +1,40 @@
package org.libremediaconverter.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* No two entries of [TestTags] may share a value.
*
* A duplicated value is the one mistake this table invites -- the constants are added in blocks of
* near-identical lines, and a copy-paste that keeps the old string still compiles, still reads
* correctly at the call site, and still passes every test in the file that placed it. It surfaces
* later, in someone else's PR, as an affordance that "resolves to exactly one node" finding two,
* with nothing in that diff to explain it.
*
* Read by reflection rather than from a hand-written list, because a hand-written list would be a
* second copy of the table with the same copy-paste failure in it.
*/
class TagTableUniquenessTest {
private fun tagsIn(vararg holders: Class<*>): List<String> = holders.flatMap { holder ->
holder.declaredFields
.filter { it.type == String::class.java }
.map { it.get(null) as String }
}
@Test
fun `every tag constant has its own value`() {
val tags = tagsIn(
TestTags::class.java,
TestTags.Converter::class.java,
TestTags.Join::class.java,
)
// Without this the check would pass on an empty list, which is what a reflection call
// that stopped finding the constants would hand it.
assertTrue("reflection found only ${tags.size} tag constants, so it is not reading the table", tags.size > 20)
assertEquals(emptyList<String>(), tags.groupBy { it }.filterValues { it.size > 1 }.keys.toList())
}
}
@@ -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)
}
}
@@ -2,7 +2,9 @@ package org.libremediaconverter.work
import android.app.ForegroundServiceStartNotAllowedException
import androidx.work.WorkInfo
import androidx.work.WorkRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -144,8 +146,48 @@ class FailureOutcomeTest {
)
}
@Test
fun `the attempt bound outlasts a night rather than being a round number`() {
// The KDoc argues the value rather than picking one: ten attempts against WorkManager's
// default backoff span about eight and a half hours, which is what turns "the user will
// have opened the app before this gives up" into a claim instead of a hope.
//
// Asserted as that span rather than as the literal 10, so a deliberate re-tune keeping the
// property passes while an accidental one fails. The accident is not hypothetical: at 2 the
// job gives up about ninety seconds after process death -- losing exactly the long
// conversion the retry exists to protect -- and every other test in this file still passes,
// because they are all written against the constant rather than against its value.
var delay = WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS
var span = 0L
repeat(FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS) {
span += delay
// Doubling per attempt, clamped, exactly as WorkManager schedules it. Coerced each
// time round so the arithmetic cannot overflow whatever the bound is set to.
delay = (delay * 2).coerceAtMost(WorkRequest.MAX_BACKOFF_MILLIS)
}
assertTrue(
"the retry budget must outlast a night; it spans ${span / MILLIS_PER_HOUR.toDouble()} hours",
span >= MINIMUM_RETRY_SPAN_MS,
)
}
private fun denied() = ForegroundServiceStartNotAllowedException(
"startForegroundService() not allowed: service " +
"org.libremediaconverter/androidx.work.impl.foreground.SystemForegroundService",
)
private companion object {
const val MILLIS_PER_HOUR = 60L * 60 * 1000
/**
* How long the denied-start retries have to keep going.
*
* Eight hours rather than the eight and a half the default backoff actually produces. The
* claim is about covering a night between one opening of the app and the next; pinning the
* exact arithmetic would instead fail on a WorkManager release that re-tuned its own
* constants without anything this app decides having changed.
*/
const val MINIMUM_RETRY_SPAN_MS = 8L * MILLIS_PER_HOUR
}
}
@@ -0,0 +1,183 @@
package org.libremediaconverter.work
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.work.Configuration
import androidx.work.ListenableWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import kotlinx.coroutines.runBlocking
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.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* The edge that feeds the reattachment decision.
*
* [Reattachment.choose] is a pure function with twenty tests, and every input it reasons over is
* computed here — by the one part of reattachment that has to touch WorkManager and the
* filesystem. That asymmetry was the gap: the rule was pinned exhaustively while the values it
* ran on were pinned nowhere, so a regression in this file left the whole suite green. Two
* demonstrated ones: dropping the empty-file filter offered a zero-byte staged file as a savable
* result, and hardcoding [JobSnapshot.outputModifiedAt] to zero starved the newest-file tie-break
* of the only data it has.
*
* A real `WorkManager` and a real `cacheDir`, because both are what the code under test is for.
* The worker never runs: [EchoingWorkerFactory] stands in for a job that finished in a process
* that no longer exists, which is the only way a snapshot with an output path comes to exist at
* all.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class JobSnapshotsTest {
private lateinit var app: Application
private lateinit var workManager: WorkManager
private lateinit var stagingDir: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
WorkManagerTestInitHelper.initializeTestWorkManager(
app,
Configuration.Builder()
.setMinimumLoggingLevel(Log.ASSERT)
.setExecutor(SynchronousExecutor())
.setTaskExecutor(SynchronousExecutor())
.setWorkerFactory(EchoingWorkerFactory)
.build(),
)
workManager = WorkManager.getInstance(app)
stagingDir = File(app.cacheDir, "conversions").apply { mkdirs() }
stagingDir.listFiles()?.forEach { it.delete() }
}
@Test
fun `a staged file with nothing in it is not an output`() {
// Zero bytes is what a job killed before its engine wrote anything leaves behind. Treating
// it as a result would publish it: the user taps Save and gets a zero-byte "conversion"
// rather than a message, which is worse than not being offered it.
val empty = stagedFile("empty.mp4", bytes = 0)
val real = stagedFile("real.mp4", bytes = 4096)
// Never created at all -- the OS reclaimed the cache, or the file was saved and deleted.
val reclaimed = File(stagingDir, "reclaimed.mp4")
listOf(empty, real, reclaimed).forEach(::finishedWithOutput)
val snapshots = snapshots()
assertEquals(
"only a file with bytes in it is a result",
mapOf(
empty.absolutePath to false,
real.absolutePath to true,
reclaimed.absolutePath to false,
),
snapshots.associate { it.outputPath to it.outputExists },
)
// The path is still reported for all three. It is what the worker said; whether it still
// names anything is the separate question above.
assertEquals(
setOf(empty.absolutePath, real.absolutePath, reclaimed.absolutePath),
snapshots.mapNotNull { it.outputPath }.toSet(),
)
// And a file that is not an output has no time either: an mtime read off a zero-byte
// leftover would feed the tie-break a moment nothing produced.
assertEquals(0L, snapshotFor(snapshots, empty).outputModifiedAt)
}
@Test
fun `each result carries the time its own file was last written`() {
val older = stagedFile("older.mp4", bytes = 4096)
val newer = stagedFile("newer.mp4", bytes = 4096)
// Set explicitly rather than relying on the order the two were written: a filesystem is
// free to give both the same mtime, and then the fixture would be testing nothing.
assertTrue(older.setLastModified(OLDER_MS))
assertTrue(newer.setLastModified(NEWER_MS))
assertTrue(
"the two fixtures must really carry different times, got ${older.lastModified()}",
older.lastModified() < newer.lastModified(),
)
listOf(older, newer).forEach(::finishedWithOutput)
val snapshots = snapshots()
// Compared against what the filesystem stored rather than against what was requested,
// because mtime granularity is the filesystem's business and not this test's claim.
assertEquals(older.lastModified(), snapshotFor(snapshots, older).outputModifiedAt)
assertEquals(newer.lastModified(), snapshotFor(snapshots, newer).outputModifiedAt)
// Why the field exists, asserted through the rule that reads it: the tag query has no
// ORDER BY, so without a real time here an arbitrary winner would win every launch while
// the other result stayed unreachable for as long as its file existed.
assertEquals(newer.absolutePath, Reattachment.choose(snapshots)?.job?.outputPath)
}
private fun snapshots(): List<JobSnapshot> = runBlocking {
workManager.jobSnapshots(
tag = ConversionWorker::class.java.name,
outputPathKey = ConversionWorker.KEY_OUTPUT_PATH,
)
}
private fun snapshotFor(snapshots: List<JobSnapshot>, output: File): JobSnapshot =
snapshots.single { it.outputPath == output.absolutePath }
private fun stagedFile(name: String, bytes: Int): File =
File(stagingDir, name).apply { writeBytes(ByteArray(bytes)) }
/**
* A conversion that finished with [output] as its result and nobody watching.
*
* Built rather than taken from `ConversionWorker.request`, because what has to reach
* `jobSnapshots` is the *output* `Data` of a finished job, and a request only carries input.
*/
private fun finishedWithOutput(output: File) {
workManager.enqueue(
OneTimeWorkRequestBuilder<ConversionWorker>()
.setInputData(workDataOf(ConversionWorker.KEY_OUTPUT_PATH to output.absolutePath))
.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
const val NEWER_MS = OLDER_MS + 24L * 60 * 60 * 1000
}
}
/**
* Stands in for whichever job finished before this process existed, reporting the output path it
* was handed.
*
* The real [ConversionWorker] cannot run here — it drives Media3 and FFmpeg through native
* libraries that do not exist on the JVM — and what `jobSnapshots` needs from it is only a
* SUCCEEDED `WorkInfo` carrying an output path. Echoing the input means one factory can produce
* several jobs with results of their own, which is what the ordering and aliasing cases need.
*/
private object EchoingWorkerFactory : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker = object : Worker(appContext, workerParameters) {
override fun doWork(): Result {
val path = inputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
?: return Result.success()
return Result.success(workDataOf(ConversionWorker.KEY_OUTPUT_PATH to path))
}
}
}
@@ -10,11 +10,11 @@ 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.ConversionDependencies
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.EnginePreference
@@ -35,19 +35,24 @@ import java.util.UUID
*
* These drive the real worker rather than the naming function, because the naming function was
* never the part that was wrong. What was wrong is which name the worker asked for.
*
* Both workers, for the same reason. The join side collided harder — `joined.<ext>` is one string
* for every join of a format, where a conversion at least needed two inputs of the same name — and
* it was the half with no test at all: reverting `ConcatWorker` to that constant left all 257
* tests green.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class PerJobStagingTest {
private lateinit var app: Application
private lateinit var publisher: OutputPublisher
private lateinit var publisher: NamingPublisher
private lateinit var stagingDir: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = AlwaysRoomPublisher(app)
publisher = NamingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
@@ -56,6 +61,9 @@ class PerJobStagingTest {
stagingDir = publisher.createStagingFile("anything").parentFile!!
stagingDir.listFiles()?.forEach { it.delete() }
// Asking for the directory above is itself a staging request; the tests are about the ones
// the workers make.
publisher.requestedNames.clear()
}
@After
@@ -104,8 +112,34 @@ class PerJobStagingTest {
)
}
@Test
fun `two joins of the same format stage under names of their own`() {
runBlocking { concatWorker(JOB_A).doWork() }
runBlocking { concatWorker(JOB_B).doWork() }
// Read off what the worker asked for rather than off the directory, and not for
// convenience: ConcatEngine is native, so neither join gets past it here, and the catch on
// the way out deletes whatever was staged. The name is where the collision lived --
// "joined.${format.extension}" is one string for every join of a format, so two joins were
// one file, exactly as two conversions of a same-named input were.
val names = publisher.requestedNames
assertEquals("each join must stage under a name of its own, got $names", 2, names.toSet().size)
assertTrue("the first join's name must carry its own job id, got ${names[0]}", names[0].contains("$JOB_A"))
assertTrue("the second join's name must carry its own job id, got ${names[1]}", names[1].contains("$JOB_B"))
}
private fun stagedNames(): List<String> = stagingDir.listFiles().orEmpty().map { it.name }.sorted()
private fun concatWorker(id: UUID): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
context = app,
inputData = workDataOf(
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "file:///tmp/second.mp4"),
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
ConcatWorker.KEY_FORMAT to JOIN_FORMAT.name,
),
runAttemptCount = 0,
).setId(id).build()
private fun conversionWorker(
id: UUID,
runAttemptCount: Int = 0,
@@ -129,6 +163,7 @@ class PerJobStagingTest {
const val DISPLAY_NAME = "input.mp4"
const val INPUT_BYTES = 1024L
val SPEC = OutputFormat.MP4_H265.spec
val JOIN_FORMAT = OutputFormat.MP4_H264
val JOB_A: UUID = UUID.fromString("00000000-0000-4000-8000-00000000000a")
val JOB_B: UUID = UUID.fromString("00000000-0000-4000-8000-00000000000b")
}
@@ -35,6 +35,16 @@ class ReattachmentTest {
assertNull(Reattachment.choose(listOf(job(state = WorkInfo.State.FAILED))))
}
@Test
fun `a failed job is not reattached to even when it left a file behind`() {
// The fixture that matters, and the one every other FAILED case here was missing: a job
// killed mid-write leaves a partial in staging -- the 2 MB orphan the device pass found --
// so the exclusion has to hold for a FAILED job that really does name a file on disk.
// Ranking it like a result would offer the user a truncated file with a Save button.
val partial = job(state = WorkInfo.State.FAILED, outputPath = STAGED, outputExists = true)
assertNull(Reattachment.choose(listOf(partial)))
}
@Test
fun `a finished result still on disk is offered`() {
val result = job(state = WorkInfo.State.SUCCEEDED, outputPath = "/cache/out.mp4", outputExists = true)
@@ -0,0 +1,173 @@
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.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.convert.StagingNames
import org.libremediaconverter.convert.installTestWorkManager
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.libremediaconverter.model.QualityTier
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.UUID
/**
* Input `Data` naming something this build does not define.
*
* Not a malformed-input hypothetical: WorkManager keeps queued and finished work for about a week,
* so a downgrade — or any rollback with work still in the queue — hands this build a job enqueued
* by another one. That is the same previous-version case [JobTags] is written for.
*
* What made it worth a test is *where* the reads are. All three sit above the workers' `try`, so an
* unknown name threw `IllegalArgumentException` out of `doWork()` entirely: WorkManager logged
* FAILURE with `reschedule = false`, the output `Data` reached the UI with zero entries so the
* screen said "Conversion failed." with nothing else, and the staged file was never deleted. That
* is the signature `setForeground` was moved inside the `try` to end, reached through a different
* door.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class WorkerEnumFallbackTest {
private lateinit var app: Application
private lateinit var publisher: NamingPublisher
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = NamingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
// The progress notification builds its cancel action from WorkManager.getInstance().
installTestWorkManager(app, Data.EMPTY)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
@Test
fun `a quality tier this build does not define falls back to the default`() {
val transcoder = RequestRecordingTranscoder()
ConversionDependencies.software = { transcoder }
val result = runBlocking { conversionWorker(quality = "ULTRA_FIDELITY").doWork() }
// A Result at all is half the assertion -- the read is above the try, so the defect was an
// exception rather than a wrong answer. The other half is which tier ran: falling back to
// something arbitrary would silently convert at a quality nobody asked for.
assertEquals(ListenableWorker.Result.success(), stripOutput(result))
assertEquals(listOf(QualityTier.FAST), transcoder.qualities)
}
@Test
fun `an engine preference this build does not define does not end the job`() {
// Refused on space, which is the first thing below the three reads: it proves the reads
// were reached and returned, without dragging in a routing decision this test is not about.
publisher.refuseSpace = true
val result = runBlocking { conversionWorker(preference = "FORCE_QUANTUM").doWork() }
assertEquals(
ListenableWorker.Result.failure(
workDataOf(ConversionWorker.KEY_ERROR to "Not enough free space to convert."),
),
result,
)
}
@Test
fun `an output format this build does not define falls back to the default`() {
runBlocking { concatWorker(format = "AVI_MPEG4").doWork() }
// The join itself fails -- ConcatEngine is native and there is no seam for it here -- so
// what is asserted is the name it staged under, which is where the format actually lands.
// A format nobody could resolve must produce the default's extension, not no extension and
// not a throw on the way past.
assertEquals(
listOf(StagingNames.forJob(CONCAT_ID, ConcatWorker.DEFAULT_FORMAT.extension)),
publisher.requestedNames,
)
}
/** [ListenableWorker.Result.Success] compares its output data, which these tests do not pin. */
private fun stripOutput(result: ListenableWorker.Result): ListenableWorker.Result =
if (result is ListenableWorker.Result.Success) ListenableWorker.Result.success() else result
private fun conversionWorker(
quality: String = QualityTier.FAST.name,
preference: String = EnginePreference.FORCE_SOFTWARE.name,
): ConversionWorker = TestListenableWorkerBuilder<ConversionWorker>(
context = app,
inputData = workDataOf(
ConversionWorker.KEY_INPUT_URI to INPUT.toString(),
ConversionWorker.KEY_DISPLAY_NAME to DISPLAY_NAME,
ConversionWorker.KEY_SIZE_BYTES to INPUT_BYTES,
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_QUALITY to quality,
ConversionWorker.KEY_ENGINE_PREFERENCE to preference,
),
runAttemptCount = 0,
).setId(CONVERSION_ID).build()
private fun concatWorker(format: String): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
context = app,
inputData = workDataOf(
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "file:///tmp/second.mp4"),
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
ConcatWorker.KEY_FORMAT to format,
),
runAttemptCount = 0,
).setId(CONCAT_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 SPEC = OutputFormat.MP4_H265.spec
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000021")
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000022")
}
}
/** An engine that writes the output and remembers what it was asked to produce. */
private class RequestRecordingTranscoder : SoftwareTranscoder {
val qualities = mutableListOf<QualityTier>()
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
) {
qualities += request.quality
output.writeBytes(ByteArray(OUTPUT_BYTES))
}
private companion object {
const val OUTPUT_BYTES = 512
}
}
@@ -24,6 +24,31 @@ open class AlwaysRoomPublisher(context: Context) : OutputPublisher(context) {
override fun hasSpaceFor(bytes: Long): Boolean = true
}
/**
* An [AlwaysRoomPublisher] that records the staging names it is asked for.
*
* For a conversion the staged file survives the job and a directory listing says everything. For a
* join it does not: `ConcatEngine` is native, so no test here gets past it, and the catch on the way
* out deletes what was staged. The name the worker *asked* for is then the only place its job id
* and its output format are legible at all — the same reason `SpaceCheckTest` records the question
* rather than the verdict.
*/
open class NamingPublisher(context: Context) : AlwaysRoomPublisher(context) {
/** Every name passed to [createStagingFile], in order. */
val requestedNames = mutableListOf<String>()
/** Set to refuse every space check, the way `FakeFailures.FullDisk` does. */
var refuseSpace = false
override fun hasSpaceFor(bytes: Long): Boolean = !refuseSpace
override fun createStagingFile(name: String): File {
requestedNames += name
return super.createStagingFile(name)
}
}
/**
* An engine that writes the output file and nothing else.
*
+667 -132
View File
@@ -1,127 +1,597 @@
# API 37 is not tested in CI: a crash in Google's `android-37.0` emulator image
# API 37 on the emulator: a guest gralloc bug that only the host GL renderer triggers
**Status:** open upstream, worked around by removing API 37 from the E2E matrix.
The app itself is verified good on real API 37 hardware — this is an emulator bug only.
**Last verified:** 2026-08-21, against emulator `37.1.11.0` and system image revision 6
**Status:** the bug is real and still open upstream, but the previous diagnosis in this file was
wrong about its most important detail. **The renderer decides whether API 37 boots**, and once it
boots, disabling SystemUI collapses the crash rate far enough to run a suite —
`tools/local-emulator/run-e2e.sh 37` gets through the whole instrumented suite and comes back with
**2 failures, 0 errors and the two by-design skips** (measured 49 / 2 / 0 / 2 at `22c7914`, where
the suite was 49 tests — [Reading these totals](#reading-these-totals) before comparing any total
with another). The crashes do not stop outright, and the two failures are real; both are quantified
below. CI now takes API 37 as two jobs — a gating leg and an advisory one for those two
failures — see [So should CI take API 37?](#so-should-ci-take-api-37).
**Last verified:** 2026-08-22, emulator `37.1.11.0` (build 15917651), Fedora 44,
against system images `android-37.0` rev 6 **and** `android-37.1` rev 8.
`minSdk` is 33 and `targetSdk` is 37, and the E2E matrix in
[`status_check.yml`](../.github/workflows/status_check.yml) runs API 33 through 36.
API 37 is deliberately absent. This is why.
## The correction
## Summary
This file previously said, under "What was ruled out":
The `android-37.0` emulator system image crashes `surfaceflinger` in a loop. The app
under test never gets a working framework, so every instrumented test fails regardless
of what the app does. The bug is in the emulator image, not in this project.
> **GPU mode.** Both `swiftshader_indirect` and `host` crash, with the same assertion and
> the same frames. The crash is in the gralloc mapper, below the renderer.
The crash is an assertion inside the emulator's own gralloc implementation:
**That is wrong.** The mapper is below the renderer, but *whether the mapper's bad path is
reached* is not. Re-measured on 2026-08-22, seven runs, one variable at a time:
| # | system image | `-gpu` | GLES the emulator chose | booted? | surfaceflinger aborts |
|---|---|---|---|---|---|
| r01 | `android-37.0` rev 6 | `host` | host (Mesa Iris Xe) | **no**, 422 s | 71, looping |
| r02 | `android-37.1` rev 8 | `host` | host (Mesa Iris Xe) | **no**, 362 s | 65, looping |
| r03 | `android-37.0` rev 6 | `swangle_indirect` | ANGLE | **yes, 85 s** | 1 |
| r04 | `android-37.0` rev 6 | `host` + `-feature -GLDMA,-GLDMA2,-GLDirectMem` | host | **no**, 363 s | 57, looping |
| r05 | `android-37.0` rev 6 | `angle_indirect` | ANGLE | **yes, 112 s** | 2 |
| r06 | `android-37.1` rev 8 | `swangle_indirect` | ANGLE | **yes, 285 s** | 23 |
| r07 | `android-37.0` rev 6 | `host` + `-feature -HostComposition` | host | **no**, wedged adb at 208 s | not readable |
The discriminator is exact across the **six runs that reported**: a run boots if and only if the
emulator log says something other than `gles_mode_selected:host`. r07 is excluded on purpose — it
wedged adb at 208 s and is recorded below as inconclusive rather than ruled out, and a row this
page calls inconclusive cannot also be counted as evidence. Excluding it costs nothing: r07 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 would add no information either way.
One caveat about how independent those rows are, because the table flatters itself. `-gpu
angle_indirect` (r05) and `-gpu swangle_indirect` (r03) both logged `gles_mode_selected:swangle`
and both reported the same adapter, differing only in the Vulkan backend beneath
(`vulkan_mode_selected:lavapipe` against `swiftshader`). So they are closer to one GLES path
reached two ways than to two renderers agreeing — note that at API 33–36
[`docs/local-emulator.md`](local-emulator.md) records `angle_indirect` resolving to ANGLE on
*llvmpipe*, a genuinely different adapter, which it did not do here. What is 7-for-7 is the
host-GLES-versus-not split, not "two independent renderers both work".
```
# r01, r02, r04, r07 -- never boots
INFO | emuglConfig_init: vulkan_mode_selected:host gles_mode_selected:host
INFO | Graphics Adapter Android Emulator OpenGL ES Translator (Mesa Intel(R) Iris(R) Xe Graphics (TGL GT2))
# r03, r05, r06 -- boots
INFO | emuglConfig_init: vulkan_mode_selected:swiftshader gles_mode_selected:swangle
INFO | Graphics Adapter Android Emulator OpenGL ES Translator (ANGLE (Google, Vulkan 1.2.0
| (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver-5.0.0))
```
### Why the wrong claim looked right
It rested on two samples of two different things, and neither of them was ANGLE.
- The **local** `swiftshader_indirect` sample was void. On this workstation *every*
SwiftShader-GLES launch segfaults the host emulator before the guest matters at all —
SELinux denies `execheap` to SwiftShader's Reactor JIT. That is
[`docs/local-emulator.md`](local-emulator.md), and it was not yet understood when this file
was written. So "`swiftshader_indirect` crashes" was true, for an entirely unrelated reason,
and told you nothing about the gralloc assertion.
- The **CI** sample was one `swiftshader_indirect` run on a GPU-less `ubuntu-latest`, and the
**local** sample was one `-gpu host` run. Two renderers, one measurement each, and the pair
written up as "both GPU modes".
`angle_indirect` and `swangle_indirect` — the two modes that work — had never been tried on
API 37. Neither had a second system image.
The lesson is the same one `docs/local-emulator.md` ends on, which makes it worth repeating:
"both backends fail" is a claim about a matrix, and a matrix needs cells, not inference. Two
observations of two different configurations do not establish anything about a third.
## What the bug actually is
`surfaceflinger` aborts inside the emulator's own gralloc mapper:
```
Executable: /system/bin/surfaceflinger
signal 6 (SIGABRT), code -1 (SI_QUEUE), tid: RegionSampling
Abort message: 'Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma'
#03 mapper.ranchu.so GoldfishMapper::readFromHost(cb_handle_t const&) const
#04 mapper.ranchu.so GoldfishMapper::GoldfishMapper()::'lambda'(...)::__invoke
#05 libui.so android::Gralloc5Mapper::lock(...)
#06 libui.so android::GraphicBufferMapper::lock(...)
#07 libui.so android::GraphicBuffer::lockAsync(...)
#08 libui.so android::GraphicBuffer::lock(...)
#09 surfaceflinger android::RegionSamplingThread::threadMain()
#03 /vendor/lib64/hw/mapper.ranchu.so GoldfishMapper::readFromHost(cb_handle_t const&) const+543
#04 /vendor/lib64/hw/mapper.ranchu.so GoldfishMapper::GoldfishMapper()::'lambda'(...)::__invoke+704
#05 /system/lib64/libui.so android::Gralloc5Mapper::lock(...)+63
#06 /system/lib64/libui.so android::GraphicBufferMapper::lock(...)+198
#07 /system/lib64/libui.so android::GraphicBuffer::lockAsync(...)+545
#08 /system/lib64/libui.so android::GraphicBuffer::lock(...)+67
#09 /system/bin/surfaceflinger android::RegionSamplingThread::threadMain()+2571
```
`RegionSamplingThread` is SystemUI's navigation-bar luma sampling. It calls
`GraphicBuffer::lock`, which routes into `GoldfishMapper::readFromHost`, which asserts
that the host has *not* negotiated the `ReadColorBufferDma` capability. On this image
the host has, so the assertion fails and `surfaceflinger` aborts. It restarts and
aborts again.
`RegionSamplingThread` is SystemUI's nav-bar luma sampling. It locks a `GraphicBuffer` for CPU
read; that routes through the Gralloc5 mapper into `GoldfishMapper::readFromHost`, which is the
*non-DMA* readback path and asserts that the host has not negotiated `ReadColorBufferDma`. The
host always has, so the assert fires whenever that path is taken.
## Impact
Two facts pin down what "always" means:
The failure surfaces in two different ways depending on how far the job gets, which is
why it took several rounds to identify:
- **The capability is negotiated regardless of renderer.** The evidence is the aborts
themselves: the assertion that fires is `!hasReadColorBufferDma`, and it fires under ANGLE
(r03/r05/r06) as well as under the host translator — just far less often. That is a direct
observation of the guest having negotiated DMA readback under both, and it stands alone.
(Supporting only, and weaker than it first looks: `ANDROID_EMU_read_color_buffer_dma` appears
in exactly one file in the SDK, `emulator/lib64/libgfxstream_backend.so`, which every `-gpu`
mode goes through. A string search establishes where the extension is implemented, not that
it is negotiated on every path.)
- **It is not gated by any feature flag the emulator exposes.** See the ruled-out list below.
| Guest RAM | Where it dies | What CI reports |
|---|---|---|
| 1536 MB | during APK install | `Unknown failure: cmd: Can't find service: package` |
| 2560 MB | during the test run | `There were failing tests` — all of them |
So the renderer does not decide whether the guest *believes* DMA readback exists. It decides how
often `RegionSamplingThread` ends up in `readFromHost` — which under the host GL translator is
constantly, and under ANGLE is occasionally.
At 2560 MB the install succeeds and the tests actually execute, then fail wholesale.
The first failure in the report is misleading:
### Why one abort takes down the whole device
`surfaceflinger` is a critical service. When it dies, `init` kills the framework with it:
```
kotlin.UninitializedPropertyAccessException: lateinit property output has not
been initialized
at Media3EngineTest.tearDown(Media3EngineTest.kt:53)
java.lang.IllegalStateException: WorkManager is not initialized properly.
You have explicitly disabled WorkManagerInitializer in your manifest, ...
08-22 21:40:28.253 I/init: Sending SIGKILL to service 'zygote' (pid 470) process group...
08-22 21:40:28.260 I/init: Service 'zygote' (pid 470) received SIGKILL
```
Neither is a real defect in this project. `tearDown` throws because `setUp` never got
far enough to assign `output`, and WorkManager's `InitializationProvider` never runs
because content-provider installation fails on a framework whose `surfaceflinger` is
crash-looping. The same tests pass at API 33, 34, 35, and 36 in the same CI run, and
the first `surfaceflinger` abort is timestamped *before* the test results are reported.
This is not inference. The full suite was run against a physical API 37 device and
passed — see [Verified on real API 37 hardware](#verified-on-real-api-37-hardware)
below. `ConversionWorkerTest` and `ConcatWorkerTest`, which drive a real WorkManager
round trip and are among the tests that failed this way in CI, both pass there.
Everything above zygote goes with it, which is why the symptoms look nothing like a graphics
bug. Under `-gpu host` the cycle repeats every five to seven seconds forever and
`sys.boot_completed` is never set. Under ANGLE the aborts are sparse enough that the boot
usually completes between them — but they do not stop, and each one is a framework restart.
That is the difference between "boots" and "is usable", and it is the reason this is not simply
fixed by changing the renderer. See [Can the suite run on it?](#can-the-suite-run-on-it) below.
## Environment
Reproduced identically in two unrelated environments, so it is not specific to a host
GPU, driver, or CI runner.
| | GitHub Actions | Local workstation |
|---|---|---|
| Host | `ubuntu-latest`, no GPU | Fedora, Intel Iris Xe (TGL GT2) |
| Host | `ubuntu-latest`, no GPU | Fedora 44, Intel Iris Xe (TGL GT2), kernel `7.1.8-200.fc44` |
| Emulator | `37.1.11.0` (build 15917651) | `37.1.11.0` (build 15917651) |
| GPU mode | `swiftshader_indirect` | `host` |
| Result | boots, aborts during tests | aborts before boot completes |
| GPU mode measured | `swiftshader_indirect` | `host`, `angle_indirect`, `swangle_indirect` |
System image: `system-images;android-37.0;google_apis;x86_64`, `Pkg.Revision=6`,
`AndroidVersion.ApiLevel=37.0`, `AndroidVersion.ExtensionLevel=22`
Images, both reproducing it:
```
Build fingerprint: google/sdk_gphone64_x86_64/emu64xa:17/CE2A.260420.019/15611780:userdebug/dev-keys
Kernel Release: 6.12.58-android16-6-gccafb60de224-ab14828483
system-images;android-37.0;google_apis;x86_64 Pkg.Revision=6 ApiLevel=37.0 ExtensionLevel=22
fingerprint google/sdk_gphone64_x86_64/emu64xa:17/CE2A.260420.019/15611780:userdebug/dev-keys
system-images;android-37.1;google_apis_ps16k;x86_64 Pkg.Revision=8 ApiLevel=37.1 ExtensionLevel=23
ro.build.version.codename=REL (a release image, not a preview)
```
**Note the `ps16k` in the second one — it is not optional, and it is why the 37.1 result is
interpretable.** From API 37.1 onward Google ships *only* 16 KB-page x86_64 images; there is no
plain `google_apis` variant to pick. `sdkmanager --list` for 37.1 and 37.2-beta* offers nothing
but `google_apis_ps16k` and `google_apis_playstore_ps16k`. That makes page-size alignment a
prerequisite rather than a detail: a `.so` that is not 16 KB aligned will not load on such a
guest, and the resulting failure looks like an app bug. Checked before the first `ps16k` boot,
using the same test `build.yml` applies to release APKs — all 20 libraries in the committed
`bin/ffmpeg-kit-next-8.1.1.aar`, both ABIs, report `0x4000`:
```
$ for f in jni/*/*.so; do readelf -lW "$f" | awk '$1=="LOAD"{print $NF}' | sort -u; done
0x4000 (x20: libavcodec, libavdevice, libavfilter, libavformat, libavutil,
libc++_shared, libffmpegkit, libffmpegkit_abidetect, libswresample, libswscale
-- arm64-v8a and x86_64)
```
So when `android-37.1` reproduced the abort, that was the gralloc bug and not a page-size
mismatch. `image_pkg_for_api` in `tools/local-emulator/run-e2e.sh` encodes the `ps16k` tag for
37.1; if this ever fails after an FFmpeg rebuild, re-run the alignment check first.
## What was ruled out, and how
Each of these was tested rather than reasoned about, because the first three attempts
at this bug were plausible fixes that turned out to address earlier, unrelated failures.
**A newer system image.** This file's own revisit trigger was "a new `android-37.0` system image
revision ships (this was revision 6)". That trigger was written too narrowly and would never have
fired: `android-37.0` is *still* revision 6, but Google shipped a whole new minor level.
`android-37.1` `google_apis_ps16k` revision 8 — a `REL` build, not a beta — was installed and
tested (r02, r06) and **behaves identically**: same assertion, same frames, never boots under
`-gpu host`, and *worse* under ANGLE (23 aborts to `37.0`'s 1). `android-37.2-beta3` exists too
but was not needed; two independent images agreeing settles it, and a beta could not be used by
CI anyway.
**Guest memory.** The emulator raises an undersized guest to a minimum on its own, but
only for API levels it recognises, and it does not recognise `"37.0"`. API 33 bumps to
2048 MB and 34/35/36 to 2560 MB, while API 37 logged no bump at all and ran at the
`pixel_6` default of 1536 MB. Setting `ram-size: 2560M` explicitly fixed that asymmetry
and did change the outcome — the job got past install and into the test run — but it is
not the underlying bug. At the moment of failure the guest reported `MemTotal 2527392
kB` with `MemAvailable 1507104 kB`: 1.5 GB free, and no OOM kills.
**An ATD image.** Still does not exist for API 37. `sdkmanager --list` offers `aosp_atd` and
`google_atd` for API 30 through 36 and nothing above:
**GPU mode.** Both `swiftshader_indirect` and `host` crash, with the same assertion and
the same frames. The crash is in the gralloc mapper, below the renderer.
```
system-images;android-36;google_atd;x86_64 | 1 | Google APIs ATD Intel x86_64 Atom System Image
(no android-37 ATD of any kind)
```
**Disabling the DMA feature.** `GLDMA` is the host feature that most plausibly backs the
guest's `hasReadColorBufferDma`. Launching with `-feature -GLDMA` was accepted by the
emulator — the log confirms `Feature 'GLDMA' (51) is overridden to 'disabled'` — and
`surfaceflinger` still aborted 13 times and the device never finished booting. Whatever
sets that guest capability, it is not this flag.
For API 37 the only x86_64 images are `google_apis`, `google_apis_playstore`, their `ps16k`
16 KB-page variants, and Wear OS. Check again when revisiting.
**An ATD image.** `google_atd` / `aosp_atd` images are built for automated testing and
ship without the SystemUI package set, which is what drives `RegionSamplingThread` in
the first place. That would likely sidestep the bug class entirely, but **no ATD image
exists for `android-37.0`** — only `google_apis`, `google_apis_playstore`, the `ps16k`
16 KB-page variants, and Wear OS. Check again when revisiting; if an ATD image appears,
try it before anything else here.
**The DMA feature flags.** `GLDMA` alone was ruled out previously; `GLDMA2` and `GLDirectMem`
were not, and the per-image `advancedFeatures.ini` turns all three on. Disabling all three
together (r04) is accepted by the emulator and changes nothing:
```
INFO | Feature 'GLDMA' (51) is overridden to 'disabled'
INFO | Feature 'GLDMA2' (52) is overridden to 'disabled'
INFO | Feature 'GLDirectMem' (53) is overridden to 'disabled'
... 57 surfaceflinger aborts, device never boots
```
**Host composition.** `-feature -HostComposition` (r07) was the best remaining guess at what
forces the readback. It did not help; it made things worse, wedging adb entirely at 208 s so the
crash buffer could not even be read. Recorded as inconclusive rather than ruled out, because no
evidence came back from it.
**Guest feature negotiation differing from API 36.** It does not. The image-level
`advancedFeatures.ini` for `android-37.0` is byte-identical to `android-36`'s except for one
unrelated line:
```
$ diff android-36/google_apis/x86_64/advancedFeatures.ini android-37.0/google_apis/x86_64/advancedFeatures.ini
+QemuCameraSensorOrientation = on
```
`GLDMA`, `GLDMA2`, `GLDirectMem`, `GrallocSync`, `HostComposition` and `YUVCache` are on in
both. API 36 boots and passes. So nothing about the host/guest feature handshake changed — the
regression is in the guest's Gralloc5 mapper or in what API 37's `RegionSamplingThread` asks of
it, not in what the emulator advertises.
**Guest memory.** Ruled out previously and not revisited; every run above used
`hw.ramSize=2560`, the same value the E2E matrix pins, and none of them OOMed.
**A host-side crash.** Not this bug, and worth stating because the other emulator failure on this
workstation *is* host-side. Every run above left `coredumpctl` empty and produced zero
`avc: denied` lines, and the qemu process was still alive at the end of the ones that never
booted (`emulator_alive=yes`). The host emulator is fine; the guest is not.
## Can the suite run on it?
**Almost, and less so than it was.** `tools/local-emulator/run-e2e.sh 37` runs the whole suite
locally. Measured at `22c7914`: **49 tests, 2 failures, 0 errors, 2 skipped** — 45 passed, the two
`Media3EngineTest` failures dissected below, and the two `assumeTrue` skips every level has. It
costs two deviations from how every other level is run, and both are worth understanding before
trusting the leg.
**That was the high-water mark.** On 2026-08-24 a test that touches system UI joined the suite,
and the level stopped *finishing* rather than merely failing two —
[see below](#something-does-depend-on-system-ui-now-and-it-is-excluded-rather-than-trusted).
Two `Media3EngineTest` failures is what **CI's gating leg** expects, because it filters on
`notAnnotation`; a local `run-e2e.sh 37` does not filter and sees more.
Two things about that total before it is compared with anything. It is the size of the suite on
the checkout that ran, not a property of API 37 — `app/src/androidTest` held 49 `@Test` methods at
`22c7914`, and a newer checkout reports its own count; see
[Reading these totals](#reading-these-totals). And **the Pixel has never run 49**: its green run
was 40 / 0 / 0 / 2 at `edd6385`, the same suite nine tests earlier. What compares across the two
is two failures against none, and the same two skips — not the totals.
The same numbers and the same two test names came back twice, which is real corroboration — but
by two different routes, and only one of them is the harness. The first was driven by hand
(`pm disable-user`, then several minutes of incidental framework restarts, then `e2e-run.sh`
directly); the second went through `disable_region_sampling`'s `stop; start`. **The harness path
itself has one green measurement.** What would make this routine is a second consecutive
`run-e2e.sh 37` whose only failures are the same two.
### Booting is not the same as being usable
Changing the renderer gets the device to `sys.boot_completed=1`, and that is all it gets you. The
aborts do not stop, and each one is a framework restart. A five-minute test run does not survive
that. What it looks like from Gradle:
```
Shell command failed (1): rm -rf "/sdcard/Android/media/org.libremediaconverter/..."
rm: ...: Transport endpoint is not connected
Starting 0 tests on lmc_e2e_api37(AVD) - 17
Shell command failed (20): am get-current-user
cmd: Can't find service: activity
Device emulator-5572 failed to uninstall test APK org.libremediaconverter.
[cmd: Can't find service: package]
Test run failed to complete. No test results.
onError: commandError=false message=INSTRUMENTATION_ABORTED: System has crashed.
```
Measured idle rate on `android-37.0` under `swangle_indirect`: **10 aborts in 150 s, then 11 more
in the next 150 s**. Steady, not a start-up transient.
### The fix is to remove the region-sampling listener, not to survive it
`RegionSamplingThread` exists only because SystemUI registers a nav-bar luma-sampling listener.
Take SystemUI away and the thread is never started, so the mapper's bad path is never called:
```
$ adb shell pm disable-user --user 0 com.android.systemui
Package com.android.systemui new state: disabled-user
=== aborts at start of measurement: 36
=== idle 180s with SystemUI disabled ===
=== aborts after: 36 NEW IN WINDOW: 0
--- services still up? ---
activity Service activity: found
package Service package: found
window Service window: found
```
**Zero in 180 s, against 10–11 per 150 s.** That is the strongest evidence that region sampling
is the dominant trigger, and it is worth recording even by someone who never wants the workaround.
It does not establish it as the *only* trigger: the paragraph below has an abort surviving the
disable, and nothing measured here says whether that residue is a second caller of the readback
path or a disable that did not fully take.
Do not read that as "the crashes stop", though, because the harness path does not reproduce a
clean zero. Its own post-disable check on the run recorded below printed
```
quiet check: 1 new surfaceflinger aborts in 45 s (want 0)
surfaceflinger hasReadColorBufferDma aborts: 4 (whole run)
```
So what is reliably achieved is a **rate collapse** — from roughly one abort every fourteen
seconds to one every forty-five — which a 47-second Gradle run survives and a five-minute one
might not. The 180-second zero above is one measurement on a device that had been up for twelve
minutes and had already cycled its framework several times. The harness prints the quiet-check
delta on every run precisely so this is visible rather than assumed.
One ordering detail cost a whole run and is now encoded in `disable_region_sampling`: by the time
`sys.boot_completed` flips, SystemUI has **already registered**, and `pm disable-user` does not
retract an existing registration — it only stops the package being started again. Disabling it
and proceeding straight to the tests fails exactly as before. The harness therefore does
`stop; start` afterwards, so the framework that comes back never starts SystemUI at all.
### The two deviations, stated plainly
1. **The renderer is ANGLE, not the host GPU.** Shared with nothing else in the matrix — API
33–36 run `-gpu host` locally, and CI runs `swiftshader_indirect`.
2. **SystemUI is disabled.** The API 37 leg does not run the same device configuration as any
other leg or as the Pixel. It was defensible here because nothing in this suite touched
system UI — Media3, FFmpeg and WorkManager tests — and because the alternative is no local
API 37 coverage at all. **Anything that ever does depend on system UI must not trust this
leg.** Something now does; see the section below.
### Something does depend on system UI now, and half of it is excluded
Added 2026-08-24, and the first entry on this page that is not a codec.
`SafPickerRoundTripTest` drives the real system file picker and rotates the display. Both reach
the gralloc mapper — DocumentsUI is another app's windows, and a rotation rebuilds every surface
on screen — and **disabling SystemUI does not help**, because it removes the *idle* trigger
(RegionSamplingThread's nav-bar luma sampling) and not this one.
Measured one method per fresh emulator, `android-37.0`, `swangle_indirect`, SystemUI disabled and
verified quiet — separately, because inferring the second from the first is the mistake this
page's opening correction is about:
| test | result on android-37.0 | `hasReadColorBufferDma` aborts in the window |
|---|---|---|
| `thePickedInputSurvivesARealRotation` | **fails**: `INSTRUMENTATION_ABORTED: System has crashed.`, `Expected 1 tests, received 0`. The framework dies **during** it, so the JUnit XML carries a failure with no text at all. | 3 |
| `pickingAFileThroughTheSystemPickerFillsInTheFileCard` | **passes** | 4 |
So a rotation, which rebuilds every surface at once, is what the mapper does not survive. Merely
starting DocumentsUI is not. Only the rotation test carries `@FailsOnEmulatorApi37`; the picker
test runs on the gating leg like anything else.
#### The correction that produced that table
**The first version of this section said both tests failed, and put the marker on the class.** The
picker test had indeed failed at API 37 — with `androidx.test.uiautomator.StaleObjectException`,
which looked like a framework restart invalidating an accessibility node, because that is exactly
what it looks like.
It was the test's own bug. `UiObject2` caches the `AccessibilityNodeInfo` it was found with, and
DocumentsUI is still settling when a node first appears; the handle went stale before `click()`.
CI then reproduced it **deterministically** at API 33, 34 and 35 — every cold runner emulator, not
intermittently — which is what made it obviously not an API 37 property. It had passed locally
only because the emulator was warm.
The lesson is worth more than the measurement: **an annotation is a claim about an image, and a
broken test makes every image look broken.** Re-measure after fixing a test before deciding what
the platform did. Both the abort and the stale node produce "the run fell over", and only one of
them was the image.
#### Two consequences worth stating rather than discovering
- **`run-e2e.sh 37` applies no annotation filter**, unlike CI, so a local API 37 run includes the
rotation test and therefore **does not finish**: its totals come back short and which later
tests ran is arbitrary. The summary row says so.
- **The advisory job is still named `E2E API 37 Media3 hardware transcode (advisory)`** and now
carries a test that is neither Media3 nor a transcode. Renaming a check is a branch-protection
change and was deliberately not made in the same PR; the name is stale, the behaviour is
correct.
### The two remaining failures are the same bug, one layer down
```
org.libremediaconverter.convert.Media3EngineTest > runsFromAThreadWithNoLooper FAILED
org.libremediaconverter.convert.Media3EngineTest > transcodesH264ToH265AndReportsProgress FAILED
androidx.media3.transformer.ExportException: Codec exception:
CodecInfo{type=VideoDecoder, ..., mime=video/avc, name=c2.goldfish.h264.decoder}
at androidx.media3.transformer.DefaultCodec.maybeDequeueOutputBuffer(DefaultCodec.java:398)
Caused by: android.media.MediaCodec$CodecException:
at android.media.MediaCodec.native_dequeueOutputBuffer(Native Method)
```
Three measurements say this is the emulator image and not this app, and not the software
renderer. A fourth bullet offers a mechanism, and is inference rather than measurement:
- **Control at API 35 under the identical renderer.** `GPU_MODE=swangle_indirect
tools/local-emulator/run-e2e.sh 35` → **49 / 0 / 0 / 2** at `22c7914`, green.
`c2.goldfish.h264.decoder` is perfectly happy under ANGLE one API level down, so the renderer is
not what breaks it.
- **Real API 37 hardware passes**, see below. There is no `c2.goldfish.*` codec on a Pixel.
- **API 36 against API 37 on CI, back to back, everything else held.** Same two tests, same
`-gpu swiftshader_indirect`, same SystemUI-disable path — `pm disable-user`, `stop`, wait for
`system_server` to actually be gone, `start`, then verify against `pm list packages -d`. Both
runs were narrowed to the two failing tests:
```
-Pandroid.testInstrumentationRunnerArguments.class=\
org.libremediaconverter.convert.Media3EngineTest#transcodesH264ToH265AndReportsProgress,\
org.libremediaconverter.convert.Media3EngineTest#runsFromAThreadWithNoLooper
```
and the filter is confirmed three independent ways: `tests="2"` in the XML, `Expected 2 tests`
in the abort message, and `run started: 2 tests` in the guest logcat.
| run | api | result XML |
|---|---|---|
| [32660148155](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660148155) | 37.0 | `tests="2" failures="2" errors="0" skipped="0"` |
| [32660152961](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660152961) | 36 | `tests="2" failures="0" errors="0" skipped="0" time="4.603"` |
API 37 fails with the signature above — `name=c2.goldfish.h264.decoder`,
`MediaCodec$CodecException` at `dequeueOutputBuffer(MediaCodec.java:4274)`. API 36 passes both in
4.603 s, and `c2.goldfish.h264.decoder` is in *its* logcat too (44 mentions), so the two runs are
not being served by different decoder names. **What this falsifies is "the stripped
configuration is what breaks these tests"** — a reading none of the other measurements
addresses, because they all compare against a device that still had SystemUI. Here SystemUI is
absent and the framework has been restarted on both sides, and the healthy image is green anyway.
Two things it does **not** control, which is why it narrows the claim rather than closing it:
- **The restarts were not performed under equal conditions.** API 36 did its `stop`/`start` with
`dma_aborts=0`; API 37's did the same restart with two aborts already logged. "A framework
restart performed while the abort loop is running" therefore remains uncontrolled.
- **The images differ on the encoder side.** These tests transcode H.264 → H.265. The API 37
logcat carries `c2.goldfish.hevc.decoder` (16 mentions in the control run) where API 36 carries
`c2.android.hevc.encoder` (32). The pipeline is not identical end to end, which is a second
reason "the image ships a broken h264 decoder" is the wrong *shape* of claim: what is measured
is that these two tests fail on the API 37 image, pass at API 36 under the same renderer *and*
the same disable path, and pass at 33–36 without needing that path at all — because nothing
below 37 has the bug it works around.
- The failing call is `dequeueOutputBuffer` on the *goldfish* decoder — the emulator's own codec,
which like `RegionSamplingThread` gets its frames out of a host-side colour buffer. Same
readback machinery, one layer down. This is inference rather than a measurement, and is flagged
as such; what is measured is the first three bullets.
**Do not try `-feature -HardwareDecoder`.** It is the obvious next idea and it is much worse:
forcing the guest onto software decoders took the run from 2 failures to **46**, across
`RemuxTest`, `ForcedFailureTest`, `HardwareFallbackTest` and `UnopenableUriTest` as well. The
suite depends on those decoders existing.
### The intact-SystemUI counterfactual cannot be measured on CI
The control the block above still lacks is the obvious one: run those same two tests at API 37
with SystemUI **left running**. Passing would put the failure on the disable rather than on the
image; failing on the decoder would make the decoder attribution direct instead of inferred.
**Seven dispatches of `api37-debug.yml`, zero verdicts.** Not bad luck — a mechanism, which is why
this is written down rather than left as a gap for the next person to spend seven runs on:
| arm | run | result XML | what actually happened |
|---|---|---|---|
| E1 | [32660528355](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660528355) | `tests="1" failures="1"`, `<failure>` body empty | `Expected 2 tests, received 0. INSTRUMENTATION_ABORTED: System has crashed.` |
| E2 | [32660533845](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660533845) | `tests="0"` | never installed: `Failed to commit install session ... Failure calling service package: Broken pipe (32)` |
| E3 | [32660539259](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660539259) | `tests="0"` | `Test run failed to complete. No test results.` |
| E4 | [32661117237](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32661117237) | `tests="2" failures="2"` | both failed in `@Before`, never reached MediaCodec |
| E5 | [32661121972](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32661121972) | `tests="2" failures="2"` | same |
| S1 | [32661127224](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32661127224) | `tests="1" failures="1"` | same, single-test arm |
| S2 | [32661132024](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32661132024) | `tests="1" failures="1"` | same |
While the framework is crash-looping, the guest cannot reliably create per-user private
directories. An app installed during the loop has no cache directory — and `Media3EngineTest`
copies its H.264 fixture into `context.cacheDir` in `@Before`, so it dies there, **before any
MediaCodec exists**:
```
W/ContextImpl( 8216): Failed to ensure /data/user/0/org.libremediaconverter/cache
I/TestRunner( 8216): run started: 1 tests
E/TestRunner( 8216): failed: transcodesH264ToH265AndReportsProgress(...)
E/TestRunner( 8216): java.io.FileNotFoundException:
/data/user/0/org.libremediaconverter/cache/sample_h264.mp4: open failed: ENOENT
at org.libremediaconverter.convert.Media3EngineTest.setUp(Media3EngineTest.kt:47)
```
Not app-specific: `com.google.android.googlesdksetup` and `com.google.android.apps.nexuslauncher`
hit the same `Failed to ensure /data/user/0/<pkg>/cache` in the same logcats.
**The result XML masks this, and reading only the report gets you the wrong bug.** What E4, E5, S1
and S2 report is
```
<failure>kotlin.UninitializedPropertyAccessException: lateinit property output has not been initialized
at org.libremediaconverter.convert.Media3EngineTest.tearDown(Media3EngineTest.kt:56)
```
— `tearDown` failing because `setUp` threw before it assigned `output`. That looks like a
teardown defect in this repository and is not one; the cause is only in the guest logcat.
So the obstacle is structural: install, data-directory creation and instrumentation start-up do
not fit between framework kills, and four of the seven runs show the directory creation itself is
broken during the loop. More dispatches of this shape would repeat these outcomes. The
counterfactual is still open on the **Pixel 10 Pro XL**, the one API 37 device here that is not an
emulator — but a Pixel has no `c2.goldfish.*` codec at all, so it answers "does the app work at
API 37", not "is that codec broken".
#### Abort cadence, corrected
`.github/workflows/api37-debug.yml` carried "roughly every 20 s" for the kill cycle in its own
comments. That number was the watchdog's **sampling** interval, not the cadence, and the two got
conflated. Measured across the **six runs whose crash buffer could be read** — r07 wedged adb
before one could be taken, so it contributes no gaps — successive `hasReadColorBufferDma` aborts
run **20 s to 90 s, median 60–70 s — three to five aborts in a four-minute window**.
Slower than assumed, and still not slow enough: install, data-directory creation and
instrumentation start-up do not fit inside one gap.
`sys.boot_completed` held at `1` throughout every one of those test windows. The device reports
itself booted while zygote is being killed under it, which is why no boot-state check catches
this and why `stop`/`start` waits must poll `pidof system_server` and `service check` instead
(see `disable_region_sampling` in `tools/local-emulator/run-e2e.sh`).
### So should CI take API 37?
**Yes, as two jobs: a gating `E2E API 37` and an advisory leg carrying the two tests that do not
pass.** That reverses the answer this section gave, and the reversal is measured rather than
argued — two of its three reasons were claims *about CI*, and CI had never been measured. The
instrument that measured it is [`.github/workflows/api37-debug.yml`](../.github/workflows/api37-debug.yml),
dispatch-only, a copy of the E2E job with the matrix replaced by inputs.
Every run below is `ubuntu-latest`, KVM on, `pixel_6`, x86_64, disk 8G, RAM 2560M, emulator
`37.1.11.0` build 15917651 — the same emulator build the local investigation used. Every **API
37** row is `system-images;android-37.0;google_apis;x86_64`; c2 is the API 36 control and runs
that level's own image, which is the whole point of it.
| # | run | api | `-gpu` | SystemUI | suite | verdict |
|---|---|---|---|---|---|---|
| c1 | [32644947334](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32644947334) | 37.0 | swiftshader_indirect | running | `Starting 0 tests` | FAIL |
| c2 | [32644965828](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32644965828) | **36** | swiftshader_indirect | running | 57 tests, BUILD SUCCESSFUL | green control |
| c3 | [32644970240](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32644970240) | 37.0 | swangle_indirect | running | `Starting 0 tests` | FAIL |
| c5 | [32645543238](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32645543238) | 37.0 | swangle_indirect | disabled | 57 / 2 / 0 / 2 | suite ran |
| c6 | [32646029143](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32646029143) | 37.0 | swiftshader_indirect | one-shot disable, **did not hold** | `Starting 0 tests` | FAIL |
| c8 | [32646611485](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32646611485) | 37.0 | swiftshader_indirect | disabled, verified | 57 / 2 / 0 / 2 | suite ran |
| c9 | [32646615706](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32646615706) | 37.0 | swiftshader_indirect | disabled, verified | 57 / 2 / 0 / 2 | suite ran |
| c10 | [32646619472](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32646619472) | 37.0 | swiftshader_indirect | disabled, verified | 57 / 2 / 0 / 2 | suite ran |
| c11 | [32647138060](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32647138060) | 37.0 | swiftshader_indirect | disabled, verified | 57 / 2 / 0 / 2 | suite ran |
57 is that checkout's own `@Test` count at `acc71bc`, so those are whole-suite runs and not
truncated ones — see [Reading these totals](#reading-these-totals). Taking the three old reasons
in turn:
1. **"Nothing says a runner would be stable" — measured, and it is.** `-gpu swiftshader_indirect`
on a GPU-less runner resolves to `gles_mode_selected:swiftshader`, a third renderer that
locally never survives to say anything (Fedora's SELinux denies `execheap` to SwiftShader's
Reactor JIT — see [`local-emulator.md`](local-emulator.md)). It boots `android-37.0` in about
60 s. The local discriminator — fatal iff `gles_mode_selected:host` — holds, and a runner with
no GPU can never select host, so CI was never in the fatal class. Switching CI's `-gpu` changes
nothing either way: c1 and c3 both fail with SystemUI up, under swiftshader and swangle
respectively, and c5 and c8–c11 show the suite running under either once SystemUI is gone.
2. **"Bespoke device surgery" — still true, and now a written caveat rather than a reason to skip
the level.** It is one env flag, `E2E_DISABLE_SYSTEM_UI`, read by `.github/scripts/e2e-run.sh`
and unset on every other leg. What it costs is stated where it can be read from the failing
check: the API 37 row runs a device configuration no other leg and no Pixel run uses. What
makes it dependable is verification, not repetition — c6 is the counter-case, a one-shot
`pm disable-user` that reported `new state: disabled-user` and then started SystemUI eight more
times. The verified form is 4/4; the unverified form was 3/4.
3. **"Permanently red or permanently allow-listed" — this was the real objection, and it is the
one the split answers.** The two failures are marked `@FailsOnEmulatorApi37` in
`app/src/androidTest`. The gating job runs `notAnnotation` on that marker and must be green;
the advisory job runs `annotation` on the *same* marker, reports, and never blocks. One marker
rather than two lists, so a test cannot silently end up in neither job — which would read as
green.
The cost is about three minutes on the API 37 leg — the `stop`/`start` plus a 45 s quiet window,
and another round when the first does not verify. Measured wall clock for the whole job, boot
included: 6–7 minutes at API 37 against ~6 at API 36.
Two things this does **not** buy. The advisory job is expected red, so a *third* failure there is
the signal and the run's logcat is the only thing that distinguishes it — which is why that job
uploads diagnostics unconditionally. And a green `E2E API 37` still does not replace the release
check on the Pixel: the emulator leg runs without SystemUI, and the Pixel does not.
The *local* story changed at the same time and independently: API 37 is no longer a level nobody
can look at. A regression that shows up at 37 and not at 36 can be reproduced on this workstation
in about four minutes.
## Verified on real API 37 hardware
The bug is confined to the emulator image. On 2026-08-21 the whole instrumented suite
was run against a physical device and passed:
Unchanged and still true. On 2026-08-21 the whole instrumented suite ran green on a physical
device:
```
Device: Pixel 10 Pro XL (mustang), arm64-v8a
@@ -132,65 +602,94 @@ API: 37 (Android 17, codename REL -- a release build, not a preview)
40 tests, 0 failures, 0 errors, 2 skipped BUILD SUCCESSFUL
```
**That "40" is a measurement of the tree it ran on, not a baseline for today**, and it is not a
contradiction of the totals in [`docs/local-emulator.md`](local-emulator.md) either.
### Reading these totals
Every total in this file and in [`docs/local-emulator.md`](local-emulator.md) is the size of
`app/src/androidTest` on the checkout that produced it, and nothing else. The reported total has
equalled that checkout's `@Test` count everywhere it has been checked:
| checkout | `@Test` methods | total the run reported |
|---|---|---|
| `edd6385` | 40 | 40 — the Pixel run above |
| `22c7914` | 49 | 49 — the four local levels, and API 37 |
| `18c53a3` | 57 | not run |
So the number to expect is not written down here. It is derived from the checkout in front of
you, which is the only thing that cannot go stale:
```bash
grep -rho '@Test' app/src/androidTest | wc -l
```
**Before each release, run the suite on the Pixel 10 Pro XL and expect that many tests, 0
failures, 0 errors, 2 skipped.** The failure, error and skip counts are the invariant; the total
is not. A total that disagrees with your own checkout's count is the signal — an old checkout, a
stale build, or tests that never ran — and it is worth stopping on either way.
The two skips are `RealMediaBenchmark.hardwareVersusSoftwareOnRealVideo` and
`av1InputRoutesAccordingToDeviceDecodeSupport`, which `assumeTrue` their sample files
are present and skip when they are not. That is by design and unrelated to API level.
`av1InputRoutesAccordingToDeviceDecodeSupport`, which `assumeTrue` their sample files are present
and skip when they are not. That is by design and unrelated to API level.
One harmless warning appears during the run and can be ignored:
`No UID for androidx.test.services in user 0`, from an `appops` call the test services
package makes before it is fully registered.
So the app is correct on Android 17. What is missing is only *automated* coverage in
CI. Until the image is fixed, run the suite on a physical API 37 device before release;
that is the substitute for the missing matrix row.
`No UID for androidx.test.services in user 0`, from an `appops` call the test services package
makes before it is fully registered.
## Reproducing it
Locally, with `-gpu host` so the emulator itself does not segfault on Intel graphics:
Both halves, so the renderer claim can be checked rather than taken on trust:
```bash
export ANDROID_HOME="$HOME/Android/Sdk"
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
sdkmanager --install "system-images;android-37.0;google_apis;x86_64"
echo no | avdmanager create avd -n api37_repro \
-k "system-images;android-37.0;google_apis;x86_64" -d pixel_6 --force
$ANDROID_HOME/emulator/emulator -avd api37_repro \
-no-window -gpu host -noaudio -no-boot-anim -camera-back none -no-snapshot &
# never boots -- surfaceflinger aborts every ~6 s, forever
emulator -avd api37_repro -no-window -gpu host \
-noaudio -no-boot-anim -camera-back none -no-snapshot &
# Boot never completes. Count the aborts:
adb logcat -d -b crash | grep -c hasReadColorBufferDma
# boots in ~85 s, having aborted once or twice on the way
emulator -avd api37_repro -no-window -gpu swangle_indirect \
-noaudio -no-boot-anim -camera-back none -no-snapshot &
```
`sys.boot_completed` never reaches `1`, `pgrep -f system_server` stays empty, and
`keystore2`'s watchdog logs `await_boot_completed ... Overdue` indefinitely.
Count the aborts either way:
To see the CI-side form instead, restore the API 37 row in the E2E matrix of
`status_check.yml` (`api-level: "37.0"` — a bare `37` fails earlier still, during SDK
setup, because there is no `platforms;android-37`).
```bash
adb -s emulator-5554 logcat -d -b crash | grep -c hasReadColorBufferDma
```
Under `-gpu host`, `sys.boot_completed` never reaches `1`, `pgrep -f system_server` stays empty,
and `keystore2`'s watchdog logs `await_boot_completed ... Overdue` indefinitely.
`tools/local-emulator/run-e2e.sh 37` does all of this, with the working renderer picked
automatically — see `gpu_for_api` in that file.
## Filing this upstream
Not yet filed. To file it:
Not yet filed. The report is stronger than it was, because the renderer dependency narrows it:
1. Go to <https://issuetracker.google.com/> and sign in with a Google account.
2. Choose **Report an issue**, then pick the component for the Android emulator — search
the component picker for "Emulator"; it sits under the Android Studio component tree.
If the picker is unclear, Android Studio's **Help → Submit Feedback** opens the same
tracker with the component preselected, and the emulator's own **Extended controls →
Help → File a bug** does likewise.
3. Title it for the mechanism, not the symptom, so it is searchable — for example:
`surfaceflinger aborts in GoldfishMapper::readFromHost (hasReadColorBufferDma) on
android-37.0 google_apis x86_64`.
4. Paste the assertion and backtrace from the top of this document, the environment
table, and the reproduction steps above. State explicitly that it reproduces on two
unrelated hosts under both GPU modes — that is the detail that stops it being closed
as a local graphics problem.
5. List what was ruled out. Bugs that arrive with `-feature -GLDMA` already eliminated
tend not to bounce back asking for it.
6. Attach:
- the guest tombstone, via `adb pull /data/tombstones` (or the `pbtombstone` output
the crash log names)
1. Go to <https://issuetracker.google.com/>, **Report an issue**, and pick the Android emulator
component (search the component picker for "Emulator"; Android Studio's **Help → Submit
Feedback** opens the same tracker with it preselected).
2. Title it for the mechanism: `surfaceflinger aborts in GoldfishMapper::readFromHost
(hasReadColorBufferDma) on android-37.0 and android-37.1 x86_64 -- fatal under -gpu host,
intermittent under ANGLE`.
3. Paste the assertion and backtrace, the environment block, and the seven-row matrix. The
matrix is the valuable part: it shows the abort is not renderer-specific but its *frequency*
is, which points at the readback path rather than at any one GL implementation.
4. State that it reproduces on two independent system images (`37.0` rev 6 and `37.1` rev 8) and
on two unrelated hosts, and that `-feature -GLDMA,-GLDMA2,-GLDirectMem` does not suppress it.
5. Attach:
- the guest tombstone, via `adb pull /data/tombstones` (or the `pbtombstone` output the crash
log names)
- `adb logcat -d -b crash > crash.txt`
- the emulator's own stdout log, captured by redirecting the launch command
- the emulator's own stdout log (`-verbose -debug all`, redirected)
- the AVD's `config.ini`
- a link to a failing CI job, which shows it on hardware you do not control:
<https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32545625459/job/96963461184>
@@ -199,16 +698,52 @@ Record the issue number here once filed.
## When to revisit
Re-add the API 37 row when any of these happens:
The old trigger list named "a new `android-37.0` revision", which is why nothing ever fired even
though a new API level shipped. Watch for these instead:
- a new `android-37.0` system image revision ships (this was revision 6)
- an ATD image appears for API 37
- the upstream issue is marked fixed
- **any new API 37.x system image**, not just a new revision of `37.0` — `37.1` rev 8 and
`37.2-beta*` already exist, and more will. Test with `-gpu host`: if it boots, the guest mapper
is fixed.
- **an ATD image for API 37.** Still none as of 2026-08-22. ATD images ship without SystemUI,
which is what drives `RegionSamplingThread`, so one would very likely sidestep the bug
entirely. Try it before anything else here.
- **the upstream issue being marked fixed.**
- **`E2E API 37 Media3 hardware transcode (advisory)` going green.** Nothing announces this: the
job is `continue-on-error`, so it fixing itself looks exactly like a check nobody reads
quietly ceasing to be red. It is listed here because that makes it the *least* likely of these
triggers to be noticed, not the most. When it happens, delete `@FailsOnEmulatorApi37` from
everything carrying it rather than deleting the job — the gating leg picks them back up on its
own, and the advisory job then runs nothing and can go.
Until then the gap is narrower than the missing row suggests. `targetSdk` is 37, so the
app is compiled and unit-tested against it; the API-dependent behaviour this matrix
exists to exercise — the foreground service type, absent below 34, `dataSync` at 34,
`mediaProcessing` from 35 — is covered at 35 and 36; and the full instrumented suite has
been run green on real API 37 hardware. What is missing is *automated* API 37 coverage,
so a regression there would not be caught by a pull request. Run the suite on a physical
API 37 device before each release for as long as this row is absent.
**It is not two tests any more.** As of 2026-08-24 the marker is on `Media3EngineTest`'s two
methods *and* on `SafPickerRoundTripTest` as a class, and the two groups fail for unrelated
reasons — a codec and the gralloc mapper. They can go green independently, so check both before
concluding the marker is done; and the job's name still says "Media3 hardware transcode", which
half of what it runs is not.
## Correction owed to `CLAUDE.md`
`CLAUDE.md` currently says:
> - **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its own
> gralloc mapper, so every test fails there regardless of this app.
> `docs/api-37-emulator-crash.md` records the evidence and the ruled-out fixes; CI's matrix
> therefore stops at API 36 even though targetSdk is 37.
The first sentence is right, and now under-specified in one direction and over-specified in the
other: it is not only `android-37.0` (it is `37.1` too), and it does not crash-loop under every
renderer. **The last clause is now simply false: CI's matrix does not stop at API 36 any more.**
Proposed replacement, offered for review rather than applied here — `CLAUDE.md` is left alone
deliberately, because several branches touch it:
> - **The API 37 images crash-loop surfaceflinger under the host GL renderer.** Both
> `android-37.0` and `android-37.1` abort inside their own gralloc mapper
> (`RegionSamplingThread` → `GoldfishMapper::readFromHost`), and when surfaceflinger dies init
> SIGKILLs zygote, so the framework restarts under the test run. Under `-gpu host` it never
> boots at all; under `-gpu swangle_indirect` it boots and the aborts merely become
> intermittent. `docs/api-37-emulator-crash.md` has the seven-run matrix and the ruled-out
> list, and `tools/local-emulator/run-e2e.sh` picks the working renderer per API level.
> CI takes API 37 as two jobs: a gating `E2E API 37` that disables SystemUI first, and an
> advisory leg carrying the two `@FailsOnEmulatorApi37` tests. The gating leg therefore runs a
> device configuration nothing else does. **API 37 still needs a manual check on the Pixel 10
> Pro XL before each release** — it is the only API 37 run with SystemUI intact.
+52 -17
View File
@@ -1,8 +1,10 @@
# Emulators do run on this host: the segfault is SwiftShader's JIT against SELinux
**Status:** solved. Local instrumented runs work with `-gpu host`, and the suite is green
on API 33–36 — 49 tests, 0 failures, 0 errors, 2 skipped on every level. See
[The sweep, run](#the-sweep-run).
**Status:** solved. Local instrumented runs work with `-gpu host`, and the whole suite is green
on API 33–36 — 0 failures, 0 errors and the two by-design skips on every level, measured as
49 / 0 / 0 / 2 at `22c7914`, where the suite was 49 tests. See [The sweep, run](#the-sweep-run),
and [Reading these totals](api-37-emulator-crash.md#reading-these-totals) before comparing any
total with another checkout's.
**Last verified:** 2026-08-22, emulator `37.1.11.0` (build 15917651), Fedora 44,
kernel `7.1.8-200.fc44`, `selinux-policy-44.6-1.fc44`
@@ -190,11 +192,28 @@ emulator -avd <name> -no-window -gpu host \
`.github/scripts/e2e-run.sh` for the run itself. Use it rather than the raw command:
```bash
tools/local-emulator/run-e2e.sh # API 33 34 35 36
tools/local-emulator/run-e2e.sh # API 33 34 35 36 37
tools/local-emulator/run-e2e.sh 35 # one level
tools/local-emulator/run-e2e.sh 37 37.1 # both API 37 images
GPU_MODE=swangle_indirect tools/local-emulator/run-e2e.sh 35
```
Levels are the labels above, not SDK ints: API 37's SDK directories are dotted
(`android-37.0`, `android-37.1`) and there is no `android-37`, so `37` is accepted as a
spelling of `37.0`. Setting `GPU_MODE` forces one renderer on every level, which is what
you want when measuring a mode; leaving it unset lets `gpu_for_api` pick, which is what
you want when running the suite — 33–36 need `host` and 37 must not have it.
**A bare `run-e2e.sh` exits 1, and that is the design.** API 37 is in the default list
deliberately — leaving it out is what left the level unlooked-at for as long as it was — and
it is permanently two failures short of green: `Media3EngineTest` cannot drive the emulator's
`c2.goldfish.h264.decoder` on those images, which
[`api-37-emulator-crash.md`](api-37-emulator-crash.md) pins on the image and not on this app
(API 35 under the same renderer is green). The summary row names the two expected failures so
that a third is visibly new, and the script repeats the point on the way out. Anything that
treats a non-zero exit as breakage — a wrapper, a hook, a habit — should name the levels it
wants: `run-e2e.sh 33 34 35 36` is the sweep that can be green.
`swangle_indirect` is the fallback worth knowing about. It is entirely software, so it
does not depend on reaching the session's GPU — useful over plain SSH, where `-gpu host`
has not been tested and may not find a device. It is also the closest local analogue to
@@ -236,8 +255,9 @@ after an AGP upgrade.
## The sweep, run
`tools/local-emulator/run-e2e.sh`, one invocation per level so each got a freshly created
AVD, `-gpu host` throughout, 2026-08-22 19:42–19:56. Every level matches the physical
Pixel 10 Pro XL (API 37) baseline of 49 / 0 / 0 / 2 exactly:
AVD, `-gpu host` throughout, 2026-08-22 19:42–19:56, on `22c7914`. All four levels agree exactly,
and 49 is that checkout's whole suite — every `@Test` in `app/src/androidTest`, two of which skip
by design everywhere:
| API | Android | AVD | Boot | `connectedDebugAndroidTest` | Tests | Failures | Errors | Skipped |
|---|---|---|---|---|---|---|---|---|
@@ -246,6 +266,12 @@ Pixel 10 Pro XL (API 37) baseline of 49 / 0 / 0 / 2 exactly:
| 35 | 15 | `lmc_e2e_api35` | 40 s | 3 m 46 s | 49 | 0 | 0 | 2 |
| 36 | 16 | `lmc_e2e_api36` | 90 s | 2 m 18 s | 49 | 0 | 0 | 2 |
The physical Pixel has never reported 49, and an earlier version of this paragraph said the
sweep matched it exactly. Its green API 37 run was 40 / 0 / 0 / 2, at `edd6385` — the same suite
nine tests earlier. What matches is 0 failures, 0 errors and the same two skips; totals only ever
match between runs of one checkout, which
[`api-37-emulator-crash.md`](api-37-emulator-crash.md#reading-these-totals) sets out.
Thirteen and a half minutes for the four levels, AVD creation and cold boots included;
fifteen with the pre-warm build in front of them. Nothing needed a retry, and no level
produced a `diagnostics-api*.txt` — `e2e-run.sh` writes that only on the failure path, so
@@ -352,9 +378,11 @@ and the same binaries against the same kernel boot fine under `-gpu host`.
before `sys.boot_completed` is ever set. Nothing in the guest — system image variant,
RAM, disk size, ATD versus `google_apis` — can influence a host-side `mprotect` denial,
so none of those axes was varied. (The API 37 failure in
[`api-37-emulator-crash.md`](api-37-emulator-crash.md) is genuinely guest-side and
genuinely unrelated: there the host emulator survives and the guest's `surfaceflinger`
aborts.)
[`api-37-emulator-crash.md`](api-37-emulator-crash.md) is genuinely guest-side — there the host
emulator survives and the guest's `surfaceflinger` aborts — but it is **not** unrelated, as this
paragraph originally claimed. Both are decided by the renderer, in opposite directions: below 37
you must avoid SwiftShader GLES and `-gpu host` is the answer; at 37 you must avoid the *host* GL
translator and `-gpu host` is the thing that never boots.)
**Turning the SELinux boolean on** — deliberately *not* done, though it would almost
certainly work:
@@ -401,8 +429,13 @@ are easy to forget to look at.
"SwiftShader 4.0.0.1" as reported by the GLES translator.
- **If `-gpu host` regresses** after a Mesa or kernel update, fall back to
`GPU_MODE=swangle_indirect`, which needs no GPU at all.
- **This changes nothing about API 37.** That image is broken for a different reason and
still must be checked on the physical Pixel 10 Pro XL before each release.
- **API 37 needs the opposite renderer, and this file used to say it needed nothing.** The
original bullet here read "This changes nothing about API 37"; that turned out to be wrong.
The API 37 images abort `surfaceflinger` under the *host* GL translator and boot under ANGLE —
the exact mirror of the rule above — and `run-e2e.sh` therefore picks the renderer per API
level. See [`api-37-emulator-crash.md`](api-37-emulator-crash.md), which was rewritten on
2026-08-22 with the seven-run matrix. API 37 still must be checked on the physical Pixel 10 Pro
XL before each release.
## Correction owed to `CLAUDE.md`
@@ -432,12 +465,14 @@ Proposed replacement for the section, offered for review rather than applied her
> `auto` (the default), `off` and `guest` do when headless. `-gpu host` works, and the
> harness both picks it and refuses the others. `docs/local-emulator.md` has the
> backtrace and the mode matrix.
> - **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its
> own gralloc mapper, so every test fails there regardless of this app —
> `docs/api-37-emulator-crash.md` records the evidence and the ruled-out fixes. This is
> unrelated to the renderer above: it is a guest-side bug that CI hits too, which is why
> the matrix stops at API 36 even though targetSdk is 37. **API 37 needs a manual check
> on the Pixel 10 Pro XL before each release.**
> - **API 37 needs the opposite renderer, and SystemUI turned off.** Both `android-37.0` and
> `android-37.1` abort surfaceflinger inside their own gralloc mapper, and init SIGKILLs
> zygote each time. Under `-gpu host` they never boot; under `-gpu swangle_indirect` they
> boot, and disabling SystemUI removes the trigger. `run-e2e.sh` does all of that per level,
> and the local API 37 result is two failures and the two usual skips, not a clean run. CI
> takes API 37 as a gating leg plus an advisory one carrying those two tests.
> `docs/api-37-emulator-crash.md` has the matrix and the reasoning. **API 37 needs a manual
> check on the Pixel 10 Pro XL before each release.**
The wording is worth getting right rather than merely correcting, because the original was
not a careless sentence — it was a reasonable inference from three crashes, written down
+33
View File
@@ -42,6 +42,18 @@ annotation = "1.+"
junit = "4.+"
androidxJunit = "1.+"
espressoCore = "3.+"
# UiAutomator. FLOATING, and the argument for it is the one the guard already makes:
# androidx.test.uiautomator is inside `floatedGroupPrefixes` ("androidx."), so `2.+` reads
# as "the newest RELEASED 2.x" exactly the way `work = "2.+"` does -- and this library does
# publish alphas above its stable, so without the guard it would be a pin.
#
# Not pinned like ktlint/detekt/robolectric, because it is not that kind of dependency. Those
# are pinned because a new *rule* or a new *runtime* makes untouched files fail -- the tool
# changes its verdict on code nobody edited. UiAutomator has no verdict: it clicks what a
# selector names, and a selector that stops matching is this repo's test to fix, in a diff
# that says so. `2.` and not bare `+` because 3.x does not exist yet and a major is where the
# selector API would be free to change under exactly that assumption.
uiautomator = "2.+"
# PINNED, unlike its neighbours. Under semver a 0.x minor is allowed to break, and
# this library is load-bearing exactly where breakage is hardest to see: the wrapper
# reaches for smartexception.java.Exceptions only when an FFmpeg call FAILS, so a
@@ -80,6 +92,17 @@ jacoco = "0.8.15"
# 4.16.1 is the newest RELEASED version; the 4.17 line is beta-only at the time of writing.
robolectric = "4.16.1"
# kotlinx-coroutines-test. Already on the unit-test classpath transitively, through
# compose-ui-test-junit4 -- declared here because a source file now imports it, and a direct
# import of a transitive is a dependency nobody chose.
#
# PINNED, for the same reason as robolectric above: org.jetbrains.kotlinx is not one of the
# groups in the prerelease guard's `floatedGroupPrefixes`, so a "1.+" here would be free to
# resolve to a milestone build. This value is what the Compose BOM already resolves it to, so
# stating it changes nothing in the graph today; if the BOM moves ahead, Gradle takes the
# higher version and this stays a floor rather than a conflict.
coroutinesTest = "1.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -128,12 +151,22 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
# The only way to touch UI this app does not own. Compose's own matchers stop at this
# process's composition, and the system file picker is a DocumentsUI activity in another
# process -- so a SAF round trip is unreachable without it.
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
# Robolectric — an Android runtime for the JVM test source set, so file-lifecycle behaviour
# that needs a real Context can be verified without a device. The instrumented suite cannot
# run on the development host at all (see CLAUDE.md), so an androidTest-only red test is not
# a TDD loop anyone here can execute.
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
# Only for its `runTest`, and only so the one test that deliberately lets a coroutine error
# escape owns the collector callback while it does -- otherwise the error is kept process-wide
# and rethrown at whichever `runTest` starts next. See ConversionViewModelProbeFailureTest.
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
[plugins]
# com.android.application and org.jetbrains.kotlin.plugin.compose are deliberately absent.
# They come from the root buildscript classpath (see build.gradle.kts) so that a newer KGP
View File
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env bash
#
# Files a GitHub issue AND puts it on the project board, as one operation.
#
# Usage: tools/github/file-issue.sh --title TITLE (--body TEXT | --body-file PATH) [options]
#
# --status NAME board column, matched case-insensitively against the board's own
# options; a miss lists what is available. Default: Backlog
# --label NAME repeatable. Passed through to `gh issue create` unchanged.
# --project N project number. Default: $ISSUE_PROJECT_NUMBER, else 6
# --repo OWNER/NAME default: whatever `gh repo view` resolves in the working directory
# --dry-run resolve and validate everything, create nothing
#
# EXIT CODE: 0 only when the issue exists, is on the board, AND reads back carrying the
# Status that was asked for. 2 for a usage or validation error, before anything is created.
# **3 means the issue was created but did not reach the board** -- the number is printed on
# a line of its own, because that combination is the entire failure this script exists to
# prevent and it must never be quiet.
#
# WHY THIS EXISTS
#
# `gh issue create` does not touch the project board. The issue is created, carries its
# labels, and is invisible in the Kanban -- which looks exactly like a ticket nobody filed.
# Measured 2026-08-24: eight issues filed as a scripted batch all reached the board; one
# filed as a one-off a few minutes later did not, and was caught only because someone went
# looking. A batch carries the board step inside its loop. One-offs are where it slips, so
# one-offs are what this is for.
#
# Adding an item and setting a field value are GraphQL-only. REST can list project items
# and field definitions, but the `fields` array it returns on an item carries Title and
# nothing else -- a REST-only check reports every item's Status as unset, which is why the
# read-back at the end is a GraphQL query rather than the cheaper REST one.
#
# WHAT IT DELIBERATELY DOES NOT DO
#
# It does not cache the project, field or option ids. Resolving them by name costs one
# GraphQL query per run, and it means a renamed or reordered column cannot make this write
# a stale id. The ids are the fragile part; the names are what people actually use.
#
# It does not apply triage labels for you. `above-cut` and `backlog` are labels from one
# specific 2026-08-22 triage pass -- they mean "worked autonomously overnight" and "held for
# manual review", not "this is in the Backlog column". Status carries board state. Pass
# --label only for things that are true about the issue itself.
#
# It does not create the project, the Status field, or a missing option. Anything absent is
# an error to report, not to invent.
set -euo pipefail
readonly EXIT_USAGE=2
readonly EXIT_ORPHANED=3
die() {
printf 'file-issue: %s\n' "$1" >&2
exit "${2:-$EXIT_USAGE}"
}
title=""
body=""
body_file=""
status="Backlog"
project="${ISSUE_PROJECT_NUMBER:-6}"
repo=""
dry_run=0
labels=()
while [ $# -gt 0 ]; do
case "$1" in
--title) [ $# -ge 2 ] || die "--title needs a value"; title="$2"; shift 2 ;;
--body) [ $# -ge 2 ] || die "--body needs a value"; body="$2"; shift 2 ;;
--body-file) [ $# -ge 2 ] || die "--body-file needs a path"; body_file="$2"; shift 2 ;;
--status) [ $# -ge 2 ] || die "--status needs a value"; status="$2"; shift 2 ;;
--label) [ $# -ge 2 ] || die "--label needs a value"; labels+=("$2"); shift 2 ;;
--project) [ $# -ge 2 ] || die "--project needs a number"; project="$2"; shift 2 ;;
--repo) [ $# -ge 2 ] || die "--repo needs OWNER/NAME"; repo="$2"; shift 2 ;;
--dry-run) dry_run=1; shift ;;
-h|--help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"
exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
[ -n "$title" ] || die "--title is required"
if [ -n "$body" ] && [ -n "$body_file" ]; then
die "pass --body or --body-file, not both"
fi
[ -n "$body" ] || [ -n "$body_file" ] || die "one of --body or --body-file is required"
if [ -n "$body_file" ] && [ ! -r "$body_file" ]; then
die "--body-file is not readable: $body_file"
fi
case "$project" in
''|*[!0-9]*) die "--project must be a number, got: $project" ;;
esac
command -v gh >/dev/null 2>&1 || die "gh is not on PATH"
if [ -z "$repo" ]; then
repo=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') \
|| die "could not resolve the repository; pass --repo OWNER/NAME"
fi
owner="${repo%%/*}"
[ -n "$owner" ] || die "could not read an owner out of: $repo"
# ---------------------------------------------------------------------------
# Resolve the board by NAME. Every id below is read fresh; none is hardcoded.
# ---------------------------------------------------------------------------
# The $names in the query are GraphQL variables, declared by the query and bound by the
# -f flags. Expanding them in the shell would send this shell's idea of $owner to the
# API instead of declaring a parameter -- which is why every query here is single-quoted.
# shellcheck disable=SC2016
board=$(gh api graphql \
-f query='
query($owner: String!, $number: Int!) {
user(login: $owner) {
projectV2(number: $number) {
id
title
field(name: "Status") {
... on ProjectV2SingleSelectField { id options { id name } }
}
}
}
}' \
-f owner="$owner" -F number="$project" 2>&1) \
|| die "could not read project $project for $owner. A 403 naming scopes means gh is
missing 'project'; a 403 naming a rate limit is the GraphQL budget, not permissions. The
API said: $board"
project_id=$(printf '%s' "$board" | jq -r '.data.user.projectV2.id // empty')
field_id=$(printf '%s' "$board" | jq -r '.data.user.projectV2.field.id // empty')
project_title=$(printf '%s' "$board" | jq -r '.data.user.projectV2.title // empty')
[ -n "$project_id" ] || die "no project number $project under user $owner"
[ -n "$field_id" ] || die "project $project has no single-select field named 'Status'"
# Case-insensitive match, so "backlog" and "Backlog" both work. The canonical name is
# what gets reported back, so a sloppy argument still produces an exact log line.
option=$(printf '%s' "$board" | jq -r --arg want "$status" '
.data.user.projectV2.field.options[]
| select((.name | ascii_downcase) == ($want | ascii_downcase))
| "\(.id)\t\(.name)"' | head -n 1)
if [ -z "$option" ]; then
printf 'file-issue: no Status option named %s. Available:\n' "$status" >&2
printf '%s' "$board" | jq -r '.data.user.projectV2.field.options[] | " " + .name' >&2
exit "$EXIT_USAGE"
fi
option_id="${option%%$'\t'*}"
status_canonical="${option#*$'\t'}"
printf 'repo %s\n' "$repo"
printf 'board %s (project %s)\n' "$project_title" "$project"
printf 'status %s\n' "$status_canonical"
printf 'labels %s\n' "${labels[*]:-(none)}"
printf 'title %s\n' "$title"
if [ "$dry_run" -eq 1 ]; then
printf '\ndry run: everything above resolved; nothing was created.\n'
exit 0
fi
# ---------------------------------------------------------------------------
# Create. Past this line a failure can leave an issue off the board, so every
# error path prints the number.
# ---------------------------------------------------------------------------
create_args=(--repo "$repo" --title "$title")
if [ -n "$body_file" ]; then
create_args+=(--body-file "$body_file")
else
create_args+=(--body "$body")
fi
for label in ${labels[@]+"${labels[@]}"}; do
create_args+=(--label "$label")
done
issue_url=$(gh issue create "${create_args[@]}") || die "gh issue create failed; nothing was filed"
issue_number="${issue_url##*/}"
case "$issue_number" in
''|*[!0-9]*) die "could not read an issue number out of: $issue_url" ;;
esac
orphaned() {
printf 'file-issue: %s\n' "$1" >&2
printf 'file-issue: THE ISSUE EXISTS BUT IS NOT ON THE BOARD. Fix it by hand:\n' >&2
printf '%s\n' "$issue_url" >&2
exit "$EXIT_ORPHANED"
}
content_id=$(gh api "/repos/$repo/issues/$issue_number" --jq '.node_id') \
|| orphaned "could not read the node id for #$issue_number"
# shellcheck disable=SC2016 # GraphQL variables, as above
item_id=$(gh api graphql \
-f query='
mutation($project: ID!, $content: ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $content}) {
item { id }
}
}' \
-f project="$project_id" -f content="$content_id" \
--jq '.data.addProjectV2ItemById.item.id') \
|| orphaned "could not add #$issue_number to the board"
[ -n "$item_id" ] || orphaned "the board add returned no item id for #$issue_number"
# shellcheck disable=SC2016 # GraphQL variables, as above
gh api graphql \
-f query='
mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $project, itemId: $item, fieldId: $field,
value: {singleSelectOptionId: $option}
}) { projectV2Item { id } }
}' \
-f project="$project_id" -f item="$item_id" -f field="$field_id" -f option="$option_id" \
>/dev/null \
|| orphaned "#$issue_number is on the board but its Status could not be set"
# ---------------------------------------------------------------------------
# Read back. A mutation returning 200 is not evidence the board shows what was
# asked for -- this is the only check that is.
# ---------------------------------------------------------------------------
# shellcheck disable=SC2016 # GraphQL variables, as above
readback=$(gh api graphql \
-f query='
query($item: ID!) {
node(id: $item) {
... on ProjectV2Item {
content { ... on Issue { number } }
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { name }
}
}
}
}' \
-f item="$item_id") \
|| orphaned "#$issue_number was written but could not be read back"
seen_number=$(printf '%s' "$readback" | jq -r '.data.node.content.number // empty')
seen_status=$(printf '%s' "$readback" | jq -r '.data.node.fieldValueByName.name // empty')
if [ "$seen_number" != "$issue_number" ]; then
orphaned "read-back names issue #${seen_number:-<none>}, expected #$issue_number"
fi
if [ "$seen_status" != "$status_canonical" ]; then
orphaned "read-back Status is ${seen_status:-<unset>}, expected $status_canonical"
fi
printf '\n#%s on %s as %s -- verified by read-back\n' \
"$issue_number" "$project_title" "$seen_status"
printf '%s\n' "$issue_url"
+390 -50
View File
@@ -3,13 +3,26 @@
# Runs the instrumented suite on a local emulator, on this workstation, for one or more
# API levels.
#
# Usage: tools/local-emulator/run-e2e.sh [API ...] # default: 33 34 35 36
# Usage: tools/local-emulator/run-e2e.sh [API ...] # default: 33 34 35 36 37
#
# GPU_MODE=host renderer to use; see the refusal list below
# API levels are the labels below, not SDK ints: 33-36, plus `37` (= `37.0`) and `37.1`.
#
# GPU_MODE= force one renderer on every level; unset means per-API (gpu_for_api)
# EMULATOR_PORT=5560 console port, so the serial is deterministic
# BOOT_TIMEOUT=300 seconds to wait for sys.boot_completed
# KEEP_AVD=1 do not delete an AVD this script created
#
# EXIT CODE: 0 only if every level was green; 1 if any level failed, wedged or could not be
# set up; 2 if it refused to start at all. **A bare `run-e2e.sh` therefore exits 1 by design.**
# API 37 is in the default list on purpose -- leaving it out is what left the level unlooked-at
# for as long as it was -- and it is permanently short of green, on the emulator image rather
# than on anything this app does. Since 2026-08-24 it does not even FINISH: one of its expected
# failures kills the framework, so the totals come back short with an arbitrary tail. The summary
# names every failure it expects, so an unnamed one is visibly new, and the last line printed
# says the same thing. Anything that reads a non-zero exit as breakage should name the levels it
# wants: `run-e2e.sh 33 34 35 36` is the sweep that can be green.
# docs/api-37-emulator-crash.md has the measurements.
#
# WHY THIS EXISTS, AND WHAT IT DELIBERATELY DOES NOT DO
#
# It is a *launcher*, not a second test harness. The diagnostics -- the FAILED-vs-WEDGED
@@ -34,6 +47,14 @@
# those modes was measured crashing. docs/local-emulator.md has the backtrace, the faulting
# page's RW-without-E segment flags, and the full mode matrix.
#
# AND THE ONE THING API 37 NEEDS THAT 33-36 DO NOT: the opposite renderer. On the API 37
# images the guest's Gralloc5 mapper aborts surfaceflinger from RegionSamplingThread
# (`Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma`). Under `-gpu host` that
# repeats every few seconds and the device never boots; under ANGLE it fires a handful of
# times and the boot survives. So `host` is required below 37 and forbidden at 37, which is
# why the renderer is chosen per level in gpu_for_api rather than set once.
# docs/api-37-emulator-crash.md has that matrix.
#
# THE OTHER LOCAL-ONLY HAZARD: a physical Pixel is usually plugged into this machine, so
# `adb` is ambiguous in a way it never is on a runner, and an unpinned run would install
# and execute this suite on the phone. Every path below pins the emulator serial.
@@ -65,12 +86,14 @@ export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
GPU_MODE="${GPU_MODE:-host}"
# Empty means "let each level pick" -- see gpu_for_api. Setting GPU_MODE forces one renderer
# on every level, which is what you want when measuring a mode, not when running the suite.
GPU_MODE="${GPU_MODE:-}"
EMULATOR_PORT="${EMULATOR_PORT:-5560}"
BOOT_TIMEOUT="${BOOT_TIMEOUT:-300}"
SERIAL="emulator-${EMULATOR_PORT}"
APIS=("$@")
[ "${#APIS[@]}" -eq 0 ] && APIS=(33 34 35 36)
[ "${#APIS[@]}" -eq 0 ] && APIS=(33 34 35 36 37)
# Matches CI. `disk-size: 8G` because the FFmpeg libraries do not fit the default userdata
# partition; `ram-size: 2560M` because the emulator's own floor varies by API level and
@@ -83,21 +106,28 @@ RESULTS_DIR="app/build/outputs/androidTest-results"
LOG_DIR="${TMPDIR:-/tmp}/lmc-local-e2e"
mkdir -p "$LOG_DIR"
# The two things that outlive a level, declared here rather than where they are first
# assigned, because the cleanup trap below can fire before either has been reached.
EMU_PID=""
CREATED_AVDS=()
# ---------------------------------------------------------------- renderer preflight ---
case "$GPU_MODE" in
swiftshader_indirect | auto | off | guest)
echo "REFUSING to launch with -gpu $GPU_MODE."
echo "On this host that resolves to SwiftShader's GLES, whose JIT is denied execheap by"
echo "SELinux; the emulator segfaults (exit 139) before boot. See docs/local-emulator.md."
echo "Working modes: host (default), angle_indirect, swangle_indirect."
exit 2
;;
host | angle_indirect | swangle_indirect) ;;
*)
echo "Unrecognised GPU_MODE '$GPU_MODE'. Known-good: host, angle_indirect, swangle_indirect."
exit 2
;;
esac
if [ -n "$GPU_MODE" ]; then
case "$GPU_MODE" in
swiftshader_indirect | auto | off | guest)
echo "REFUSING to launch with -gpu $GPU_MODE."
echo "On this host that resolves to SwiftShader's GLES, whose JIT is denied execheap by"
echo "SELinux; the emulator segfaults (exit 139) before boot. See docs/local-emulator.md."
echo "Working modes: host, angle_indirect, swangle_indirect."
exit 2
;;
host | angle_indirect | swangle_indirect) ;;
*)
echo "Unrecognised GPU_MODE '$GPU_MODE'. Known-good: host, angle_indirect, swangle_indirect."
exit 2
;;
esac
fi
# A courtesy, not a gate: the boolean being on means SwiftShader would work too, and the
# refusal list above could be relaxed. It is off on a stock Fedora.
@@ -121,19 +151,109 @@ host_forensics() {
journalctl --since "$since" --no-pager 2> /dev/null | grep -E 'avc: .*denied' | tail -10 || echo " (none)"
}
# The API 37 counterpart of host_forensics. `-gpu host` there aborts surfaceflinger in a loop
# and the device never boots; the working renderers abort it a few times and survive. Either way
# the count is the number to look at, and the crash buffer is where it lives -- so print it on
# every 37 level, not only on the failure path, because a level that passed with 40 aborts is
# telling you something a level that passed with 1 is not.
guest_forensics() {
local api="$1" n
case "$api" in 37 | 37.*) ;; *) return 0 ;; esac
n="$(emu_adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma')"
echo " surfaceflinger hasReadColorBufferDma aborts: ${n:-?} (docs/api-37-emulator-crash.md)"
}
# API label -> system image. API 33-36 are plain integers with a `google_apis` image. API 37
# is not: its SDK directories are dotted minor versions (`android-37.0`, `android-37.1`), there
# is no `android-37`, and from 37.1 onwards Google ships only 16 KB-page (`ps16k`) images for
# x86_64. `37` is accepted as a spelling of `37.0` because that is what people type.
image_pkg_for_api() {
case "$1" in
37 | 37.0) echo "system-images;android-37.0;google_apis;x86_64" ;;
37.1) echo "system-images;android-37.1;google_apis_ps16k;x86_64" ;;
*) echo "system-images;android-$1;google_apis;x86_64" ;;
esac
}
# The renderer requirement is per-API and the two levels want OPPOSITE things, which is why this
# is a function and not a constant.
#
# 33-36: must NOT be SwiftShader GLES (host-side SELinux/execheap segfault) -- `host` is right.
# 37.x: must NOT be the host GL translator. With `-gpu host` the guest's Gralloc5 mapper
# aborts surfaceflinger in a loop and the device never boots; under ANGLE the same
# assertion fires a handful of times and the boot survives it. Measured, not guessed --
# docs/api-37-emulator-crash.md has the matrix.
#
# `swangle_indirect` rather than `angle_indirect` for 37: both boot, and swangle names its
# renderer outright instead of resolving through `auto`'s path.
gpu_for_api() {
if [ -n "$GPU_MODE" ]; then
echo "$GPU_MODE"
return
fi
case "$1" in
37 | 37.*) echo "swangle_indirect" ;;
*) echo "host" ;;
esac
}
# `lmc_e2e_api37.0` would be a legal AVD name but an awkward one to type and to grep for.
# `37` and `37.0` therefore give two AVD names (`lmc_e2e_api37`, `lmc_e2e_api37_0`) for the one
# image. Harmless -- two AVDs off the same system image cost only disk -- and deliberately not
# normalised, so that `run-e2e.sh 37 37.0` does not have both levels fight over one AVD.
avd_for_api() { echo "lmc_e2e_api${1//./_}"; }
# Where avdmanager actually put the AVD. `$HOME/.android/avd` is only the default:
# ANDROID_AVD_HOME, ANDROID_USER_HOME and ANDROID_SDK_HOME each move it, and hardcoding the
# default meant a machine that sets any of them silently ran every level at stock RAM and
# userdata size. Rather than encode a precedence that cannot be verified from here, look in
# every location avdmanager honours and let the existence check pick.
avd_config_path() {
local avd="$1" base cfg
for base in "${ANDROID_AVD_HOME:-}" \
"${ANDROID_USER_HOME:+$ANDROID_USER_HOME/avd}" \
"${ANDROID_SDK_HOME:+$ANDROID_SDK_HOME/.android/avd}" \
"$HOME/.android/avd"; do
[ -n "$base" ] || continue
cfg="$base/${avd}.avd/config.ini"
if [ -f "$cfg" ]; then
echo "$cfg"
return 0
fi
done
return 1
}
ensure_avd() {
local api="$1" avd="$2"
local pkg="system-images;android-${api};google_apis;x86_64"
local pkg
pkg="$(image_pkg_for_api "$api")"
local img_dir="$ANDROID_HOME/system-images/${pkg#system-images;}"
img_dir="${img_dir//;//}"
if avdmanager list avd -c 2> /dev/null | grep -qx "$avd"; then
echo " reusing existing AVD $avd"
else
if [ ! -d "$ANDROID_HOME/system-images/android-${api}/google_apis/x86_64" ]; then
if [ ! -d "$img_dir" ]; then
echo " installing $pkg"
yes | sdkmanager --install "$pkg" > /dev/null 2>&1 || {
# Read sdkmanager's own status, not the pipeline's. `yes` never ends, so the moment
# sdkmanager exits and closes the pipe, `yes` dies of SIGPIPE with 141 -- and this
# script runs under `pipefail`, which takes the rightmost NON-ZERO status. A package
# that installed perfectly therefore reported "FAILED to install".
#
# Measured rather than reasoned: under `set -o pipefail`, `yes | true` exits 141 on
# every run, and `yes | sh -c 'exit 3'` exits 3 -- so the pipeline status cannot tell
# a clean install from a broken one, while ${PIPESTATUS[1]} reports 0 and 3.
#
# The `echo no | avdmanager` below is deliberately NOT changed. One line fits the pipe
# buffer, so echo has already exited before the close and there is no signal to
# receive; `echo no | true` measured 0 on every run. Only an unbounded producer is
# exposed to this.
yes | sdkmanager --install "$pkg" > /dev/null 2>&1
if [ "${PIPESTATUS[1]}" -ne 0 ]; then
echo " FAILED to install $pkg"
return 1
}
fi
fi
echo " creating AVD $avd from $pkg"
echo no | avdmanager create avd -n "$avd" -k "$pkg" -d pixel_6 --force > /dev/null 2>&1 || {
@@ -144,18 +264,37 @@ ensure_avd() {
fi
# Written into config.ini rather than passed on the command line, which is how
# reactivecircus/android-emulator-runner applies the same two settings in CI.
local cfg="$HOME/.android/avd/${avd}.avd/config.ini"
sed -i -e '/^disk\.dataPartition\.size=/d' -e '/^hw\.ramSize=/d' "$cfg"
printf 'disk.dataPartition.size=%s\nhw.ramSize=%s\n' "$DISK_SIZE_BYTES" "$RAM_SIZE_MB" >> "$cfg"
# reactivecircus/android-emulator-runner applies the same two settings in CI. On the reuse
# path too, so an AVD left over from an older run gets today's pins.
#
# A level that cannot be pinned FAILS rather than running at the defaults. Unpinned, it
# dies much later with "not enough space", which reads as a device problem -- CI's own
# history is where that lesson comes from -- and nothing points back to a `sed` that
# edited a path this script guessed wrong.
local cfg
if ! cfg="$(avd_config_path "$avd")"; then
echo " FAILED: no config.ini for $avd in any directory avdmanager uses"
echo " (ANDROID_AVD_HOME=${ANDROID_AVD_HOME:-unset}, ANDROID_USER_HOME=${ANDROID_USER_HOME:-unset},"
echo " ANDROID_SDK_HOME=${ANDROID_SDK_HOME:-unset}, HOME=$HOME)"
return 1
fi
if ! sed -i -e '/^disk\.dataPartition\.size=/d' -e '/^hw\.ramSize=/d' "$cfg"; then
echo " FAILED to rewrite $cfg"
return 1
fi
if ! printf 'disk.dataPartition.size=%s\nhw.ramSize=%s\n' \
"$DISK_SIZE_BYTES" "$RAM_SIZE_MB" >> "$cfg"; then
echo " FAILED to write the RAM/disk pins into $cfg"
return 1
fi
}
boot_emulator() {
local avd="$1" api="$2"
local avd="$1" api="$2" gpu="$3"
local boot_log="$LOG_DIR/emulator-api${api}.log"
emulator -avd "$avd" -port "$EMULATOR_PORT" \
-no-window -gpu "$GPU_MODE" -noaudio -no-boot-anim -camera-back none -no-snapshot \
-no-window -gpu "$gpu" -noaudio -no-boot-anim -camera-back none -no-snapshot \
> "$boot_log" 2>&1 &
EMU_PID=$!
@@ -181,6 +320,103 @@ boot_emulator() {
return 1
}
# API 37 only, and the reason API 37 can be run at all.
#
# The abort that breaks these images is reached from SurfaceFlinger's RegionSamplingThread,
# which exists only because SystemUI registers a nav-bar luma-sampling listener. Each abort
# kills surfaceflinger, and init responds by SIGKILLing zygote -- so the whole framework
# restarts underneath the test run, which arrives as `Can't find service: package` and
# `INSTRUMENTATION_ABORTED: System has crashed`. Under the host GL renderer that repeats
# forever; under ANGLE it is roughly one every fifteen seconds, which a five-minute suite does
# not survive either.
#
# Removing the listener removes the whole chain. Measured on android-37.0 under
# swangle_indirect: 10-11 aborts per 150 s idle with SystemUI running, and 0 in 180 s with it
# disabled, framework services up throughout.
#
# THIS IS A DEVIATION, and it is deliberately loud rather than silent. The API 37 leg does not
# run the same device configuration as API 33-36 or as the Pixel. It is defensible only
# because nothing in this suite touched SystemUI -- Media3, FFmpeg and WorkManager tests --
# and because the alternative is no API 37 coverage at all. Anything that ever does depend on
# system UI must not trust this leg. docs/api-37-emulator-crash.md explains why.
#
# "Touched", past tense, since 2026-08-24. SafPickerRoundTripTest drives DocumentsUI and rotates
# the display, and both reach the gralloc mapper these images abort 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; the
# picker test passes.
#
# THIS SCRIPT APPLIES NO ANNOTATION FILTER, unlike CI, so a local `run-e2e.sh 37` runs the
# rotation test anyway -- and because that test kills the framework rather than merely failing,
# THE LEVEL DOES NOT FINISH. Its totals come back short and which later tests ran is arbitrary.
# CI's gating leg never sees it.
#
# The retry loop is not defensive padding: at the moment boot_completed flips, the framework
# may be in one of its restarts and `pm` is simply not published yet. The first attempt at this
# failed exactly that way, with `cmd: Can't find service: package`.
#
# The framework restart at the end is not optional, and finding that out cost a run. By the
# time `sys.boot_completed` flips, SystemUI has already registered its region-sampling listener,
# and `pm disable-user` does not retract a registration that already happened -- it only stops
# the package being started again. So the first attempt disabled SystemUI, reported success, and
# then died exactly as before with `Starting 0 tests` and four more aborts. `stop; start` cycles
# zygote deliberately, and the framework that comes back up does not start SystemUI at all.
disable_region_sampling() {
local api="$1" out i before after ready
case "$api" in 37 | 37.*) ;; *) return 0 ;; esac
out=""
for i in $(seq 1 20); do
out="$(emu_adb shell pm disable-user --user 0 com.android.systemui 2>&1 | tr -d '\r')"
case "$out" in
*"new state: disabled"*)
echo " SystemUI disabled on attempt $i"
break
;;
esac
out=""
sleep 5
done
if [ -z "$out" ]; then
echo " WARNING: could not disable SystemUI after 20 attempts."
echo " Expect INSTRUMENTATION_ABORTED -- docs/api-37-emulator-crash.md"
return 0
fi
echo " restarting the framework so the region-sampling listener goes with it"
emu_adb shell stop > /dev/null 2>&1
emu_adb shell start > /dev/null 2>&1
# There is no property worth waiting on here, and an earlier version of this only looked
# like it was waiting on one: `stop` does not clear sys.boot_completed, so it still reads
# `1` throughout the restart and any loop over it returns at once. The loop below is the
# wait -- and it polls the better thing anyway, since `Can't find service: package` is the
# failure it exists to prevent.
ready=0
for i in $(seq 1 30); do
if emu_adb shell service check package 2> /dev/null | grep -q ': found' \
&& emu_adb shell service check activity 2> /dev/null | grep -q ': found'; then
ready=1
break
fi
sleep 5
done
if [ "$ready" -ne 1 ]; then
echo " WARNING: package and activity services still absent 150 s after the restart."
echo " Expect INSTRUMENTATION_ABORTED -- docs/api-37-emulator-crash.md"
fi
# Prove it worked rather than assume it. Zero new aborts over this window is what makes the
# difference between a run that completes and one that reports `Starting 0 tests`.
before="$(emu_adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma')"
emu_adb shell 'sleep 45' > /dev/null 2>&1
after="$(emu_adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma')"
echo " quiet check: $((after - before)) new surfaceflinger aborts in 45 s (want 0)"
if [ "$((after - before))" -ne 0 ]; then
echo " WARNING: region sampling is still live; the run may not survive."
fi
return 0
}
# CI gets this from the action's `disable-animations: true`.
disable_animations() {
local s
@@ -189,17 +425,81 @@ disable_animations() {
done
}
# `${EMU_PID:-0}` used to guard these three calls, and it guarded the wrong thing: EMU_PID
# is *empty*, not unset, if the background launch never produced a job, and `kill` reads pid
# 0 as "the sender's whole process group" -- this script and, on a terminal, everything else
# in the foreground group with it. The `kill -0` wait loop had the same shape and would have
# spent its full grace period testing the group. Nothing to stop is now a return, never a
# guess. (boot_emulator's own `kill -0 "$EMU_PID"` is unguarded and cannot reach that form:
# it runs only after the assignment.)
#
# max_wait is a parameter so the interrupt path need not sit through the full grace period.
stop_emulator() {
local max_wait="${1:-30}" waited=0
[ -n "${EMU_PID:-}" ] || return 0
emu_adb emu kill > /dev/null 2>&1
local waited=0
while kill -0 "${EMU_PID:-0}" 2> /dev/null && [ "$waited" -lt 30 ]; do
while kill -0 "$EMU_PID" 2> /dev/null && [ "$waited" -lt "$max_wait" ]; do
sleep 2
waited=$((waited + 2))
done
kill -9 "${EMU_PID:-0}" 2> /dev/null
wait "${EMU_PID:-0}" 2> /dev/null
kill -9 "$EMU_PID" 2> /dev/null
wait "$EMU_PID" 2> /dev/null
EMU_PID=""
}
delete_created_avds() {
local avd
[ "${KEEP_AVD:-0}" = "1" ] && return 0
for avd in ${CREATED_AVDS[@]+"${CREATED_AVDS[@]}"}; do
# A SIGKILLed emulator does not get to remove its own lock files, and avdmanager can
# refuse over them. Staying silent there would leak the very thing this exists to clean.
avdmanager delete avd -n "$avd" > /dev/null 2>&1 \
|| echo " WARNING: could not delete AVD $avd -- 'avdmanager delete avd -n $avd' by hand"
done
CREATED_AVDS=()
}
# What an interrupted sweep used to leave behind: a headless emulator holding console port
# $EMULATOR_PORT, and an lmc_e2e_apiNN AVD. The next run's `emulator -port` then collides
# with the orphan, and `emu_adb` can resolve to it -- on a workstation that also has the
# Pixel plugged in, exactly the ambiguity the ANDROID_SERIAL pinning exists to prevent. A
# sweep is up to five boots long, so the window for one Ctrl-C is not small.
#
# Idempotent, and called explicitly on the normal path so its output cannot land after the
# summary; the EXIT trap then finds nothing left to do. The emulator logs are deliberately
# NOT removed -- they live in $LOG_DIR and are the only evidence a failed boot leaves.
CLEANED=0
cleanup() {
[ "$CLEANED" = "1" ] && return 0
CLEANED=1
stop_emulator "${1:-30}"
delete_created_avds
}
# 6 s, not 30: Ctrl-C has already reached the emulator through the foreground process group,
# so this is only waiting for it to finish writing, and `kill -9` follows regardless. The
# EXIT trap is disarmed before exiting so the status below is the one that survives.
#
# bash runs a trap only between commands, so this starts when whatever was in the foreground
# returns -- which for Ctrl-C is immediately, because the same interrupt reached that command
# too. `kill -INT` aimed at this script alone waits for the foreground command to finish.
# Invoked indirectly -- installed as the INT and TERM trap a few lines below. Both codes,
# because shellcheck 0.9.0 reports this as unreachable commands (SC2317) and 0.11.0 as an
# uninvoked function (SC2329); CI pins 0.11.0 but a local install may be either.
# shellcheck disable=SC2317,SC2329
on_signal() {
echo
echo "interrupted (SIG$1) -- stopping the emulator and removing the AVDs this run created"
echo " emulator logs kept in $LOG_DIR"
cleanup 6
trap - EXIT
exit "$2"
}
trap 'on_signal INT 130' INT
trap 'on_signal TERM 143' TERM
trap cleanup EXIT
# The XML is authoritative. The console counter double-counts skips, so a run that reports
# "42 tests" on stdout can be 40 in the report.
#
@@ -236,37 +536,44 @@ PY
}
# ------------------------------------------------------------------------------ main ---
CREATED_AVDS=()
SUMMARY=()
overall=0
for api in "${APIS[@]}"; do
if [ "$api" = "37" ] || [ "$api" = "37.0" ]; then
echo "SKIPPING API $api: the android-37.0 image crash-loops surfaceflinger."
echo " See docs/api-37-emulator-crash.md. Test API 37 on the physical Pixel."
continue
fi
# Whether the red exit is the expected one depends on which level produced it, and only the
# loop knows that -- so it is recorded where `overall` is set rather than guessed from the
# summary afterwards. A note at the end claiming a genuine API 34 failure was "by design"
# would be the same defect it is there to prevent, one layer up.
NON37_RED=0
mark_red() {
overall=1
case "$1" in 37 | 37.*) ;; *) NON37_RED=1 ;; esac
}
avd="lmc_e2e_api${api}"
for api in "${APIS[@]}"; do
avd="$(avd_for_api "$api")"
gpu="$(gpu_for_api "$api")"
started="$(date '+%Y-%m-%d %H:%M:%S')"
echo "=============================================================="
echo "API $api (avd=$avd gpu=$GPU_MODE serial=$SERIAL)"
echo "API $api (avd=$avd gpu=$gpu serial=$SERIAL)"
echo " image: $(image_pkg_for_api "$api")"
echo "=============================================================="
if ! ensure_avd "$api" "$avd"; then
SUMMARY+=("API $api: AVD SETUP FAILED")
overall=1
mark_red "$api"
continue
fi
if ! boot_emulator "$avd" "$api"; then
if ! boot_emulator "$avd" "$api" "$gpu"; then
host_forensics "$started"
guest_forensics "$api"
SUMMARY+=("API $api: BOOT FAILED")
overall=1
mark_red "$api"
stop_emulator
continue
fi
disable_region_sampling "$api"
disable_animations
rm -rf "$RESULTS_DIR"
@@ -281,23 +588,56 @@ for api in "${APIS[@]}"; do
unset ANDROID_SERIAL E2E_EXTRA_GRADLE_ARGS
line="$(summarise_results "$api")"
guest_forensics "$api"
# API 37 is in the default list on purpose, and it is expected to be red. Leaving it out would
# put the level back where this whole exercise found it -- untested and unlooked-at -- but a
# summary that just says "N failures" with no explanation trains people to ignore the exit
# code. So the row NAMES the expected ones, and anything else is then obviously new.
#
# The list grew on 2026-08-24 and the shape of the row changed with it. The two
# Media3EngineTest failures are a codec; the third is the gralloc bug reached through system
# UI, and it takes the framework DOWN rather than merely failing -- so the level does not
# finish, and the totals come back SHORT (50 of 59 when this was written) with the later
# tests never run. A run whose totals do not add up is expected here now, which it never
# was before.
case "$api" in
37 | 37.*)
line="$line
expected here: 2 Media3EngineTest failures on c2.goldfish.h264.decoder, plus
SafPickerRoundTripTest.thePickedInputSurvivesARealRotation -- which kills the framework
rather than merely failing, so the run ABORTS partway and the total comes back SHORT with
an arbitrary tail. That is expected here too, and never was before. Anything else is new.
CI's gating leg sees only the first two: the rotation test carries @FailsOnEmulatorApi37
and this script, unlike CI, applies no annotation filter.
docs/api-37-emulator-crash.md"
;;
esac
if [ "$rc" -ne 0 ]; then
line="$line [gradle exit $rc]"
overall=1
mark_red "$api"
host_forensics "$started"
fi
SUMMARY+=("$line")
stop_emulator
done
if [ "${KEEP_AVD:-0}" != "1" ]; then
for avd in ${CREATED_AVDS[@]+"${CREATED_AVDS[@]}"}; do
avdmanager delete avd -n "$avd" > /dev/null 2>&1
done
fi
cleanup
echo
echo "===================== LOCAL E2E SUMMARY ======================"
printf '%s\n' ${SUMMARY[@]+"${SUMMARY[@]}"}
echo "=============================================================="
# An unexplained red exit trains people to stop reading exit codes, and this one is expected
# whenever API 37 is in the sweep -- which the default list makes the common case. Said here
# rather than only in the docs, because this is where it is actually read. Only when 37.x is
# the ONLY thing that went red: a note calling a real failure elsewhere "by design" would be
# worse than no note at all.
if [ "$overall" -ne 0 ] && [ "$NON37_RED" -eq 0 ]; then
echo "note: the only level that went red is API 37, which exits non-zero by design -- it is"
echo " permanently short of green, and since 2026-08-24 it does not even finish. Confirm"
echo " its row above names every failure it shows; docs/api-37-emulator-crash.md says why"
echo " each of them is the image rather than this app."
fi
exit "$overall"