Compare commits

...
Author SHA1 Message Date
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-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
36 changed files with 1940 additions and 230 deletions
+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.
+24 -2
View File
@@ -182,6 +182,23 @@ jobs:
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.
@@ -263,8 +280,13 @@ jobs:
# docs/api-37-emulator-crash.md has the per-method measurements, and the
# correction that produced them.
#
# api-level must be "37.0". A bare 37 is not an SDK package and fails
# during setup, which cost a run to discover.
# api-level must be a POINT release. A bare 37 is not an SDK package and
# fails during setup, which cost a run to discover. `37.0` is the choice
# here rather than the only option: `37.1` and `37.2-beta*` exist and
# abort the same way, and api37-debug.yml's inputs document both, with
# the wrinkle that above 37.0 they ship only as google_apis_ps16k.
# docs/api-37-emulator-crash.md measures 37.0 rev 6 and 37.1 rev 8 side
# by side, so pinning 37.0 is a decision, not a constraint.
#
# notAnnotation removes the three tests that do not pass on this image; they
# run in the advisory job below, off the same marker so they cannot end up
+6 -5
View File
@@ -185,11 +185,12 @@ install for code that can never run — and on API 37 the full APK does not fit
`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).
**It does not cover inline `run:` blocks in the workflows**, and a good deal of this repo's bash
lives there. `actionlint` does cover them — it runs shellcheck over each `run:` — and reports one
pre-existing `info` finding in `build.yml`. It is not wired in because every action here is
pinned by SHA, and actionlint's usual installer is a `curl | bash` off a moving branch; doing it
properly means pinning a container digest. Tracked separately rather than bolted on.
**`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
+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
+14 -3
View File
@@ -1,3 +1,4 @@
import org.gradle.api.tasks.PathSensitivity
import org.gradle.testing.jacoco.tasks.JacocoReport
plugins {
@@ -206,6 +207,16 @@ detekt {
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
// no location for either, and the test JVM dies rather than reporting a number.
tasks.withType<Test>().configureEach {
// ReleasePermissionTest reads .github/workflows/build.yml, and Gradle cannot infer that a
// test depends on a file outside the source set. Without this the task stays UP-TO-DATE
// when the workflow changes, so the guard goes stale exactly when it matters. Measured:
// deleting the release job's `contents: write` and re-running gave "BUILD SUCCESSFUL in
// 614ms" with the test never executing; the same mutation under --rerun-tasks failed it.
// A guard that does not re-run when its subject changes is not a guard.
inputs.file(rootProject.file(".github/workflows/build.yml"))
.withPropertyName("releaseWorkflow")
.withPathSensitivity(PathSensitivity.RELATIVE)
extensions.configure<JacocoTaskExtension> {
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
@@ -333,9 +344,9 @@ dependencies {
// the tests stay green.
testImplementation(platform(libs.compose.bom))
testImplementation(libs.compose.ui.test.junit4)
// For `runTest` alone, in EscapedCoroutineErrors.kt. 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.
// 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))
@@ -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)
@@ -1,5 +1,7 @@
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
@@ -10,6 +12,7 @@ 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
@@ -52,12 +55,138 @@ import org.libremediaconverter.ui.TestTags
* 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 [pickingAFileThroughTheSystemPickerFillsInTheFileCard] fails on the
* assertion that names it.
* 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
@@ -115,6 +244,10 @@ class SafPickerRoundTripTest {
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
@@ -208,25 +341,276 @@ class SafPickerRoundTripTest {
* 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() {
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
// THIS 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.
tapPickerNode(By.text(FixtureDocumentsProvider.ROOT_TITLE)) {
// Which screen the picker opens on is its own business: it lands on Recent, where the
// roots are a strip at the bottom, but a device with a populated Recent may need the
// drawer. Looking in the second place widens where the root is searched for; it does
// not weaken what has to be found, which is still this root.
device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click()
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)",
)
}
tapPickerNode(By.text(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME))
/**
* 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(),
)
}
}
awaitNode(TestTags.Converter.FILE_CARD_NAME)
/**
* 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
}
/**
@@ -244,21 +628,24 @@ class SafPickerRoundTripTest {
* 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* — every
* attempt still goes through [awaitPickerNode], which fails outright if the node is absent.
* The MIME mutation's bite is untouched: a root that is not in the picker is not found on any
* attempt, and the failure is still "the system picker never showed" rather than a stale one.
* 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, ifAbsent: () -> Unit = {}) {
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, if (attempt == 0) ifAbsent else ({}))
val node = awaitPickerNode(selector, timeoutMs, if (attempt == 0) ifAbsent else ({}))
?: return false
device.waitForIdle()
try {
node.click()
return
return true
} catch (e: StaleObjectException) {
stale = e
}
@@ -267,19 +654,17 @@ class SafPickerRoundTripTest {
}
/**
* The picker node [selector] names, or a failure that says which one was missing.
* 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, ifAbsent: () -> Unit = {}) =
device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS)
private fun awaitPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit) =
device.wait(Until.findObject(selector), timeoutMs)
?: run {
ifAbsent()
requireNotNull(device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS)) {
"the system picker never showed $selector"
}
device.wait(Until.findObject(selector), timeoutMs)
}
/**
@@ -306,9 +691,69 @@ class SafPickerRoundTripTest {
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.
*
@@ -319,6 +764,19 @@ class SafPickerRoundTripTest {
*/
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"
@@ -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)
}
}
@@ -120,6 +120,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)
@@ -241,13 +264,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) {
@@ -138,31 +138,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 +167,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"
@@ -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
}
}
@@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.StateRestorationTester
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
@@ -44,11 +45,11 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AppRootRestorationTest {
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors]. Every Compose test
// class in this source set starts there, whether or not it is the one that happens to be
// running when another test's escaped coroutine error is delivered.
// 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 = createDrainedComposeRule()
val composeRule = createComposeRule()
private val restoration = StateRestorationTester(composeRule)
@@ -1,53 +0,0 @@
package org.libremediaconverter
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlinx.coroutines.test.runTest
/**
* Clears coroutine errors this module's tests deliberately let escape, so they land on the test
* that caused them instead of on the next one to start.
*
* **Every Compose test class in `src/test` has to start here.** `createComposeRule` runs the
* composition inside `runTest`, and `runTest` opens by throwing `UncaughtExceptionsBeforeTest` for
* anything already sitting in kotlinx-coroutines-test's collector -- a process-wide
* `CoroutineExceptionHandler` it installs once and never removes.
*
* There is one deposit into that collector here, and it is not a mistake:
* `ConversionViewModelProbeFailureTest.an OutOfMemoryError is not swallowed` proves an OOM raised
* inside the probe is rethrown rather than reported as an unreadable file. `onInputPicked` runs it
* in `viewModelScope.launch`, which has no exception handler by design -- the ViewModel's own KDoc
* says a real OOM should reach the thread's handler and take the process down. On the JVM the
* collector takes it instead, holds it, and hands it to whichever `runTest` starts next.
*
* It surfaced as two *different* Compose test classes failing on two consecutive runs of the same,
* green, code, with a message naming neither the test nor the error's origin. Which class catches
* it moves because the throw happens on a real `Dispatchers.IO` thread, after the state assertion
* that ends the test that caused it -- so it can be delivered long after that class is done.
*
* A `@Before` method cannot do this: the compose rule's `runTest` wraps the statement that calls
* `@Before`, so it has already thrown. `@BeforeClass` cannot either -- Robolectric runs it outside
* the sandbox classloader, where the collector is a different object. Draining while the rule is
* being *constructed* is early enough, because JUnit builds a fresh test-class instance, and with
* it every `@get:Rule` field, before evaluating any rule.
*
* The real fix is a seam: give the probe hop an injectable dispatcher the way
* `ConversionViewModel`'s constructor already does for `cleanupDispatcher`, and the error would
* have somewhere to land. That is a production change, so it belongs in its own commit.
*/
fun drainEscapedCoroutineErrors() {
// Entering a test scope is what flushes the collector; the flush is reported as this
// throwing, and there is nothing to assert about an error another test already asserted on.
runCatching { runTest {} }
}
/**
* [createComposeRule], with [drainEscapedCoroutineErrors] run first. Use this rather than
* `createComposeRule` directly in `src/test`.
*
* It also keeps the one mixed import in one place: the rule comes from the **v2** package
* (`androidx.compose.ui.test.junit4.v2`) while `StateRestorationTester`, which takes it, does not.
*/
fun createDrainedComposeRule() = run {
drainEscapedCoroutineErrors()
createComposeRule()
}
@@ -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)
}
@@ -7,6 +7,7 @@ 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
@@ -15,7 +16,6 @@ import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.OutputSpec
@@ -57,9 +57,8 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AdvancedPanelSavedStateTest {
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors].
@get:Rule
val composeRule = createDrainedComposeRule()
val composeRule = createComposeRule()
/**
* `canBeSaved = { true }` deliberately.
@@ -6,6 +6,7 @@ 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
@@ -16,7 +17,6 @@ import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
@@ -61,9 +61,11 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AdvancedPickerTest {
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors].
// 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 = createDrainedComposeRule()
val composeRule = createComposeRule()
private val restoration = StateRestorationTester(composeRule)
@@ -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
}
}
@@ -2,6 +2,7 @@ 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
@@ -9,7 +10,6 @@ import androidx.media3.common.util.UnstableApi
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.EnginePreference
@@ -47,7 +47,7 @@ import org.robolectric.RobolectricTestRunner
class ConverterLeafTagsTest {
@get:Rule
val composeRule = createDrainedComposeRule()
val composeRule = createComposeRule()
private fun assertResolvesToOneNode(tag: String) {
composeRule.onAllNodesWithTag(tag).assertCountEquals(1)
@@ -6,6 +6,7 @@ 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
@@ -13,7 +14,6 @@ import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
@@ -48,7 +48,7 @@ import org.robolectric.RobolectricTestRunner
class ConverterPickerSelectionTest {
@get:Rule
val composeRule = createDrainedComposeRule()
val composeRule = createComposeRule()
/**
* The chip carrying [label] inside the row tagged [rowTag].
@@ -1,6 +1,7 @@
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
@@ -9,7 +10,6 @@ import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.Validation
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
@@ -39,9 +39,8 @@ import java.io.File
@RunWith(RobolectricTestRunner::class)
class ConverterScreenContentTest {
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
@get:Rule
val composeRule = createDrainedComposeRule()
val composeRule = createComposeRule()
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
private val savedAs = mutableListOf<String>()
@@ -6,6 +6,7 @@ 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
@@ -15,7 +16,6 @@ import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.OutputSpec
@@ -72,9 +72,8 @@ import java.io.File
@RunWith(RobolectricTestRunner::class)
class ConverterStateAffordancesTest {
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
@get:Rule
val composeRule = createDrainedComposeRule()
val composeRule = createComposeRule()
/**
* Every callback the screen fired, in order, tagged with the value it carried.
@@ -3,13 +3,13 @@ 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.createDrainedComposeRule
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.InputKind
@@ -56,7 +56,7 @@ import org.robolectric.RobolectricTestRunner
class FileCardTest {
@get:Rule
val composeRule = createDrainedComposeRule()
val composeRule = createComposeRule()
@Test
fun `a file no provider could measure says so in words rather than showing a zero`() {
@@ -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
}
}
@@ -2,13 +2,13 @@ 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.createDrainedComposeRule
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
@@ -32,7 +32,7 @@ import org.robolectric.RobolectricTestRunner
class JoinLeafTagsTest {
@get:Rule
val composeRule = createDrainedComposeRule()
val composeRule = createComposeRule()
private fun input(displayName: String) = InputFile(
uri = Uri.parse("content://test/$displayName"),
@@ -1,5 +1,6 @@
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
@@ -8,7 +9,6 @@ import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
@@ -35,9 +35,8 @@ import java.io.File
@RunWith(RobolectricTestRunner::class)
class JoinScreenContentTest {
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
@get:Rule
val composeRule = createDrainedComposeRule()
val composeRule = createComposeRule()
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
private val savedAs = mutableListOf<String>()
@@ -7,6 +7,7 @@ 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
@@ -17,7 +18,6 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
@@ -60,9 +60,8 @@ import java.io.File
@RunWith(RobolectricTestRunner::class)
class JoinStateAffordancesTest {
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
@get:Rule
val composeRule = createDrainedComposeRule()
val composeRule = createComposeRule()
/** Which callback the screen invoked, in order, with what it passed. Empty until one fires. */
private val events = mutableListOf<String>()
@@ -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))
}
}
+3 -2
View File
@@ -162,8 +162,9 @@ androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomato
# a TDD loop anyone here can execute.
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
# Only for its `runTest`, and only to drain the collector kotlinx-coroutines-test installs
# process-wide. See EscapedCoroutineErrors.kt in the JVM test source set.
# 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]
View File
+16 -2
View File
@@ -236,10 +236,24 @@ ensure_avd() {
else
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 || {