Compare commits
201
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5761faced6 | ||
|
|
c2c0bfa848 | ||
|
|
016030f3e4 | ||
|
|
d354f6470c | ||
|
|
dbedfb4708 | ||
|
|
c6e9a480e1 | ||
|
|
d8110d7a62 | ||
|
|
6f3966cc69 | ||
|
|
7595177e81 | ||
|
|
a507736d3d | ||
|
|
1ff5c4463c | ||
|
|
70337b2c12 | ||
|
|
fea480b000 | ||
|
|
ddfb1dd78e | ||
|
|
348eaa2f22 | ||
|
|
104d02de03 | ||
|
|
90814222b7 | ||
|
|
2d4898ad44 | ||
|
|
83ac7eff2c | ||
|
|
b2c11bbfe0 | ||
|
|
3a5210ec5d | ||
|
|
5f3eda9c40 | ||
|
|
79cca0eb47 | ||
|
|
2c0bc4a583 | ||
|
|
7a47285f37 | ||
|
|
8a2bc86cac | ||
|
|
9b3b9f952b | ||
|
|
a84b24ba27 | ||
|
|
0e2525195b | ||
|
|
713d813a65 | ||
|
|
c360e82a10 | ||
|
|
699d608b47 | ||
|
|
ad47ce6c96 | ||
|
|
c60d5d54c6 | ||
|
|
d59e9acce5 | ||
|
|
324c9a4555 | ||
|
|
8a88fc4ae7 | ||
|
|
44d4c61738 | ||
|
|
44493d9943 | ||
|
|
b2790e13d9 | ||
|
|
de6d9526ba | ||
|
|
bb3358f209 | ||
|
|
04850a0415 | ||
|
|
8ab433b647 | ||
|
|
5e58334230 | ||
|
|
d9c32c6ce5 | ||
|
|
c43d865651 | ||
|
|
8a23f2a0b8 | ||
|
|
25992863e6 | ||
|
|
232cbd1949 | ||
|
|
099b7fd7c4 | ||
|
|
7f2a6e1376 | ||
|
|
dc8b7c3944 | ||
|
|
1d80e88f9b | ||
|
|
27d7cc0a86 | ||
|
|
6df1bdf31a | ||
|
|
c6b581ab5a | ||
|
|
c27881ab64 | ||
|
|
34641df19d | ||
|
|
0f39964193 | ||
|
|
71141b5734 | ||
|
|
81ad102f2a | ||
|
|
0f41bc3f6b | ||
|
|
4f1a007b58 | ||
|
|
bf78e06969 | ||
|
|
93ebfa6b4a | ||
|
|
d94906ef42 | ||
|
|
959bd8be13 | ||
|
|
b93ef79931 | ||
|
|
d739b425c0 | ||
|
|
cd77aceff4 | ||
|
|
febd141bea | ||
|
|
3d8b89bfab | ||
|
|
c4bb7d4d2d | ||
|
|
3599307040 | ||
|
|
3f731d8ea7 | ||
|
|
cc424dd08f | ||
|
|
b49295d2bf | ||
|
|
25aac95db9 | ||
|
|
dce516224c | ||
|
|
2b7520061b | ||
|
|
4e46eb99f6 | ||
|
|
62040b2161 | ||
|
|
83b557409e | ||
|
|
0eb00d2003 | ||
|
|
c887af0d83 | ||
|
|
2e0c6737d6 | ||
|
|
68f841e84f | ||
|
|
b53f326f9e | ||
|
|
85461943d6 | ||
|
|
238142d9cc | ||
|
|
9c809d4e16 | ||
|
|
bf2214a549 | ||
|
|
989069207e | ||
|
|
72ff7adfcc | ||
|
|
994ea8a3dd | ||
|
|
3e9528454c | ||
|
|
0702916229 | ||
|
|
3fd34c24a6 | ||
|
|
d01a46a708 | ||
|
|
3806641cb2 | ||
|
|
5f9498150c | ||
|
|
8d8703ab49 | ||
|
|
27b7654418 | ||
|
|
4a8e30099e | ||
|
|
e7d84cc69f | ||
|
|
a8b494b846 | ||
|
|
865a4a7c8e | ||
|
|
e856679395 | ||
|
|
49c483d877 | ||
|
|
1b220856ab | ||
|
|
40ae524388 | ||
|
|
58a29ab093 | ||
|
|
a1d79c212a | ||
|
|
bc66906dc3 | ||
|
|
d37c391c60 | ||
|
|
3fb25235c0 | ||
|
|
c0d99f7f86 | ||
|
|
95902a7889 | ||
|
|
1535b61a96 | ||
|
|
2efd1f9a0d | ||
|
|
240528facb | ||
|
|
f98e49942f | ||
|
|
25f162923c | ||
|
|
d0b9745220 | ||
|
|
b36d56c932 | ||
|
|
3f140fc2b1 | ||
|
|
b3208ef8c7 | ||
|
|
5a8aedf53d | ||
|
|
a0b6a3dde8 | ||
|
|
ba27b8306b | ||
|
|
bda5abea6c | ||
|
|
8bd5fedcc8 | ||
|
|
21eeb6f3f8 | ||
|
|
a83cb60c61 | ||
|
|
2063fe06aa | ||
|
|
47a423413b | ||
|
|
dab28d5f44 | ||
|
|
aed4d83e70 | ||
|
|
4aba3bbd2e | ||
|
|
dbba213c51 | ||
|
|
8ac6e2b1c2 | ||
|
|
4ff44be1d7 | ||
|
|
7f951baf8f | ||
|
|
5ec2bba64b | ||
|
|
fd2bb1d889 | ||
|
|
ad28293b72 | ||
|
|
b9abe85580 | ||
|
|
4375a377bc | ||
|
|
3925f1aa9f | ||
|
|
a3c835b7c9 | ||
|
|
650ca8fca3 | ||
|
|
b18f45def7 | ||
|
|
d674bc4848 | ||
|
|
02555ceb91 | ||
|
|
1b1d5c6d04 | ||
|
|
2f3f461cc1 | ||
|
|
6166763f24 | ||
|
|
46ad95350b | ||
|
|
e968deb5a2 | ||
|
|
3d55004286 | ||
|
|
e06b0826a0 | ||
|
|
7b578c1ccf | ||
|
|
9e7f80feaa | ||
|
|
3c5a37fd3c | ||
|
|
af13155c27 | ||
|
|
4ea5afefe1 | ||
|
|
7e4f22322b | ||
|
|
2ee97e30b7 | ||
|
|
d8f1590d2b | ||
|
|
3d51fefeff | ||
|
|
6cd17f25aa | ||
|
|
fea88a281f | ||
|
|
7c69d0699a | ||
|
|
baaaa934e0 | ||
|
|
2bed40d080 | ||
|
|
175472ae88 | ||
|
|
df2e42a2b3 | ||
|
|
64c1a60a97 | ||
|
|
1779f20a03 | ||
|
|
225ecdd7e6 | ||
|
|
b3a705e3da | ||
|
|
577dae998b | ||
|
|
acc71bcaee | ||
|
|
b97d7c36a3 | ||
|
|
93398c4616 | ||
|
|
19a1e66277 | ||
|
|
8fdad6e20b | ||
|
|
742703d360 | ||
|
|
6c34fad17c | ||
|
|
9c4f14202f | ||
|
|
2747bb8627 | ||
|
|
6d6d2189ca | ||
|
|
f8e6bfa2a3 | ||
|
|
5a1b8832d3 | ||
|
|
7ae660ee37 | ||
|
|
775a44753b | ||
|
|
961cfa72a2 | ||
|
|
da6f2807e9 | ||
|
|
792286a2d7 | ||
|
|
739bffa5a0 |
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Exercises e2e-report-shape.sh's baseline counter against fixture source, with no emulator and
|
||||
# no CI run. Run it directly:
|
||||
#
|
||||
# .github/scripts/e2e-report-shape-test.sh
|
||||
#
|
||||
# WHY THIS CAN EXIST AT ALL: the counter is a pure function of the working tree. It greps
|
||||
# `app/src/androidTest` for `@FailsOnEmulatorApi37` and compares the total against the number
|
||||
# committed in FailsOnEmulatorApi37.kt. Nothing about that needs a device, which is the whole
|
||||
# reason #120 could be measured rather than argued about.
|
||||
#
|
||||
# WHY A THROWAWAY REPO ROOT rather than a knob on the script. The report finds its own root from
|
||||
# `BASH_SOURCE`, so a copy of it placed at `<root>/.github/scripts/` reads `<root>/app/src/...`.
|
||||
# Building that root is three mkdirs and costs the shipped script nothing:
|
||||
#
|
||||
# - the REAL script is what runs, byte for byte, so reverting the matcher reddens this test
|
||||
# rather than a testing-only code path beside it;
|
||||
# - no environment variable exists that could point the LIVE count somewhere else, which is
|
||||
# the failure mode #83 built the baseline check to prevent in the first place;
|
||||
# - XML_DIR resolves inside the throwaway root, so a stale app/build/outputs left by a real
|
||||
# run on a developer machine cannot leak into the numbers here.
|
||||
#
|
||||
# WHAT IT GUARDS (#120). The old matcher looked for the string anywhere on any line, so a KDoc
|
||||
# saying `Deliberately not @FailsOnEmulatorApi37` counted as a marked test and every PR got a
|
||||
# deviation notice that was wrong. The obvious repair -- count only lines that are nothing but
|
||||
# the annotation -- silently stops counting `@FailsOnEmulatorApi37 @Test`, which is legal Kotlin,
|
||||
# and undercounting is the direction that hides a genuine new marker. The fixture carries every
|
||||
# shape at once -- three that count and three that must not, enumerated in its own header -- so
|
||||
# both mistakes fail here instead of on a PR: against testdata/marker-shapes the old matcher says
|
||||
# 5, own-line-only says 2, and the shipped one 3.
|
||||
#
|
||||
# NOT WIRED INTO CI, deliberately and as a known gap. Adding a step to the Static analysis job
|
||||
# would add a new way for a gating job to go red, and #120 was explicit that nothing about it may
|
||||
# change any job's status or the pass/fail rules. shellcheck still covers this file, since that
|
||||
# step reads `git ls-files '*.sh'` rather than a fixed list.
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPORT="$SCRIPT_DIR/e2e-report-shape.sh"
|
||||
FIXTURE_DIR="$SCRIPT_DIR/testdata/marker-shapes"
|
||||
FIXTURE="$FIXTURE_DIR/MarkerShapes.kt"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
# Single quotes: the path is expanded when the trap fires, not when it is set.
|
||||
trap 'rm -rf -- "$TMP"' EXIT
|
||||
|
||||
failures=0
|
||||
|
||||
pass() { printf 'ok %s\n' "$1"; }
|
||||
|
||||
fail() {
|
||||
failures=$((failures + 1))
|
||||
printf 'FAIL %s\n' "$1"
|
||||
shift
|
||||
printf ' %s\n' "$@"
|
||||
}
|
||||
|
||||
# assert_contains <name> <haystack> <needle>
|
||||
# `case` rather than grep: the strings being matched carry backticks and an em dash, and this
|
||||
# way neither the shell nor a regex engine gets an opinion about them.
|
||||
assert_contains() {
|
||||
case "$2" in
|
||||
*"$3"*) pass "$1" ;;
|
||||
*) fail "$1" "wanted to find: $3" "in:" "$2" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
assert_absent() {
|
||||
case "$2" in
|
||||
*"$3"*) fail "$1" "did NOT want to find: $3" "in:" "$2" ;;
|
||||
*) pass "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# make_root <marked-tree-dir> <baseline-number>
|
||||
# Assembles a throwaway repo root around the given tree and prints its path.
|
||||
make_root() {
|
||||
local tree="$1" baseline="$2" root testdir
|
||||
root="$(mktemp -d "$TMP/root.XXXXXX")"
|
||||
testdir="$root/app/src/androidTest/java/org/libremediaconverter"
|
||||
mkdir -p "$root/.github/scripts" "$testdir"
|
||||
cp -- "$REPORT" "$root/.github/scripts/"
|
||||
cp -- "$tree"/*.kt "$testdir/"
|
||||
|
||||
# The synthetic stand-in for the committed baseline. Its KDoc names the marker the way the real
|
||||
# file does -- in brackets, never with an `@` -- because the real file lives inside the tree
|
||||
# being counted, so an `@` spelling here would add a phantom to every number below.
|
||||
cat > "$testdir/FailsOnEmulatorApi37.kt" <<EOF
|
||||
package org.libremediaconverter
|
||||
|
||||
/** Stand-in for the real marker file. Only [FAILS_ON_EMULATOR_API37_BASELINE] is read. */
|
||||
const val FAILS_ON_EMULATOR_API37_BASELINE = $baseline
|
||||
EOF
|
||||
|
||||
# A clean, untruncated run of exactly <baseline> tests, all failing -- which is what the
|
||||
# advisory leg looks like when nothing has drifted. It leaves the marked count as the only
|
||||
# field that can deviate, so every assertion below is about the thing under test.
|
||||
cat > "$root/gradle.log" <<EOF
|
||||
> Task :app:connectedDebugAndroidTest
|
||||
Starting $baseline tests on test(AVD) - 16
|
||||
There was $baseline failure(s).
|
||||
EOF
|
||||
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
# run_report <root> -- stdout of the real script; its summary lands in <root>/summary.md.
|
||||
run_report() {
|
||||
E2E_WEDGED_AFTER='' GITHUB_STEP_SUMMARY="$1/summary.md" \
|
||||
bash "$1/.github/scripts/e2e-report-shape.sh" 37 "$1/gradle.log" \
|
||||
"$1/app/src/androidTest/java/org/libremediaconverter/FailsOnEmulatorApi37.kt"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The fixture still carries every shape.
|
||||
#
|
||||
# Three of the checks below are covered twice over -- deleting a real annotation moves the count
|
||||
# and fails a case further down. The two decoys are not: drop the KDoc mention and the count
|
||||
# stays 3, so the precision this whole ticket is about would stop being tested and nothing would
|
||||
# say so. That asymmetry is why the shapes are asserted by name rather than only by their effect
|
||||
# on the total.
|
||||
# ---------------------------------------------------------------------------
|
||||
fixture_text="$(cat -- "$FIXTURE")"
|
||||
assert_contains "fixture: the import" "$fixture_text" 'import org.libremediaconverter.FailsOnEmulatorApi37'
|
||||
assert_contains "fixture: annotation own line" "$fixture_text" '
|
||||
@FailsOnEmulatorApi37
|
||||
@Test'
|
||||
assert_contains "fixture: annotation with @Test on one line" "$fixture_text" '@FailsOnEmulatorApi37 @Test'
|
||||
assert_contains "fixture: annotation nested and indented" "$fixture_text" '
|
||||
@FailsOnEmulatorApi37'
|
||||
assert_contains "fixture: KDoc mention (this is #120)" "$fixture_text" "* Deliberately not \`@FailsOnEmulatorApi37\`"
|
||||
assert_contains "fixture: commented-out annotation" "$fixture_text" '// @FailsOnEmulatorApi37'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The fixture's three real annotations against a baseline of 3: no deviation.
|
||||
#
|
||||
# This one case fails under both wrong matchers -- the old one counts 5, own-line-only counts 2 --
|
||||
# which is why it is first.
|
||||
# ---------------------------------------------------------------------------
|
||||
root="$(make_root "$FIXTURE_DIR" 3)"
|
||||
out="$(run_report "$root")"
|
||||
assert_contains "3 real markers, baseline 3: reports a match" "$out" ' baseline: matches (3 expected, 3 failed)'
|
||||
assert_absent "3 real markers, baseline 3: says nothing about the tree" "$out" 'the tree carries'
|
||||
assert_contains "3 real markers, baseline 3: summary agrees" \
|
||||
"$(cat -- "$root/summary.md")" '**Matches the committed baseline of 3**'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. A fourth REAL annotation. The count has to move and the deviation has to fire.
|
||||
#
|
||||
# The important half of #120: precision was the bug, but a matcher that stopped noticing a new
|
||||
# marker would have been a worse one, silently.
|
||||
# ---------------------------------------------------------------------------
|
||||
plus_one="$(mktemp -d "$TMP/plusone.XXXXXX")"
|
||||
cp -- "$FIXTURE" "$plus_one/"
|
||||
cat > "$plus_one/FourthMarker.kt" <<'EOF'
|
||||
package org.libremediaconverter.fixture
|
||||
|
||||
class FourthMarker {
|
||||
@FailsOnEmulatorApi37
|
||||
@Test
|
||||
fun addedToday() = Unit
|
||||
}
|
||||
EOF
|
||||
root="$(make_root "$plus_one" 3)"
|
||||
out="$(run_report "$root")"
|
||||
assert_contains "a 4th real marker: the deviation fires, and counts 4" "$out" \
|
||||
" baseline DEVIATION: the tree carries 4 tests marked \`@FailsOnEmulatorApi37\` but the baseline says 3 — update FAILS_ON_EMULATOR_API37_BASELINE"
|
||||
assert_contains "a 4th real marker: the summary carries it too" "$(cat -- "$root/summary.md")" \
|
||||
"- the tree carries 4 tests marked \`@FailsOnEmulatorApi37\` but the baseline says 3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Delete the same-line annotation and the count must drop to 2.
|
||||
#
|
||||
# This is the trap, pinned down. `@FailsOnEmulatorApi37 @Test` on one line is what separates the
|
||||
# shipped matcher from `^[[:space:]]*@NAME[[:space:]]*$`, and without this case the fixture entry
|
||||
# guarding it could be deleted as decoration -- case 1 would then pass under the wrong matcher.
|
||||
# Here the same-line entry is worth exactly one, and it is asserted to be.
|
||||
# ---------------------------------------------------------------------------
|
||||
minus_same_line="$(mktemp -d "$TMP/minus.XXXXXX")"
|
||||
sed -e '/@FailsOnEmulatorApi37 @Test/d' -- "$FIXTURE" > "$minus_same_line/MarkerShapes.kt"
|
||||
root="$(make_root "$minus_same_line" 3)"
|
||||
out="$(run_report "$root")"
|
||||
assert_contains "same-line annotation removed: counts 2, so it was worth 1" "$out" \
|
||||
" baseline DEVIATION: the tree carries 2 tests marked \`@FailsOnEmulatorApi37\` but the baseline says 3 — update FAILS_ON_EMULATOR_API37_BASELINE"
|
||||
|
||||
echo
|
||||
if [ "$failures" -eq 0 ]; then
|
||||
echo "e2e-report-shape-test.sh: all checks passed"
|
||||
exit 0
|
||||
fi
|
||||
echo "e2e-report-shape-test.sh: $failures check(s) failed"
|
||||
exit 1
|
||||
Executable
+353
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Reports the SHAPE of an instrumented run -- how many tests were expected, how many
|
||||
# reported, how many failed, and whether the run completed at all -- to the step log and to
|
||||
# the job summary. In advisory mode it also compares that shape against a committed baseline
|
||||
# and says plainly whether it matches.
|
||||
#
|
||||
# WHY THIS EXISTS (#83): the advisory API 37 leg is red on every PR by design, so a NEW failure
|
||||
# joining the known ones is invisible -- nothing in a red X distinguishes "the known ones" from
|
||||
# "the known ones plus yours". CLAUDE.md tells everyone not to read that job's red as their
|
||||
# change breaking something, which is correct, and which also means nobody looks.
|
||||
#
|
||||
# WHY NOT A BARE FAILURE COUNT, measured rather than assumed. On this image the run is usually
|
||||
# truncated: `Test run failed to complete. Expected 3 tests, received 2.` with
|
||||
# `INSTRUMENTATION_ABORTED: System has crashed.` A count taken from a truncated run misleads in
|
||||
# both directions -- a fourth marked test can still yield the same number if the abort lands
|
||||
# earlier, and the known set getting worse can LOWER it. So all four fields are recorded, and
|
||||
# the one saying the run was truncated is recorded with them.
|
||||
#
|
||||
# WHY IT IS A SEPARATE SCRIPT rather than a function inside e2e-run.sh: it is a pure seam. It
|
||||
# reads a captured log plus the test XML and writes a report, so it can be run against a REAL
|
||||
# log saved from a REAL CI run -- which is how the baseline comparison was shown to fire
|
||||
# without waiting on an emulator. `git ls-files '*.sh'` also picks it up for shellcheck for
|
||||
# free.
|
||||
#
|
||||
# THIS SCRIPT NEVER FAILS A RUN. It is a diagnostic, and e2e-run.sh's header explains why that
|
||||
# rule is absolute here. Every field defaults to `unknown` and every comparison is guarded,
|
||||
# because an unset variable under `set -u`, or a `[ "" -eq 3 ]`, is exactly how a diagnostic
|
||||
# becomes the thing that turns a leg red. It exits 0 unconditionally.
|
||||
#
|
||||
# Usage:
|
||||
# e2e-report-shape.sh <label> <gradle-log> [<baseline-file>]
|
||||
# E2E_WEDGED_AFTER=<seconds> the wrapper timeout killed gradle after that many seconds
|
||||
#
|
||||
# With a third argument the run is compared against the baseline in that file (advisory mode)
|
||||
# and a `::notice::` is emitted per deviation. NEVER `::error::`: the advisory job is
|
||||
# `continue-on-error: true` and stays that way, and an error annotation would be a new way for
|
||||
# a diagnostic to change a conclusion.
|
||||
#
|
||||
# WHY THE WEDGE ARRIVES AS AN ENV VAR (#118) rather than being read out of the log like every
|
||||
# other field: there is nothing in the log to read. A wedge is gradle never returning, so gradle
|
||||
# never printed a verdict, never printed a truncation line, and never aborted instrumentation --
|
||||
# the log of a wedged leg is the log of a run that simply stops. Measured on job 98035980326:
|
||||
# `expected: 59`, `received: 59`, `completed cleanly: yes`, six seconds before the wedge warning,
|
||||
# for a leg that the timeout had killed 22 minutes in. Only e2e-run.sh knows, because only it
|
||||
# saw `timeout` exit 124, so it says so. Guessing it from a log that ends abruptly would call
|
||||
# every cancelled run a wedge.
|
||||
#
|
||||
# It is read as a STRING and only ever interpolated into one. `[ -n ... ]`, never `-gt`: it
|
||||
# crosses a process boundary from a shell that deliberately sets it EMPTY on every non-wedge
|
||||
# path, and an arithmetic test on an empty string is the header's rule four paragraphs up.
|
||||
set -uo pipefail
|
||||
|
||||
LABEL="${1:-unknown}"
|
||||
LOG="${2:-}"
|
||||
BASELINE_FILE="${3:-}"
|
||||
WEDGED_AFTER="${E2E_WEDGED_AFTER:-}"
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
XML_DIR="$REPO_ROOT/app/build/outputs/androidTest-results/connected/debug"
|
||||
|
||||
# Gradle colours its output even when it is piped, so `FAILED` arrives wrapped in escape codes.
|
||||
# The numeric lines parsed below are not coloured, but stripping is cheap insurance against a
|
||||
# pattern that would otherwise silently match nothing.
|
||||
ESC="$(printf '\033')"
|
||||
scan() { [ -s "$LOG" ] && sed -e "s/${ESC}\[[0-9;]*[a-zA-Z]//g" -- "$LOG"; }
|
||||
first_number() { grep -oE '[0-9]+' | head -1; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source 1: the runner's own output. This is the ONLY place a truncation is visible. The test
|
||||
# XML read below is written even for an aborted run and says nothing whatever about the abort
|
||||
# -- measured on run 32865281555, where the XML reports a tidy tests="3" failures="3" for a run
|
||||
# the runner had just described as truncated. That is the reason this parses stdout at all.
|
||||
# ---------------------------------------------------------------------------
|
||||
starting_line="$(scan | grep -aoE 'Starting [0-9]+ tests on .*' | tail -1)"
|
||||
abort_line="$(scan | grep -aoE 'Test run failed to complete\. Expected [0-9]+ tests, received [0-9]+\.' | tail -1)"
|
||||
aborted_hits="$(scan | grep -ac 'INSTRUMENTATION_ABORTED' || true)"
|
||||
failure_line="$(scan | grep -aoE 'There was [0-9]+ failure\(s\)\.' | tail -1)"
|
||||
failed_names="$(scan | grep -aoE 'Execute [A-Za-z0-9_.$]+: FAILED' | sed -e 's/^Execute //' -e 's/: FAILED$//' | sort -u)"
|
||||
|
||||
expected="$(printf '%s' "$starting_line" | first_number)"
|
||||
expected_src="\`$starting_line\`"
|
||||
abort_expected="$(printf '%s' "$abort_line" | grep -oE 'Expected [0-9]+' | first_number)"
|
||||
abort_received="$(printf '%s' "$abort_line" | grep -oE 'received [0-9]+' | first_number)"
|
||||
log_failed="$(printf '%s' "$failure_line" | first_number)"
|
||||
|
||||
# `Starting N tests` is missing when the framework restarted under the run and Gradle never got
|
||||
# a test list. The truncation line still carries the number it was told to expect.
|
||||
if [ -z "$expected" ] && [ -n "$abort_expected" ]; then
|
||||
expected="$abort_expected"
|
||||
expected_src="\`$abort_line\`"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source 2: the JUnit XML. Measured on both a truncated advisory run and a green gating leg:
|
||||
# `<testsuites tests="N" failures="M">` is present in both, and aggregates every suite. It is
|
||||
# the authority on how many results landed and how many were failures. It is NOT an authority
|
||||
# on whether the run finished, which is what source 1 is for.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Read ONLY when the runner said a test run happened. `app/build` survives between runs on a
|
||||
# developer machine -- tools/local-emulator/run-e2e.sh drives several API levels against one
|
||||
# checkout -- so a leg that never got as far as starting tests would otherwise be reported from
|
||||
# the previous leg's XML, which is a wrong answer rather than a missing one.
|
||||
xml_head=""
|
||||
xml_count=0
|
||||
if [ -n "$starting_line$abort_line" ] && [ -d "$XML_DIR" ]; then
|
||||
while IFS= read -r f; do
|
||||
xml_count=$((xml_count + 1))
|
||||
[ -z "$xml_head" ] && xml_head="$(grep -ao '<testsuites[^>]*>' "$f" | head -1)"
|
||||
done < <(find "$XML_DIR" -maxdepth 1 -name 'TEST-*.xml' -print 2> /dev/null | sort)
|
||||
fi
|
||||
xml_tests="$(printf '%s' "$xml_head" | grep -oE ' tests="[0-9]+"' | first_number)"
|
||||
xml_failed="$(printf '%s' "$xml_head" | grep -oE ' failures="[0-9]+"' | first_number)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derive the four fields, each with where its number came from. Everything stays a string, so a
|
||||
# missing source reads `unknown` rather than becoming 0 -- a report claiming 0 tests when it
|
||||
# merely could not see them would announce a deviation on every cancelled run.
|
||||
# ---------------------------------------------------------------------------
|
||||
received="unknown"
|
||||
received_src="no source"
|
||||
if [ -n "$xml_tests" ]; then
|
||||
received="$xml_tests"
|
||||
received_src="test XML \`<testsuites tests=\"$xml_tests\">\`"
|
||||
elif [ -n "$abort_received" ]; then
|
||||
received="$abort_received"
|
||||
received_src="\`$abort_line\`"
|
||||
elif [ -n "$expected" ] && [ -z "$abort_line" ]; then
|
||||
received="$expected"
|
||||
received_src="the run was not truncated, so every expected test reported"
|
||||
# ... unless it was killed, in which case "not truncated" is only "gradle never got as far as
|
||||
# saying so". This is the branch the wedged leg in #118 took -- with no XML written yet, the
|
||||
# number is what the runner was TOLD to run, and the source line said the opposite in the same
|
||||
# table that called the leg clean. The number is deliberately left alone: it is still the best
|
||||
# available answer, and only the claim about where it came from was wrong.
|
||||
[ -n "$WEDGED_AFTER" ] \
|
||||
&& received_src="no test XML was written and gradle never printed a truncation line — but the leg was killed mid-run, so this is what it was told to run, not what reported"
|
||||
fi
|
||||
|
||||
failed="unknown"
|
||||
failed_src="no source"
|
||||
if [ -n "$xml_failed" ]; then
|
||||
failed="$xml_failed"
|
||||
failed_src="test XML \`<testsuites failures=\"$xml_failed\">\`"
|
||||
elif [ -n "$log_failed" ]; then
|
||||
failed="$log_failed"
|
||||
failed_src="\`$failure_line\`"
|
||||
fi
|
||||
|
||||
if [ -z "$expected" ]; then
|
||||
expected="unknown"
|
||||
expected_src="no \`Starting N tests\` line"
|
||||
fi
|
||||
|
||||
# A run whose start nobody can see is not a run of zero tests. Cancellation (this workflow sets
|
||||
# cancel-in-progress) and the `Starting 0 tests` shape a framework restart produces both land
|
||||
# here, and both have to say so rather than compare a number that does not exist.
|
||||
no_run="none"
|
||||
if [ "$expected" = "unknown" ] && [ "$received" = "unknown" ]; then
|
||||
no_run="nothing"
|
||||
elif [ "$expected" = "0" ]; then
|
||||
no_run="zero"
|
||||
fi
|
||||
|
||||
if [ -n "$abort_line" ]; then
|
||||
completed="**no**"
|
||||
completed_src="\`$abort_line\` with \`INSTRUMENTATION_ABORTED\`"
|
||||
elif [ "${aborted_hits:-0}" -gt 0 ]; then
|
||||
completed="**no**"
|
||||
completed_src="\`INSTRUMENTATION_ABORTED\` in the runner output"
|
||||
elif [ "$no_run" = "nothing" ]; then
|
||||
# "cleanly" would be a lie about a run that left no evidence it happened.
|
||||
completed="unknown"
|
||||
completed_src="no runner output to read"
|
||||
elif [ -n "$WEDGED_AFTER" ]; then
|
||||
# The wedge is checked LAST of the four, so it only ever overrides the `yes`. The two "no"s
|
||||
# above are already right and name the abort, which the wedge row does not; `unknown` is
|
||||
# already right too. A wedge on top of an abort is both facts, and both get printed.
|
||||
completed="**no**"
|
||||
completed_src="the wrapper timeout killed gradle after ${WEDGED_AFTER}s — instrumentation itself was never aborted, which is why nothing in the log says so"
|
||||
else
|
||||
completed="yes"
|
||||
completed_src="no truncation line and no \`INSTRUMENTATION_ABORTED\`"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Advisory mode: compare against the committed baseline.
|
||||
#
|
||||
# ONE number covers both compared fields, and that is deliberate rather than a shortcut. The
|
||||
# marker means "cannot pass on this image", so the number of tests carrying it is both how many
|
||||
# the advisory leg should run and how many should fail. A smaller `failed` means one now passes
|
||||
# -- which is the trigger to delete the annotation, written down in FailsOnEmulatorApi37.kt.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `advisory` and `baseline` are two variables on purpose. "A comparison was asked for" and "a
|
||||
# number was found to compare against" are different facts, and collapsing them is how this
|
||||
# report would go quietly back to being the thing #83 filed: the `sed` below is anchored, so
|
||||
# indenting the const into an object -- or renaming it, or moving it to another file -- empties
|
||||
# `baseline`, and a single flag would take the whole comparison down with it while the table
|
||||
# kept printing. An unreadable baseline is itself a deviation, and is announced as one.
|
||||
advisory="no"
|
||||
baseline=""
|
||||
marked=""
|
||||
deviations=()
|
||||
if [ -n "$BASELINE_FILE" ]; then
|
||||
advisory="yes"
|
||||
[ -f "$BASELINE_FILE" ] \
|
||||
&& baseline="$(sed -nE 's/^const val FAILS_ON_EMULATOR_API37_BASELINE = ([0-9]+).*/\1/p' "$BASELINE_FILE" | head -1)"
|
||||
# What the tree actually carries. Reported next to the baseline so a stale baseline shows up
|
||||
# here rather than only once the emulator disagrees with it.
|
||||
#
|
||||
# ANCHORED AT LINE START, AND WHITESPACE-OR-END-OF-LINE AFTER THE NAME (#120). The #81 check
|
||||
# this replaces matched the string anywhere on any line, so #113's KDoc reading `Deliberately
|
||||
# not @FailsOnEmulatorApi37` counted as a fourth marked test and the report announced a
|
||||
# deviation on every PR. That is worse than a wrong number: #83 built this so a new failure
|
||||
# could not be invisible, and a notice that is wrong every time teaches everyone to skim past
|
||||
# deviation notices.
|
||||
#
|
||||
# THE OBVIOUS REPAIR IS A TRAP, and the reason for the second half of the pattern.
|
||||
# `^[[:space:]]*@NAME[[:space:]]*$` -- "the annotation on a line of its own" -- also stops
|
||||
# counting `@FailsOnEmulatorApi37 @Test`, which is legal Kotlin, and UNDERcounting is the
|
||||
# dangerous direction: it hides a genuine new marker, which is the one thing this exists to
|
||||
# catch. Measured against `testdata/marker-shapes`, a fixture carrying every shape at once:
|
||||
# the old matcher says 5, own-line-only says 2, this one says 3. On the real tree, 4 / 3 / 3.
|
||||
# e2e-report-shape-test.sh runs that fixture through this whole script.
|
||||
#
|
||||
# `^[[:space:]]*@` cannot match an `import` line, so the old `grep -v import` goes with it
|
||||
# rather than staying to imply a filter is still doing work.
|
||||
#
|
||||
# This is a regex over source text and not a parser. An annotation inside a multi-line string,
|
||||
# or inside a `/* */` block that opened mid-line, would still be counted. Neither exists here;
|
||||
# if one ever does, this check wants a different tool rather than a longer regex.
|
||||
if [ -d "$REPO_ROOT/app/src/androidTest" ]; then
|
||||
marked="$(grep -rhcE '^[[:space:]]*@FailsOnEmulatorApi37([[:space:]]|$)' \
|
||||
"$REPO_ROOT/app/src/androidTest" --include='*.kt' \
|
||||
| awk '{ total += $1 } END { print total + 0 }' || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$advisory" = "yes" ] && [ -z "$baseline" ]; then
|
||||
deviations+=("the committed baseline could not be read from \`$(basename -- "$BASELINE_FILE")\` — has \`FAILS_ON_EMULATOR_API37_BASELINE\` been renamed, indented into a class, or moved? Nothing was compared")
|
||||
fi
|
||||
|
||||
if [ -n "$baseline" ]; then
|
||||
if [ "$no_run" = "nothing" ]; then
|
||||
deviations+=("no test run observed — the runner never reported starting one, where the baseline expects $baseline tests carrying \`@FailsOnEmulatorApi37\`")
|
||||
elif [ "$no_run" = "zero" ]; then
|
||||
deviations+=("the runner started 0 tests, where the baseline expects $baseline — on this image that is the framework having restarted under the run, not an empty test list")
|
||||
else
|
||||
if [ "$expected" != "unknown" ] && [ "$expected" != "$baseline" ]; then
|
||||
deviations+=("the runner started $expected tests, the baseline is $baseline")
|
||||
fi
|
||||
if [ "$failed" != "unknown" ] && [ "$failed" != "$baseline" ]; then
|
||||
deviations+=("$failed tests failed, the baseline is $baseline — every test carrying the marker is expected to fail on this image, so fewer means one now passes and more means a new one joined")
|
||||
fi
|
||||
fi
|
||||
if [ -n "$marked" ] && [ "$marked" != "$baseline" ]; then
|
||||
deviations+=("the tree carries $marked tests marked \`@FailsOnEmulatorApi37\` but the baseline says $baseline — update FAILS_ON_EMULATOR_API37_BASELINE")
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emit. Step log first, so the common case needs neither the summary page nor an artifact.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "----- RUN SHAPE (api${LABEL}) -----"
|
||||
echo " expected: $expected"
|
||||
echo " received: $received"
|
||||
echo " failed: $failed"
|
||||
# Above `completed cleanly`, because it is the line that says what happened to the leg and the
|
||||
# other one only qualifies it. A reader who stops after three rows still sees it.
|
||||
if [ -n "$WEDGED_AFTER" ]; then
|
||||
echo " wedged: yes -- gradle was killed after ${WEDGED_AFTER}s and never returned"
|
||||
fi
|
||||
echo " completed cleanly: ${completed//\*/}"
|
||||
if [ -n "$abort_received" ]; then
|
||||
echo " received before the abort: $abort_received"
|
||||
fi
|
||||
if [ -n "$failed_names" ]; then
|
||||
echo " failed tests:"
|
||||
printf '%s\n' "$failed_names" | sed -e 's/^/ /'
|
||||
fi
|
||||
if [ "$advisory" = "yes" ]; then
|
||||
if [ "${#deviations[@]}" -eq 0 ]; then
|
||||
echo " baseline: matches ($baseline expected, $baseline failed)"
|
||||
else
|
||||
printf ' baseline DEVIATION: %s\n' "${deviations[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# A notice, never an error. See the header.
|
||||
if [ "${#deviations[@]}" -gt 0 ]; then
|
||||
for d in "${deviations[@]}"; do
|
||||
echo "::notice::E2E api${LABEL}: $d"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{
|
||||
echo "### E2E api${LABEL} — run shape"
|
||||
echo
|
||||
echo "| field | value | where it came from |"
|
||||
echo "| --- | --- | --- |"
|
||||
echo "| expected | $expected | $expected_src |"
|
||||
echo "| received | $received | $received_src |"
|
||||
echo "| failed | $failed | $failed_src |"
|
||||
if [ -n "$WEDGED_AFTER" ]; then
|
||||
echo "| wedged | **yes** | \`timeout\` fired after ${WEDGED_AFTER}s and killed gradle (exit 124), which is what e2e-run.sh then captured the wedge diagnostics for |"
|
||||
fi
|
||||
echo "| completed cleanly | $completed | $completed_src |"
|
||||
if [ -n "$abort_received" ]; then
|
||||
echo "| received before the abort | $abort_received | the same line — the XML above counts the truncated test as a failure, this number does not |"
|
||||
fi
|
||||
echo
|
||||
if [ -n "$failed_names" ]; then
|
||||
echo "Failed:"
|
||||
echo
|
||||
printf '%s\n' "$failed_names" | sed -e 's/^/- `/' -e 's/$/`/'
|
||||
echo
|
||||
fi
|
||||
if [ "$xml_count" -gt 1 ]; then
|
||||
echo "> $xml_count test XML files were present; the counts above come from the first."
|
||||
echo
|
||||
fi
|
||||
if [ "$advisory" = "yes" ]; then
|
||||
if [ "${#deviations[@]}" -eq 0 ]; then
|
||||
echo "**Matches the committed baseline of $baseline** — $baseline tests carry \`@FailsOnEmulatorApi37\` and all $baseline failed, which is what this job is for."
|
||||
elif [ -z "$baseline" ]; then
|
||||
echo "**The committed baseline could not be read, so nothing was compared.** Announced as a notice, not an error: this job is advisory and its conclusion is unchanged by anything here."
|
||||
echo
|
||||
printf -- '- %s\n' "${deviations[@]}"
|
||||
else
|
||||
echo "**DEVIATION from the committed baseline of $baseline.** Announced as a notice, not an error: this job is advisory and its conclusion is unchanged by anything here."
|
||||
echo
|
||||
printf -- '- %s\n' "${deviations[@]}"
|
||||
fi
|
||||
echo
|
||||
echo "<sub>The baseline lives beside the marker, in \`FailsOnEmulatorApi37.kt\`. \`completed cleanly\` is recorded rather than compared: the truncation is intermittent — of eight advisory runs read on 2026-08-25, seven aborted and one did not — so comparing it would announce a deviation on a run that is fine.</sub>"
|
||||
else
|
||||
echo "<sub>No baseline comparison: that is the advisory API 37 leg only. The shape is recorded here anyway because a truncated run reports fewer results than it ran, which is what issue #108 looks like on a gating leg.</sub>"
|
||||
fi
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# The summary page is the deliverable -- "readable without opening a log" is what #83 asked
|
||||
# for -- and GitHub exposes no API for reading a job summary back, so a write that silently
|
||||
# did not happen would be invisible. This line is in the step log, which can be read.
|
||||
echo " (the table above is also on the job summary page)"
|
||||
else
|
||||
echo " (GITHUB_STEP_SUMMARY is unset -- step log only)"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+149
-2
@@ -34,12 +34,117 @@ TMP="${RUNNER_TEMP:-/tmp}"
|
||||
LOGCAT_LOG="$TMP/logcat-api${LABEL}.txt"
|
||||
DIAG_LOG="$TMP/diagnostics-api${LABEL}.txt"
|
||||
WEDGE_LOG="$TMP/wedge-diagnostics-api${LABEL}.txt"
|
||||
# Gradle's own output, captured to a file as well as the step log, because the run-shape report
|
||||
# below has to parse it. Uploaded with the diagnostics, so a report that reads wrong can be
|
||||
# checked against what it read.
|
||||
GRADLE_LOG="$TMP/gradle-api${LABEL}.txt"
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# ~5 min is a healthy leg (measured across API 33-36), and this wraps only the gradle client,
|
||||
# a subset of that. 20 min is generous enough never to trip on a slow-but-working run, and far
|
||||
# enough under the job's 60-min cap that a genuine wedge still leaves time to capture it.
|
||||
WEDGE_TIMEOUT=1200
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API 37 only, and nothing else sets it, so this is inert everywhere it is not wanted --
|
||||
# the same shape as E2E_EXTRA_GRADLE_ARGS below. The other four E2E legs run byte-identical
|
||||
# commands with it unset.
|
||||
#
|
||||
# WHY IT RUNS HERE, BEFORE THE LOGCAT STREAM: `adb shell stop` ends the `adb logcat` started
|
||||
# below, and nothing restarts it, so a disable performed after that point would cost this leg
|
||||
# its whole diagnostic story for the part of the run that matters. Everything this function
|
||||
# counts comes from `adb logcat -d -b crash`, which is a fresh read each time and independent
|
||||
# of the stream.
|
||||
#
|
||||
# WHAT IT IS FOR: the android-37.x images abort surfaceflinger from RegionSamplingThread inside
|
||||
# their own gralloc mapper (docs/api-37-emulator-crash.md). surfaceflinger is a critical service,
|
||||
# so init SIGKILLs zygote with it and the framework restarts under the run -- Gradle then reports
|
||||
# `cmd: Can't find service: package` and `Starting 0 tests`. RegionSamplingThread exists only
|
||||
# because SystemUI registers a nav-bar luma-sampling listener, so removing the package removes
|
||||
# the whole chain. Measured cadence of those kills: 20-90 s apart, median 60-70 s, three to five
|
||||
# in a four-minute window -- fast enough that install and instrumentation start-up do not fit
|
||||
# inside one gap.
|
||||
#
|
||||
# NOTHING HERE TRUSTS A COMMAND'S OWN REPORT, and that is not paranoia: of four runs of an
|
||||
# earlier one-shot version, one (32646029143) reported `new state: disabled-user` and then
|
||||
# started SystemUI eight more times, with ten more aborts. `pm disable-user` can be accepted by
|
||||
# a system_server that is SIGKILLed before the state is written, and `pm disable-user` does not
|
||||
# retract SystemUI's existing region-sampling registration either -- by the time boot completes
|
||||
# it has already registered, so only a framework restart brings back a SystemUI-less
|
||||
# surfaceflinger. Hence: disable, take the framework DOWN and confirm system_server is really
|
||||
# gone (an earlier probe asked `service check` 0.3 s after `stop` and got `found` from the
|
||||
# system_server that was still exiting, so its wait was not a wait), bring it back, verify the
|
||||
# package against `pm list packages -d`, and require a 45 s window with zero new aborts.
|
||||
# Three rounds, because one is not reliable and the failure is silent.
|
||||
# ---------------------------------------------------------------------------
|
||||
count_aborts() { adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma'; }
|
||||
systemui_disabled() { adb shell pm list packages -d 2> /dev/null | grep -q 'com.android.systemui'; }
|
||||
|
||||
disable_region_sampling() {
|
||||
local round=1 i out before after
|
||||
while [ "$round" -le 3 ]; do
|
||||
echo "--- SystemUI disable, round $round ---"
|
||||
for i in $(seq 1 10); do
|
||||
out="$(adb shell pm disable-user --user 0 com.android.systemui 2>&1 | tr -d '\r')"
|
||||
echo " pm attempt $i: $out"
|
||||
case "$out" in *"new state: disabled"*) break ;; esac
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo " restarting the framework"
|
||||
adb shell stop
|
||||
for i in $(seq 1 20); do
|
||||
[ -z "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ] && break
|
||||
sleep 2
|
||||
done
|
||||
echo " system_server down after ~$((i * 2)) s"
|
||||
adb shell start
|
||||
for i in $(seq 1 30); do
|
||||
if adb shell service check package 2> /dev/null | grep -q ': found' \
|
||||
&& adb shell service check activity 2> /dev/null | grep -q ': found' \
|
||||
&& [ -n "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ]; then
|
||||
echo " services back after ~$((i * 5)) s"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if systemui_disabled; then
|
||||
echo " verified: com.android.systemui is in pm list packages -d"
|
||||
else
|
||||
echo " NOT DISABLED after the restart -- the package state did not survive"
|
||||
round=$((round + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
before="$(count_aborts)"
|
||||
sleep 45
|
||||
after="$(count_aborts)"
|
||||
echo " abort rate, SystemUI disabled: $((after - before)) new in 45 s (total ${after:-0})"
|
||||
[ "$((after - before))" -eq 0 ] && break
|
||||
echo " still aborting after round $round"
|
||||
round=$((round + 1))
|
||||
done
|
||||
|
||||
# A warning rather than an exit. If the disable did not take, the run is about to report
|
||||
# `Starting 0 tests` and fail on its own -- and it will do so with the logcat, the crash
|
||||
# buffer and the diagnostics attached, which is more useful than dying here with none of it.
|
||||
if systemui_disabled; then
|
||||
echo " final state: SystemUI disabled"
|
||||
else
|
||||
echo "::warning::E2E api${LABEL}: SystemUI is still enabled -- expect INSTRUMENTATION_ABORTED"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ "${E2E_DISABLE_SYSTEM_UI:-}" = "1" ]; then
|
||||
echo "::group::E2E api${LABEL} -- removing the region-sampling listener"
|
||||
disable_region_sampling
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
# Stream logcat from now until the step ends, into a file that survives to the artifact upload.
|
||||
# Without this, a failure that happens on-device leaves nothing behind: `adb logcat -d` at the
|
||||
# end only has whatever is still in the ring buffer, and a chatty test run evicts the cause.
|
||||
@@ -116,18 +221,60 @@ status=0
|
||||
# script rather than forking it: that runs several API levels back to back against one checkout
|
||||
# and passes `--rerun`, so a level cannot be skipped as up-to-date and report the previous
|
||||
# level's results as its own. CI gets a fresh runner per level and does not need it.
|
||||
#
|
||||
# `2>&1 | tee`, and the `2>&1` is the load-bearing half. The step log merges both streams, so
|
||||
# reading one cannot tell you which stream a line came from -- and the single line the report
|
||||
# below needs most, `Test run failed to complete. ... INSTRUMENTATION_ABORTED`, is not on
|
||||
# stdout. Capturing stdout alone would leave the report saying "completed cleanly: yes" forever,
|
||||
# which is precisely the comparison that cannot fire. pipefail is already set and tee exits 0,
|
||||
# so the pipeline's status is still gradle's -- including the 124 that means the wrapper fired.
|
||||
#
|
||||
# `tee` and not `tee -a`, unlike the logcat above: CI gets a fresh runner per leg, but
|
||||
# tools/local-emulator/run-e2e.sh reuses one machine, and an appended log would have the report
|
||||
# reading the PREVIOUS run of the same API level. The console goes plain rather than showing
|
||||
# gradle's live progress bar, which is what it already did in CI.
|
||||
# shellcheck disable=SC2086
|
||||
timeout -k 30s "$WEDGE_TIMEOUT" \
|
||||
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64 --stacktrace \
|
||||
${E2E_EXTRA_GRADLE_ARGS:-} || status=$?
|
||||
${E2E_EXTRA_GRADLE_ARGS:-} 2>&1 | tee "$GRADLE_LOG" || status=$?
|
||||
echo "::endgroup::"
|
||||
|
||||
# Whether the wrapper timeout fired, decided ONCE. 124 is `timeout` saying it killed the
|
||||
# command, and two places downstream need that fact: capture_wedge below, and the report, which
|
||||
# otherwise calls a killed leg `completed cleanly: yes` (#118). Deriving it twice is how those
|
||||
# two would drift apart -- the report would keep printing after someone changed what a wedge
|
||||
# means here. It stays a string: empty on every other path, so those legs pass an empty
|
||||
# E2E_WEDGED_AFTER and the report behaves exactly as before.
|
||||
wedged=""
|
||||
[ "$status" -eq 124 ] && wedged="$WEDGE_TIMEOUT"
|
||||
|
||||
# The run-shape report: expected/received/failed and whether the run finished, every time,
|
||||
# green or red. It never changes `status` -- it is a diagnostic, and the header's rule about
|
||||
# diagnostics applies to it as much as to every probe below.
|
||||
#
|
||||
# E2E_WEDGED_AFTER is the wedge, told to the report rather than left for it to infer. It cannot
|
||||
# be inferred: a wedge is gradle never returning, so gradle printed no verdict at all, and the
|
||||
# log the report reads looks like a run that simply stopped. Only this script knows the
|
||||
# difference, because only this script saw the exit status.
|
||||
#
|
||||
# The baseline argument, and only it, turns on the comparison, and only the advisory API 37 job
|
||||
# passes E2E_ADVISORY=1. Comparing on the gating legs would announce a deviation on all five of
|
||||
# them every run, since they run the whole suite rather than the marked three. They still get
|
||||
# the report: a truncated run reporting fewer results than it ran is what #108 looks like, and
|
||||
# `completed cleanly` is the field that shows it.
|
||||
if [ "${E2E_ADVISORY:-}" = "1" ]; then
|
||||
E2E_WEDGED_AFTER="$wedged" bash "$SCRIPT_DIR/e2e-report-shape.sh" "$LABEL" "$GRADLE_LOG" \
|
||||
"$REPO_ROOT/app/src/androidTest/java/org/libremediaconverter/FailsOnEmulatorApi37.kt" || true
|
||||
else
|
||||
E2E_WEDGED_AFTER="$wedged" bash "$SCRIPT_DIR/e2e-report-shape.sh" "$LABEL" "$GRADLE_LOG" || true
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
kill "$LOGCAT_PID" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 124 ]; then
|
||||
if [ -n "$wedged" ]; then
|
||||
capture_wedge "api${LABEL}"
|
||||
else
|
||||
echo "::error::E2E api${LABEL} failed (exit $status)"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// NOT A TEST, AND NEVER COMPILED. This is fixture data for e2e-report-shape-test.sh, which
|
||||
// copies it into a throwaway repo root and runs the real report script against that. It lives
|
||||
// under .github/ deliberately: Gradle only compiles app/src/**, ktlint and detekt are applied to
|
||||
// :app only, and the report's own count reads app/src/androidTest -- so nothing here can reach
|
||||
// the build, the linters, or the number the advisory job compares against. Verified by running
|
||||
// the report against the real repo root with this file committed: still 3.
|
||||
//
|
||||
// It carries every shape the counter has to tell apart, in one file, because the bug in #120 was
|
||||
// exactly that two of them look alike to a substring match. Three count and three must not:
|
||||
//
|
||||
// COUNTS the annotation on its own line
|
||||
// COUNTS the annotation sharing a line with @Test -- legal Kotlin, and the case the
|
||||
// obvious "own line only" repair silently drops
|
||||
// COUNTS the annotation indented inside a nested class
|
||||
// must NOT a KDoc mentioning it -- this is #120 itself, copied from Media3EngineTest
|
||||
// must NOT a commented-out annotation
|
||||
// must NOT the import
|
||||
//
|
||||
// Three count. That is what the synthetic baseline in the test is set to, so the fixture and the
|
||||
// baseline agree exactly the way the real tree and FAILS_ON_EMULATOR_API37_BASELINE do.
|
||||
//
|
||||
// The `@Test` here is spelled the way a real test spells it so the fixture reads like source
|
||||
// rather than like a regex exercise. Nothing runs it.
|
||||
|
||||
package org.libremediaconverter.fixture
|
||||
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.FailsOnEmulatorApi37
|
||||
|
||||
class MarkerShapes {
|
||||
@FailsOnEmulatorApi37
|
||||
@Test
|
||||
fun ownLine() = Unit
|
||||
|
||||
@FailsOnEmulatorApi37 @Test
|
||||
fun sameLineAsTest() = Unit
|
||||
|
||||
/**
|
||||
* Deliberately not `@FailsOnEmulatorApi37`: nothing here decodes or encodes, so no emulator
|
||||
* codec is involved and the API 37 image has no quarrel with it.
|
||||
*/
|
||||
@Test
|
||||
fun mentionedInKdoc() = Unit
|
||||
|
||||
// @FailsOnEmulatorApi37 -- taken off on 2026-01-01, kept as a note rather than deleted
|
||||
@Test
|
||||
fun commentedOut() = Unit
|
||||
|
||||
class Nested {
|
||||
@FailsOnEmulatorApi37
|
||||
@Test
|
||||
fun indentedDeeper() = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
name: API 37 debug
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHAT THIS IS FOR, AND WHY IT IS SEPARATE
|
||||
#
|
||||
# The android-37.x emulator images abort surfaceflinger inside their own gralloc
|
||||
# mapper. That was established locally, under -gpu host and under ANGLE
|
||||
# (docs/api-37-emulator-crash.md), but NOT on a GitHub runner: CI runs
|
||||
# -gpu swiftshader_indirect, and the one local measurement of that mode was void for a
|
||||
# purely local reason (Fedora's SELinux denies execheap to SwiftShader's Reactor JIT --
|
||||
# docs/local-emulator.md). What CI actually does at API 37 was an open question, and
|
||||
# this workflow is the instrument that answered it.
|
||||
#
|
||||
# IT IS STILL THE INSTRUMENT. status_check.yml now carries API 37 -- a gating leg that
|
||||
# disables SystemUI first, and an advisory one for the two @FailsOnEmulatorApi37 tests
|
||||
# -- so this file's job is no longer to decide that, but to test a change to it for one
|
||||
# dispatch instead of one commit. The next questions it exists for are written down
|
||||
# under "When to revisit" in docs/api-37-emulator-crash.md: a new API 37.x image, or an
|
||||
# ATD image for 37, either of which could retire the whole workaround.
|
||||
#
|
||||
# It is a copy of that E2E job with the matrix replaced by workflow_dispatch inputs,
|
||||
# so one hypothesis costs one dispatch rather than one commit. It triggers on nothing
|
||||
# else: no push, no pull_request, no schedule. Nothing depends on it and it gates
|
||||
# nothing.
|
||||
#
|
||||
# TWO THINGS THIS DELIBERATELY DOES NOT DO:
|
||||
#
|
||||
# - It does not fork .github/scripts/e2e-run.sh. That script owns the FAILED-vs-WEDGED
|
||||
# split, the SIGQUIT thread dump and the streamed logcat, and it is the copy CI
|
||||
# exercises every day. This calls it, exactly as status_check.yml does.
|
||||
# - It does not change status_check.yml. If a configuration here turns out to work,
|
||||
# the change to the real matrix is proposed separately.
|
||||
#
|
||||
# THE WATCHDOG IS THE POINT, not a nicety. reactivecircus/android-emulator-runner calls
|
||||
# killEmulator() from its own catch block, so a run whose emulator never boots is torn
|
||||
# down before a single `script:` line executes -- no probe, no e2e-run.sh, no artifacts,
|
||||
# nothing to read afterwards. That is precisely the failure shape API 37 is suspected of.
|
||||
# The watchdog therefore starts BEFORE the action, from outside it, and samples the device
|
||||
# on its own clock.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
api_level:
|
||||
description: 'API level, as the SDK spells it. 37.0, 37.1, 37.2-beta3, 36 ... A bare 37 does not exist and fails during SDK setup.'
|
||||
type: string
|
||||
default: '37.0'
|
||||
target:
|
||||
description: 'System image target. android-37.1 and 37.2-beta* ship ONLY as google_apis_ps16k -- there is no plain google_apis above 37.0.'
|
||||
type: string
|
||||
default: 'google_apis'
|
||||
channel:
|
||||
description: 'SDK channel. beta is required for any 37.2-beta* image.'
|
||||
type: choice
|
||||
options: ['stable', 'beta', 'dev', 'canary']
|
||||
default: 'stable'
|
||||
gpu_mode:
|
||||
description: 'The -gpu argument. swiftshader_indirect is what status_check.yml uses today; swangle_indirect is what works locally on API 37.'
|
||||
type: choice
|
||||
options:
|
||||
- swiftshader_indirect
|
||||
- swangle_indirect
|
||||
- angle_indirect
|
||||
- host
|
||||
- auto
|
||||
- guest
|
||||
- 'off'
|
||||
default: 'swiftshader_indirect'
|
||||
disable_system_ui:
|
||||
description: 'Take SystemUI out before the suite runs, which is what stops SurfaceFlinger RegionSamplingThread reaching the mapper bug. Restarts the framework.'
|
||||
type: boolean
|
||||
default: false
|
||||
run_tests:
|
||||
description: 'Run the instrumented suite. false boots, probes and stops -- the cheap loop when the question is only whether it boots and at what abort rate.'
|
||||
type: boolean
|
||||
default: true
|
||||
emulator_boot_timeout:
|
||||
description: 'Seconds the action waits for sys.boot_completed. Do not lower this for a software renderer: a slow boot would be misreported as a failed one.'
|
||||
type: string
|
||||
default: '600'
|
||||
emulator_extra_options:
|
||||
description: 'Appended verbatim to the emulator command line -- e.g. "-verbose", or "-feature -GLDMA,-GLDMA2". The action interpolates it into a sh -c, so "| tee $RUNNER_TEMP/emulator.log" also works and is uploaded.'
|
||||
type: string
|
||||
default: ''
|
||||
disable_animations:
|
||||
description: 'The action settings-puts three animation scales after boot. Each is an adb call that throws if the framework is mid-restart, which would kill the run before the probe. false removes three of those calls.'
|
||||
type: boolean
|
||||
default: true
|
||||
gradle_extra_args:
|
||||
description: 'Passed to e2e-run.sh as E2E_EXTRA_GRADLE_ARGS, its existing hook -- e.g. "--rerun", or -Pandroid.testInstrumentationRunnerArguments.class=... to run one class instead of the suite.'
|
||||
type: string
|
||||
default: ''
|
||||
measure_baseline:
|
||||
description: 'Measure the abort rate for 45 s BEFORE disabling SystemUI. Answers "how fast is it aborting"; costs 45 s of crash-looping first, which is a worse starting point for the disable.'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
run-name: >-
|
||||
api ${{ inputs.api_level }}/${{ inputs.target }} · gpu ${{ inputs.gpu_mode }} ·
|
||||
systemui ${{ inputs.disable_system_ui && 'disabled' || 'running' }} ·
|
||||
tests ${{ inputs.run_tests && 'yes' || 'no' }}
|
||||
|
||||
# No `concurrency` block, unlike status_check.yml. Every dispatch here runs on the same
|
||||
# ref (main), so a group keyed on github.ref with cancel-in-progress would make two
|
||||
# parallel experiments cancel each other -- which is the opposite of what this is for.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GRADLE_CACHE_PATHS: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
|
||||
jobs:
|
||||
e2e-api37:
|
||||
name: E2E API ${{ inputs.api_level }} (${{ inputs.gpu_mode }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25' # Matches the daemon JVM pinned in gradle/gradle-daemon-jvm.properties
|
||||
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.GRADLE_CACHE_PATHS }}
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
# Without this the emulator falls back to software rendering and takes minutes
|
||||
# longer to boot, when it boots at all.
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
# Both helpers live in RUNNER_TEMP rather than in the repository: they are debug
|
||||
# instrumentation for this workflow only, and writing them here keeps the whole
|
||||
# experiment in one file that can be read top to bottom.
|
||||
- name: Write the watchdog and the probe
|
||||
env:
|
||||
LABEL: ${{ inputs.api_level }}
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/watchdog.sh" <<'WATCHDOG'
|
||||
#!/usr/bin/env bash
|
||||
# Samples the device from outside the emulator action, because the action tears the
|
||||
# emulator down on a boot timeout before any script: line runs. Everything here is
|
||||
# `timeout`-wrapped: a wedged adb must not stall the sampler, and no probe may fail.
|
||||
SERIAL="emulator-5554"
|
||||
|
||||
# adb is resolved by path, not by name. The emulator action puts platform-tools on
|
||||
# PATH with core.addPath, which only affects LATER steps -- this one already exists
|
||||
# by then, so a bare `adb` here is not the runner's adb and may be nothing at all.
|
||||
# The first version of this file assumed otherwise and every sample came back
|
||||
# boot=? dma_aborts=0 while the action's own adb was working fine two steps away.
|
||||
# Re-resolved every iteration because platform-tools may be installed after this
|
||||
# starts, and echoed to stdout so a repeat of that failure is visible immediately.
|
||||
ADB=""
|
||||
resolve_adb() {
|
||||
for c in "$ADB" "${ANDROID_HOME:-}/platform-tools/adb" "${ANDROID_SDK_ROOT:-}/platform-tools/adb" "$(command -v adb 2> /dev/null)"; do
|
||||
if [ -n "$c" ] && [ -x "$c" ]; then
|
||||
[ "$c" = "$ADB" ] || echo "watchdog: adb resolved to $c"
|
||||
ADB="$c"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
OUT="$RUNNER_TEMP/watchdog-api$LABEL.txt"
|
||||
CRASH="$RUNNER_TEMP/crash-buffer-api$LABEL.txt"
|
||||
GUESTLOG="$RUNNER_TEMP/watchdog-logcat-api$LABEL.txt"
|
||||
STOP="$RUNNER_TEMP/watchdog.stop"
|
||||
|
||||
# A continuous guest logcat, restarted whenever the device goes away. During a
|
||||
# surfaceflinger crash loop the framework restarts every few seconds and adb goes
|
||||
# with it, so a single `adb logcat` would end at the first restart.
|
||||
(
|
||||
while [ ! -f "$STOP" ]; do
|
||||
if resolve_adb; then
|
||||
timeout 120 "$ADB" -s "$SERIAL" wait-for-device > /dev/null 2>&1 \
|
||||
&& timeout 3000 "$ADB" -s "$SERIAL" logcat -v time >> "$GUESTLOG" 2>&1
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
) &
|
||||
|
||||
echo "watchdog started $(date -u +%FT%TZ) -- serial $SERIAL" >> "$OUT"
|
||||
i=0
|
||||
while [ "$i" -lt 300 ]; do
|
||||
i=$((i + 1))
|
||||
[ -f "$STOP" ] && break
|
||||
if ! resolve_adb; then
|
||||
echo "$(date -u +%T) no adb yet" >> "$OUT"
|
||||
sleep 20
|
||||
continue
|
||||
fi
|
||||
boot="$(timeout 20 "$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2> /dev/null | tr -d '\r\n')"
|
||||
sf="$(timeout 20 "$ADB" -s "$SERIAL" shell pidof surfaceflinger 2> /dev/null | tr -d '\r\n')"
|
||||
zy="$(timeout 20 "$ADB" -s "$SERIAL" shell pidof zygote64 2> /dev/null | tr -d '\r\n')"
|
||||
# Kept as a file rather than a variable so the last successful read survives the
|
||||
# action killing the emulator -- which is when it is most worth having.
|
||||
if timeout 30 "$ADB" -s "$SERIAL" logcat -d -b crash > "$CRASH.new" 2> /dev/null; then
|
||||
mv "$CRASH.new" "$CRASH"
|
||||
fi
|
||||
dma="$(grep -c 'hasReadColorBufferDma' "$CRASH" 2> /dev/null || true)"
|
||||
sigabrt="$(grep -c 'signal 6' "$CRASH" 2> /dev/null || true)"
|
||||
printf '%s boot=%-4s surfaceflinger=%-8s zygote64=%-8s dma_aborts=%-5s sigabrt=%s\n' \
|
||||
"$(date -u +%T)" "${boot:-?}" "${sf:-none}" "${zy:-none}" "${dma:-0}" "${sigabrt:-0}" >> "$OUT"
|
||||
# One shot, the first time the device is up: which GLES implementation the guest
|
||||
# actually got. This is the guest-side answer to the same question the emulator's
|
||||
# own gles_mode_selected line answers host-side.
|
||||
if [ "$boot" = "1" ] && [ ! -f "$RUNNER_TEMP/renderer-api$LABEL.txt" ]; then
|
||||
{
|
||||
echo "=== booted at $(date -u +%FT%TZ), watchdog sample $i ==="
|
||||
timeout 30 "$ADB" -s "$SERIAL" shell dumpsys SurfaceFlinger 2>&1 | head -40
|
||||
echo "--- getprop ---"
|
||||
timeout 20 "$ADB" -s "$SERIAL" shell getprop 2>&1 | grep -Ei 'egl|gles|gpu|ranchu|gfxstream' || true
|
||||
} > "$RUNNER_TEMP/renderer-api$LABEL.txt" 2>&1
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
echo "watchdog finished $(date -u +%FT%TZ) after $i samples" >> "$OUT"
|
||||
WATCHDOG
|
||||
|
||||
cat > "$RUNNER_TEMP/probe.sh" <<'PROBE'
|
||||
#!/usr/bin/env bash
|
||||
# Runs on the booted device, before the suite. Two jobs: record what the guest got,
|
||||
# and measure the gralloc abort RATE -- which is the number that decides whether a
|
||||
# five-minute test run can survive, and the one comparable with the local figures in
|
||||
# docs/api-37-emulator-crash.md (10-11 per 150 s idle under ANGLE with SystemUI up).
|
||||
#
|
||||
# Never exits non-zero. The action runs script: lines in one try/catch, so a failing
|
||||
# probe would skip e2e-run.sh entirely and the run would measure nothing.
|
||||
exec > >(tee -a "$RUNNER_TEMP/probe-api$LABEL.txt") 2>&1
|
||||
echo "===== probe api$LABEL -- $(date -u +%FT%TZ) ====="
|
||||
adb shell getprop sys.boot_completed
|
||||
adb shell getprop ro.build.fingerprint
|
||||
adb shell getprop ro.build.version.sdk
|
||||
echo "--- SurfaceFlinger (the GLES line names the renderer the guest is on) ---"
|
||||
adb shell dumpsys SurfaceFlinger 2>&1 | head -30
|
||||
echo "--- binder services ---"
|
||||
for s in package activity window; do adb shell service check "$s" 2>&1; done
|
||||
|
||||
count_aborts() { adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma'; }
|
||||
systemui_disabled() { adb shell pm list packages -d 2> /dev/null | grep -q 'com.android.systemui'; }
|
||||
|
||||
if [ "${MEASURE_BASELINE:-true}" = "true" ]; then
|
||||
before="$(count_aborts)"
|
||||
sleep 45
|
||||
after="$(count_aborts)"
|
||||
echo "--- abort rate, SystemUI running: $((after - before)) new in 45 s (total ${after:-0}) ---"
|
||||
else
|
||||
# Skipped on purpose when the question is reliability rather than rate: every
|
||||
# second spent measuring is a second of crash-looping, and the disable is what
|
||||
# has to land. A real CI leg would disable as early as it can, so measure that.
|
||||
echo "--- baseline window skipped (MEASURE_BASELINE=false) ---"
|
||||
fi
|
||||
|
||||
if [ "${DISABLE_SYSTEM_UI:-false}" = "true" ]; then
|
||||
# Three rounds, because ONE round is not reliable and the failure is silent.
|
||||
# Measured: of four runs of the same configuration, three came back with the
|
||||
# suite running and one (32646029143) had SystemUI restarting throughout --
|
||||
# `ActivityManager: Start proc N:com.android.systemui ... GradientColorWallpaper`
|
||||
# eight more times after a `pm disable-user` that had reported
|
||||
# `new state: disabled-user`, and ten more RegionSampling aborts with it. The
|
||||
# framework is being SIGKILLed under this loop, so a package-state change can be
|
||||
# lost with the system_server that accepted it. (An earlier version of this comment
|
||||
# said "every ~20 s". That was the watchdog's SAMPLING interval, not the cadence.
|
||||
# Measured: 20-90 s between aborts, median 60-70 s, 3-5 in a four-minute window --
|
||||
# docs/api-37-emulator-crash.md, "Abort cadence, corrected".)
|
||||
#
|
||||
# Nothing here trusts a command's own report. Each round: disable, take the
|
||||
# framework down and confirm it is DOWN before bringing it back (the previous
|
||||
# version asked `service check` 0.3 s after `stop` and got `found` from the
|
||||
# system_server that was still exiting, so its wait was not a wait), then verify
|
||||
# the package is really disabled and that no abort lands in a quiet window.
|
||||
round=1
|
||||
while [ "$round" -le 3 ]; do
|
||||
echo "--- disable round $round ---"
|
||||
for i in $(seq 1 10); do
|
||||
out="$(adb shell pm disable-user --user 0 com.android.systemui 2>&1 | tr -d '\r')"
|
||||
echo " pm attempt $i: $out"
|
||||
case "$out" in *"new state: disabled"*) break ;; esac
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# pm disable-user does not retract SystemUI's existing region-sampling
|
||||
# registration -- by the time boot completes it has already registered. Only a
|
||||
# framework restart brings back a SystemUI-less SurfaceFlinger. See
|
||||
# disable_region_sampling in tools/local-emulator/run-e2e.sh.
|
||||
echo " restarting the framework"
|
||||
adb shell stop
|
||||
for i in $(seq 1 20); do
|
||||
[ -z "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ] && break
|
||||
sleep 2
|
||||
done
|
||||
echo " system_server down after $((i * 2)) s"
|
||||
adb shell start
|
||||
for i in $(seq 1 30); do
|
||||
if adb shell service check package 2> /dev/null | grep -q ': found' \
|
||||
&& adb shell service check activity 2> /dev/null | grep -q ': found' \
|
||||
&& [ -n "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ]; then
|
||||
echo " services back after $((i * 5)) s"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if systemui_disabled; then
|
||||
echo " verified: com.android.systemui is in pm list packages -d"
|
||||
else
|
||||
echo " NOT DISABLED after the restart -- the package state did not survive"
|
||||
round=$((round + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
before="$(count_aborts)"
|
||||
sleep 45
|
||||
after="$(count_aborts)"
|
||||
echo "--- abort rate, SystemUI disabled: $((after - before)) new in 45 s (total ${after:-0}) ---"
|
||||
[ "$((after - before))" -eq 0 ] && break
|
||||
echo " still aborting after round $round"
|
||||
round=$((round + 1))
|
||||
done
|
||||
systemui_disabled && echo "final state: SystemUI disabled" || echo "final state: SystemUI STILL ENABLED -- expect Starting 0 tests"
|
||||
fi
|
||||
|
||||
echo "--- crash buffer (tail 60) ---"
|
||||
adb logcat -d -b crash 2>&1 | tail -60
|
||||
echo "===== probe done ====="
|
||||
exit 0
|
||||
PROBE
|
||||
|
||||
chmod +x "$RUNNER_TEMP/watchdog.sh" "$RUNNER_TEMP/probe.sh"
|
||||
echo "helpers written to $RUNNER_TEMP"
|
||||
|
||||
- name: Start the watchdog
|
||||
env:
|
||||
LABEL: ${{ inputs.api_level }}
|
||||
run: |
|
||||
echo "ANDROID_HOME=${ANDROID_HOME:-<unset>} ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT:-<unset>}"
|
||||
echo "adb on PATH: $(command -v adb || echo '<none -- the watchdog will fall back to ANDROID_HOME>')"
|
||||
nohup bash "$RUNNER_TEMP/watchdog.sh" > "$RUNNER_TEMP/watchdog-stdout.txt" 2>&1 < /dev/null &
|
||||
disown
|
||||
echo "watchdog pid $!"
|
||||
|
||||
- name: Instrumented tests
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
env:
|
||||
LABEL: ${{ inputs.api_level }}
|
||||
DISABLE_SYSTEM_UI: ${{ inputs.disable_system_ui }}
|
||||
MEASURE_BASELINE: ${{ inputs.measure_baseline }}
|
||||
# e2e-run.sh's own hook, unset in CI's real workflow and therefore inert there.
|
||||
E2E_EXTRA_GRADLE_ARGS: ${{ inputs.gradle_extra_args }}
|
||||
with:
|
||||
api-level: ${{ inputs.api_level }}
|
||||
target: ${{ inputs.target }}
|
||||
channel: ${{ inputs.channel }}
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
emulator-boot-timeout: ${{ inputs.emulator_boot_timeout }}
|
||||
emulator-options: -no-window -gpu ${{ inputs.gpu_mode }} -noaudio -no-boot-anim -camera-back none ${{ inputs.emulator_extra_options }}
|
||||
disable-animations: ${{ inputs.disable_animations }}
|
||||
# disk-size, ram-size: kept exactly as status_check.yml pins them, so this
|
||||
# measures the renderer and not a different device. 8G because the APK plus
|
||||
# FFmpeg does not fit the default userdata partition; 2560M because the
|
||||
# emulator's own RAM floor varies by API level and 2560M is the highest of them.
|
||||
disk-size: 8G
|
||||
ram-size: 2560M
|
||||
# Two lines, because the action splits script: on newlines and runs each as its
|
||||
# own `sh -c`. The first is this workflow's own probe; the second is CI's real
|
||||
# harness, invoked unmodified. run_tests: false replaces it with an echo rather
|
||||
# than a second copy of the job.
|
||||
script: |
|
||||
bash ${{ runner.temp }}/probe.sh
|
||||
${{ inputs.run_tests && format('bash .github/scripts/e2e-run.sh {0}', inputs.api_level) || 'echo "run_tests=false -- suite skipped, boot and probe only"' }}
|
||||
|
||||
- name: Stop the watchdog
|
||||
if: always()
|
||||
run: |
|
||||
touch "$RUNNER_TEMP/watchdog.stop"
|
||||
echo "----- watchdog samples -----"
|
||||
cat "$RUNNER_TEMP/watchdog-api${{ inputs.api_level }}.txt" 2>/dev/null || echo "(no watchdog output)"
|
||||
echo "----- renderer -----"
|
||||
cat "$RUNNER_TEMP/renderer-api${{ inputs.api_level }}.txt" 2>/dev/null || echo "(never booted, or dumpsys unavailable)"
|
||||
echo "----- crash buffer, last read before teardown (tail 80) -----"
|
||||
tail -80 "$RUNNER_TEMP/crash-buffer-api${{ inputs.api_level }}.txt" 2>/dev/null || echo "(none)"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: api37-debug-run${{ github.run_number }}
|
||||
path: |
|
||||
${{ runner.temp }}/watchdog-api*.txt
|
||||
${{ runner.temp }}/watchdog-logcat-api*.txt
|
||||
${{ runner.temp }}/watchdog-stdout.txt
|
||||
${{ runner.temp }}/crash-buffer-api*.txt
|
||||
${{ runner.temp }}/renderer-api*.txt
|
||||
${{ runner.temp }}/probe-api*.txt
|
||||
${{ runner.temp }}/emulator*.log
|
||||
${{ runner.temp }}/logcat-api*.txt
|
||||
${{ runner.temp }}/diagnostics-api*.txt
|
||||
${{ runner.temp }}/wedge-diagnostics-api*.txt
|
||||
app/build/reports/androidTests/
|
||||
app/build/outputs/androidTest-results/
|
||||
if-no-files-found: warn
|
||||
@@ -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.
|
||||
|
||||
@@ -156,7 +156,54 @@ jobs:
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
# Shell is the other language in this repo -- four scripts, one of them the CI
|
||||
# entry point itself -- and nothing was checking it. `git ls-files` rather than a
|
||||
# fixed list, so a script added later is covered without editing this workflow.
|
||||
#
|
||||
# Full severity, `info` included. The findings it raises today are answered with
|
||||
# targeted `disable` directives carrying their reason, the same way
|
||||
# config/detekt/detekt.yml carries only the rules this codebase legitimately
|
||||
# breaks. A blanket --severity=warning would have hidden them and the next real
|
||||
# one alike.
|
||||
#
|
||||
# PINNED BY DIGEST, for the reason CLAUDE.md already gives for pinning ktlint,
|
||||
# detekt and JaCoCo: a new rule in a linter makes files nobody touched stop
|
||||
# passing, so CI goes red on a PR whose diff cannot explain it. That is not
|
||||
# hypothetical here. The first cut of this step used the runner's ambient
|
||||
# shellcheck, which is 0.9.0, and 0.9.0 reports a trap handler as seven
|
||||
# unreachable commands (SC2317) where 0.11.0 reports it once on the declaration
|
||||
# (SC2329) -- same script, same directive, different answer, and a red build on
|
||||
# the PR that introduced the step. The version is printed so a finding that
|
||||
# appears out of nowhere can be tied to a bump of this line.
|
||||
- name: shellcheck
|
||||
env:
|
||||
SHELLCHECK: koalaman/shellcheck@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d
|
||||
run: |
|
||||
docker run --rm "$SHELLCHECK" --version
|
||||
git ls-files -z '*.sh' | xargs -0 -r docker run --rm -v "$PWD:/mnt" "$SHELLCHECK"
|
||||
|
||||
# actionlint closes the half shellcheck cannot see. The step above reads .sh files;
|
||||
# a good deal of this repo's bash lives in inline `run:` blocks instead -- the release
|
||||
# verification here, the emulator setup and teardown in this file and in
|
||||
# api37-debug.yml. actionlint parses each workflow and runs shellcheck over every
|
||||
# `run:`, on top of its own checks for expression syntax, `needs:` references, matrix
|
||||
# keys and action input names.
|
||||
#
|
||||
# Pinned by digest for the same reason shellcheck is, and with a second reason of its
|
||||
# own: actionlint's documented install is `bash <(curl -s .../download-actionlint.bash)`
|
||||
# off a moving branch, which would sit badly in a repo that pins every action by SHA.
|
||||
- name: actionlint
|
||||
env:
|
||||
ACTIONLINT: rhysd/actionlint@sha256:9d36088643581e728c969f35141f88139fec77280b2be23c1f66f8e40e1025e7
|
||||
run: |
|
||||
docker run --rm "$ACTIONLINT" -version
|
||||
docker run --rm -v "$PWD:/repo" -w /repo "$ACTIONLINT" -color
|
||||
|
||||
# `!cancelled()` rather than a plain sequence: a shellcheck failure above must not
|
||||
# cost the ktlint/detekt/lint lists. Same reason this step passes --continue -- one
|
||||
# round trip should produce every list, not stop at the first.
|
||||
- name: ktlint, detekt and Android lint
|
||||
if: '!cancelled()'
|
||||
run: ./gradlew :app:ktlintCheck :app:detekt :app:lintDebug --continue --stacktrace
|
||||
|
||||
# The XML matters as much as the HTML: it is the one that can be diffed between
|
||||
@@ -178,8 +225,9 @@ jobs:
|
||||
# across it -- none below 34, dataSync at 34, mediaProcessing from 35. Testing a
|
||||
# single level would leave two thirds of that branch unexercised.
|
||||
#
|
||||
# It stops at 36 rather than targetSdk 37 because the android-37.0 emulator image
|
||||
# is broken, not because 37 does not matter. See docs/api-37-emulator-crash.md.
|
||||
# It reaches targetSdk 37, but the API 37 row is not like the other four and the
|
||||
# comment on it says how. Two tests are excluded there and run in their own
|
||||
# advisory job below. See docs/api-37-emulator-crash.md.
|
||||
#
|
||||
# FFmpeg is not built here. The AAR is committed under bin/, so a red run means the
|
||||
# code is broken rather than that a cross-compile hiccuped.
|
||||
@@ -204,13 +252,49 @@ jobs:
|
||||
api-level: "35"
|
||||
- label: "36"
|
||||
api-level: "36"
|
||||
# No API 37 row. targetSdk is 37, but the android-37.0 emulator image
|
||||
# crash-loops surfaceflinger inside its own gralloc mapper, so every test
|
||||
# fails there no matter what this app does. Ruling that in took four CI
|
||||
# rounds, so the evidence and the ruled-out fixes are written down rather
|
||||
# than left to be rediscovered: docs/api-37-emulator-crash.md. That file
|
||||
# also records what to try first when re-adding it -- note that the row
|
||||
# needs api-level "37.0", since a bare 37 fails during SDK setup.
|
||||
# API 37, and it is NOT the same device as the four rows above it.
|
||||
#
|
||||
# CAVEAT, read this before trusting a green here: this leg runs with
|
||||
# SystemUI disabled and the framework restarted under it. No other leg
|
||||
# and no Pixel run uses that configuration. It is defensible only because
|
||||
# nothing THIS LEG RUNS touches system UI -- Media3, FFmpeg and
|
||||
# WorkManager tests -- and because the alternative is no CI coverage of
|
||||
# the level this app targets. **Anything that ever does depend on system
|
||||
# UI must not trust this row.** E2E_DISABLE_SYSTEM_UI is what does it;
|
||||
# .github/scripts/e2e-run.sh explains the mechanism and why every step of
|
||||
# it is verified rather than assumed.
|
||||
#
|
||||
# "this leg" and not "this suite", since 2026-08-24, and the difference is
|
||||
# now load-bearing: SafPickerRoundTripTest DOES touch system UI. It drives
|
||||
# DocumentsUI and rotates the display, and both reach the gralloc mapper
|
||||
# this image aborts in -- disabling SystemUI removes the IDLE trigger, not
|
||||
# those. Measured per method on android-37.0: the ROTATION test takes the
|
||||
# framework down (INSTRUMENTATION_ABORTED) and carries
|
||||
# @FailsOnEmulatorApi37, so notAnnotation below keeps it off this row; the
|
||||
# PICKER test passes and runs here like anything else. A rotation rebuilds
|
||||
# every surface at once, and starting another app's activity does not.
|
||||
#
|
||||
# So this row does now run one test that depends on system UI, and the
|
||||
# caveat above still applies to it: a green here is not evidence the picker
|
||||
# works on a device with SystemUI running -- the Pixel release check is.
|
||||
# docs/api-37-emulator-crash.md has the per-method measurements, and the
|
||||
# correction that produced them.
|
||||
#
|
||||
# api-level must be a POINT release. A bare 37 is not an SDK package and
|
||||
# fails during setup, which cost a run to discover. `37.0` is the choice
|
||||
# here rather than the only option: `37.1` and `37.2-beta*` exist and
|
||||
# abort the same way, and api37-debug.yml's inputs document both, with
|
||||
# the wrinkle that above 37.0 they ship only as google_apis_ps16k.
|
||||
# docs/api-37-emulator-crash.md measures 37.0 rev 6 and 37.1 rev 8 side
|
||||
# by side, so pinning 37.0 is a decision, not a constraint.
|
||||
#
|
||||
# notAnnotation removes the three tests that do not pass on this image; they
|
||||
# run in the advisory job below, off the same marker so they cannot end up
|
||||
# in both or neither. docs/api-37-emulator-crash.md has the measurements.
|
||||
- label: "37"
|
||||
api-level: "37.0"
|
||||
disable-system-ui: "1"
|
||||
gradle-args: "-Pandroid.testInstrumentationRunnerArguments.notAnnotation=org.libremediaconverter.FailsOnEmulatorApi37"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -236,6 +320,12 @@ jobs:
|
||||
|
||||
- name: Instrumented tests
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
# Both of these are empty on every row but 37, and both are read with a
|
||||
# `:-` default in e2e-run.sh, so the four legs below 37 run the identical
|
||||
# gradle command they always have.
|
||||
env:
|
||||
E2E_DISABLE_SYSTEM_UI: ${{ matrix.disable-system-ui }}
|
||||
E2E_EXTRA_GRADLE_ARGS: ${{ matrix.gradle-args }}
|
||||
with:
|
||||
api-level: ${{ matrix.api-level }}
|
||||
target: google_apis
|
||||
@@ -287,6 +377,7 @@ jobs:
|
||||
path: |
|
||||
${{ runner.temp }}/logcat-api${{ matrix.label }}.txt
|
||||
${{ runner.temp }}/diagnostics-api${{ matrix.label }}.txt
|
||||
${{ runner.temp }}/gradle-api${{ matrix.label }}.txt
|
||||
if-no-files-found: warn
|
||||
|
||||
# Only exists when the wrapper timeout tripped, so `ignore` keeps healthy runs quiet
|
||||
@@ -297,3 +388,130 @@ jobs:
|
||||
name: e2e-wedge-api${{ matrix.label }}
|
||||
path: ${{ runner.temp }}/wedge-diagnostics-api${{ matrix.label }}.txt
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The three API 37 tests the gating row above excludes, run on their own so they
|
||||
# stay visible instead of disappearing behind a notAnnotation.
|
||||
#
|
||||
# continue-on-error: it reports, it never blocks. That is the whole reason it is
|
||||
# a separate job rather than a sixth matrix row: a row would share the gating
|
||||
# job's `E2E API <label>` name, and a check cannot be both required and advisory
|
||||
# under one name.
|
||||
#
|
||||
# It was named for WHAT IT RUNS, and that is now APPROXIMATE rather than exact.
|
||||
# When this job was created it held two tests, both driving a full H.264 -> H.265
|
||||
# hardware transcode through Media3Engine -- which is exactly what separated them
|
||||
# from the two Media3EngineTest cases that pass here, since those two never decode
|
||||
# video. Since 2026-08-25 it also holds SafPickerRoundTripTest's rotation case,
|
||||
# which drives no transcode at all: a real rotation rebuilds every surface at once
|
||||
# and takes the framework down on this image (INSTRUMENTATION_ABORTED), which is a
|
||||
# different failure from the decoder one below.
|
||||
#
|
||||
# The name is kept anyway, and that is a decision rather than an oversight. This is
|
||||
# not a required context, it is red on every PR by design, and it is one people
|
||||
# have learned to look for -- renaming a check costs more than the imprecision
|
||||
# does. **The marker is the definition, not the name:** what this job holds is the
|
||||
# tests that cannot pass on the API 37 emulator image, whatever their subject. The
|
||||
# theory about the Media3 pair is in the next paragraph, where it can be corrected
|
||||
# without touching the name.
|
||||
#
|
||||
# THEORY, NOT SETTLED: the exception surfaces at `dequeueOutputBuffer` on
|
||||
# `c2.goldfish.h264.decoder`, the emulator's own codec, which gets its frames out
|
||||
# of a host-side colour buffer -- the same readback machinery that aborts
|
||||
# surfaceflinger on this image. It is about the MEDIA3 PAIR only; the rotation
|
||||
# case above fails for its own reason. What is MEASURED is narrower: those two
|
||||
# fail on
|
||||
# the API 37 emulator image; pass at API 36 on this runner under the same renderer
|
||||
# AND the same SystemUI-disable path; pass at API 33-36 without that path at all,
|
||||
# since nothing below 37 needs it; and pass on a physical Pixel 10 Pro XL at 37. That the decoder is the culprit rather than something else
|
||||
# the decode path touches is inference. docs/api-37-emulator-crash.md separates
|
||||
# the two, and the images also differ on the encoder side, which is why "broken
|
||||
# h264 decoder" is not written into this job's name.
|
||||
#
|
||||
# WHEN THIS GOES GREEN, delete the annotation rather than this job: the gating
|
||||
# row picks the tests back up automatically, and this job goes empty and can go
|
||||
# with it.
|
||||
# ---------------------------------------------------------------------------
|
||||
e2e-api37-advisory:
|
||||
name: E2E API 37 Media3 hardware transcode (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
needs: ffmpeg
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
env:
|
||||
E2E_LABEL: "37-media3-transcode"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.GRADLE_CACHE_PATHS }}
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Instrumented tests
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
env:
|
||||
E2E_DISABLE_SYSTEM_UI: "1"
|
||||
# The complement of the gating row's notAnnotation, off the same marker,
|
||||
# so a test can never be excluded from both jobs or run in both.
|
||||
E2E_EXTRA_GRADLE_ARGS: "-Pandroid.testInstrumentationRunnerArguments.annotation=org.libremediaconverter.FailsOnEmulatorApi37"
|
||||
# Turns on the baseline comparison in the run-shape report, and only here. Every leg
|
||||
# prints the shape; this is the one that also says whether it matches
|
||||
# FAILS_ON_EMULATOR_API37_BASELINE, because this is the one whose test list is the
|
||||
# marker. A deviation is a `::notice::` -- this job stays continue-on-error and stays
|
||||
# out of the required contexts, so nothing the report finds can change a conclusion.
|
||||
E2E_ADVISORY: "1"
|
||||
with:
|
||||
# Every device pin below matches the gating row exactly, so a difference
|
||||
# between the two jobs is the test selection and nothing else.
|
||||
api-level: "37.0"
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: true
|
||||
disk-size: 8G
|
||||
ram-size: 2560M
|
||||
script: bash .github/scripts/e2e-run.sh ${{ env.E2E_LABEL }}
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-report-api${{ env.E2E_LABEL }}
|
||||
path: |
|
||||
app/build/reports/androidTests/
|
||||
app/build/outputs/androidTest-results/
|
||||
if-no-files-found: warn
|
||||
|
||||
# Uploaded always, and here it matters more than anywhere else in this file:
|
||||
# this job is EXPECTED to be red, so the logcat is the only thing that says
|
||||
# whether it is red for the known reason or for a new one.
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-diagnostics-api${{ env.E2E_LABEL }}
|
||||
path: |
|
||||
${{ runner.temp }}/logcat-api${{ env.E2E_LABEL }}.txt
|
||||
${{ runner.temp }}/diagnostics-api${{ env.E2E_LABEL }}.txt
|
||||
${{ runner.temp }}/gradle-api${{ env.E2E_LABEL }}.txt
|
||||
if-no-files-found: warn
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-wedge-api${{ env.E2E_LABEL }}
|
||||
path: ${{ runner.temp }}/wedge-diagnostics-api${{ env.E2E_LABEL }}.txt
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
.kotlin
|
||||
|
||||
# The FFmpeg AAR is committed under bin/ so test runs do not depend on a rebuild.
|
||||
# Build outputs from tools/ffmpeg are not.
|
||||
|
||||
@@ -64,16 +64,50 @@ of the first one that fails.
|
||||
on this machine (below), so without it an androidTest compile error is not discovered until CI.
|
||||
ktlint and detekt also cover the `test`/`androidTest` source sets that `lintDebug` skips.
|
||||
|
||||
## Instrumented tests do not run locally
|
||||
## Instrumented tests: where they actually run
|
||||
|
||||
Two independent reasons, so do not spend time on either:
|
||||
This section said the opposite until 2026-08-24, and both of its claims had been false for two
|
||||
days. Read it as the current answer, and see the git history if you need the old one.
|
||||
|
||||
- **Emulators segfault on this host.** qemu dies on every AVD. Instrumented tests run on CI or on
|
||||
the physical Pixel, never in a local emulator.
|
||||
- **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its own gralloc
|
||||
mapper, so every test fails there regardless of this app. `docs/api-37-emulator-crash.md` records
|
||||
the evidence and the ruled-out fixes; CI's matrix therefore stops at API 36 even though targetSdk
|
||||
is 37. **API 37 needs a manual check on the Pixel 10 Pro XL before each release.**
|
||||
- **Local emulators work, for API 33-36.** `tools/local-emulator/run-e2e.sh` runs them on this
|
||||
host. The segfault that made this look impossible was not a broken machine: SwiftShader's Reactor
|
||||
JIT writes generated shader code onto the heap and executes it, Fedora's SELinux policy denies
|
||||
`execheap`, and qemu dies. Choosing a different renderer avoids it entirely — `-gpu host`,
|
||||
`angle_indirect` and `swangle_indirect` all boot, while `auto`, `off`, `guest` and
|
||||
`swiftshader_indirect` do not. `docs/local-emulator.md` has the evidence and the per-API renderer
|
||||
table.
|
||||
- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Three** of the 60 instrumented
|
||||
tests cannot pass on that image, for two unrelated reasons: two Media3 hardware transcodes fail
|
||||
inside the emulator's own `c2.goldfish.h264.decoder`, and one SAF test takes the framework down
|
||||
when it rotates the display. All three carry `@FailsOnEmulatorApi37` and run in a separate
|
||||
`continue-on-error` job; the gating leg runs the other 57.
|
||||
|
||||
That job is still called `E2E API 37 Media3 hardware transcode (advisory)`, which no longer
|
||||
describes everything in it. The name is kept deliberately — it is not a required context and
|
||||
people have learned to look for it — so **read the marker, not the name**, for what it holds.
|
||||
**It is red on every PR, by design**: do not read it as your change breaking something, and do
|
||||
not read a green run as evidence those three tests pass.
|
||||
`docs/api-37-emulator-crash.md` has the measurements.
|
||||
|
||||
**That instruction is also why nobody looks, so the job now reports its own shape** — expected,
|
||||
received, failed, and whether the run completed — to the job summary, and compares it against
|
||||
`FAILS_ON_EMULATOR_API37_BASELINE`, committed beside the marker. A deviation is a `::notice::`;
|
||||
the job stays advisory and its conclusion is untouched. **Add or remove a `@FailsOnEmulatorApi37`
|
||||
and that number changes in the same diff**, or the next run says so. A bare failure count would
|
||||
not have worked: the run is usually truncated by an `INSTRUMENTATION_ABORTED`, and the test XML
|
||||
is written anyway and says nothing about it — `.github/scripts/e2e-report-shape.sh` is where that
|
||||
is measured and explained.
|
||||
|
||||
Every leg prints that table, advisory or not, and **on the wedge path it also carries a `wedged:`
|
||||
row** (#118). `completed cleanly` only ever meant "instrumentation was not aborted", which stays
|
||||
true of a leg the `WEDGE_TIMEOUT` killed 22 minutes in — so without that row the table read
|
||||
`received: 59, completed cleanly: yes` for a leg that had just died. The wedge is passed to the
|
||||
report as `E2E_WEDGED_AFTER` by `e2e-run.sh`, which is the only thing that can know it: a wedge
|
||||
is gradle never returning, so the log it left says nothing about it.
|
||||
|
||||
Still true, and the reason the advisory job is not simply deleted: **API 37 needs a manual check on
|
||||
the Pixel 10 Pro XL before each release.** Those three tests are the one thing CI cannot answer
|
||||
for.
|
||||
|
||||
On a device or emulator, build only the ABI it can execute:
|
||||
|
||||
@@ -96,10 +130,136 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
- The `model` package is excluded from `ReturnCount` and `CyclomaticComplexMethod` only. It is the
|
||||
decision layer, where one branch is one documented user-visible outcome and the metric counts
|
||||
answers rather than complexity. Every other rule still applies there.
|
||||
- **Coverage is reported, not gated** — currently ~31% of lines. A floor needs a baseline that has
|
||||
settled first.
|
||||
- **Coverage is reported, not gated** — **88.9% of lines (2087/2348), 75.4% of branches
|
||||
(1011/1340)**, measured 2026-08-29 with `./gradlew :app:jacocoTestReport`, against 546 JVM tests
|
||||
in 76 classes.
|
||||
|
||||
**Every figure this file carried before 2026-08-24 was an artifact, roughly half the real one.**
|
||||
Robolectric loads classes through its own sandbox classloader with no source location, JaCoCo
|
||||
skips no-location classes by default, and nothing told it otherwise — so **not one Robolectric
|
||||
test counted**, and Robolectric is what exercises the framework edge here. The
|
||||
`isIncludeNoLocationClasses` block in `app/build.gradle.kts` is what fixes it; **do not delete
|
||||
it as stray config**, and re-run the numbers if you ever touch it. Same commit, same 335 tests:
|
||||
29.7% -> 69.2% with that block alone.
|
||||
|
||||
The old entry also explained the wrong thing. It said coverage **fell** as the suite grew from 11
|
||||
test files to 43 because "the denominator outran the numerator" on framework-edge code "the JVM
|
||||
cannot reach". The JVM reaches that code fine. What actually happened is that the new tests were
|
||||
disproportionately Robolectric, so each one added denominator and no numerator — the measurement
|
||||
was punishing exactly the tests that were hardest to write.
|
||||
|
||||
Two things still hold. A floor needs a baseline that has settled, and this one has not. It moved
|
||||
39 points in a single build change on 2026-08-24; then another 16 as the #52 test push and the
|
||||
fixes it turned up landed — 69.2% -> 84.9% line, 53.2% -> 63.8% branch — while the denominator
|
||||
grew 2194 -> 2321, because that work added production code of its own; then again on 2026-08-27
|
||||
as #132 and #133's ten children landed — 84.9% -> 87.1% line, 63.8% -> **69.1%** branch, 454 ->
|
||||
502 tests. **Branch moved four times as far as line that last time**, and that is the shape to
|
||||
expect from this kind of work rather than a surprise: those children targeted decision code —
|
||||
enum fallbacks, refusal arms, cursor shapes, a `when` over container rules — where one test
|
||||
chooses a branch the suite had never taken. Line coverage barely notices; branch coverage is the
|
||||
whole point.
|
||||
|
||||
Then #153's five children on 2026-08-29 — 87.1% -> 88.9% line, 69.1% -> **75.4%** branch, 502 ->
|
||||
546 tests.
|
||||
|
||||
**That last branch figure moved for two reasons, and only one of them is new tests.** The
|
||||
numerator rose 974 -> 1011; the denominator *fell* 1410 -> 1340. Both are the seam work. Pulling
|
||||
a `when` out of a lambda inside a `collect` deletes the coroutine state machine's synthesized
|
||||
branches around it, and what is left is a plain function whose branches a test can choose:
|
||||
`ConversionViewModel$observe$1$1` went from carrying the whole mapping to 6 branches, while the
|
||||
extracted `ConversionViewModelKt` covers 41 of 42 and `JoinViewModelKt` 38 of 39. So a seam is
|
||||
worth more than the tests it enables — it also stops the measurement counting scaffolding.
|
||||
|
||||
Be careful quoting a branch move on its own for that reason. A percentage that rises because the
|
||||
denominator shrank is not the same claim as one that rises because more branches are tested, and
|
||||
this entry has a history of explaining its own numbers wrongly.
|
||||
|
||||
And **re-measure before quoting**: this entry was once written quoting 81.4%, measured four hours
|
||||
earlier, and was already three points stale by the time it was ready to merge.
|
||||
- **Testable code is not done until it is tested.** If a piece is unit testable, it gets unit
|
||||
tests before it counts as done. If it is e2e testable, it gets e2e tests. Both clauses apply —
|
||||
a change that is both needs both.
|
||||
|
||||
Three things make that a real bar rather than a slogan here:
|
||||
|
||||
- **Unit-testable is broader than it looks.** The pure-seam pattern — `work/FailureOutcome.kt`
|
||||
documents the reasoning — turns "needs a device" into "a pure function plus a thin edge".
|
||||
Robolectric is in the JVM source set, `compose-ui-test-junit4` with it, so Compose screens are
|
||||
unit testable too. Reach for the seam before concluding something cannot be unit tested.
|
||||
- **E2E is runnable locally**, API 33-36, via `tools/local-emulator/run-e2e.sh` — see
|
||||
"Instrumented tests: where they actually run" above. That was believed impossible until the
|
||||
SELinux/renderer cause was found, and it is what makes the e2e half of this norm enforceable.
|
||||
- **A test has to bite.** Revert the line it covers, confirm it goes red, restore. A review of
|
||||
this codebase ran 46 mutations against a 257-test suite and **9 were vacuous** — five of them
|
||||
passing the whole suite over a completely unguarded code path. Green is not evidence.
|
||||
|
||||
Name what you did not cover and why. Genuine exemptions exist; implied coverage is the problem.
|
||||
|
||||
- `kotlin.code.style=official`. Gradle stays Kotlin DSL.
|
||||
|
||||
- **A stacked PR does not merge with `gh pr merge`, and `MERGED` is not proof it reached `main`.**
|
||||
Two separate traps, both measured on 2026-08-27 while landing #144-#151.
|
||||
|
||||
`gh pr merge` uses the GraphQL mutation, which refuses a stacked PR outright: *"This pull request
|
||||
is part of a stack and must be merged using the asynchronous merge REST API."* So does
|
||||
`PUT .../pulls/{n}/merge`. The one that works is
|
||||
`gh api -X PUT repos/OWNER/REPO/pulls/N/merge-async -f merge_method=merge`, which returns a uuid
|
||||
to poll at `.../merge-async/{uuid}` until `status` is `merged` or `failed`.
|
||||
|
||||
**The second trap is worse, because nothing looks wrong.** GitHub retargets a stacked PR's base
|
||||
to `main` when the PR below it merges, but *asynchronously*. Merge a stack faster than that
|
||||
settles — five PRs about thirty seconds apart, in the case that found this — and each one merges
|
||||
into its own base branch, which has itself already been merged and left behind. Every call
|
||||
returns `status: merged` and every one is true. `gh pr list --state open` comes back empty, every
|
||||
PR shows `MERGED`, and **none of the content is on `main`**.
|
||||
|
||||
What caught it was a coverage re-measure reading two points lower than the same tree had measured
|
||||
an hour earlier; a fresh `git pull` changed nothing, which is what turned it into a question.
|
||||
`git merge-base --is-ancestor <merge-sha> origin/main` answers it in one line. Do that after
|
||||
merging a stack, or merge one at a time and re-read `baseRefName` between. #160 is what the
|
||||
recovery cost.
|
||||
|
||||
The auto-retarget belongs to the stacking feature specifically. A PR opened with a plain
|
||||
`gh pr create --base some-branch` does **not** retarget when that branch merges — it is left
|
||||
pointing at a dead base and has to be moved by hand.
|
||||
|
||||
- **File one-off issues with `tools/github/file-issue.sh`, not `gh issue create`.** `gh issue
|
||||
create` does not touch the project board, so the issue exists, carries its labels, and is
|
||||
invisible in the Kanban — indistinguishable from never having been filed. Measured 2026-08-24:
|
||||
eight issues filed as a scripted batch all reached the board; one filed as a one-off minutes
|
||||
later did not. A batch carries the board step in its loop; **one-offs are where it slips**, which
|
||||
is what the script is for. It resolves the project and Status ids by name rather than caching
|
||||
them, and it **reads the item back** — a mutation returning 200 is not evidence the board shows
|
||||
what was asked for. Exit 3 means the issue was created but did not reach the board, and prints
|
||||
the number so it cannot be lost quietly.
|
||||
|
||||
`above-cut` and `backlog` are **labels from the 2026-08-22 triage pass** — "worked autonomously
|
||||
overnight" and "held for manual review". They are not board columns. Status carries board state;
|
||||
do not put a cut label on a newly filed ticket.
|
||||
|
||||
- **shellcheck runs in CI**, inside the Static analysis job, over `git ls-files '*.sh'` so a new
|
||||
script is covered without editing the workflow. It runs at full severity, `info` included: the
|
||||
two findings that raises today are answered with targeted `disable` directives carrying their
|
||||
reason, exactly as `config/detekt/detekt.yml` carries only the rules this codebase legitimately
|
||||
breaks. Do not silence it with `--severity=warning` — that hides the next real finding too.
|
||||
**It is pinned by image digest, and joins ktlint/detekt/JaCoCo in the "Dependency versions"
|
||||
rule above** — for exactly the reason stated there, demonstrated the day it was added. The first
|
||||
cut used the runner's ambient shellcheck. That is **0.9.0**, while the container used to check
|
||||
locally was 0.11.0, and the two disagree about how to report a trap handler: 0.11.0 says
|
||||
`SC2329` once on the declaration, 0.9.0 says `SC2317` on each of seven lines in the body. Same
|
||||
script, same directive, one green and one red. Directives that must survive both name both codes.
|
||||
|
||||
Locally, use the same pin rather than whatever is installed:
|
||||
`podman run --rm -v "$PWD:/mnt:z" docker.io/koalaman/shellcheck@sha256:61862eba... <files>`
|
||||
(the digest is in `status_check.yml`; there is no shellcheck system package on this host).
|
||||
|
||||
**`actionlint` covers the half shellcheck cannot see** — the inline `run:` blocks, where a good
|
||||
deal of this repo's bash lives. It runs shellcheck over each `run:` plus its own checks on
|
||||
expression syntax, `needs:` references, matrix keys and action inputs. It sits in the same job,
|
||||
**pinned by digest** for the reason above and one of its own: its documented installer is a
|
||||
`curl | bash` off a moving branch, which does not belong in a repo that pins every action by SHA.
|
||||
Locally: `podman run --rm -v "$PWD:/repo:z" -w /repo docker.io/rhysd/actionlint@sha256:9d360886... -color`.
|
||||
|
||||
## Dependency versions
|
||||
|
||||
Libraries **float on minor + patch** (`coreKtx = "1.+"`). Three groups deliberately do not:
|
||||
@@ -135,3 +295,12 @@ Because versions float, a build can change without a commit. `./gradlew :app:dep
|
||||
`@OptIn`. Android lint's `UnsafeOptInUsageError` catches a missed one.
|
||||
- **Release builds ship both ABIs.** `-PabiFilters` is a test-run override only; `build.yml`
|
||||
verifies the released APK carries every ABI and that all native libraries are 16 KB aligned.
|
||||
- **A JUnit `Timeout` — rule or `@Test(timeout=)` — cannot be used in the JVM suite.** Both run the
|
||||
test body on a separate thread, and every Compose test here goes through Robolectric's paused
|
||||
main looper: `UnsupportedOperationException: main looper can only be controlled from main
|
||||
thread`, from `ShadowPausedLooper` under `RobolectricIdlingStrategy.runUntilIdle`. The identical
|
||||
tests pass with the timeout removed, so it is the mechanism, not the test. What bounds a hung
|
||||
run instead is `timeout` on the `Test` tasks plus the jstack watchdog beside it in
|
||||
`app/build.gradle.kts`, neither of which moves a thread. `HangBoundTest` guards both numbers,
|
||||
and **a timed-out run writes no XML for the class that hung** — the dump is its only
|
||||
attribution, so do not delete the watchdog as stray config.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+161
-2
@@ -1,4 +1,7 @@
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.testing.jacoco.tasks.JacocoReport
|
||||
import java.io.File
|
||||
import java.time.Duration
|
||||
|
||||
plugins {
|
||||
// Applied by id: these two come from the root buildscript classpath, which is what
|
||||
@@ -188,6 +191,149 @@ detekt {
|
||||
}
|
||||
|
||||
// Pin the coverage agent rather than inheriting whatever Gradle bundles.
|
||||
// Robolectric loads every class it touches through its own sandbox classloader, and those
|
||||
// classes arrive with no source location. JaCoCo skips no-location classes by default, so
|
||||
// without this block **not one Robolectric test counts** -- and Robolectric is what exercises
|
||||
// the framework edge here: the workers, the publisher, both ViewModels, every Compose screen.
|
||||
//
|
||||
// Measured on e06b082, same 335 tests, same 0 failures, only this block added:
|
||||
//
|
||||
// LINE 29.7% -> 69.2% OutputPublisher 0.0% -> 97.5%
|
||||
// BRANCH 29.8% -> 53.2% ConversionViewModel 0.0% -> 85.4%
|
||||
//
|
||||
// The discriminator, if this ever looks like superstition: inside ConverterScreenKt, `describe`
|
||||
// is the one non-Composable and is exercised by a plain JVM test -- it reported 8/8 covered while
|
||||
// every @Composable in the same class reported 0, including ones whose mutations demonstrably
|
||||
// failed the build when reverted.
|
||||
//
|
||||
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
|
||||
// no location for either, and the test JVM dies rather than reporting a number.
|
||||
tasks.withType<Test>().configureEach {
|
||||
// ReleasePermissionTest reads .github/workflows/build.yml, and Gradle cannot infer that a
|
||||
// test depends on a file outside the source set. Without this the task stays UP-TO-DATE
|
||||
// when the workflow changes, so the guard goes stale exactly when it matters. Measured:
|
||||
// deleting the release job's `contents: write` and re-running gave "BUILD SUCCESSFUL in
|
||||
// 614ms" with the test never executing; the same mutation under --rerun-tasks failed it.
|
||||
// A guard that does not re-run when its subject changes is not a guard.
|
||||
inputs.file(rootProject.file(".github/workflows/build.yml"))
|
||||
.withPropertyName("releaseWorkflow")
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
|
||||
// Same reasoning, same trap: HangBoundTest reads the two numbers below out of this file, and
|
||||
// they are the one part of the change that does not compile. Without this the task stays
|
||||
// UP-TO-DATE when the build script changes, so the guard would go stale on exactly the edit
|
||||
// it exists to catch.
|
||||
inputs.file(project.file("build.gradle.kts"))
|
||||
.withPropertyName("moduleBuildScript")
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
|
||||
// --- Bounding a hung run (#125) -----------------------------------------------------------
|
||||
//
|
||||
// This suite had no timeout of any kind, so a hang ran until something outside it gave up.
|
||||
// #125 is a real Java-level deadlock -- a lock-order inversion between Room's
|
||||
// TransactionExecutor and WorkManager's SerialExecutorImpl, reached through the WorkInfo flow
|
||||
// -- and one local run sat in it for 47 minutes. On CI it would burn the Unit tests job's
|
||||
// 30-minute cap and report as a job timeout with no cause at all.
|
||||
//
|
||||
// WHY NOT A JUnit `Timeout` RULE, which is the obvious answer: it runs the test body on a
|
||||
// separate thread, and this suite is thread-affine. Measured here, `@Rule Timeout` and
|
||||
// `@Test(timeout = ...)` against a `createComposeRule()` Robolectric test both give:
|
||||
//
|
||||
// java.lang.UnsupportedOperationException: main looper can only be controlled from main
|
||||
// at org.robolectric.shadows.ShadowPausedLooper.executeOnLooper
|
||||
// at androidx.compose.ui.test.RobolectricIdlingStrategy.runUntilIdle
|
||||
//
|
||||
// The same two tests with the timeout removed pass, so that is the mechanism and not the
|
||||
// probe. Nothing that moves a test off its own thread can be used here.
|
||||
//
|
||||
// `Task.timeout` moves nothing -- it stops the forked test JVM from outside. Its weakness is
|
||||
// that it kills without a thread dump, and the jstack is the only reason #125 could be named
|
||||
// at all; the watchdog below is what answers that, and it only dumps.
|
||||
//
|
||||
// THE NUMBER, against the slowest observed *pass* rather than the typical one. Eight CI runs
|
||||
// sampled 2026-08-26, whole `./gradlew :app:testDebugUnitTest` invocation with compilation in
|
||||
// it and this task a subset: 62, 76, 77, 79, 81, 84, 86 and 90 seconds. Locally the task
|
||||
// itself is ~11 s over 454 tests. Ten minutes is ~6.7x the slowest of those and a third of
|
||||
// the job's 30-minute cap, so a fired timeout still has room to be reported and uploaded. It
|
||||
// is deliberately nowhere near the observed duration: a timeout that fires on a healthy slow
|
||||
// runner turns a real signal into noise and teaches people to re-run reflexively.
|
||||
timeout.set(Duration.ofMinutes(10))
|
||||
|
||||
// The dump, two minutes before the kill. jstack is what turned #125 from "CI timed out" into
|
||||
// a named lock-order inversion, and `Task.timeout` on its own would have thrown it away.
|
||||
//
|
||||
// It is deliberately incapable of failing a build: it reads a live process and writes a file.
|
||||
// Nothing here kills, interrupts or signals anything, so the worst a misfire can do is leave a
|
||||
// stack trace nobody needed. It has one, and it is the ordinary CI shape rather than an exotic
|
||||
// case: the worker is found by scanning this daemon's descendants for GradleWorkerMain, which
|
||||
// cannot tell one invocation's worker from the next, and the Unit tests job runs
|
||||
// testDebugUnitTest and jacocoTestReport back to back against the same daemon. If this task's
|
||||
// own worker lived and died inside a single poll, the watchdog can adopt the following one.
|
||||
//
|
||||
// Everything it needs is read here, at configuration time, and captured by value. Reaching
|
||||
// back through the task or the project from inside the action would not survive the
|
||||
// configuration cache, which `gradle.properties` turns on for every build.
|
||||
val threadDump = layout.buildDirectory.file("reports/hang/$name-threads.txt").get().asFile
|
||||
val taskPath = path
|
||||
val dumpAfterNanos = Duration.ofMinutes(8).toNanos()
|
||||
val captureWindowNanos = Duration.ofMinutes(1).toNanos()
|
||||
val pollMillis = 1_000L
|
||||
doFirst {
|
||||
val watchdog = Thread {
|
||||
val startedAt = System.nanoTime()
|
||||
var worker: ProcessHandle? = null
|
||||
while (true) {
|
||||
Thread.sleep(pollMillis)
|
||||
val elapsed = System.nanoTime() - startedAt
|
||||
val watched = worker
|
||||
if (watched == null) {
|
||||
// Gradle forks the worker moments after this task starts. If none has shown
|
||||
// up by the end of the capture window there is nothing to watch, and going on
|
||||
// polling would only risk adopting some other build's.
|
||||
if (elapsed > captureWindowNanos) return@Thread
|
||||
worker = ProcessHandle.current().descendants()
|
||||
.filter { it.info().commandLine().orElse("").contains("GradleWorkerMain") }
|
||||
.findFirst().orElse(null)
|
||||
} else if (!watched.isAlive) {
|
||||
return@Thread // the run finished; this is the healthy exit
|
||||
} else if (elapsed >= dumpAfterNanos) {
|
||||
val jstack = File(File(System.getProperty("java.home"), "bin"), "jstack")
|
||||
threadDump.parentFile.mkdirs()
|
||||
if (jstack.canExecute()) {
|
||||
ProcessBuilder(jstack.absolutePath, "-l", watched.pid().toString())
|
||||
.redirectErrorStream(true)
|
||||
.redirectOutput(threadDump)
|
||||
.start()
|
||||
.waitFor()
|
||||
} else {
|
||||
threadDump.writeText("no jstack at ${jstack.absolutePath}\n")
|
||||
}
|
||||
// To stdout as well as to the file, and that is the half that matters on CI:
|
||||
// the Unit tests job uploads app/build/reports/tests/ and nothing else, so a
|
||||
// dump that only ever existed under reports/hang/ would be unreachable from a
|
||||
// red run -- which is the "timed out with no cause" this exists to end. The
|
||||
// step log always survives, and needs no workflow edit to say so.
|
||||
println(
|
||||
"$taskPath is still running after ${Duration.ofNanos(elapsed).toMinutes()} " +
|
||||
"minutes and is about to be timed out. Thread dump of pid " +
|
||||
"${watched.pid()}, also written to $threadDump -- look for 'Found one " +
|
||||
"Java-level deadlock' (that is #125).\n" + threadDump.readText(),
|
||||
)
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}
|
||||
watchdog.isDaemon = true
|
||||
watchdog.name = "hang-watchdog"
|
||||
watchdog.start()
|
||||
}
|
||||
|
||||
extensions.configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
}
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = libs.versions.jacoco.get()
|
||||
}
|
||||
@@ -211,8 +357,11 @@ val jacocoGeneratedExcludes = listOf(
|
||||
)
|
||||
|
||||
// AGP 9 compiles Kotlin through its built-in compiler, which writes here rather than to the
|
||||
// classic `tmp/kotlin-classes/debug`. All hand-written code in this module is Kotlin, so the
|
||||
// javac output (BuildConfig and R only) is not read at all.
|
||||
// classic `tmp/kotlin-classes/debug`. All hand-written code in the MAIN source set is Kotlin, so
|
||||
// the javac output (BuildConfig and R only) is not read at all. There is now one hand-written
|
||||
// Java file in the module -- androidTest's FixtureDocumentsProvider, which cannot be Kotlin
|
||||
// because the process it runs in has no Kotlin stdlib; its own header explains why. It is in
|
||||
// androidTest, so it is not in this task's classDirectories and this stays accurate.
|
||||
val jacocoDebugKotlinClasses = layout.buildDirectory.dir(
|
||||
"intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes",
|
||||
)
|
||||
@@ -306,11 +455,21 @@ dependencies {
|
||||
// the tests stay green.
|
||||
testImplementation(platform(libs.compose.bom))
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
// For `runTest` alone, in ConversionViewModelProbeFailureTest. It arrives transitively
|
||||
// with the rule above anyway; declared because a test file imports it directly, and an
|
||||
// import of something nobody asked for breaks the day the library that pulled it in stops.
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
androidTestImplementation(platform(libs.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
// androidTest only, and it has to be: UiAutomator drives the whole device, including
|
||||
// windows belonging to other packages. The system file picker is one -- DocumentsUI runs
|
||||
// in its own process, so Compose's matchers cannot see it and Espresso's cannot either
|
||||
// (both are scoped to this process's view hierarchy). Nothing on the JVM has a device to
|
||||
// drive, so there is no unit-test counterpart to add it to.
|
||||
androidTestImplementation(libs.androidx.uiautomator)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The first manifest this source set has ever had, and it exists for one component.
|
||||
|
||||
SafPickerRoundTripTest drives the real system file picker. DocumentsUI only shows what a
|
||||
DocumentsProvider offers it, so a test that picks a file needs a provider to pick from, and
|
||||
that provider has to be declared: a ContentProvider is instantiated by the system from a
|
||||
manifest entry and cannot be registered from test code.
|
||||
|
||||
It is declared HERE rather than in src/debug on purpose. src/debug would put a fake storage
|
||||
root inside the shipped debug APK, where it would show up in every developer's own file
|
||||
picker and in every other app's; this way it is installed only by the instrumentation APK,
|
||||
alongside the test that needs it, and is gone the moment that APK is uninstalled.
|
||||
|
||||
The four attributes are not decoration. Each one is required for the picker to see it:
|
||||
|
||||
exported DocumentsUI is another app; an unexported provider is invisible to it.
|
||||
permission MANAGE_DOCUMENTS is held by DocumentsUI and essentially nothing else,
|
||||
so this is what stops any installed app from reading the fixture. The
|
||||
provider is exported to the *picker*, not to the world.
|
||||
grantUriPermissions How the app under test ends up able to read the URI it was handed. The
|
||||
picker returns the document URI with FLAG_GRANT_READ_URI_PERMISSION,
|
||||
and that flag does nothing unless the provider allows grants. Without
|
||||
it the pick "succeeds" and every read of the result fails.
|
||||
DOCUMENTS_PROVIDER The action DocumentsUI queries the package manager for. No filter, no
|
||||
root in the drawer.
|
||||
|
||||
The authority carries the .test suffix because this component belongs to the instrumentation
|
||||
package (org.libremediaconverter.test), not to the app. Authorities are global to the device:
|
||||
reusing the app's would collide with the app on any device where both are installed.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<provider
|
||||
android:name="org.libremediaconverter.saf.FixtureDocumentsProvider"
|
||||
android:authorities="org.libremediaconverter.test.fixtures"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.libremediaconverter
|
||||
|
||||
/**
|
||||
* Marks an instrumented test that does not pass on the `android-37.x` **emulator** system images.
|
||||
*
|
||||
* This is a marker, not a skip. Nothing reads it except CI, and CI reads it twice — once with
|
||||
* `notAnnotation` to build the gating API 37 leg, and once with `annotation` to build the advisory
|
||||
* one — so a test carrying it runs in exactly one of the two and can never fall through both.
|
||||
* That is the whole reason there is one annotation rather than a pair of test lists: two lists
|
||||
* drift, and the drift is silent in both directions (a test that runs nowhere reads as green).
|
||||
*
|
||||
* It says only what has been measured: **on the emulator, at API 37.** The same tests pass on a
|
||||
* physical Pixel 10 Pro XL at API 37 and at API 33–36 on the same runner under the same renderer,
|
||||
* so this must never be read as "this test is allowed to fail at API 37" — only as "the API 37
|
||||
* emulator image cannot currently answer this one". `docs/api-37-emulator-crash.md` has the
|
||||
* measurements and the one bullet in them that is still inference.
|
||||
*
|
||||
* Removing it is the goal, and the trigger is written down: a new API 37.x system image, or an
|
||||
* ATD image for 37. Delete the annotation from the tests, and the advisory job goes empty and
|
||||
* the gating one grows by two.
|
||||
*
|
||||
* **How many tests carry it is committed below**, as [FAILS_ON_EMULATOR_API37_BASELINE], and the
|
||||
* advisory job checks the run against it. Adding or removing a marker means changing that number
|
||||
* in the same diff.
|
||||
*/
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
|
||||
annotation class FailsOnEmulatorApi37
|
||||
|
||||
/**
|
||||
* How many tests carry [FailsOnEmulatorApi37] — the advisory API 37 job's committed baseline.
|
||||
*
|
||||
* **No Kotlin reads this, and it is not stray config.** `.github/scripts/e2e-report-shape.sh`
|
||||
* parses it out of this file by name, with a line-anchored pattern, and the advisory job compares
|
||||
* the run it just did against it: this many tests should start, and all of them should fail.
|
||||
* Deleting it, renaming it, or indenting it into a class stops the comparison — the report would
|
||||
* keep printing with nothing to compare to, so it announces that it could not read the baseline
|
||||
* rather than falling quiet. If you see that notice, this line is what it means.
|
||||
*
|
||||
* **One number, both checks, and that is what the marker means.** A test carrying it cannot pass
|
||||
* on this image, so the count is simultaneously how many the advisory leg runs and how many fail.
|
||||
* A *smaller* failure count is the interesting direction: it means one of them now passes, which
|
||||
* is the trigger the KDoc above names for deleting the annotation.
|
||||
*
|
||||
* So: adding or removing a [FailsOnEmulatorApi37] means changing this number, in this file, in
|
||||
* the same diff. The report says so on the run itself if you forget — it prints the tree's own
|
||||
* `grep` count beside this one.
|
||||
*
|
||||
* Why a baseline at all (#83): that job is `continue-on-error` and red on every PR by design, so
|
||||
* a red X cannot distinguish the known failures from the known failures plus a new one. Counting
|
||||
* failures alone does not fix it either — the run is usually truncated by an
|
||||
* `INSTRUMENTATION_ABORTED`, so the count is a number taken from a partial run. The report
|
||||
* records the truncation next to the counts for that reason.
|
||||
*/
|
||||
const val FAILS_ON_EMULATOR_API37_BASELINE = 3
|
||||
@@ -36,8 +36,20 @@ import java.io.File
|
||||
* 1. that the hardware path is worth having a second engine for at all, and
|
||||
* 2. that x264's CRF is worth the GPL licence the app carries for it.
|
||||
*
|
||||
* Skips itself when the sample files are absent, so it is harmless in CI. Populate with:
|
||||
* adb push <file>.mp4 /sdcard/Android/data/org.libremediaconverter/files/
|
||||
* Skips itself when the sample files are absent, so it is harmless in CI — every green E2E
|
||||
* leg reports two skips, and these are they.
|
||||
*
|
||||
* The two files it looks for, by exact name:
|
||||
*
|
||||
* - [H264_SAMPLE] for [hardwareVersusSoftwareOnRealVideo]
|
||||
* - [AV1_SAMPLE] for [av1InputRoutesAccordingToDeviceDecodeSupport]
|
||||
*
|
||||
* **Where they go, and how, is on [samples] — read it before staging anything.** This used to
|
||||
* carry an `adb push` line naming the external files dir, which [samples] then explains cannot
|
||||
* work: a pushed file stays owned by the shell user and the app reads EACCES, surfacing as an
|
||||
* unparseable input rather than a permission error. The instruction and its own refutation sat
|
||||
* twelve lines apart. It is named in one place now rather than restated here, because restating
|
||||
* it is what let the two drift.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
|
||||
@@ -11,14 +11,26 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.FailsOnEmulatorApi37
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.AudioPlan
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.CopyPlanner
|
||||
import org.libremediaconverter.model.InputKind
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.VideoPlan
|
||||
import java.io.File
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -57,6 +69,7 @@ class Media3EngineTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@FailsOnEmulatorApi37
|
||||
fun transcodesH264ToH265AndReportsProgress(): Unit = runBlocking {
|
||||
val seen = mutableListOf<Int>()
|
||||
|
||||
@@ -119,6 +132,7 @@ class Media3EngineTest {
|
||||
* HandlerThread indirection holds before any of that lands in Phase 2.
|
||||
*/
|
||||
@Test
|
||||
@FailsOnEmulatorApi37
|
||||
fun runsFromAThreadWithNoLooper() {
|
||||
val pool = Executors.newSingleThreadExecutor()
|
||||
try {
|
||||
@@ -167,6 +181,63 @@ class Media3EngineTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The builders that used to throw where nothing could catch them.
|
||||
*
|
||||
* `EditedMediaItem.Builder` rejects a composition with both tracks removed —
|
||||
* checkState("Audio and video cannot both be removed") — and the engine builds it on its own
|
||||
* HandlerThread. That build sat *between* two narrow `runCatching` blocks, one around
|
||||
* `buildTransformer` and one around `start`, so the exception reached the thread's uncaught
|
||||
* handler and took the process with it while the continuation was never resumed.
|
||||
*
|
||||
* `ContainerCapabilities.validate` now refuses the spec that gets here from the picker; this
|
||||
* is the other half — the engine surviving a request that arrives without being validated.
|
||||
* Deliberately not `@FailsOnEmulatorApi37`: nothing here decodes or encodes, so no emulator
|
||||
* codec is involved. The builder refuses the input before any media is touched.
|
||||
*/
|
||||
@Test
|
||||
fun aPlanThatRemovesBothTracksFailsInsteadOfKillingTheProcess() {
|
||||
val request = ConversionRequest(
|
||||
spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
probe = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
),
|
||||
)
|
||||
// Asserted rather than assumed: ConversionRequest's default probe says hasVideo = true,
|
||||
// and with it this same spec plans to (Encode, Drop) and nothing throws at all — which
|
||||
// would make the whole test vacuous without a word of warning.
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
|
||||
val failure = runCatching {
|
||||
runBlocking {
|
||||
withTimeout(BUILDER_TIMEOUT_MS) {
|
||||
engine.transcode(Uri.fromFile(input), output, request) {}
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
|
||||
// Two assertions, and the second is not pedantry. withTimeout raises
|
||||
// TimeoutCancellationException, and `java.util.concurrent.CancellationException` *extends*
|
||||
// IllegalStateException — so testing only the type below would call an unresumed
|
||||
// continuation a pass. A hang is the other half of this defect and every bit as bad as the
|
||||
// crash: the worker would sit holding a foreground service forever.
|
||||
assertFalse(
|
||||
"the continuation was never resumed — the failure escaped instead of being reported: " +
|
||||
"$failure",
|
||||
failure is CancellationException,
|
||||
)
|
||||
assertTrue(
|
||||
"the builder's refusal must surface as a failed job, not a dead process; got $failure",
|
||||
failure is IllegalStateException,
|
||||
)
|
||||
}
|
||||
|
||||
private fun durationMsOf(file: File): Long {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
@@ -208,5 +279,12 @@ class Media3EngineTest {
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 120L
|
||||
|
||||
/**
|
||||
* Short on purpose. Nothing is decoded or encoded on this path — the builder refuses the
|
||||
* input outright — so anything approaching this is a hang, which is what the test is
|
||||
* looking for.
|
||||
*/
|
||||
const val BUILDER_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package org.libremediaconverter.saf;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.os.CancellationSignal;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.DocumentsContract.Document;
|
||||
import android.provider.DocumentsContract.Root;
|
||||
import android.provider.DocumentsProvider;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* One file, offered to the system file picker, so that picking one can be tested at all.
|
||||
*
|
||||
* <p>DocumentsUI does not browse a filesystem: it lists what {@link DocumentsProvider}s hand it.
|
||||
* So a test that drives the real picker has to supply the thing being picked, and it has to
|
||||
* supply it as a manifest-declared component, because a {@code ContentProvider} is instantiated
|
||||
* by the system and cannot be registered from test code. {@code
|
||||
* app/src/androidTest/AndroidManifest.xml} is that declaration and says why each of its
|
||||
* attributes is load-bearing.
|
||||
*
|
||||
* <h2>The only Java file in this module, and it has to be</h2>
|
||||
*
|
||||
* <p>Everything else here is Kotlin. This cannot be: <b>the Kotlin standard library is not on
|
||||
* this class's classpath at runtime.</b>
|
||||
*
|
||||
* <p>Instrumentation code normally never notices. The test APK's dex is loaded into the app's
|
||||
* process, where the app APK supplies {@code kotlin.jvm.internal.Intrinsics} — so the test APK is
|
||||
* built without it, deliberately, since packaging a second copy is what {@code
|
||||
* checkDebugAndroidTestDuplicateClasses} exists to prevent. A provider is different. It is a
|
||||
* component of the instrumentation <i>package</i>, so when DocumentsUI queries it the system
|
||||
* starts a plain {@code org.libremediaconverter.test} process with only the test APK on its dex
|
||||
* path, and no app APK anywhere. The Kotlin version of this file crashed there on its first
|
||||
* query, before returning a single row:
|
||||
*
|
||||
* <pre>
|
||||
* FATAL EXCEPTION: binder:6369_2
|
||||
* Process: org.libremediaconverter.test
|
||||
* java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics;
|
||||
* at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument
|
||||
* </pre>
|
||||
*
|
||||
* <p>The compiler emits that reference for the null checks on almost every function, so there is
|
||||
* no Kotlin dialect that avoids it. For the same reason nothing here imports {@code androidx.*}:
|
||||
* those classes are absent from this process for exactly the same reason. Framework and JDK only.
|
||||
*
|
||||
* <h2>Why a provider rather than a file in Downloads</h2>
|
||||
*
|
||||
* <p>That would have worked, and it would have tested less. Two properties are what {@code
|
||||
* SafPickerRoundTripTest} actually needs:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>The root declares {@link Root#COLUMN_MIME_TYPES}, and DocumentsUI filters by it.</b>
|
||||
* That is what gives the screen's MIME filter a mutation with a shape: ask for a type this
|
||||
* root does not offer and the root itself is not in the picker, so the failure reads as
|
||||
* "the fixture root is not there" rather than "one file among the hundreds in Downloads was
|
||||
* not listed".
|
||||
* <li><b>The contents are exactly this and nothing else.</b> A shared directory accumulates
|
||||
* whatever earlier runs and other tests left in it, and a picker test that finds the wrong
|
||||
* file passes.
|
||||
* </ul>
|
||||
*
|
||||
* <p>The descriptor is opened on a real file rather than served through a pipe, deliberately.
|
||||
* {@code InputQuery.sizeOf} falls back to {@code ParcelFileDescriptor.statSize} when a provider
|
||||
* omits {@code OpenableColumns.SIZE}, and a pipe's {@code statSize} is {@code -1} — an unknown
|
||||
* size, which is a different case with a screen of its own. This fixture is meant to be an
|
||||
* ordinary, fully described file, so that the one thing under test is the round trip.
|
||||
*/
|
||||
public final class FixtureDocumentsProvider extends DocumentsProvider {
|
||||
|
||||
/**
|
||||
* What the picker calls this root.
|
||||
*
|
||||
* <p>Deliberately not a word any other root uses. The picker's own landing screen already
|
||||
* offers "Images", "Audio", "Videos" and "Documents", and a UiAutomator selector that could
|
||||
* match two things is not a selector.
|
||||
*/
|
||||
public static final String ROOT_TITLE = "LMC R38 fixtures";
|
||||
|
||||
/**
|
||||
* What the file card has to end up showing.
|
||||
*
|
||||
* <p>The same string reaches the assertion two ways — as the picker row UiAutomator taps, and
|
||||
* as {@code OpenableColumns.DISPLAY_NAME} on the URI the app is handed — which is exactly the
|
||||
* round trip under test.
|
||||
*/
|
||||
public static final String FIXTURE_DISPLAY_NAME = "lmc-r38-fixture.mp4";
|
||||
|
||||
/**
|
||||
* The type the root advertises, and the one the MIME mutation has to stop matching.
|
||||
*
|
||||
* <p>A real type rather than something invented, so the wildcard filter the screen passes
|
||||
* today is not the only filter under which this test could pass.
|
||||
*/
|
||||
public static final String FIXTURE_MIME_TYPE = "video/mp4";
|
||||
|
||||
private static final String ROOT_ID = "lmc-r38-root";
|
||||
private static final String ROOT_DOCUMENT_ID = "root";
|
||||
private static final String FIXTURE_DOCUMENT_ID = "root/" + FIXTURE_DISPLAY_NAME;
|
||||
|
||||
/** Already in this source set, and already a real H.264 MP4 the engines can open. */
|
||||
private static final String FIXTURE_ASSET = "sample_h264.mp4";
|
||||
|
||||
private static final String[] DEFAULT_ROOT_PROJECTION = {
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_SUMMARY,
|
||||
Root.COLUMN_MIME_TYPES,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_ICON,
|
||||
};
|
||||
|
||||
private static final String[] DEFAULT_DOCUMENT_PROJECTION = {
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
};
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single root.
|
||||
*
|
||||
* <p>{@link Root#COLUMN_MIME_TYPES} is the important column. Left null it would mean "this
|
||||
* root supports everything", the picker would list it whatever was asked for, and the MIME
|
||||
* mutation would have nothing to bite on.
|
||||
*/
|
||||
@Override
|
||||
public Cursor queryRoots(String[] projection) {
|
||||
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION);
|
||||
cursor.newRow()
|
||||
.add(Root.COLUMN_ROOT_ID, ROOT_ID)
|
||||
.add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
|
||||
.add(Root.COLUMN_TITLE, ROOT_TITLE)
|
||||
.add(Root.COLUMN_SUMMARY, "Instrumentation fixture")
|
||||
.add(Root.COLUMN_MIME_TYPES, FIXTURE_MIME_TYPE)
|
||||
.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY)
|
||||
.add(Root.COLUMN_ICON, android.R.drawable.ic_menu_gallery);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {
|
||||
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
|
||||
if (ROOT_DOCUMENT_ID.equals(documentId)) {
|
||||
addDirectoryRow(cursor);
|
||||
} else if (FIXTURE_DOCUMENT_ID.equals(documentId)) {
|
||||
addFixtureRow(cursor);
|
||||
} else {
|
||||
throw new FileNotFoundException("no such document: " + documentId);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder)
|
||||
throws FileNotFoundException {
|
||||
MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
|
||||
if (ROOT_DOCUMENT_ID.equals(parentDocumentId)) {
|
||||
addFixtureRow(cursor);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal)
|
||||
throws FileNotFoundException {
|
||||
if (!FIXTURE_DOCUMENT_ID.equals(documentId)) {
|
||||
throw new FileNotFoundException("no such document: " + documentId);
|
||||
}
|
||||
return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
|
||||
private void addDirectoryRow(MatrixCursor cursor) {
|
||||
cursor.newRow()
|
||||
.add(Document.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
|
||||
.add(Document.COLUMN_DISPLAY_NAME, ROOT_TITLE)
|
||||
.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
|
||||
.add(Document.COLUMN_FLAGS, 0)
|
||||
.add(Document.COLUMN_SIZE, null);
|
||||
}
|
||||
|
||||
private void addFixtureRow(MatrixCursor cursor) throws FileNotFoundException {
|
||||
File file = fixtureFile();
|
||||
cursor.newRow()
|
||||
.add(Document.COLUMN_DOCUMENT_ID, FIXTURE_DOCUMENT_ID)
|
||||
.add(Document.COLUMN_DISPLAY_NAME, FIXTURE_DISPLAY_NAME)
|
||||
.add(Document.COLUMN_MIME_TYPE, FIXTURE_MIME_TYPE)
|
||||
.add(Document.COLUMN_FLAGS, 0)
|
||||
.add(Document.COLUMN_SIZE, file.length())
|
||||
.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixture on disk, unpacked from this APK's own assets the first time anything asks.
|
||||
*
|
||||
* <p>On demand rather than seeded once in {@link #onCreate()}, because this process is started
|
||||
* by whoever queries the provider and can be killed between two queries of the same test.
|
||||
*
|
||||
* <p>A failure here is reported as {@link FileNotFoundException} rather than swallowed. A
|
||||
* provider that answers with a zero-byte file would put the test on the "Size unknown" screen
|
||||
* with nothing saying why.
|
||||
*/
|
||||
private File fixtureFile() throws FileNotFoundException {
|
||||
File file = new File(getContext().getFilesDir(), FIXTURE_DISPLAY_NAME);
|
||||
if (file.length() > 0L) {
|
||||
return file;
|
||||
}
|
||||
try (InputStream source = getContext().getAssets().open(FIXTURE_ASSET);
|
||||
OutputStream sink = new FileOutputStream(file)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = source.read(buffer)) != -1) {
|
||||
sink.write(buffer, 0, read);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new FileNotFoundException("could not unpack " + FIXTURE_ASSET + ": " + e);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
package org.libremediaconverter.saf
|
||||
|
||||
import android.app.UiAutomation
|
||||
import androidx.compose.ui.test.ComposeTimeoutException
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.BySelector
|
||||
import androidx.test.uiautomator.Configurator
|
||||
import androidx.test.uiautomator.StaleObjectException
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.FailsOnEmulatorApi37
|
||||
import org.libremediaconverter.MainActivity
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
|
||||
/**
|
||||
* Choosing a file, through the real system picker, and still having it after a rotation.
|
||||
*
|
||||
* Two defects, and neither is reachable from anywhere else in this repo.
|
||||
*
|
||||
* **The picker is opened with a filter, and a filter can hide the user's file.** `ConverterScreen`
|
||||
* launches `ActivityResultContracts.OpenDocument` with a MIME array; DocumentsUI hides every root
|
||||
* and every document that array does not match. Narrow it and the app still compiles, still
|
||||
* renders, still passes every JVM test — and the user taps "Choose file" and is shown an empty
|
||||
* picker. Nothing in either source set drove SAF **as a picker** before this: the only SAF coverage
|
||||
* is the publish side, in `OutputPublisherPublishTest`, against hand-written `ContentProvider`
|
||||
* fakes. The launcher wiring, the filter, and the read grant that comes back had never been
|
||||
* executed by a test.
|
||||
*
|
||||
* **The picked file has to survive a rotation.** `MainActivity` declares no `configChanges`, so
|
||||
* every rotation destroys and recreates it, and `ConversionViewModel` holds the picked file in a
|
||||
* plain `MutableStateFlow` with no `SavedStateHandle` behind it. The only thing that carries it
|
||||
* across is the retained `ViewModelStore` the Activity gets from resolving the ViewModel through
|
||||
* `LocalViewModelStoreOwner`. Scope it to the composition instead and the file is gone.
|
||||
*
|
||||
* ### Why these two are one test class
|
||||
*
|
||||
* A rotation test alone has no bite of its own. `AppRootRestorationTest` already catches
|
||||
* `rememberSaveable` -> `remember` on the JVM, and a second test whose only mutation is one an
|
||||
* existing test catches is the vacuous test this whole decomposition exists to prevent. So the
|
||||
* rotation here runs **from a real picked input**, which is a state no JVM test can produce:
|
||||
* `AppRootRestorationTest` injects a stub `content` lambda specifically to avoid standing up
|
||||
* either ViewModel, and `StateRestorationTester` saves into an in-memory map rather than a
|
||||
* `Bundle`.
|
||||
*
|
||||
* ### #93: what actually failed was reading the screen, not the picker
|
||||
*
|
||||
* Ninety minutes after this class landed it started failing on gating legs at API 33, 34, 35 and
|
||||
* 37 — on diffs that were two KDoc comments, a MIME lookup table and a README paragraph (#93).
|
||||
* Every failure named the fixture root, so it read as a root-discovery race, and the ticket was
|
||||
* filed on that reading. It was not one, and it was not the `StaleObjectException` #80 had fixed
|
||||
* an hour earlier either.
|
||||
*
|
||||
* **DocumentsUI was fine.** On the API 34 leg of run 32806342548 its own
|
||||
* `ProvidersAccess: Matched roots` names
|
||||
* `content://org.libremediaconverter.test.fixtures/root/lmc-r38-root` five times inside the sixty
|
||||
* seconds the test spent failing, `ActivityTaskManager` logged the `PickActivity` as `Displayed`,
|
||||
* and the provider process started on cue.
|
||||
*
|
||||
* **This process could not read any window at all.** Two counts settle it. Across that whole leg
|
||||
* UiAutomator logged `Retrieving node with selector` 1095 times and `Node not found with selector`
|
||||
* 1095 times — not one selector ever matched, from the first query of the run. The green leg of
|
||||
* the same job asked 7 times and found 5. `UiDevice.getWindowRoots` builds its search set from
|
||||
* `UiAutomation.getWindows()` and, on API 21 and up, from nothing else; an empty list there makes
|
||||
* every selector unfindable and says nothing whatever about SAF. The corroborating detail is that
|
||||
* `By.desc("Show roots")` — the toolbar button, present on that screen whether the roots list is
|
||||
* stale or not — was also not found, 28 s after the picker was displayed.
|
||||
*
|
||||
* **A fresh picker is not the repair, and this was measured rather than assumed.** The same leg
|
||||
* opened a *second* `PickActivity` for the second test, in the same DocumentsUI process
|
||||
* (pid 3299), and read exactly as little from it. So whatever was broken outlived one window.
|
||||
* [requireAReadableScreen] is the part aimed at that: it asks whether this process can see the
|
||||
* app's own window *before* the picker is opened, and [rebuildUiAutomation] tears the connection
|
||||
* down and builds another if it cannot.
|
||||
*
|
||||
* **The check has since caught the real thing, in CI, and the connection rebuild did not repair
|
||||
* it.** Run 32811493607, API 35 and API 37 legs, both tests, 12 s each instead of 60:
|
||||
*
|
||||
* ```
|
||||
* java.lang.AssertionError: UiAutomator cannot see this app's own window, so it could not have
|
||||
* seen the picker's either. This is not a SAF failure.
|
||||
* at SafPickerRoundTripTest.requireAReadableScreen
|
||||
* ```
|
||||
*
|
||||
* That is the diagnosis this class could not previously give, and it moves the question off SAF
|
||||
* for good.
|
||||
*
|
||||
* ### What the window list said, and why nothing here can fix it
|
||||
*
|
||||
* [describeWindows] was added to that failure so the next occurrence would close the question
|
||||
* rather than reopen it. It did — on the API 34 leg of run 32812248131 and again, character for
|
||||
* character, on the API 33 leg of run 32812892103:
|
||||
*
|
||||
* ```
|
||||
* ... Waking the device, dismissing the keyguard and rebuilding the UiAutomation connection all
|
||||
* failed to make it readable. What it could see: com.android.systemui[type=3], android[type=3]
|
||||
* ```
|
||||
*
|
||||
* `type=3` is `AccessibilityWindowInfo.TYPE_SYSTEM`. The list is **not** empty — it holds the
|
||||
* system windows and **not one `TYPE_APPLICATION` window**, on a device where the framework had
|
||||
* already logged `Displayed org.libremediaconverter/.MainActivity`. So the application layer
|
||||
* never reaches accessibility on those boots, and every selector in this class, the picker's and
|
||||
* the app's alike, is unfindable for the whole instrumentation run.
|
||||
*
|
||||
* Three CI runs on this branch caught the fault, at API 33, 34, 35 and 37, and every one of them
|
||||
* printed that same list. It is not one level's quirk.
|
||||
*
|
||||
* ### And that list is what identified the occluder
|
||||
*
|
||||
* `android[type=3]` is `system_server`, and what it was holding is in the same logcat, minutes
|
||||
* before this class ever ran:
|
||||
*
|
||||
* ```
|
||||
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
|
||||
* Reason: Input dispatching timed out (Application does not have a focused window)
|
||||
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
|
||||
* ```
|
||||
*
|
||||
* **The launcher ANRs on a loaded runner emulator, and the dialog it leaves behind never goes
|
||||
* away.** It is opaque and fullscreen, so `AccessibilityWindowManager` drops every application
|
||||
* window beneath it — which is how the app can be `Displayed` and unreadable at once, the
|
||||
* contradiction that made #93 look like a SAF bug for six PRs. It is present on both legs
|
||||
* examined, at API 33 and 34, at the failure timestamp.
|
||||
*
|
||||
* So [dismissASystemErrorDialog] is tried first, and it is the remedy with a mechanism behind it.
|
||||
* The other two are kept behind it and are **measured as not the cause**: [unlockTheDevice] (the
|
||||
* keyguard theory, from `KeyguardViewMediator` reporting an unprovisioned device — dismissing it
|
||||
* changed nothing) and [rebuildUiAutomation]. A second `PickActivity` is not a remedy for this
|
||||
* either, and that was measured too: the first failing leg opened one and read as little from it.
|
||||
*
|
||||
* **What is honest about the dialog remedy: it has been shown to do no harm, not to work.** It
|
||||
* was forced on with no dialog present and the suite stayed green, which is the way a blind
|
||||
* `click()` could have broken a healthy run. Dismissing a real ANR dialog has not been observed,
|
||||
* because the fault has never been reproduced locally — not on six warm runs, not on cold
|
||||
* full-suite runs at API 34 and 35 on freshly created AVDs under `swangle_indirect` at two cores,
|
||||
* not under host load. If it recurs, the message now names the dialog and the window list, so the
|
||||
* next step is a measurement rather than another theory.
|
||||
*
|
||||
* ### The whole pick is retried, which is a separate and smaller claim
|
||||
*
|
||||
* [pickTheFixture] also backs out and asks for another picker when the walk comes up short. That
|
||||
* is not the answer to the paragraph above; it is the answer to a picker whose *lists* were built
|
||||
* before their data arrived, which is a real thing DocumentsUI does and which
|
||||
* [tapPickerNode]'s re-find cannot reach either — it re-acquires a handle inside the one picker.
|
||||
*
|
||||
* One API 37 run failed a step deeper than the rest: the root appeared and
|
||||
* `[TEXT='\Qlmc-r38-fixture.mp4\E']` did not. **That shape has not been reproduced or
|
||||
* diagnosed.** It is covered here only because a fresh pick re-walks from Recent, and that is
|
||||
* worth writing down rather than letting the retry read as a fix for something nobody measured.
|
||||
*
|
||||
* ### The mutations, and what they printed
|
||||
*
|
||||
* Both were run, not asserted. Narrowing the wildcard array `ConverterScreen.kt` passes to
|
||||
* `pickInput.launch` — to `arrayOf("application/x-lmc-no-such-type")` — empties the picker of the
|
||||
* fixture root entirely, and both tests fail on the assertion that names it. **Re-run after the
|
||||
* #93 retry landed**, because a retry that tolerated an absent root would have made this mutation
|
||||
* vacuous, which is the one thing that must not happen here:
|
||||
*
|
||||
* ```
|
||||
* java.lang.AssertionError: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E'],
|
||||
* in 3 separate pickers (the last one left org.libremediaconverter in front)
|
||||
* at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:268)
|
||||
* ```
|
||||
*
|
||||
* The root is absent from all three pickers, so all three report it, and the cost of saying so is
|
||||
* bounded: 126 s and 127 s for the two tests, against the 1200 s wrapper timeout in
|
||||
* `.github/scripts/e2e-run.sh`. The clause about what was left in front is not decoration either
|
||||
* — it is what says the retry really did get back to the app between attempts rather than tapping
|
||||
* behind a picker that never closed.
|
||||
*
|
||||
* **That mutation only shows the retry failing correctly.** Showing it *recovering* needs a
|
||||
* failure that goes away, so one was injected: a field making the first
|
||||
* [walkThePickerToTheFixture] of each test return a selector nothing matches. Both tests then
|
||||
* passed, with `ActivityTaskManager` logging four `OPEN_DOCUMENT` starts for the two of them —
|
||||
* two pickers each. That is the run which says the reopened pick completes: that
|
||||
* `pickInput.launch` is not refused from the re-resumed Activity, and that the second test's
|
||||
* reopen, which lands in the last-accessed stack rather than on Recent, still walks to the file.
|
||||
* Making the ViewModel composition-scoped leaves the picker test alone and fails
|
||||
* [thePickedInputSurvivesARealRotation], with `:app:testDebugUnitTest` still BUILD SUCCESSFUL —
|
||||
* which is the divergence this ticket was filed to establish, and which was doubted on it. It is
|
||||
* `viewModel()` -> `viewModel(viewModelStoreOwner = remember { <a plain ViewModelStoreOwner> })`,
|
||||
* **plus** `factory = ViewModelProvider.AndroidViewModelFactory()` and a `MutableCreationExtras`
|
||||
* carrying `APPLICATION_KEY`. The factory half is not decoration: an owner that is not a
|
||||
* `HasDefaultViewModelProviderFactory` contributes no creation extras, and the default factory
|
||||
* cannot construct an `AndroidViewModel` without them — so the owner swap alone crashes on
|
||||
* construction instead of demonstrating the scope. The PR body quotes both failures verbatim.
|
||||
*
|
||||
* ### It has to be an unlocked emulator
|
||||
*
|
||||
* The Pixel 10 Pro XL is secure-locked and cannot be unlocked from a shell, so the picker cannot be
|
||||
* driven there at all. That is why this gap survived as long as it did.
|
||||
* `tools/local-emulator/run-e2e.sh` runs API 33-36 on the development host, and both tests pass
|
||||
* there: **59 / 0 / 0 / 2 at API 33 and again at API 36**, whole suite, 2026-08-24.
|
||||
*
|
||||
* ### Why only the rotation test carries [FailsOnEmulatorApi37]
|
||||
*
|
||||
* This class is the first thing in the suite that touches system UI, and the android-37.x images
|
||||
* are where that stops being free: surfaceflinger aborts inside the guest's Gralloc5 mapper, init
|
||||
* SIGKILLs zygote with it, and the framework restarts underneath the run. Disabling SystemUI --
|
||||
* the deviation the API 37 leg already makes -- removes the *idle* trigger, not this one.
|
||||
*
|
||||
* The marker is on one method and not on the class, because that is what was measured, one method
|
||||
* per fresh emulator, on `android-37.0` under `swangle_indirect`:
|
||||
*
|
||||
* ```
|
||||
* thePickedInputSurvivesARealRotation INSTRUMENTATION_ABORTED: System has crashed.
|
||||
* Expected 1 tests, received 0
|
||||
* pickingAFileThroughTheSystemPickerFillsInTheFileCard PASSED
|
||||
* ```
|
||||
*
|
||||
* A rotation rebuilds every surface on screen at once, which the mapper does not survive; merely
|
||||
* starting DocumentsUI does not.
|
||||
*
|
||||
* **The first version of this said the class, and it was wrong.** The picker test had failed at
|
||||
* API 37 too -- with a `StaleObjectException` that turned out to be this file's own bug rather
|
||||
* than the image's, and which CI then reproduced deterministically at API 33, 34 and 35. Fixing
|
||||
* it ([tapPickerNode]) and re-measuring is what separated the two. An annotation is a claim about
|
||||
* an image, and a broken test makes every image look broken; **re-measure after fixing a test
|
||||
* before deciding what the platform did.**
|
||||
*
|
||||
* The annotation says only that, and CI reads it twice, so the rotation test runs on the advisory
|
||||
* API 37 leg and not the gating one. **Do not read it as "a rotation is allowed to lose the
|
||||
* file".** That is what API 33 through 36 are for, and they answer it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SafPickerRoundTripTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val device: UiDevice =
|
||||
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
|
||||
/** The app under test, whose own window is what [requireAReadableScreen] asks for. */
|
||||
private val appPackage: String =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.packageName
|
||||
|
||||
/** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */
|
||||
private var rotated = false
|
||||
|
||||
/**
|
||||
* Leave the device the way it was found — and only if this test moved it.
|
||||
*
|
||||
* Two things are deliberate here, and both are about the *other* tests on the device rather
|
||||
* than about these two.
|
||||
*
|
||||
* The flag, because this runs after every test in the class, not only the one that rotated. An
|
||||
* unconditional restore issues a WindowManager rotation request after the picker test as well,
|
||||
* which has nothing to undo; JUnit does not promise method order, so that is an interaction
|
||||
* between two tests that no single-class run would ever show. Tracked as a flag rather than
|
||||
* read back off `isNaturalOrientation`, because a device whose *natural* orientation is
|
||||
* landscape would answer that question the wrong way round.
|
||||
*
|
||||
* And `unfreezeRotation`, because `setOrientationNatural` does not merely rotate: it freezes
|
||||
* the rotation there. A run that stopped after it would hand the next test a device that
|
||||
* cannot rotate at all.
|
||||
*/
|
||||
@After
|
||||
fun restoreOrientation() {
|
||||
if (!rotated) return
|
||||
device.setOrientationNatural()
|
||||
device.unfreezeRotation()
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickingAFileThroughTheSystemPickerFillsInTheFileCard() {
|
||||
pickTheFixture()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME)
|
||||
.assertTextEquals(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
|
||||
|
||||
// Not the same assertion twice. The name above comes from a metadata query, which a URI
|
||||
// with no read grant answers just as well; this line only appears once something has
|
||||
// opened the file and read its header. It is what says the picker handed back a URI the
|
||||
// app can actually USE -- delete grantUriPermissions from the fixture's manifest entry and
|
||||
// the name still arrives while this goes red.
|
||||
//
|
||||
// The whole "Container: MP4" and not "MP4": DetailRow renders the label and the value as
|
||||
// one semantics node.
|
||||
awaitNode(TestTags.Converter.detailRow(CONTAINER_LABEL))
|
||||
composeRule.onNodeWithTag(TestTags.Converter.detailRow(CONTAINER_LABEL))
|
||||
.assertTextEquals("$CONTAINER_LABEL: MP4")
|
||||
}
|
||||
|
||||
@Test
|
||||
@FailsOnEmulatorApi37
|
||||
fun thePickedInputSurvivesARealRotation() {
|
||||
pickTheFixture()
|
||||
// The identity hash rather than the Activity itself, so nothing here keeps a destroyed
|
||||
// Activity reachable across the recreation it is being used to detect.
|
||||
val before = System.identityHashCode(composeRule.activity)
|
||||
|
||||
device.setOrientationLandscape()
|
||||
rotated = true
|
||||
composeRule.waitForIdle()
|
||||
|
||||
// Two guards before the assertion that matters, because both of the ways this test could
|
||||
// pass while proving nothing are silent ones.
|
||||
//
|
||||
// A device that ignored the rotation request would leave the app exactly as it was, and
|
||||
// "the file is still there" would then be a statement about a screen nothing happened to.
|
||||
assertNotEquals(
|
||||
"the device did not actually rotate, so nothing below is about a rotation",
|
||||
NATURAL_ROTATION,
|
||||
device.displayRotation,
|
||||
)
|
||||
// And a rotation that did NOT recreate the Activity -- a configChanges attribute added to
|
||||
// the manifest, an aspect-ratio or orientation lock -- would make this a recomposition
|
||||
// test. The retained ViewModelStore is only interesting because the Activity around it
|
||||
// really was destroyed and rebuilt.
|
||||
assertNotEquals(
|
||||
"the rotation did not recreate MainActivity, so the retained ViewModelStore was never used",
|
||||
before,
|
||||
System.identityHashCode(composeRule.activity),
|
||||
)
|
||||
|
||||
awaitNode(TestTags.Converter.FILE_CARD_NAME)
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME)
|
||||
.assertTextEquals(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
|
||||
}
|
||||
|
||||
// --- driving the picker ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Taps "Choose file", walks the system picker to the fixture, and returns once the app has it.
|
||||
*
|
||||
* Everything between the first tap and the last belongs to `com.google.android.documentsui`,
|
||||
* which is why UiAutomator is here at all: Compose's matchers stop at this process's
|
||||
* composition and Espresso's at its view hierarchy, and the picker is neither.
|
||||
*
|
||||
* **What is retried here is the whole pick.** [tapPickerNode]'s re-find re-acquires a handle
|
||||
* to a node inside the picker that is already open, so it cannot reach a list that was built
|
||||
* before its data arrived. Backing out and tapping "Choose file" again gets a *second*
|
||||
* `PickActivity`, which rebuilds every list in it — and is what a user does when a picker
|
||||
* comes up wrong. It is **not** the answer to the unreadable-screen failure in the class
|
||||
* KDoc; [requireAReadableScreen], one line above, is the part aimed at that.
|
||||
*
|
||||
* The first attempt keeps the full [PICKER_TIMEOUT_MS]; the later ones use
|
||||
* [REOPENED_TIMEOUT_MS], because by then the picker's process, its provider and its root cache
|
||||
* are all warm and the only thing being waited on is one screen. That is what keeps the cost
|
||||
* of a genuinely absent root bounded — see the class KDoc.
|
||||
*/
|
||||
private fun pickTheFixture() {
|
||||
var missing: BySelector? = null
|
||||
repeat(PICK_ATTEMPTS) { attempt ->
|
||||
requireAReadableScreen()
|
||||
openThePicker()
|
||||
missing = walkThePickerToTheFixture(
|
||||
if (attempt == 0) PICKER_TIMEOUT_MS else REOPENED_TIMEOUT_MS,
|
||||
)
|
||||
if (missing == null) {
|
||||
awaitNode(TestTags.Converter.FILE_CARD_NAME)
|
||||
return
|
||||
}
|
||||
dismissThePicker()
|
||||
}
|
||||
throw AssertionError(
|
||||
"the system picker never showed $missing, in $PICK_ATTEMPTS separate pickers " +
|
||||
"(the last one left ${device.currentPackageName} in front)",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to go near the picker until this process can read a window it already knows is there.
|
||||
*
|
||||
* **This is the check that would have answered #93 outright**, instead of leaving six PRs to
|
||||
* infer a SAF fault from a picker that was never the problem. It is here because of what the
|
||||
* failing logcat counts. Across the whole API 34 leg UiAutomator
|
||||
* asked for a node 1095 times and logged `Node not found` 1095 times — it never read anything,
|
||||
* from the first query of the run onwards. The green leg of the same job asked 7 times and
|
||||
* found 5. So the window list `UiDevice` searches, `UiAutomation.getWindows()`, was empty for
|
||||
* that entire instrumentation run; on API 21 and up that list is the *only* place
|
||||
* `getWindowRoots` looks, so an empty one makes every selector unfindable and says nothing
|
||||
* about the app, the picker or the fixture.
|
||||
*
|
||||
* The probe is deliberately the app's **own** window, asked while the app is in front and
|
||||
* before anything is tapped. It is the one window that must be readable for any of the rest to
|
||||
* mean anything, so a failure here is unambiguous — where "the picker never showed the root"
|
||||
* was not, and is what sent #93 looking at package installation and root caches.
|
||||
*
|
||||
* The repair is [rebuildUiAutomation]. It has been forced on and measured — a rebuilt
|
||||
* connection still reads windows, which is the way it could have been worse than nothing —
|
||||
* but it has **never been run against the real fault**, because the fault has never been
|
||||
* reproduced on demand. See the class KDoc. What is certain is that a fresh picker is *not*
|
||||
* the repair: the failing leg opened a second `PickActivity` for the second test, in the
|
||||
* same DocumentsUI process, and read exactly as little from it.
|
||||
*/
|
||||
private fun requireAReadableScreen() {
|
||||
val app = By.pkg(appPackage)
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||
dismissASystemErrorDialog()
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||
unlockTheDevice()
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||
rebuildUiAutomation()
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) != true) {
|
||||
throw AssertionError(
|
||||
"UiAutomator cannot see this app's own window, so it could not have seen the " +
|
||||
"picker's either. This is not a SAF failure. Closing a system error dialog, " +
|
||||
"waking the device, dismissing the keyguard and rebuilding the UiAutomation " +
|
||||
"connection all failed to make it readable. What it could see: " +
|
||||
describeWindows(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a system "isn't responding" dialog, if that is what is on top of the app.
|
||||
*
|
||||
* **This is the occluder #93 turned out to have**, and it took the window list in the failure
|
||||
* message to find it. `AppNotRespondingDialog` belongs to `system_server`, so it is the
|
||||
* `android[type=3]` in `com.android.systemui[type=3], android[type=3]` — and it is opaque and
|
||||
* fullscreen, so `AccessibilityWindowManager` drops every application window beneath it. The
|
||||
* app is `Displayed` and unreadable at the same time, which is exactly the contradiction this
|
||||
* class spent #93 failing to explain. It is not even this app's dialog:
|
||||
*
|
||||
* ```
|
||||
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
|
||||
* Reason: Input dispatching timed out (Application does not have a focused window)
|
||||
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
|
||||
* ```
|
||||
*
|
||||
* The launcher ANRs on a loaded runner emulator minutes before this class runs, and the dialog
|
||||
* it leaves behind never goes away on its own.
|
||||
*
|
||||
* Dismissed by resource id rather than by button text, because the text is localised and the
|
||||
* ids are not, and by id rather than by "the first button in the system window", because that
|
||||
* would click whatever system window happened to be there. `aerr_wait` first: it dismisses the
|
||||
* dialog and leaves the offending app alone, which is the polite answer when the app is not
|
||||
* ours. Back is not tried — `BaseErrorDialog` swallows key events.
|
||||
*/
|
||||
private fun dismissASystemErrorDialog() {
|
||||
for (id in ERROR_DIALOG_BUTTONS) {
|
||||
val button = device.findObject(By.res(id)) ?: continue
|
||||
button.click()
|
||||
device.waitForIdle()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wakes the display and asks the keyguard to go away.
|
||||
*
|
||||
* The cheapest explanation for "this process cannot see the app's own window" is that
|
||||
* something is in front of it, and on a runner emulator that something is the lock screen:
|
||||
* these images come up unprovisioned, and `KeyguardViewMediator` says so in as many words --
|
||||
* `we need to show the keyguard since the device isn't provisioned yet`. An occluded window is
|
||||
* not in the accessibility window list, which is the same symptom as a broken connection and
|
||||
* has a far more ordinary cause.
|
||||
*
|
||||
* `wm dismiss-keyguard` rather than a swipe, because it is a request to the window manager
|
||||
* rather than a gesture that has to land somewhere this process cannot see. It is only
|
||||
* attempted on the failure path -- a device that was readable never reaches here -- so a run
|
||||
* where the keyguard was never up pays nothing and is not altered.
|
||||
*/
|
||||
private fun unlockTheDevice() {
|
||||
device.wakeUp()
|
||||
device.executeShellCommand("wm dismiss-keyguard")
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
/** The accessibility window list, for a failure message that says what was actually there. */
|
||||
private fun describeWindows(): String {
|
||||
val windows = InstrumentationRegistry.getInstrumentation().uiAutomation.windows
|
||||
if (windows.isEmpty()) return "no windows at all (UiAutomation.getWindows() is empty)"
|
||||
return windows.joinToString(", ") { "${it.root?.packageName ?: "?"}[type=${it.type}]" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down this run's `UiAutomation` connection and establishes a new one.
|
||||
*
|
||||
* `Instrumentation.getUiAutomation` hands back the existing connection unless the flags differ
|
||||
* from the ones it was created with, in which case it destroys it and builds another — so
|
||||
* asking for different flags and then for the original ones back is how a test reaches the
|
||||
* connection at all. `UiDevice` re-reads the flags from `Configurator` on every call rather
|
||||
* than caching an instance, so the next selector goes through the new connection.
|
||||
*
|
||||
* `FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES` is toggled rather than chosen: it is only being
|
||||
* used as a value that differs from whatever is configured, and it is put back.
|
||||
*
|
||||
* **Forced on and measured, because the obvious way for this to be worse than nothing is
|
||||
* silent.** `UiDevice` puts `FLAG_RETRIEVE_INTERACTIVE_WINDOWS` on the service info during its
|
||||
* own initialisation, and `getWindows()` is empty without it — so a rebuilt connection that
|
||||
* did not get the flag back would cause exactly the emptiness this is meant to cure, on the
|
||||
* one path where it is the last hope. Run unconditionally on every attempt, on a cold API 34
|
||||
* emulator, both tests passed, and logcat shows the connection really being replaced rather
|
||||
* than handed back: `Init UiAutomation[id=2, flags=0]`, then `id=4, flags=1`, then
|
||||
* `id=6, flags=0`, with `Registering UiTestAutomationService` between each.
|
||||
*/
|
||||
private fun rebuildUiAutomation() {
|
||||
val configurator = Configurator.getInstance()
|
||||
val flags = configurator.uiAutomationFlags
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
configurator.uiAutomationFlags = flags xor UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES
|
||||
instrumentation.getUiAutomation(configurator.uiAutomationFlags)
|
||||
configurator.uiAutomationFlags = flags
|
||||
instrumentation.getUiAutomation(flags)
|
||||
}
|
||||
|
||||
/** Waits for the app to be showing its own screen again, then asks for a picker. */
|
||||
private fun openThePicker() {
|
||||
awaitNode(TestTags.Converter.CHOOSE_FILE)
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Null once the fixture URI is with the app, or the selector whose list never carried it.
|
||||
*
|
||||
* Three things have to be there, in order, and the `when` names them in that order so that a
|
||||
* failure says which one was missing rather than "the picker did not work".
|
||||
*
|
||||
* **The first branch is what tells an unreadable picker from an absent root.** In #93 neither
|
||||
* the root *nor the toolbar's "Show roots" button* could be found for sixty seconds, and a
|
||||
* stale roots list would have left the toolbar findable. Both arrived as one message. Asking
|
||||
* for the picker's package on its own separates them: `never showed BySelector [PKG=...]`
|
||||
* means the picker was not readable, and the root selector means the root was not offered.
|
||||
*
|
||||
* The second is the line the MIME filter mutation fails on: DocumentsUI matches the requested
|
||||
* types against `Root.COLUMN_MIME_TYPES` and drops the roots that cannot answer, so a filter
|
||||
* the fixture root does not satisfy takes the root out of the picker altogether — along with
|
||||
* "Images", "Audio", "Videos" and "Documents", measured on API 34.
|
||||
*
|
||||
* **The third takes no recovery action of its own, and that is deliberate rather than an
|
||||
* oversight.** [openTheRootsDrawer] exists because a root has a *second* place it can be
|
||||
* shown; a document in a directory listing has no second place, so there is nothing an
|
||||
* in-picker action could do. Its recovery is the outer loop: a fresh picker re-walks from
|
||||
* Recent into the root, which rebuilds the directory listing as well as the roots strip.
|
||||
*/
|
||||
private fun walkThePickerToTheFixture(timeoutMs: Long): BySelector? {
|
||||
val picker = By.pkg(DOCUMENTS_UI_PACKAGE)
|
||||
val root = By.text(FixtureDocumentsProvider.ROOT_TITLE)
|
||||
val fixture = By.text(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
|
||||
return when {
|
||||
device.wait(Until.hasObject(picker), timeoutMs) != true -> picker
|
||||
!tapPickerNode(root, timeoutMs, ifAbsent = ::openTheRootsDrawer) -> root
|
||||
!tapPickerNode(fixture, timeoutMs) -> fixture
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's own drawer, opened only when the root was not on the screen it landed on.
|
||||
*
|
||||
* **In practice it never runs, and #80 was right to say so.** A hierarchy dump taken on a
|
||||
* cold API 34 emulator while this test was passing has the fixture root on the landing
|
||||
* screen — `text="LMC R38 fixtures"` at `android:id/title`, under a `BROWSE FILES IN OTHER
|
||||
* APPS` header — with the drawer shut (`Show roots` present, `Hide roots` absent). So the
|
||||
* roots strip is the normal path and the drawer is a widening, kept because a device with a
|
||||
* populated Recent may push the strip off screen. Looking in a second place widens where the
|
||||
* root is searched for; it does not weaken what has to be found, which is still this root.
|
||||
*/
|
||||
private fun openTheRootsDrawer() {
|
||||
device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs out of the picker until the app has the window focus again.
|
||||
*
|
||||
* **The focus is asked of the Activity, not of UiAutomator, and that is not a stylistic
|
||||
* choice.** The failure this retry exists for is a picker window UiAutomator cannot see, so a
|
||||
* probe that went through the same accessibility window list would cheerfully report "the
|
||||
* picker is gone" about the window that is still in front — and the reopened pick would then
|
||||
* tap "Choose file" behind it. `Activity.hasWindowFocus` comes from the framework instead, and
|
||||
* answers about the app rather than about the picker.
|
||||
*
|
||||
* It is also why this counts backs rather than pressing a fixed number of them. One back is
|
||||
* enough from Recent and two are needed from inside the root, but a third from Recent would
|
||||
* finish `MainActivity` and take the rest of the test with it.
|
||||
*/
|
||||
private fun dismissThePicker() {
|
||||
repeat(BACK_PRESSES) {
|
||||
if (awaitAppFocus()) return
|
||||
// Before the back press, not instead of it: an app-error dialog swallows key events,
|
||||
// so a back aimed at the picker lands on the dialog and nothing moves. Measured --
|
||||
// API 34 of run 32813885120 exhausted all four presses with `android` in front, which
|
||||
// is that dialog, while the launcher it belonged to went on ANRing behind everything.
|
||||
dismissASystemErrorDialog()
|
||||
device.pressBack()
|
||||
}
|
||||
// The check after the last press, and not a spare one: `repeat` presses on its final
|
||||
// iteration too, so without this a dismissal that worked on the last press would still be
|
||||
// reported as a failure to close.
|
||||
if (!awaitAppFocus()) {
|
||||
throw AssertionError(
|
||||
"the system picker would not close: after $BACK_PRESSES back presses the app " +
|
||||
"still does not have the window focus, and ${device.currentPackageName} is " +
|
||||
"in front. What could be seen: " + describeWindows(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** True once [MainActivity] has the window focus, false if it does not take it in time. */
|
||||
private fun awaitAppFocus(): Boolean = try {
|
||||
composeRule.waitUntil("the app has the window focus back", FOCUS_TIMEOUT_MS) {
|
||||
composeRule.activity.hasWindowFocus()
|
||||
}
|
||||
true
|
||||
} catch (_: ComposeTimeoutException) {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the picker node [selector] names and taps it, re-finding it if it goes stale.
|
||||
*
|
||||
* **The re-finding is not padding, and this is not a retry of the assertion.** A `UiObject2`
|
||||
* holds an `AccessibilityNodeInfo` captured when it was found, and DocumentsUI is still
|
||||
* settling when the node first appears — its list rebinds, the roots strip lays out, a window
|
||||
* animates. If the node is replaced in that gap, `click()` throws `StaleObjectException`
|
||||
* against the handle rather than missing the target. Measured on a cold API 34 emulator:
|
||||
*
|
||||
* ```
|
||||
* androidx.test.uiautomator.StaleObjectException
|
||||
* at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1042)
|
||||
* at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
|
||||
* ```
|
||||
*
|
||||
* So what is retried is *acquiring a handle to a node that has to be there anyway*. **A node
|
||||
* that is simply not in this picker is reported rather than retried here** — it comes back as
|
||||
* `false`, and [pickTheFixture] answers it with a whole new picker, which is the only thing
|
||||
* that rebuilds a list or a window. The MIME mutation's bite is untouched either way: a root
|
||||
* that is not in the picker is not found on any attempt or in any picker, and the failure is
|
||||
* still "the system picker never showed" rather than a stale one.
|
||||
*/
|
||||
private fun tapPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit = {}): Boolean {
|
||||
var stale: StaleObjectException? = null
|
||||
repeat(TAP_ATTEMPTS) { attempt ->
|
||||
// ifAbsent only on the first attempt: it navigates, and re-navigating from a screen it
|
||||
// already reached would walk away from the node.
|
||||
val node = awaitPickerNode(selector, timeoutMs, if (attempt == 0) ifAbsent else ({}))
|
||||
?: return false
|
||||
device.waitForIdle()
|
||||
try {
|
||||
node.click()
|
||||
return true
|
||||
} catch (e: StaleObjectException) {
|
||||
stale = e
|
||||
}
|
||||
}
|
||||
throw AssertionError("$selector kept going stale between finding it and tapping it", stale)
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker node [selector] names, or null if this picker never showed it.
|
||||
*
|
||||
* [ifAbsent] runs once, after the first wait comes up empty, and then the wait is repeated. A
|
||||
* null return from `findObject` is deliberately not an error there: it is the "already on the
|
||||
* right screen" case.
|
||||
*/
|
||||
private fun awaitPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit) =
|
||||
device.wait(Until.findObject(selector), timeoutMs)
|
||||
?: run {
|
||||
ifAbsent()
|
||||
device.wait(Until.findObject(selector), timeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks until [tag] is in the composition, so an assertion cannot race the picker's result.
|
||||
*
|
||||
* The described overload of `waitUntil`, not the bare one. A timeout is how both of this
|
||||
* class's mutations report themselves, and the bare overload's message is
|
||||
* `Condition still not satisfied after 30000 ms` — which names neither the node nor the test.
|
||||
* With the description it says which affordance never arrived, which is the whole finding.
|
||||
*/
|
||||
private fun awaitNode(tag: String) {
|
||||
composeRule.waitUntil("a node tagged $tag exists", APP_TIMEOUT_MS) {
|
||||
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
/**
|
||||
* Generous on purpose. This waits on another app being started, and on FFprobe spawning a
|
||||
* native process over a `content://` URI; a timeout that merely usually passes is a flaky
|
||||
* gating leg on five API levels, which costs far more than the seconds it saves.
|
||||
*/
|
||||
const val PICKER_TIMEOUT_MS = 30_000L
|
||||
const val APP_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* The same wait once a picker has already come and gone, and shorter for a reason.
|
||||
*
|
||||
* What [PICKER_TIMEOUT_MS] is generous about is a cold start: DocumentsUI's process, the
|
||||
* fixture's provider process, the root cache. By the second attempt all three are warm and
|
||||
* the only thing left to wait on is one screen being laid out — measured at 2.7 to 3.4 s
|
||||
* from the picker starting, on cold CI emulators at API 33, 34 and 35. Ten seconds is
|
||||
* three times the worst of those, and it is what keeps a genuinely absent root — the MIME
|
||||
* mutation — from costing three full-length attempts.
|
||||
*/
|
||||
const val REOPENED_TIMEOUT_MS = 10_000L
|
||||
|
||||
/**
|
||||
* How long the app is given to take the window focus back after a back press.
|
||||
*
|
||||
* Short, because this is asked once per back press and the first one is always asked while
|
||||
* the picker is still in front, where it is *expected* to time out.
|
||||
*/
|
||||
const val FOCUS_TIMEOUT_MS = 3_000L
|
||||
|
||||
/**
|
||||
* How long this process is given to be able to read the screen at all.
|
||||
*
|
||||
* Short, and it is not waiting on anything being drawn: the app is already in front
|
||||
* when this is asked. It is waiting only on the accessibility window list existing,
|
||||
* which either does within a poll or two or -- as in #93 -- not at all.
|
||||
*/
|
||||
const val READABLE_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** `Surface.ROTATION_0`, named rather than `0` so the comparison reads. */
|
||||
const val NATURAL_ROTATION = 0
|
||||
|
||||
/**
|
||||
* How many pickers the fixture may fail to appear in before that is the finding.
|
||||
*
|
||||
* Three. Each one is a fresh `PickActivity` -- a fresh window, a fresh accessibility
|
||||
* registration, a fresh roots query and a fresh directory load -- so this bounds the thing
|
||||
* #93 measured, which is a picker that came up unreadable *once*. A root that is genuinely
|
||||
* not offered is absent from all three, which is what keeps #64's MIME mutation red.
|
||||
*/
|
||||
const val PICK_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* How many back presses may be spent getting out of a picker.
|
||||
*
|
||||
* One is enough from Recent, two from inside the fixture's own directory. Four leaves room
|
||||
* for a picker that has been navigated deeper than this test ever navigates it, and stops
|
||||
* well short of the count that would start finishing `MainActivity` instead.
|
||||
*/
|
||||
const val BACK_PRESSES = 4
|
||||
|
||||
/**
|
||||
* The package the system picker runs in.
|
||||
*
|
||||
* Named rather than resolved: `PackageManager.resolveActivity` is deprecated from API 33
|
||||
* and its replacement is a lint argument this test does not need to have. A wrong value
|
||||
* here cannot pass silently -- it is the first thing [walkThePickerToTheFixture] looks
|
||||
* for, so the failure would read `never showed BySelector [PKG='...']` on every device.
|
||||
* It is `com.google.android.documentsui` on every `google_apis` emulator image the CI
|
||||
* matrix uses and on the Pixel 10 Pro XL.
|
||||
*/
|
||||
const val DOCUMENTS_UI_PACKAGE = "com.google.android.documentsui"
|
||||
|
||||
/**
|
||||
* How many times a picker node may be re-found before its staleness is the finding.
|
||||
*
|
||||
* Three, not "until the timeout". Each attempt already waits up to [PICKER_TIMEOUT_MS] for
|
||||
* the node to exist, so this bounds only the settling window after it does; a node that is
|
||||
* still being replaced after three of those is telling you something about the device, and
|
||||
* a loop that hid it would be the flake rather than the fix.
|
||||
*/
|
||||
const val TAP_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* The buttons on the framework's app-error dialogs, by resource id.
|
||||
*
|
||||
* `aerr_wait` is first because it dismisses the dialog without killing the app under it,
|
||||
* and the app under it is usually the launcher rather than anything this suite owns.
|
||||
* `button1` catches the plainer `BaseErrorDialog` shapes that have no `aerr_` ids.
|
||||
*/
|
||||
val ERROR_DIALOG_BUTTONS = listOf(
|
||||
"android:id/aerr_wait",
|
||||
"android:id/aerr_close",
|
||||
"android:id/button1",
|
||||
)
|
||||
|
||||
/** DocumentsUI's drawer button. It carries no text, only this description. */
|
||||
const val SHOW_ROOTS_DESCRIPTION = "Show roots"
|
||||
|
||||
/** The detail row `MediaProbe` fills in for anything it could open and identify. */
|
||||
const val CONTAINER_LABEL = "Container"
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,13 @@ class AndroidDeviceCodecs private constructor(
|
||||
return AndroidDeviceCodecs(encoders, decoders)
|
||||
}
|
||||
|
||||
private fun mimeFor(codec: VideoCodec): String? = when (codec) {
|
||||
/**
|
||||
* `internal` rather than `private` so the cross-check test can ask what a [VideoCodec]
|
||||
* means here and compare it with what [NAME_TO_MIME] says the same codec's names mean.
|
||||
* The JVM test source set is a friend of `main`, so this stays invisible outside the
|
||||
* module — the precedent is `MainActivity`'s `Destination`.
|
||||
*/
|
||||
internal fun mimeFor(codec: VideoCodec): String? = when (codec) {
|
||||
VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC
|
||||
VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8
|
||||
@@ -87,20 +93,62 @@ class AndroidDeviceCodecs private constructor(
|
||||
VideoCodec.COPY, VideoCodec.NONE -> null
|
||||
}
|
||||
|
||||
/** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */
|
||||
private fun mimeForCodecName(name: String): String? = when (name.lowercase()) {
|
||||
"h264", "avc", "avc1" -> MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
"hevc", "h265", "hvc1" -> MediaFormat.MIMETYPE_VIDEO_HEVC
|
||||
"vp8" -> MediaFormat.MIMETYPE_VIDEO_VP8
|
||||
"vp9" -> MediaFormat.MIMETYPE_VIDEO_VP9
|
||||
"av1", "av01" -> MediaFormat.MIMETYPE_VIDEO_AV1
|
||||
"mpeg4" -> MediaFormat.MIMETYPE_VIDEO_MPEG4
|
||||
// Unknown to us: assume the platform can handle it and let a failed export
|
||||
// trigger the FFmpeg fallback, rather than pre-emptively refusing hardware.
|
||||
else -> null
|
||||
}
|
||||
/**
|
||||
* FFprobe-style codec names, and the MediaFormat MIME type each one asks about.
|
||||
*
|
||||
* This is the same vocabulary `CodecNames.VIDEO_ALIASES` holds, written out a second time
|
||||
* because this side has to answer in platform MIME types and `model` does not depend on
|
||||
* Android. Two copies of one vocabulary drift, and these had: `x264`, `hev1`, `x265` and
|
||||
* `vp09` resolved for display and routing and fell through to null here, so the app ran
|
||||
* the capability check blind on inputs it had already identified (#87). They are listed
|
||||
* now, which **changes behaviour** for those four names — see [mimeForCodecName].
|
||||
*
|
||||
* A map rather than a `when` because a `when` cannot be enumerated, and `CodecVocabularyTest`
|
||||
* has to walk both key sets to notice the next divergence.
|
||||
*/
|
||||
internal val NAME_TO_MIME: Map<String, String> = mapOf(
|
||||
"h264" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
"avc" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
"avc1" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
"x264" to MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
"hevc" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
"h265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
"hvc1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
"hev1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
"x265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
"vp8" to MediaFormat.MIMETYPE_VIDEO_VP8,
|
||||
"vp9" to MediaFormat.MIMETYPE_VIDEO_VP9,
|
||||
"vp09" to MediaFormat.MIMETYPE_VIDEO_VP9,
|
||||
"av1" to MediaFormat.MIMETYPE_VIDEO_AV1,
|
||||
"av01" to MediaFormat.MIMETYPE_VIDEO_AV1,
|
||||
"mpeg4" to MediaFormat.MIMETYPE_VIDEO_MPEG4,
|
||||
)
|
||||
|
||||
/** Test seam: lets instrumented tests build a probe from explicit sets. */
|
||||
/**
|
||||
* The names in [NAME_TO_MIME] that no [VideoCodec] member spells, and why.
|
||||
*
|
||||
* MPEG-4 Part 2 is decodable input the app never targets, so there is no enum for it and
|
||||
* `CodecNames` is right not to carry it. That makes it the one place the two tables
|
||||
* legitimately differ. It is listed rather than implied so the cross-check can tell a
|
||||
* documented asymmetry from a fresh drift — and so the list itself is checked: a name here
|
||||
* that `CodecNames` does resolve is a divergence being waved through, and the test fails on
|
||||
* it.
|
||||
*/
|
||||
internal val DECODE_ONLY_NAMES: Set<String> = setOf("mpeg4")
|
||||
|
||||
/**
|
||||
* Maps an FFprobe-style codec name onto a MediaFormat MIME type.
|
||||
*
|
||||
* Null keeps its documented meaning — unknown to us: assume the platform can handle it and
|
||||
* let a failed export trigger the FFmpeg fallback, rather than pre-emptively refusing
|
||||
* hardware. What changed with #87 is which names are unknown. Four that FFmpeg genuinely
|
||||
* emits used to land here and be treated as unknown while the rest of the app knew exactly
|
||||
* what they were; a device without the matching decoder now routes them to FFmpeg up front
|
||||
* instead of spending a doomed hardware attempt to find out.
|
||||
*/
|
||||
internal fun mimeForCodecName(name: String): String? = NAME_TO_MIME[name.lowercase()]
|
||||
|
||||
/** Test seam: lets a test build a probe from explicit sets, on a device or on the JVM. */
|
||||
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -71,6 +72,119 @@ data class InputFile(
|
||||
val probe: InputProbe? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One update about a running conversion, as WorkManager last reported it.
|
||||
*
|
||||
* Only the fields [conversionStateFrom] reads — the same shape, and for the same reason, as
|
||||
* `JobSnapshot` beside `Reattachment.choose`: the rule stays testable on the JVM because nothing
|
||||
* in it needs a `WorkInfo`, which a test cannot readily build.
|
||||
*
|
||||
* [outputData] stays a `Data` rather than being unpacked into five nullable strings. It is what a
|
||||
* test already builds with `workDataOf` everywhere in this suite, so unpacking would move the same
|
||||
* reads without making anything easier to drive.
|
||||
*/
|
||||
internal data class ConversionUpdate(
|
||||
val state: WorkInfo.State,
|
||||
val progressPercent: Int,
|
||||
val runAttemptCount: Int,
|
||||
val outputData: Data,
|
||||
)
|
||||
|
||||
/**
|
||||
* What the screen should show, given what WorkManager last said about the job.
|
||||
*
|
||||
* ## Why this is a function rather than the body of a `collect`
|
||||
*
|
||||
* It was the body of one. `workManager` is built in the constructor from `WorkManager.getInstance`,
|
||||
* `observe` is private, and nothing could hand either a chosen `WorkInfo` — so every arm below ran
|
||||
* only when a real worker happened to produce it. A real worker produces a terminal state with
|
||||
* well-formed output, which meant six of these arms had never been chosen by any test: the progress
|
||||
* read, both sides of the retry check, a success with no file, a failure with nothing to say, and
|
||||
* the two that map to a state the user cannot otherwise reach.
|
||||
*
|
||||
* That is the argument #141 made for `MediaProbe`'s track walk, against `WorkManager` instead of a
|
||||
* media fixture, and it takes the same answer: the branch matrix is a pure function, and what is
|
||||
* left needing the framework — the flow, the null check, the ownership check — is the thin edge.
|
||||
*
|
||||
* ## What is deliberately *not* in here
|
||||
*
|
||||
* The ownership check stays at the call site. Its comment is explicit that it guards the file
|
||||
* ownership the `SUCCEEDED` arm takes, not merely the assignment, so moving it inside would change
|
||||
* what it protects. And this function takes no responsibility for the staged file: it returns the
|
||||
* state, and the caller reads the file off it. A pure function that deletes files is not a seam.
|
||||
*
|
||||
* @param cancelled where a cancellation lands, which differs for a reattached job — see [observe].
|
||||
* @param fallbackSpec the current settings, read only when finished work predates the worker
|
||||
* reporting its own name and MIME type.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal fun conversionStateFrom(
|
||||
update: ConversionUpdate,
|
||||
input: InputFile,
|
||||
cancelled: ConversionState,
|
||||
fallbackSpec: OutputSpec,
|
||||
): ConversionState = when (update.state) {
|
||||
WorkInfo.State.RUNNING -> ConversionState.Converting(input, update.progressPercent)
|
||||
|
||||
// ENQUEUED after a run means a retry is pending. Either the six-hour foreground budget ran out
|
||||
// mid-job, or the system refused to let the job start again while the app was in the background
|
||||
// — the second being the likelier of the two, since it needs only a process restart. Nothing
|
||||
// here can tell them apart, and nothing needs to: the answer is the same.
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (update.runAttemptCount > 0) {
|
||||
ConversionState.Waiting(input)
|
||||
} else {
|
||||
ConversionState.Converting(input, 0)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> convertedFrom(update.outputData, input, fallbackSpec)
|
||||
|
||||
// A worker that dies before it can report anything leaves no output data at all — a
|
||||
// foreground-service start refused after a process restart is one way — and an exception's
|
||||
// message can be an empty string. Both would read as a failure with nothing said, so blank
|
||||
// falls back like missing does.
|
||||
WorkInfo.State.FAILED -> ConversionState.Failed(
|
||||
update.outputData.getString(ConversionWorker.KEY_ERROR)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConversionWorker.GENERIC_FAILURE_MESSAGE,
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> cancelled
|
||||
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `SUCCEEDED` arm, which is the only one that reads more than one field.
|
||||
*
|
||||
* Split out so [conversionStateFrom] stays a table of one line per state. A success with no output
|
||||
* path is a failure: the job said it finished and named nothing, and there is no file to offer.
|
||||
*/
|
||||
@UnstableApi
|
||||
private fun convertedFrom(outputData: Data, input: InputFile, fallbackSpec: OutputSpec): ConversionState {
|
||||
val path = outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
|
||||
?: return ConversionState.Failed(SUCCEEDED_WITHOUT_A_FILE_MESSAGE)
|
||||
return ConversionState.Converted(
|
||||
input = input,
|
||||
staged = File(path),
|
||||
engineUsed = outputData.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
|
||||
routeReason = outputData.getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(),
|
||||
// Work enqueued before the worker reported this carries nothing, and WorkManager keeps
|
||||
// finished work for about a week -- so this branch is ordinary for a few days rather than a
|
||||
// corner. It is the old derivation, kept because it is the same guess the app already made
|
||||
// and there is genuinely nothing better available for such a job. New work never reaches it.
|
||||
suggestedName = outputData.getString(ConversionWorker.KEY_SUGGESTED_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConversionWorker.outputNameFor(input.displayName, fallbackSpec),
|
||||
mimeType = outputData.getString(ConversionWorker.KEY_MIME_TYPE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: fallbackSpec.mimeType,
|
||||
)
|
||||
}
|
||||
|
||||
/** A job that reported success and named no file. There is nothing to offer the user to save. */
|
||||
internal const val SUCCEEDED_WITHOUT_A_FILE_MESSAGE: String =
|
||||
"Conversion reported success but produced no file."
|
||||
|
||||
sealed interface ConversionState {
|
||||
data object Idle : ConversionState
|
||||
data class Ready(val input: InputFile) : ConversionState
|
||||
@@ -101,7 +215,42 @@ sealed interface ConversionState {
|
||||
val mimeType: String = "",
|
||||
) : ConversionState
|
||||
data class Saved(val displayName: String) : ConversionState
|
||||
data class Failed(val message: String) : ConversionState
|
||||
|
||||
/**
|
||||
* The job, or the save that followed it, could not be finished.
|
||||
*
|
||||
* [retry] is non-null for exactly one cause: a [ConversionViewModel.save] whose copy to the
|
||||
* user's destination threw. That save deliberately keeps the staged file -- it can be the only
|
||||
* copy of an hour of transcoding -- and this is what lets the screen offer it again. Every
|
||||
* other failure leaves it null, because there is nothing staged to offer: a transcode that
|
||||
* died produced no output, and a save that found the file gone has nothing left to save.
|
||||
*
|
||||
* Nullable rather than a `SaveFailed` state of its own. What the screen does with the message
|
||||
* is identical either way, so a second variant would make every exhaustive `when` grow an arm
|
||||
* that duplicates this one.
|
||||
*
|
||||
* A view of the file, not a second owner of it -- see [PendingSave].
|
||||
*/
|
||||
data class Failed(val message: String, val retry: PendingSave? = null) : ConversionState
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged output a save would target from this state, or null when there is nothing to save.
|
||||
*
|
||||
* One function for two callers that have to agree. [ConversionViewModel.save] picks the file to
|
||||
* copy with it, and `ConverterScreen` registers its `CreateDocument` contract with the MIME type
|
||||
* it returns; when those two read the state separately, a retry offered after a failed save opened
|
||||
* the dialog with the *picker's* current type instead of the finished job's -- wrong for any job
|
||||
* whose spec has been edited since, and for every reattached job, whose spec was never in these
|
||||
* settings at all.
|
||||
*
|
||||
* Top-level and `internal` rather than a member of the ViewModel, so the screen can call it
|
||||
* without one -- which is also what makes the derivation testable on the JVM.
|
||||
*/
|
||||
internal fun ConversionState.pendingSave(): PendingSave? = when (this) {
|
||||
is ConversionState.Converted -> PendingSave(staged, suggestedName, mimeType)
|
||||
is ConversionState.Failed -> retry
|
||||
else -> null
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@@ -120,6 +269,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)
|
||||
@@ -135,13 +307,34 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
private var observer: Job? = null
|
||||
private var activeWorkId: UUID? = null
|
||||
|
||||
/**
|
||||
* Who is allowed to write to this screen — see [ScreenOwnership] for the rule and why
|
||||
* cancelling the superseded coroutine is not one.
|
||||
*
|
||||
* Every write below that lands after a suspension point is guarded by it: the two in
|
||||
* [onInputPicked] and the one in [observe].
|
||||
*
|
||||
* [save] is the one left out, deliberately — and not because it is safe in both directions.
|
||||
* Nothing can overwrite what it writes: it is reachable only from [ConversionState.Converted]
|
||||
* or a [ConversionState.Failed] carrying its file, so the only observation that could belongs
|
||||
* to a job already in a terminal state, which will not emit again. What it can still do is
|
||||
* land on top of a [reset] taken while its copy was in flight, putting `Saved` on a screen the
|
||||
* user has just cleared. Guarding it would drop that write instead, reporting nothing for a
|
||||
* file that may genuinely have reached the user's destination. Which of those two is right is
|
||||
* a question about what the screen should offer during a save, not about this race, so it is
|
||||
* filed as issue #123 rather than decided here in passing.
|
||||
*/
|
||||
private val ownership = ScreenOwnership()
|
||||
|
||||
/**
|
||||
* The staged output this ViewModel is responsible for deleting.
|
||||
*
|
||||
* A field rather than something read back out of [_state], because the state machine
|
||||
* cannot answer the question on the path that needs it most: a failed [save] lands on
|
||||
* [ConversionState.Failed], which carries a message and no file at all. By then the
|
||||
* only remaining reference would have been lost.
|
||||
* A field rather than something read back out of [_state], and still one now that
|
||||
* [ConversionState.Failed] carries a [PendingSave] after a failed [save]. That handle is a
|
||||
* view for the screen to offer a retry through; this one is the single reference [reset]
|
||||
* deletes through, and keeping the two apart is what stops a second owner appearing. Reading
|
||||
* the file back out of the state machine instead would mean trusting every state that has no
|
||||
* file -- `Idle`, `Saved`, a transcode failure -- to say so.
|
||||
*/
|
||||
private var pendingStaged: File? = null
|
||||
|
||||
@@ -184,6 +377,10 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
* `Data` — see [ConversionState.Converted].
|
||||
*/
|
||||
private fun reattach() {
|
||||
// Read before the launch, and before the query it is about to suspend in. This is the
|
||||
// claim the answer will belong to: anything the user does from here on supersedes it, and
|
||||
// reading it on the far side of the query would read whatever superseded it instead.
|
||||
val token = ownership.current
|
||||
viewModelScope.launch {
|
||||
val reattachment = Reattachment.choose(
|
||||
workManager.jobSnapshots(
|
||||
@@ -194,8 +391,17 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
|
||||
// The query suspends, so by now the user may have picked a file or started a
|
||||
// conversion of their own. Either owns the screen; reattaching over it would throw
|
||||
// away what they just did. Both this check and the assignment below run on the main
|
||||
// dispatcher with no suspension point between them, so nothing can interleave.
|
||||
// away what they just did.
|
||||
//
|
||||
// This catches a pick that has already *landed*, and only that. It used to claim that
|
||||
// "both this check and the assignment below run on the main dispatcher with no
|
||||
// suspension point between them, so nothing can interleave" — which was the exact
|
||||
// opposite of what happens. There is no assignment below. There is observe(), which
|
||||
// launches a *separate* coroutine that must suspend on `collect` before it can write
|
||||
// anything, so the check happens at one moment and the write lands at another with a
|
||||
// whole pick able to fit in between. That was issue #49, and believing this comment is
|
||||
// why it read as flaky CI for two days. What actually holds the line is the token
|
||||
// observe() carries: see [ScreenOwnership].
|
||||
if (_state.value !is ConversionState.Idle || activeWorkId != null) return@launch
|
||||
|
||||
// Only a job that is the sole explanation for its staged file gets to name the input.
|
||||
@@ -221,7 +427,7 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
activeWorkId = reattachment.job.id
|
||||
// No initial state of our own: the flow's first emission carries the job's real
|
||||
// state, so observe() maps it exactly as it would for a conversion started here.
|
||||
observe(reattachment.job.id, input, cancelled = ConversionState.Idle)
|
||||
observe(reattachment.job.id, input, cancelled = ConversionState.Idle, token = token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,25 +443,34 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
|
||||
fun setEnginePreference(preference: EnginePreference) = _settings.update { it.copy(enginePreference = preference) }
|
||||
|
||||
/**
|
||||
* The tap is the claim, which is why [ScreenOwnership.claim] is called here and not inside the
|
||||
* `launch`. A claim made in the coroutine would only be immediate for as long as
|
||||
* `Dispatchers.Main.immediate` happened to run it inline, and a deferred claim leaves the same
|
||||
* gap this closes: it is the difference between the user owning the screen from the moment
|
||||
* they tapped and owning it from whenever their coroutine got around to running.
|
||||
*/
|
||||
fun onInputPicked(uri: Uri) {
|
||||
val token = ownership.claim()
|
||||
viewModelScope.launch {
|
||||
// Both the metadata query and the probe touch disk, and the probe spawns FFprobe.
|
||||
// Neither belongs on the main thread.
|
||||
val file = withContext(Dispatchers.IO) { InputQuery.describe(getApplication(), uri) }
|
||||
val file = withContext(pickDispatcher) { InputQuery.describe(getApplication(), uri) }
|
||||
// Every write below the hop above is guarded, this one included: two picks in quick
|
||||
// succession suspend here together, and without this the slower one would land last
|
||||
// and put the file the user did not choose on screen.
|
||||
if (!ownership.stillHeldBy(token)) return@launch
|
||||
// Show the file as soon as its name and size are known. Probing now runs FFprobe on
|
||||
// every pick, which is a native process spawn, and making the whole screen wait on it
|
||||
// would read as the app having ignored the tap.
|
||||
_state.value = ConversionState.Ready(file)
|
||||
|
||||
val probe = withContext(Dispatchers.IO) { probeOrUnreadable(uri) }
|
||||
// Only fill in the probe if the user has not moved on in the meantime.
|
||||
_state.update { current ->
|
||||
if (current is ConversionState.Ready && current.input.uri == uri) {
|
||||
ConversionState.Ready(file.copy(probe = probe))
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
val probe = withContext(pickDispatcher) { probeOrUnreadable(uri) }
|
||||
// Only fill in the probe if the user has not moved on in the meantime. The claim is
|
||||
// what says whether they have -- it covers a second pick of the same URI, which a
|
||||
// comparison of URIs cannot, and every state a later claim could have written.
|
||||
if (!ownership.stillHeldBy(token)) return@launch
|
||||
_state.value = ConversionState.Ready(file.copy(probe = probe))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,10 +521,13 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
quality = settings.quality,
|
||||
enginePreference = settings.enginePreference,
|
||||
)
|
||||
// Tapping Convert claims the screen for this job, which is what supersedes the pick's
|
||||
// still-in-flight probe and any reattachment that has not finished asking.
|
||||
val token = ownership.claim()
|
||||
activeWorkId = request.id
|
||||
workManager.enqueue(request)
|
||||
_state.value = ConversionState.Converting(input, 0)
|
||||
observe(request.id, input)
|
||||
observe(request.id, input, token = token)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,81 +535,45 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
* picked file, ready to convert again. For one picked up by [reattach] there is no picked
|
||||
* file — the URI that job holds belongs to a process that no longer exists — so it lands
|
||||
* on Idle instead, rather than offering a Convert button over a file nothing can open.
|
||||
* @param token the claim this observation belongs to. Nothing here can write until `collect`
|
||||
* has resumed with a `WorkInfo`, which is some time after the caller decided to observe, so
|
||||
* the claim is checked again at the last possible moment rather than trusted from then. This
|
||||
* is issue #49's fix and the only thing standing between a superseded observation and the
|
||||
* user's screen — see [ScreenOwnership].
|
||||
*/
|
||||
private fun observe(id: UUID, input: InputFile, cancelled: ConversionState = ConversionState.Ready(input)) {
|
||||
private fun observe(
|
||||
id: UUID,
|
||||
input: InputFile,
|
||||
cancelled: ConversionState = ConversionState.Ready(input),
|
||||
token: Long,
|
||||
) {
|
||||
observer?.cancel()
|
||||
observer = viewModelScope.launch {
|
||||
workManager.getWorkInfoByIdFlow(id).collect { info ->
|
||||
if (info == null) return@collect
|
||||
_state.value = when (info.state) {
|
||||
WorkInfo.State.RUNNING -> ConversionState.Converting(
|
||||
input,
|
||||
info.progress.getInt(ConversionWorker.KEY_PROGRESS, 0),
|
||||
)
|
||||
|
||||
// ENQUEUED after a run means a retry is pending. Either the six-hour
|
||||
// foreground budget ran out mid-job, or the system refused to let the job
|
||||
// start again while the app was in the background — the second being the
|
||||
// likelier of the two, since it needs only a process restart. Nothing here
|
||||
// can tell them apart, and nothing needs to: the answer is the same.
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (info.runAttemptCount > 0) {
|
||||
ConversionState.Waiting(input)
|
||||
} else {
|
||||
ConversionState.Converting(input, 0)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
val path = info.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
|
||||
if (path == null) {
|
||||
ConversionState.Failed("Conversion reported success but produced no file.")
|
||||
} else {
|
||||
val staged = File(path)
|
||||
// Take responsibility for the file at the same moment the state
|
||||
// starts referring to it, so the two cannot disagree.
|
||||
pendingStaged = staged
|
||||
ConversionState.Converted(
|
||||
input = input,
|
||||
staged = staged,
|
||||
engineUsed = info.outputData
|
||||
.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
|
||||
routeReason = info.outputData
|
||||
.getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(),
|
||||
suggestedName = info.outputData
|
||||
.getString(ConversionWorker.KEY_SUGGESTED_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
// Work enqueued before the worker reported this carries
|
||||
// nothing, and WorkManager keeps finished work for about a
|
||||
// week -- so this branch is ordinary for a few days rather
|
||||
// than a corner. It is the old derivation, kept because it is
|
||||
// the same guess the app already made and there is genuinely
|
||||
// nothing better available for such a job. New work never
|
||||
// reaches it.
|
||||
?: ConversionWorker.outputNameFor(
|
||||
input.displayName,
|
||||
_settings.value.spec,
|
||||
),
|
||||
mimeType = info.outputData
|
||||
.getString(ConversionWorker.KEY_MIME_TYPE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: _settings.value.spec.mimeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A worker that dies before it can report anything leaves no output data at
|
||||
// all — a foreground-service start refused after a process restart is one
|
||||
// way — and an exception's message can be an empty string. Both would read
|
||||
// as a failure with nothing said, so blank falls back like missing does.
|
||||
WorkInfo.State.FAILED -> ConversionState.Failed(
|
||||
info.outputData.getString(ConversionWorker.KEY_ERROR)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Conversion failed.",
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> cancelled
|
||||
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
|
||||
}
|
||||
// Ahead of the `when`, not merely ahead of the assignment: the SUCCEEDED branch
|
||||
// takes ownership of the staged file, and a superseded observation must not do
|
||||
// that either. The state and `pendingStaged` are meant to refer to the same file
|
||||
// or to no file, and this is where that stays true.
|
||||
if (!ownership.stillHeldBy(token)) return@collect
|
||||
val next = conversionStateFrom(
|
||||
ConversionUpdate(
|
||||
state = info.state,
|
||||
progressPercent = info.progress.getInt(ConversionWorker.KEY_PROGRESS, 0),
|
||||
runAttemptCount = info.runAttemptCount,
|
||||
outputData = info.outputData,
|
||||
),
|
||||
input = input,
|
||||
cancelled = cancelled,
|
||||
fallbackSpec = _settings.value.spec,
|
||||
)
|
||||
// Take responsibility for the file at the same moment the state starts referring
|
||||
// to it, so the two cannot disagree. Read off the result rather than assigned
|
||||
// inside the mapping: `Converted` is the only state that carries a staged file, so
|
||||
// "the state and `pendingStaged` refer to the same file or to no file" is now the
|
||||
// shape of the code rather than a rule two branches have to keep.
|
||||
if (next is ConversionState.Converted) pendingStaged = next.staged
|
||||
_state.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,35 +585,50 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
/**
|
||||
* Copies the staged result out to the destination the user picked.
|
||||
*
|
||||
* Reached from [ConversionState.Converted] and again from a [ConversionState.Failed] that an
|
||||
* earlier save left carrying its file. [pendingSave] is what makes those one call rather than
|
||||
* two, so a retry cannot drift from the first attempt in what it copies or what it calls it.
|
||||
*
|
||||
* The existence check is not redundant with the one reattachment already made. That one ran
|
||||
* inside a tag query which, for a result offered on launch, can be hours older than the tap —
|
||||
* and `cacheDir` is exactly the directory the OS empties when it wants space, which is also
|
||||
* what the sweep does to anything a day old. Without it the file's absence arrived as
|
||||
* `staged.inputStream()` throwing, and `e.message` put a raw ENOENT path on screen.
|
||||
* `staged.inputStream()` throwing, and `e.message` put a raw ENOENT path on screen. A retry
|
||||
* meets that same check a second time, which is the point of reusing it here.
|
||||
*/
|
||||
fun save(destination: Uri) {
|
||||
val converted = _state.value as? ConversionState.Converted ?: return
|
||||
if (!converted.staged.isFile) {
|
||||
val pending = _state.value.pendingSave() ?: return
|
||||
if (!pending.staged.isFile) {
|
||||
// No retry handle: the file such a state would offer again is exactly the one that
|
||||
// has gone, so carrying it would put a button on screen that cannot do anything.
|
||||
_state.value = ConversionState.Failed(STAGED_FILE_GONE_MESSAGE)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
publisher.publish(converted.staged, destination)
|
||||
converted.staged.delete()
|
||||
publisher.publish(pending.staged, destination)
|
||||
pending.staged.delete()
|
||||
}
|
||||
}.onSuccess {
|
||||
// publish() already deleted it; nothing left to clean up.
|
||||
pendingStaged = null
|
||||
_state.value = ConversionState.Saved(converted.suggestedName)
|
||||
_state.value = ConversionState.Saved(pending.suggestedName)
|
||||
}.onFailure { e ->
|
||||
// Deliberately NOT cleared. A failed save may mean the staged file is the
|
||||
// only copy of an hour of transcoding, and the user's destination did not
|
||||
// receive it -- deleting here would destroy the work to tidy up a cache
|
||||
// directory. It stays collectable: by a later reset(), or by the sweep once
|
||||
// it is old enough to be certain nobody is coming back for it.
|
||||
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
|
||||
//
|
||||
// `pending` rides on the state so the screen can offer that file again. It used
|
||||
// to live only in `pendingStaged`, where nothing on screen could reach it -- so
|
||||
// the single button this branch rendered was "Start over", which deletes the very
|
||||
// file the paragraph above goes out of its way to keep. It is `pending` rather
|
||||
// than a fresh handle for the second failure's sake: a retry that fails again
|
||||
// lands back here still carrying the file, not on a bare Failed that would take
|
||||
// the offer away.
|
||||
_state.value = ConversionState.Failed(e.message ?: SAVE_FAILED_MESSAGE, pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,8 +642,18 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
* cancelled with [viewModelScope] if the Activity finishes first, so it is a best
|
||||
* effort rather than a guarantee. `OutputPublisher.sweepStaging` is the backstop for
|
||||
* the times it does not run.
|
||||
*
|
||||
* **It still deletes from a [ConversionState.Failed] carrying a [PendingSave], and that is a
|
||||
* decision rather than something inherited.** Deletion is acceptable there only because the
|
||||
* alternative was offered first: the screen puts "Try saving again" directly above this
|
||||
* button, so reaching it is the user saying the work is not worth keeping. Until that button
|
||||
* existed, this delete was the only thing a failed save could lead to — which was the defect.
|
||||
*/
|
||||
fun reset() {
|
||||
// Start over is a claim like any other. The cancel below is a request honoured at the next
|
||||
// suspension point, so a collector already on its way to a write has nothing left to
|
||||
// honour it at; the claim is what actually stops that write landing on top of Idle.
|
||||
ownership.claim()
|
||||
observer?.cancel()
|
||||
observer = null
|
||||
activeWorkId = null
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -51,6 +52,7 @@ import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.ui.PrimaryButtonHeight
|
||||
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
||||
import org.libremediaconverter.ui.ScreenPaddingVertical
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import java.util.Locale
|
||||
|
||||
@UnstableApi
|
||||
@@ -71,8 +73,11 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
// stands: some providers rewrite a document's extension to match it, so an MP3 offered as
|
||||
// video/webm can arrive with the wrong one. Read straight off the collected state, so this
|
||||
// recomposes because it depends on that rather than because an unrelated line happens to.
|
||||
// Through pendingSave() rather than a cast to Converted, so a retry offered after a failed
|
||||
// save opens the dialog with the type its first attempt used -- the cast answered null for a
|
||||
// Failed, and the fallback below is the current picker, which a reattached job never set.
|
||||
// Remembered against the type so the launcher re-registers only when it actually changes.
|
||||
val destinationMime = (state as? ConversionState.Converted)?.mimeType ?: settings.spec.mimeType
|
||||
val destinationMime = state.pendingSave()?.mimeType ?: settings.spec.mimeType
|
||||
val chooseDestination = rememberLauncherForActivityResult(
|
||||
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
@@ -85,6 +90,126 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { viewModel.convert() }
|
||||
|
||||
ConverterScreenContent(
|
||||
state = state,
|
||||
settings = settings,
|
||||
validation = validation,
|
||||
actions = converterActions(
|
||||
viewModel = viewModel,
|
||||
onPickInput = { pickInput.launch(arrayOf("*/*")) },
|
||||
onConvert = { requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) },
|
||||
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the ViewModel's methods each affordance on the screen calls.
|
||||
*
|
||||
* ## Why this is a function rather than an argument list
|
||||
*
|
||||
* It was an argument list, inside [ConverterScreen], which no test reached: `ConverterScreenContent`
|
||||
* builds its own [ConverterActions], so every test in the suite drove the stateless inner and none
|
||||
* of them ever saw the wiring.
|
||||
*
|
||||
* Most of the list is safe without a test, and saying so is more useful than pretending otherwise:
|
||||
* `onContainer`, `onVideoCodec`, `onAudioCodec`, `onPreset`, `onSuggestion`, `onQuality` and
|
||||
* `onEnginePreference` each take a distinct type, so binding one to another's setter does not
|
||||
* compile. Verified rather than assumed — swapping `onVideoCodec` and `onAudioCodec` fails with
|
||||
* *"Inapplicable candidate(s): fun setAudioCodec(codec: AudioCodec)"*.
|
||||
*
|
||||
* **[ConverterActions.onCancel] and [ConverterActions.onReset] are the exception.** Both are
|
||||
* `() -> Unit`, so swapping them compiles silently — also verified — and ships a Cancel button that
|
||||
* throws the conversion away and a Start-over button that leaves it on screen. That pair is what
|
||||
* `ConverterWiringTest` exists for.
|
||||
*
|
||||
* The three launcher-backed actions stay parameters: they need an `ActivityResultLauncher`, which
|
||||
* is the part that genuinely needs the composition, and keeping them out means the rest can be
|
||||
* checked without one.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal fun converterActions(
|
||||
viewModel: ConversionViewModel,
|
||||
onPickInput: () -> Unit,
|
||||
onConvert: () -> Unit,
|
||||
onSave: (suggestedName: String) -> Unit,
|
||||
): ConverterActions = ConverterActions(
|
||||
onPickInput = onPickInput,
|
||||
onPreset = viewModel::setPreset,
|
||||
onContainer = viewModel::setContainer,
|
||||
onVideoCodec = viewModel::setVideoCodec,
|
||||
onAudioCodec = viewModel::setAudioCodec,
|
||||
onSuggestion = viewModel::applySuggestion,
|
||||
onQuality = viewModel::setQuality,
|
||||
onEnginePreference = viewModel::setEnginePreference,
|
||||
onConvert = onConvert,
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = onSave,
|
||||
onReset = viewModel::reset,
|
||||
)
|
||||
|
||||
/**
|
||||
* Everything [ConverterScreenContent] can ask for, in one value.
|
||||
*
|
||||
* A holder rather than twelve parameters because detekt's `LongParameterList` sits at its default
|
||||
* threshold of six and `config/detekt/detekt.yml` does not relax it for `@Composable` the way it
|
||||
* relaxes `LongMethod` and `CyclomaticComplexMethod` -- `AdvancedPicker` already sits exactly on
|
||||
* that threshold. The rule exempts data classes, so the callbacks travel together.
|
||||
*
|
||||
* In production every one of these is a launcher or a `ConversionViewModel` call. Naming them here
|
||||
* instead of handing the content a ViewModel is the whole point of the seam: a test can render a
|
||||
* [ConversionState] no ViewModel can be driven into, since `Waiting` needs a denied foreground
|
||||
* start and `Converted` needs a worker run that has already succeeded.
|
||||
*/
|
||||
internal data class ConverterActions(
|
||||
/** Open the document picker. The `Idle` and `Ready` branches both offer it. */
|
||||
val onPickInput: () -> Unit,
|
||||
val onPreset: (OutputFormat) -> Unit,
|
||||
val onContainer: (Container) -> Unit,
|
||||
val onVideoCodec: (VideoCodec) -> Unit,
|
||||
val onAudioCodec: (AudioCodec) -> Unit,
|
||||
val onSuggestion: (OutputSpec) -> Unit,
|
||||
val onQuality: (QualityTier) -> Unit,
|
||||
val onEnginePreference: (EnginePreference) -> Unit,
|
||||
/**
|
||||
* Start the job. It asks for the notification permission first, which is why the screen never
|
||||
* calls `convert` directly -- the launcher's result callback does, whichever way it went.
|
||||
*/
|
||||
val onConvert: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
/**
|
||||
* Open the save dialog for the finished output.
|
||||
*
|
||||
* Takes the suggested name rather than reading it back off the state, because the name comes
|
||||
* from the job -- see `ConversionWorker.KEY_SUGGESTED_NAME` -- and the branch that renders the
|
||||
* button is the only place that has it.
|
||||
*/
|
||||
val onSave: (suggestedName: String) -> Unit,
|
||||
val onReset: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The converter screen, with its state handed in.
|
||||
*
|
||||
* Split from [ConverterScreen] so that state has somewhere to come from other than a live
|
||||
* `ConversionViewModel`. Driving the screen through a real one needs a `WorkManager` and a media
|
||||
* probe in the constructor, and even then two of the six states are unreachable: `Waiting` follows
|
||||
* a denied foreground start and `Converted` follows a completed worker.
|
||||
*
|
||||
* `internal` rather than private, because `src/test` is a friend of `main` and this is what the
|
||||
* state tests compose. The leaves below stay exactly where they were -- this function is a move,
|
||||
* not a redesign, and the tests that already pin those leaves are what says so.
|
||||
*/
|
||||
@UnstableApi
|
||||
@Composable
|
||||
internal fun ConverterScreenContent(
|
||||
state: ConversionState,
|
||||
settings: ConversionSettings,
|
||||
validation: Validation,
|
||||
actions: ConverterActions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -113,10 +238,11 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { pickInput.launch(arrayOf("*/*")) },
|
||||
onClick = actions.onPickInput,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight),
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.Converter.CHOOSE_FILE),
|
||||
) { Text("Choose file") }
|
||||
}
|
||||
|
||||
@@ -129,29 +255,32 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
|
||||
is ConversionState.Ready -> {
|
||||
FileCard(s.input)
|
||||
FormatPicker(settings.matchingPreset, viewModel::setPreset)
|
||||
FormatPicker(settings.matchingPreset, actions.onPreset)
|
||||
AdvancedPicker(
|
||||
spec = settings.spec,
|
||||
validation = validation,
|
||||
onContainer = viewModel::setContainer,
|
||||
onVideoCodec = viewModel::setVideoCodec,
|
||||
onAudioCodec = viewModel::setAudioCodec,
|
||||
onSuggestion = viewModel::applySuggestion,
|
||||
onContainer = actions.onContainer,
|
||||
onVideoCodec = actions.onVideoCodec,
|
||||
onAudioCodec = actions.onAudioCodec,
|
||||
onSuggestion = actions.onSuggestion,
|
||||
)
|
||||
QualityPicker(settings.quality, viewModel::setQuality)
|
||||
EnginePicker(settings.enginePreference, viewModel::setEnginePreference)
|
||||
QualityPicker(settings.quality, actions.onQuality)
|
||||
EnginePicker(settings.enginePreference, actions.onEnginePreference)
|
||||
Button(
|
||||
onClick = {
|
||||
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
},
|
||||
onClick = actions.onConvert,
|
||||
// The Advanced picker lets an impossible combination be selected on
|
||||
// purpose, so this is what stops it from being run.
|
||||
enabled = validation.isValid,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.Converter.CONVERT),
|
||||
) { Text("Convert") }
|
||||
OutlinedButton(
|
||||
onClick = { pickInput.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = actions.onPickInput,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE),
|
||||
) { Text("Choose a different file") }
|
||||
}
|
||||
|
||||
@@ -160,11 +289,13 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
Text("Converting… ${s.percent}%")
|
||||
LinearProgressIndicator(
|
||||
progress = { s.percent / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Converter.PROGRESS),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
|
||||
@@ -182,8 +313,8 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
|
||||
@@ -198,23 +329,33 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
// explains why a job was slow, makes the software fallback
|
||||
// visible, and is how the user learns a remux happened rather
|
||||
// than a re-encode.
|
||||
AssistChip(onClick = {}, label = { Text(s.routeReason) })
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(s.routeReason) },
|
||||
modifier = Modifier.testTag(TestTags.Converter.ROUTE_REASON),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { chooseDestination.launch(s.suggestedName) },
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
onClick = { actions.onSave(s.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.SAVE_FILE),
|
||||
) { Text("Save file") }
|
||||
OutlinedButton(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
|
||||
is ConversionState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.Converter.CONVERT_ANOTHER),
|
||||
) { Text("Convert another") }
|
||||
}
|
||||
|
||||
@@ -224,10 +365,36 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Start over") }
|
||||
val retry = s.retry
|
||||
if (retry == null) {
|
||||
// Nothing was staged, so "Start over" is the whole of what is on
|
||||
// offer and stays the primary button.
|
||||
Button(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
} else {
|
||||
Button(
|
||||
onClick = { actions.onSave(retry.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.RETRY_SAVE),
|
||||
) { Text("Try saving again") }
|
||||
// Start over still deletes the file this state is carrying, and that
|
||||
// is deliberate: `reset()` is what stops a full-size output sitting in
|
||||
// cache until the sweep. What makes the delete acceptable is the
|
||||
// button above it. Deletion is the user's choice only once the
|
||||
// alternative has been offered -- and until that button existed, this
|
||||
// one was the only thing a failed save could lead to.
|
||||
OutlinedButton(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,9 +404,12 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Unit) {
|
||||
internal fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Unit) {
|
||||
Text("Output format", style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag(TestTags.Converter.FORMAT_CHIPS),
|
||||
) {
|
||||
OutputFormat.entries.forEach { format ->
|
||||
FilterChip(
|
||||
selected = format == selected,
|
||||
@@ -267,7 +437,7 @@ private fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Un
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun AdvancedPicker(
|
||||
internal fun AdvancedPicker(
|
||||
spec: OutputSpec,
|
||||
validation: Validation,
|
||||
onContainer: (Container) -> Unit,
|
||||
@@ -277,14 +447,23 @@ private fun AdvancedPicker(
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
TextButton(onClick = { expanded = !expanded }) {
|
||||
TextButton(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_TOGGLE),
|
||||
) {
|
||||
Text(if (expanded) "Hide advanced" else "Advanced")
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_PANEL),
|
||||
) {
|
||||
Text("Container", style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_CONTAINER_CHIPS),
|
||||
) {
|
||||
Container.entries.forEach { container ->
|
||||
FilterChip(
|
||||
selected = container == spec.container,
|
||||
@@ -295,7 +474,10 @@ private fun AdvancedPicker(
|
||||
}
|
||||
|
||||
Text("Video", style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_VIDEO_CHIPS),
|
||||
) {
|
||||
VideoCodec.entries.forEach { codec ->
|
||||
FilterChip(
|
||||
selected = codec == spec.videoCodec,
|
||||
@@ -306,7 +488,10 @@ private fun AdvancedPicker(
|
||||
}
|
||||
|
||||
Text("Audio", style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_AUDIO_CHIPS),
|
||||
) {
|
||||
AudioCodec.entries.forEach { codec ->
|
||||
FilterChip(
|
||||
selected = codec == spec.audioCodec,
|
||||
@@ -331,9 +516,11 @@ private fun AdvancedPicker(
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSpec) -> Unit) {
|
||||
internal fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSpec) -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Converter.VALIDATION_ERROR),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
@@ -347,10 +534,11 @@ private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSp
|
||||
if (invalid.suggestions.isNotEmpty()) {
|
||||
Text("Try instead:", style = MaterialTheme.typography.labelMedium)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
invalid.suggestions.forEach { suggestion ->
|
||||
invalid.suggestions.forEachIndexed { index, suggestion ->
|
||||
AssistChip(
|
||||
onClick = { onSuggestion(suggestion) },
|
||||
label = { Text(describe(suggestion)) },
|
||||
modifier = Modifier.testTag(TestTags.Converter.suggestion(index)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -359,7 +547,7 @@ private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSp
|
||||
}
|
||||
}
|
||||
|
||||
private fun describe(spec: OutputSpec): String {
|
||||
internal fun describe(spec: OutputSpec): String {
|
||||
val video = when (spec.videoCodec) {
|
||||
VideoCodec.NONE -> null
|
||||
else -> spec.videoCodec.label
|
||||
@@ -374,9 +562,12 @@ private fun describe(spec: OutputSpec): String {
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
|
||||
internal fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
|
||||
Text("Quality", style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag(TestTags.Converter.QUALITY_CHIPS),
|
||||
) {
|
||||
QualityTier.entries.forEach { tier ->
|
||||
FilterChip(
|
||||
selected = tier == selected,
|
||||
@@ -390,9 +581,12 @@ private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
|
||||
internal fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
|
||||
Text("Engine", style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag(TestTags.Converter.ENGINE_CHIPS),
|
||||
) {
|
||||
EnginePreference.entries.forEach { preference ->
|
||||
FilterChip(
|
||||
selected = preference == selected,
|
||||
@@ -403,7 +597,7 @@ private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference
|
||||
}
|
||||
}
|
||||
|
||||
private fun EnginePreference.label(): String = when (this) {
|
||||
internal fun EnginePreference.label(): String = when (this) {
|
||||
EnginePreference.AUTO -> "Automatic"
|
||||
EnginePreference.PREFER_HARDWARE -> "Prefer hardware"
|
||||
EnginePreference.FORCE_SOFTWARE -> "Force software"
|
||||
@@ -418,21 +612,34 @@ private fun EnginePreference.label(): String = when (this) {
|
||||
* pretending it has an unknown codec.
|
||||
*/
|
||||
@Composable
|
||||
private fun FileCard(input: InputFile) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
internal fun FileCard(input: InputFile) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Converter.FILE_CARD),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(input.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
input.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NAME),
|
||||
)
|
||||
// The null is handled here rather than inside formatBytes, because "no provider would
|
||||
// say" is not a number and a formatter that invented one -- "0 B" -- is the defect
|
||||
// this card would be showing. It degrades in words, like the codec rows below it.
|
||||
Text(
|
||||
input.sizeBytes?.let(::formatBytes) ?: "Size unknown",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_BYTES),
|
||||
)
|
||||
|
||||
val probe = input.probe
|
||||
if (probe == null) {
|
||||
Text("Reading…", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"Reading…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NOTE),
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
@@ -442,6 +649,7 @@ private fun FileCard(input: InputFile) {
|
||||
InputKind.UNPARSEABLE -> Text(
|
||||
"Could not identify this file. It will be converted with FFmpeg.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NOTE),
|
||||
)
|
||||
|
||||
InputKind.IMAGE -> {
|
||||
@@ -481,22 +689,23 @@ private fun FileCard(input: InputFile) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailRow(label: String, value: String) {
|
||||
internal fun DetailRow(label: String, value: String) {
|
||||
Text(
|
||||
"$label: $value",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.testTag(TestTags.Converter.detailRow(label)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatDuration(ms: Long): String {
|
||||
internal fun formatDuration(ms: Long): String {
|
||||
val totalSeconds = ms / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return String.format(Locale.US, "%d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String = when {
|
||||
internal fun formatBytes(bytes: Long): String = when {
|
||||
bytes >= 1_000_000_000 -> String.format(Locale.US, "%.1f GB", bytes / 1e9)
|
||||
bytes >= 1_000_000 -> String.format(Locale.US, "%.1f MB", bytes / 1e6)
|
||||
bytes >= 1_000 -> String.format(Locale.US, "%.0f kB", bytes / 1e3)
|
||||
|
||||
@@ -68,42 +68,64 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
): Unit = suspendCancellableCoroutine { cont ->
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
handler.post {
|
||||
val transformer = runCatching { buildTransformer(plan, cont) }
|
||||
.getOrElse {
|
||||
cont.resumeWithException(it)
|
||||
return@post
|
||||
}
|
||||
|
||||
// Dropping the tracks the target does not have is what stops an audio-only export
|
||||
// from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A
|
||||
// produced an HEVC stream in a file named .m4a.
|
||||
val item = EditedMediaItem.Builder(MediaItem.fromUri(input))
|
||||
.setRemoveVideo(plan.video == VideoPlan.Drop)
|
||||
.setRemoveAudio(plan.audio == AudioPlan.Drop)
|
||||
.build()
|
||||
|
||||
// A Composition is the only way to ask for transmuxing; the plain
|
||||
// start(EditedMediaItem, path) overload always re-encodes. This is the remux path.
|
||||
val composition = Composition.Builder(EditedMediaItemSequence.Builder(item).build())
|
||||
.setTransmuxVideo(plan.video == VideoPlan.Copy)
|
||||
.setTransmuxAudio(plan.audio == AudioPlan.Copy)
|
||||
.build()
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
// cancel() has the same single-thread requirement as start().
|
||||
handler.post { runCatching { transformer.cancel() } }
|
||||
}
|
||||
|
||||
runCatching { transformer.start(composition, output.absolutePath) }
|
||||
.onFailure {
|
||||
cont.resumeWithException(it)
|
||||
return@post
|
||||
}
|
||||
|
||||
pollProgress(transformer, cont, onProgress)
|
||||
// One guard around the whole body, deliberately.
|
||||
//
|
||||
// This used to be two narrow ones — around `buildTransformer` and around
|
||||
// `transformer.start` — with the two Media3 builders sitting unguarded between them.
|
||||
// On this thread that is not a small gap: nothing here has a caller to throw back to,
|
||||
// so an escaping exception reaches the HandlerThread's uncaught handler and takes the
|
||||
// process down, while [cont] is never resumed either way. `EditedMediaItem.Builder`
|
||||
// does exactly that for a plan that drops both tracks
|
||||
// ("Audio and video cannot both be removed"), which a queued job can still carry.
|
||||
// Widening the guard costs nothing on success and turns every such refusal into a
|
||||
// failed job with a reason.
|
||||
runCatching { startExport(input, output, plan, cont, onProgress) }
|
||||
.onFailure { if (cont.isActive) cont.resumeWithException(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the export and hands it to Transformer. Runs on the HandlerThread; may throw.
|
||||
*
|
||||
* Everything Transformer's single-thread contract covers lives here, so that the caller has
|
||||
* exactly one place to catch. Returning normally means the export is running and [cont] belongs
|
||||
* to the listener; throwing means it never started and the caller owns resuming.
|
||||
*/
|
||||
private fun startExport(
|
||||
input: Uri,
|
||||
output: File,
|
||||
plan: ConversionPlan,
|
||||
cont: CancellableContinuation<Unit>,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
val transformer = buildTransformer(plan, cont)
|
||||
|
||||
// Dropping the tracks the target does not have is what stops an audio-only export
|
||||
// from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A
|
||||
// produced an HEVC stream in a file named .m4a.
|
||||
val item = EditedMediaItem.Builder(MediaItem.fromUri(input))
|
||||
.setRemoveVideo(plan.video == VideoPlan.Drop)
|
||||
.setRemoveAudio(plan.audio == AudioPlan.Drop)
|
||||
.build()
|
||||
|
||||
// A Composition is the only way to ask for transmuxing; the plain
|
||||
// start(EditedMediaItem, path) overload always re-encodes. This is the remux path.
|
||||
val composition = Composition.Builder(EditedMediaItemSequence.Builder(item).build())
|
||||
.setTransmuxVideo(plan.video == VideoPlan.Copy)
|
||||
.setTransmuxAudio(plan.audio == AudioPlan.Copy)
|
||||
.build()
|
||||
|
||||
// Registered before start(), so a cancellation racing the export always finds a
|
||||
// transformer to cancel.
|
||||
cont.invokeOnCancellation {
|
||||
// cancel() has the same single-thread requirement as start().
|
||||
handler.post { runCatching { transformer.cancel() } }
|
||||
}
|
||||
|
||||
transformer.start(composition, output.absolutePath)
|
||||
pollProgress(transformer, cont, onProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException if [plan] names a container Media3 cannot mux. That is a
|
||||
* routing bug rather than a runtime condition — [org.libremediaconverter.model.ConversionRouter]
|
||||
@@ -138,31 +160,6 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Media3 encodes only H.264 and H.265 of the codecs this app offers.
|
||||
*
|
||||
* VP8/VP9/AV1 targets never reach here — the router sends them to FFmpeg because
|
||||
* `Transformer.setVideoMimeType` rejects them — so anything unexpected returns null and lets
|
||||
* Transformer pick, rather than silently substituting H.265 the way the old mapping did.
|
||||
*/
|
||||
private fun videoMimeTypeFor(codec: VideoCodec): String? = when (codec) {
|
||||
VideoCodec.H264 -> MimeTypes.VIDEO_H264
|
||||
VideoCodec.H265 -> MimeTypes.VIDEO_H265
|
||||
// Never reached: only an Encode plan consults this, and COPY/NONE are not Encode.
|
||||
VideoCodec.COPY, VideoCodec.NONE -> null
|
||||
VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1 -> null
|
||||
}
|
||||
|
||||
private fun audioMimeTypeFor(codec: AudioCodec): String? = when (codec) {
|
||||
AudioCodec.AAC -> MimeTypes.AUDIO_AAC
|
||||
AudioCodec.OPUS -> MimeTypes.AUDIO_OPUS
|
||||
AudioCodec.VORBIS -> MimeTypes.AUDIO_VORBIS
|
||||
AudioCodec.PCM -> MimeTypes.AUDIO_RAW
|
||||
AudioCodec.COPY, AudioCodec.NONE -> null
|
||||
// MP3 and FLAC have no Android encoder; the router routes them to FFmpeg.
|
||||
AudioCodec.MP3, AudioCodec.FLAC -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls export progress on the Transformer's own thread.
|
||||
*
|
||||
@@ -192,7 +189,57 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* The progress interval, and the two enum-to-MIME tables.
|
||||
*
|
||||
* The tables are pure functions of a codec enum, so they sit here rather than on the instance:
|
||||
* a JVM test can then exercise every arm without constructing an engine, which would start a
|
||||
* real [HandlerThread] to answer a lookup. `internal` rather than `private` for the reason
|
||||
* `MainActivity`'s `Destination` records — the JVM test source set is a friend of `main`, so
|
||||
* these stay invisible to anything outside the module.
|
||||
*/
|
||||
internal companion object {
|
||||
const val PROGRESS_INTERVAL_MS = 250L
|
||||
|
||||
/**
|
||||
* Media3 encodes only H.264 and H.265 of the codecs this app offers.
|
||||
*
|
||||
* VP8/VP9/AV1 targets never reach here — the router sends them to FFmpeg because
|
||||
* `Transformer.setVideoMimeType` rejects them — so anything unexpected returns null and
|
||||
* lets Transformer pick, rather than silently substituting H.265 as the old mapping did.
|
||||
*/
|
||||
internal fun videoMimeTypeFor(codec: VideoCodec): String? = when (codec) {
|
||||
VideoCodec.H264 -> MimeTypes.VIDEO_H264
|
||||
VideoCodec.H265 -> MimeTypes.VIDEO_H265
|
||||
// Never reached, and no longer only asserted: `Media3EngineMimeTypesTest` drives
|
||||
// `CopyPlanner` over every spec it can be handed and shows that no Encode plan carries
|
||||
// either, which is what turns "COPY/NONE are not Encode" into a checked claim.
|
||||
VideoCodec.COPY, VideoCodec.NONE -> null
|
||||
VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1 -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Media3 encodes AAC, Opus and PCM. Three arms below are dead, not two.
|
||||
*
|
||||
* The comment this replaces named MP3 and FLAC as the exceptions, which reads as though
|
||||
* every other arm were live. **Vorbis is not.** A single router rule diverts every audio
|
||||
* codec outside {AAC, Opus, PCM} to FFmpeg, and Vorbis is outside it, so
|
||||
* `VORBIS -> AUDIO_VORBIS` names a MIME type Transformer is never actually asked for.
|
||||
*
|
||||
* The arm stays because the mapping is correct — deleting a right answer out of
|
||||
* unreachable code buys nothing — but it is an entry waiting on a routing change rather
|
||||
* than a live one. `Media3EngineMimeTypesTest` routes all six encodable codecs and asserts
|
||||
* which three arrive, so if that set moves, the disagreement fails rather than surprises.
|
||||
*/
|
||||
internal fun audioMimeTypeFor(codec: AudioCodec): String? = when (codec) {
|
||||
AudioCodec.AAC -> MimeTypes.AUDIO_AAC
|
||||
AudioCodec.OPUS -> MimeTypes.AUDIO_OPUS
|
||||
AudioCodec.VORBIS -> MimeTypes.AUDIO_VORBIS
|
||||
AudioCodec.PCM -> MimeTypes.AUDIO_RAW
|
||||
AudioCodec.COPY, AudioCodec.NONE -> null
|
||||
// MP3 and FLAC have no Android encoder at any API level, so the router sends them to
|
||||
// FFmpeg before an encoder is ever asked for.
|
||||
AudioCodec.MP3, AudioCodec.FLAC -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,12 @@ object MediaProbe {
|
||||
else -> InputKind.UNPARSEABLE
|
||||
}
|
||||
|
||||
private class Extracted(
|
||||
/**
|
||||
* `internal` rather than `private` so [extractedFrom] can be named from a test. The JVM test
|
||||
* source set is a friend of `main`, so this stays invisible outside the module — the precedent
|
||||
* is `MainActivity`'s `Destination`, and [containerFrom] beside it.
|
||||
*/
|
||||
internal class Extracted(
|
||||
val videoCodec: String?,
|
||||
val audioCodec: String?,
|
||||
val durationMs: Long,
|
||||
@@ -100,33 +105,55 @@ object MediaProbe {
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a set of track formats says about a file.
|
||||
*
|
||||
* Split out of [probeWithExtractor] so the rules below can be tested against tracks a test
|
||||
* *chooses*, rather than against whatever the committed fixtures happen to contain. The device
|
||||
* tests exercise this through real files; none of them can construct a two-video-track input,
|
||||
* a track that omits its duration, or an audio-before-video ordering on purpose.
|
||||
*
|
||||
* Three rules live here, and each is a decision rather than plumbing:
|
||||
*
|
||||
* - **First track of a type wins.** `video == null` is the whole guard. A file with two video
|
||||
* tracks must report the first, because that is the one an engine will transcode.
|
||||
* - **Duration is the maximum across tracks**, not the first one found or the last. A file
|
||||
* whose audio outlasts its video is ordinary, and reporting the video's length would cut the
|
||||
* progress bar short.
|
||||
* - **A track that omits `KEY_DURATION` contributes nothing** rather than zero. `MediaExtractor`
|
||||
* omits it for plenty of real tracks — see `MediaProbeTrackFieldsTest` — and `maxOf` against a
|
||||
* fabricated 0 would still be correct here, but reading a key that is absent is not.
|
||||
*/
|
||||
internal fun extractedFrom(formats: List<MediaFormat>): Extracted {
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var durationUs = 0L
|
||||
var width = 0
|
||||
var height = 0
|
||||
|
||||
for (format in formats) {
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (format.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
|
||||
}
|
||||
when {
|
||||
mime.startsWith("video/") && video == null -> {
|
||||
video = shortName(mime)
|
||||
width = format.intOr(MediaFormat.KEY_WIDTH)
|
||||
height = format.intOr(MediaFormat.KEY_HEIGHT)
|
||||
}
|
||||
|
||||
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
return Extracted(video, audio, durationUs / US_PER_MS, width, height)
|
||||
}
|
||||
|
||||
private fun probeWithExtractor(context: Context, uri: Uri): Extracted? {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var durationUs = 0L
|
||||
var width = 0
|
||||
var height = 0
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (format.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
|
||||
}
|
||||
when {
|
||||
mime.startsWith("video/") && video == null -> {
|
||||
video = shortName(mime)
|
||||
width = format.intOr(MediaFormat.KEY_WIDTH)
|
||||
height = format.intOr(MediaFormat.KEY_HEIGHT)
|
||||
}
|
||||
|
||||
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
Extracted(video, audio, durationUs / US_PER_MS, width, height)
|
||||
extractedFrom(extractor.trackFormats())
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Platform extractor could not read $uri.", e)
|
||||
null
|
||||
@@ -242,8 +269,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") }
|
||||
}
|
||||
@@ -257,25 +296,7 @@ object MediaProbe {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var width = 0
|
||||
var height = 0
|
||||
var fps = 0
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (mime.startsWith("video/") && video == null) {
|
||||
video = shortName(mime)
|
||||
width = format.intOr(MediaFormat.KEY_WIDTH)
|
||||
height = format.intOr(MediaFormat.KEY_HEIGHT)
|
||||
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
|
||||
} else if (mime.startsWith("audio/") && audio == null) {
|
||||
audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
ConcatInput(video, audio, width, height, fps)
|
||||
concatInputFrom(extractor.trackFormats())
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Could not probe $uri for concat; will re-encode.", e)
|
||||
ConcatInput(null, null, 0, 0, 0)
|
||||
@@ -284,11 +305,74 @@ object MediaProbe {
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
|
||||
/**
|
||||
* The join flow's read of the same track formats. See [extractedFrom] for why this is separate
|
||||
* from the extractor.
|
||||
*
|
||||
* Deliberately **not** folded into [extractedFrom] despite the overlap. This one reads frame
|
||||
* rate and does not read duration; that one reads duration and does not read frame rate. A
|
||||
* merged version would have to compute both for every caller, and `ConcatPlanner` treats an
|
||||
* unknown frame rate as "cannot prove a match" — so a field this flow does not need must not
|
||||
* start arriving as a number.
|
||||
*/
|
||||
internal fun concatInputFrom(formats: List<MediaFormat>): ConcatInput {
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var width = 0
|
||||
var height = 0
|
||||
var fps = 0
|
||||
|
||||
for (format in formats) {
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (mime.startsWith("video/") && video == null) {
|
||||
video = shortName(mime)
|
||||
width = format.intOr(MediaFormat.KEY_WIDTH)
|
||||
height = format.intOr(MediaFormat.KEY_HEIGHT)
|
||||
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
|
||||
} else if (mime.startsWith("audio/") && audio == null) {
|
||||
audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
return ConcatInput(video, audio, width, height, fps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every track format this extractor holds, read once.
|
||||
*
|
||||
* The thin edge the two pure functions above leave behind: a `trackCount` and a
|
||||
* `getTrackFormat` per index, which is the whole of what needs a real `MediaExtractor`.
|
||||
*/
|
||||
private fun MediaExtractor.trackFormats(): List<MediaFormat> = (0 until trackCount).map(::getTrackFormat)
|
||||
|
||||
/**
|
||||
* One track property as an Int, or [fallback] when the format has no Int to give.
|
||||
*
|
||||
* `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"
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* What a save has to say when the staged file is not there any more.
|
||||
@@ -23,6 +24,38 @@ const val STAGED_FILE_GONE_MESSAGE: String =
|
||||
"The finished file is no longer in the cache, so there is nothing left to save. " +
|
||||
"Start over to make it again."
|
||||
|
||||
/**
|
||||
* What to tell the user when the copy into their chosen destination did not finish.
|
||||
*
|
||||
* A fallback, not the usual message: `publish` throws with a real reason most of the time — the
|
||||
* volume filled, the provider revoked the grant — and that reason is better than this. This is for
|
||||
* the exception that arrives with nothing to say, which would otherwise reach the screen as an
|
||||
* empty failure.
|
||||
*
|
||||
* Kept next to [STAGED_FILE_GONE_MESSAGE] for exactly the reason that one names: **both ViewModels
|
||||
* need it**, and saving is what it is about. It was written out twice before — `ConversionViewModel`
|
||||
* and `JoinViewModel` each carried their own copy of the literal, agreeing by coincidence.
|
||||
*/
|
||||
const val SAVE_FAILED_MESSAGE: String = "Could not save the file."
|
||||
|
||||
/**
|
||||
* A staged file that is still there to be saved, and everything the save dialog needs to offer it.
|
||||
*
|
||||
* The three travel together because a save cannot be repeated without all of them: the file to
|
||||
* copy, the name to suggest, and the MIME type `CreateDocument` has to be registered with. None of
|
||||
* them can be rederived from the pickers once the job is over -- they come from the job's own
|
||||
* output `Data`, and a reattached job's spec was never in the current settings at all.
|
||||
*
|
||||
* Kept next to [STAGED_FILE_GONE_MESSAGE] for the same reason it is: both ViewModels need it and
|
||||
* staging is what it is about.
|
||||
*
|
||||
* **A view of the staged file, never an owner of it.** The delete still runs through each
|
||||
* ViewModel's own `pendingStaged` field, so a state carrying one of these can be dropped without
|
||||
* losing the only reference -- which is what keeps "a `Failed` that carries a file" from being a
|
||||
* new way to leak one.
|
||||
*/
|
||||
data class PendingSave(val staged: File, val suggestedName: String, val mimeType: String)
|
||||
|
||||
/**
|
||||
* Staging and publication of conversion output.
|
||||
*
|
||||
@@ -152,7 +185,7 @@ open class OutputPublisher(private val context: Context) {
|
||||
open fun publish(staged: File, destination: Uri) {
|
||||
val destinationWasEmpty = destinationIsKnownEmpty(destination)
|
||||
try {
|
||||
val out = context.contentResolver.openOutputStream(destination)
|
||||
val out = openDestination(destination)
|
||||
?: error("Could not open destination for writing: $destination")
|
||||
out.use { sink -> staged.inputStream().use { source -> source.copyTo(sink) } }
|
||||
} catch (failure: Throwable) {
|
||||
@@ -161,6 +194,22 @@ open class OutputPublisher(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens [destination] for writing, or null when the provider will not.
|
||||
*
|
||||
* A seam, and a narrow one: it exists because `openOutputStream` has **two** ways of refusing
|
||||
* and only one of them is reachable from a test otherwise. A provider that has gone away throws
|
||||
* `FileNotFoundException` from inside the call; a provider that is present and declines returns
|
||||
* null. The two are not interchangeable here — the `?: error(...)` above is the only thing that
|
||||
* turns the second into a failure rather than an NPE further down — and no fake provider can be
|
||||
* asked to produce a null return on demand.
|
||||
*
|
||||
* `protected open` rather than injected, matching `hasSpaceFor` and `createStagingFile`:
|
||||
* `WorkerStubs.kt`'s publishers already override one method to force one condition.
|
||||
*/
|
||||
protected open fun openDestination(destination: Uri): OutputStream? =
|
||||
context.contentResolver.openOutputStream(destination)
|
||||
|
||||
/**
|
||||
* True only when the destination is *positively known* to hold no bytes yet.
|
||||
*
|
||||
@@ -238,7 +287,7 @@ open class OutputPublisher(private val context: Context) {
|
||||
open fun sweepStaging(nowMs: Long = System.currentTimeMillis()) {
|
||||
val dir = stagingDir
|
||||
val listing = dir.listFiles() ?: return
|
||||
val entries = listing.map { StagingSweep.Entry(it.name, it.lastModified()) }
|
||||
val entries = snapshot(listing)
|
||||
StagingSweep.collectable(entries, nowMs).forEach { name ->
|
||||
val file = File(dir, name)
|
||||
// Re-read the timestamp rather than trusting the snapshot above. Between the
|
||||
@@ -250,6 +299,22 @@ open class OutputPublisher(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The name and age of everything [sweepStaging] found, read once.
|
||||
*
|
||||
* A seam for the *race*, not for the clock — [sweepStaging] already takes `nowMs`, so the clock
|
||||
* is the caller's. What has no seam otherwise is the window between this snapshot and the
|
||||
* per-file re-read below it, and that window is the entire reason the re-read exists.
|
||||
*
|
||||
* **It has to be here and not around `listFiles()`.** A test that changes a file before the
|
||||
* listing, or during it, changes what `StagingSweep.collectable` is given — so the file is
|
||||
* never proposed for deletion and the re-read is never reached. The race being modelled is a
|
||||
* file that *was* collectable when the snapshot was taken and is not by the time the delete
|
||||
* comes round, which is exactly one worker resuming in this same process.
|
||||
*/
|
||||
protected open fun snapshot(listing: Array<File>): List<StagingSweep.Entry> =
|
||||
listing.map { StagingSweep.Entry(it.name, it.lastModified()) }
|
||||
|
||||
private fun File.canonicalOrAbsolute(): File = runCatching { canonicalFile }.getOrDefault(absoluteFile)
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
/**
|
||||
* Which of the things writing to a screen is still allowed to.
|
||||
*
|
||||
* Both ViewModels are a state machine written to from several coroutines that each suspend before
|
||||
* they write: a pick hops to a dispatcher for the metadata query, a reattachment hops for the tag
|
||||
* query, and an observation of a WorkManager job cannot write at all until its `collect` has
|
||||
* resumed with a `WorkInfo`. Whoever resumes last wins, which is how issue #49 let a finished job
|
||||
* from an earlier session take a screen the user had already picked a file on.
|
||||
*
|
||||
* The rule this makes enforceable is one line: **every write that lands after a suspension point
|
||||
* checks the claim it was made under, and drops itself if that claim has been superseded.** The
|
||||
* claim is taken synchronously, when the user acts; the check happens immediately before the
|
||||
* write. Superseded work is *dropped*, not reordered — a dropped write cannot come back later.
|
||||
*
|
||||
* Cancelling the superseded coroutine is not a substitute and was never going to be. `Job.cancel`
|
||||
* is a request, honoured at the next suspension point; a collector that has already resumed and is
|
||||
* on its way to `_state.value = …` has no suspension point left to honour it at, so the write
|
||||
* lands anyway. Cancellation also cannot help at all in the case #49 actually reported, where
|
||||
* nothing supersedes the observation until after it has been launched. Both ViewModels still
|
||||
* cancel their old observer, because leaving a collector running is a leak — but the guarantee
|
||||
* does not rest on it.
|
||||
*
|
||||
* **Confined to the main dispatcher, and that confinement is the atomicity argument.** Every
|
||||
* claim and every check runs there, with no suspension point between a check and the write it
|
||||
* guards, so a claim can never land between the two. Nothing here is synchronized and nothing is
|
||||
* `@Volatile`: making the field visible across threads would invite exactly the off-main use this
|
||||
* cannot support, and would replace an argument that holds with one that only looks like it does.
|
||||
*/
|
||||
internal class ScreenOwnership {
|
||||
|
||||
private var claims = 0L
|
||||
|
||||
/**
|
||||
* The claim in force now.
|
||||
*
|
||||
* Read by work that is about to suspend and will want to know, when it comes back, whether
|
||||
* the screen it was reading is still the screen it is writing to. Read it *before* the
|
||||
* suspension, not after — reading it afterwards would return whatever claim superseded it,
|
||||
* which is the bug rather than the check for it.
|
||||
*/
|
||||
val current: Long get() = claims
|
||||
|
||||
/**
|
||||
* Takes the screen, invalidating every write still in flight under an older claim.
|
||||
*
|
||||
* Called synchronously from the user's action rather than from inside the coroutine it
|
||||
* starts. A claim made inside a `launch` is only immediate while the dispatcher happens to
|
||||
* run it inline, and a deferred claim is no claim at all: it would leave the same gap this
|
||||
* exists to close.
|
||||
*/
|
||||
fun claim(): Long = ++claims
|
||||
|
||||
/** Whether [token] is still the claim in force, and may therefore write. */
|
||||
fun stillHeldBy(token: Long): Boolean = token == claims
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -31,6 +32,7 @@ import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.PrimaryButtonHeight
|
||||
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
||||
import org.libremediaconverter.ui.ScreenPaddingVertical
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
|
||||
@UnstableApi
|
||||
@@ -44,13 +46,75 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
|
||||
// The contract's MIME type comes from the finished job rather than from a literal: some
|
||||
// providers rewrite a document's extension to match it, so naming MP4 for a join that is not
|
||||
// one can hand the user a file the extension lies about. Remembered against that type so the
|
||||
// launcher re-registers only when it actually changes.
|
||||
val destinationMime = (state as? JoinState.Joined)?.mimeType ?: ConcatWorker.DEFAULT_FORMAT.mimeType
|
||||
// one can hand the user a file the extension lies about. Through pendingSave() rather than a
|
||||
// cast to Joined, so a retry after a failed save opens with the type its first attempt used.
|
||||
// Remembered against that type so the launcher re-registers only when it actually changes.
|
||||
val destinationMime = state.pendingSave()?.mimeType ?: ConcatWorker.DEFAULT_FORMAT.mimeType
|
||||
val chooseDestination = rememberLauncherForActivityResult(
|
||||
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = joinActions(
|
||||
viewModel = viewModel,
|
||||
onPickInputs = { pickInputs.launch(arrayOf("video/*")) },
|
||||
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the ViewModel's methods each affordance on the join screen calls.
|
||||
*
|
||||
* The join-side twin of `converterActions`, and the transposition risk here is worse: **three**
|
||||
* `() -> Unit` bindings rather than two. `onJoin`, `onCancel` and `onReset` are mutually
|
||||
* interchangeable as far as the compiler is concerned, so a Join button that cancels, or a Cancel
|
||||
* button that starts the job, is a swap nothing but a test would catch.
|
||||
*
|
||||
* See `converterActions` for why the launcher-backed actions stay parameters.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal fun joinActions(
|
||||
viewModel: JoinViewModel,
|
||||
onPickInputs: () -> Unit,
|
||||
onSave: (suggestedName: String) -> Unit,
|
||||
): JoinActions = JoinActions(
|
||||
onPickInputs = onPickInputs,
|
||||
onJoin = viewModel::join,
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = onSave,
|
||||
onReset = viewModel::reset,
|
||||
)
|
||||
|
||||
/**
|
||||
* Everything [JoinScreenContent] can ask for, in one value.
|
||||
*
|
||||
* Five callbacks would fit under detekt's `LongParameterList` threshold, unlike the converter's
|
||||
* twelve. It is a holder anyway, so both screens present the same shape to the state tests and
|
||||
* neither one has to be reworked the first time a branch grows a button.
|
||||
*/
|
||||
internal data class JoinActions(
|
||||
/** Open the multi-document picker. The `Idle` and `Ready` branches both offer it. */
|
||||
val onPickInputs: () -> Unit,
|
||||
val onJoin: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
/** Open the save dialog. Takes the name the job chose -- see `ConcatWorker.KEY_SUGGESTED_NAME`. */
|
||||
val onSave: (suggestedName: String) -> Unit,
|
||||
val onReset: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The join screen, with its state handed in.
|
||||
*
|
||||
* The same split as [org.libremediaconverter.convert.ConverterScreenContent], for the same reason:
|
||||
* `JoinState.Waiting` follows a denied foreground start and `JoinState.Joined` follows a completed
|
||||
* concatenation, so neither is reachable by driving a real `JoinViewModel`.
|
||||
*/
|
||||
@UnstableApi
|
||||
@Composable
|
||||
internal fun JoinScreenContent(state: JoinState, actions: JoinActions, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -79,10 +143,11 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
onClick = actions.onPickInputs,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight),
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.Join.CHOOSE_FILES),
|
||||
) { Text("Choose files") }
|
||||
}
|
||||
|
||||
@@ -96,12 +161,17 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
is JoinState.Ready -> {
|
||||
s.inputs.forEach { FileRow(it) }
|
||||
Button(
|
||||
onClick = viewModel::join,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
onClick = actions.onJoin,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.Join.JOIN),
|
||||
) { Text("Join ${s.inputs.size} files") }
|
||||
OutlinedButton(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = actions.onPickInputs,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Join.CHOOSE_DIFFERENT_FILES),
|
||||
) { Text("Choose different files") }
|
||||
}
|
||||
|
||||
@@ -110,10 +180,14 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
// Indeterminate on purpose: FFmpeg reports progress against a
|
||||
// single input's duration, which means nothing across a
|
||||
// concatenation. A fabricated percentage would be worse than none.
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Join.PROGRESS),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
|
||||
@@ -126,8 +200,8 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = actions.onCancel,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
|
||||
@@ -145,20 +219,26 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(
|
||||
onClick = { chooseDestination.launch(s.suggestedName) },
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
onClick = { actions.onSave(s.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.SAVE_FILE),
|
||||
) { Text("Save file") }
|
||||
OutlinedButton(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
|
||||
is JoinState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.Join.JOIN_MORE),
|
||||
) { Text("Join more") }
|
||||
}
|
||||
|
||||
@@ -168,10 +248,32 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Start over") }
|
||||
val retry = s.retry
|
||||
if (retry == null) {
|
||||
// Nothing staged, so "Start over" is all there is and stays primary.
|
||||
Button(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
} else {
|
||||
Button(
|
||||
onClick = { actions.onSave(retry.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.RETRY_SAVE),
|
||||
) { Text("Try saving again") }
|
||||
// Start over still deletes the carried file, for the reason the
|
||||
// converter screen writes out next to the same pair of buttons:
|
||||
// the delete is a choice only once the alternative is on screen.
|
||||
OutlinedButton(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,8 +282,12 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileRow(input: InputFile) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
internal fun FileRow(input: InputFile) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.Join.fileRow(input.displayName)),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(input.displayName, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -18,7 +19,10 @@ import kotlinx.coroutines.withContext
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.convert.InputQuery
|
||||
import org.libremediaconverter.convert.PendingSave
|
||||
import org.libremediaconverter.convert.SAVE_FAILED_MESSAGE
|
||||
import org.libremediaconverter.convert.STAGED_FILE_GONE_MESSAGE
|
||||
import org.libremediaconverter.convert.ScreenOwnership
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.JobTags
|
||||
@@ -27,6 +31,108 @@ import org.libremediaconverter.work.jobSnapshots
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* One update about a running join, as WorkManager last reported it.
|
||||
*
|
||||
* The join-side twin of `ConversionUpdate`, and deliberately the same shape: this pair of seams is
|
||||
* one refactor done twice, and letting them diverge would make the two flows harder to compare than
|
||||
* the duplication costs. There is no `progressPercent` here because `ConcatWorker` publishes none —
|
||||
* a join is indeterminate.
|
||||
*/
|
||||
internal data class JoinUpdate(val state: WorkInfo.State, val runAttemptCount: Int, val outputData: Data)
|
||||
|
||||
/**
|
||||
* What the join screen should show, given what WorkManager last said about the job.
|
||||
*
|
||||
* The join-side twin of `conversionStateFrom`, extracted for the same reason and with the same two
|
||||
* exclusions: the ownership check stays at the call site, and this takes no responsibility for the
|
||||
* staged file. See that function's KDoc for the argument in full.
|
||||
*
|
||||
* Five arms had never been chosen by any test before this was cut out, because a real `ConcatWorker`
|
||||
* only ever produces a terminal state with well-formed output.
|
||||
*
|
||||
* @param cancelled where a cancellation lands, which differs for a reattached job — see [observe].
|
||||
*/
|
||||
@UnstableApi
|
||||
internal fun joinStateFrom(update: JoinUpdate, inputs: List<InputFile>, cancelled: JoinState): JoinState =
|
||||
when (update.state) {
|
||||
// BLOCKED is a job waiting on a prerequisite, which the user has nothing to do about and
|
||||
// nothing useful to be told about. It reads as "starting", like a fresh ENQUEUED.
|
||||
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
||||
|
||||
// ENQUEUED after a run means a retry is pending -- the same rule, and the same reasoning, as
|
||||
// the convert side. See `conversionStateFrom`.
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (update.runAttemptCount > 0) {
|
||||
JoinState.Waiting(inputs)
|
||||
} else {
|
||||
JoinState.Joining(inputs)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> joinedFrom(update.outputData)
|
||||
|
||||
// A worker that dies before it can report anything leaves no output data at all, and an
|
||||
// exception's message can be an empty string. Both would read as a failure with nothing said,
|
||||
// so blank falls back like missing does.
|
||||
WorkInfo.State.FAILED -> JoinState.Failed(
|
||||
update.outputData.getString(ConcatWorker.KEY_ERROR)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.GENERIC_FAILURE_MESSAGE,
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> cancelled
|
||||
}
|
||||
|
||||
/**
|
||||
* The `SUCCEEDED` arm. A join that reported success and named no file has nothing to offer.
|
||||
*/
|
||||
@UnstableApi
|
||||
private fun joinedFrom(outputData: Data): JoinState {
|
||||
val path = outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
||||
?: return JoinState.Failed(JOINED_WITHOUT_A_FILE_MESSAGE)
|
||||
return JoinState.Joined(
|
||||
staged = File(path),
|
||||
strategy = strategyFrom(outputData.getString(ConcatWorker.KEY_STRATEGY)),
|
||||
// A join enqueued before the worker reported these carries neither, and the fallback is
|
||||
// the format such a job really used -- ConcatWorker.request has always defaulted to it,
|
||||
// and the join screen has never offered a choice.
|
||||
suggestedName = outputData.getString(ConcatWorker.KEY_SUGGESTED_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT),
|
||||
mimeType = outputData.getString(ConcatWorker.KEY_MIME_TYPE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.DEFAULT_FORMAT.mimeType,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The strategy a finished join reported, or [ConcatStrategy.REENCODE] when it named none.
|
||||
*
|
||||
* **Looked up rather than `valueOf`, and that is a fix rather than a style choice.** `valueOf`
|
||||
* throws `IllegalArgumentException` on a name this build does not define, and this runs inside a
|
||||
* `viewModelScope` collect with no handler -- so the throw does not become a `Failed` state, it
|
||||
* takes the process down.
|
||||
*
|
||||
* Reachable for the reason `WorkerEnumFallbackTest` and `JobTags` are both written on: WorkManager
|
||||
* keeps finished work for about a week, so a downgrade or a rollback hands this build a job
|
||||
* enqueued by another one. `ConcatWorker` writes `result.strategy.name` into the output `Data`, so
|
||||
* a build that added a third strategy would leave this one crashing on its own completed joins.
|
||||
*
|
||||
* `ConcatWorker.kt` already made exactly this change for `KEY_FORMAT`, and says why in as many
|
||||
* words: *"Looked up rather than `valueOf` … a format name this build does not define used to throw
|
||||
* past the catch."* The same read on this side had not been changed with it.
|
||||
*
|
||||
* REENCODE is the safe default rather than an arbitrary one: it is the answer for inputs that do
|
||||
* not match, so a job whose strategy cannot be read is described as the more conservative of the
|
||||
* two rather than being claimed as a lossless stream copy.
|
||||
*/
|
||||
private fun strategyFrom(name: String?): ConcatStrategy =
|
||||
ConcatStrategy.entries.firstOrNull { it.name == name } ?: ConcatStrategy.REENCODE
|
||||
|
||||
/** A join that reported success and named no file. There is nothing to offer the user to save. */
|
||||
internal const val JOINED_WITHOUT_A_FILE_MESSAGE: String =
|
||||
"Joining reported success but produced no file."
|
||||
|
||||
sealed interface JoinState {
|
||||
data object Idle : JoinState
|
||||
data class Ready(val inputs: List<InputFile>) : JoinState
|
||||
@@ -44,7 +150,30 @@ sealed interface JoinState {
|
||||
val mimeType: String,
|
||||
) : JoinState
|
||||
data class Saved(val displayName: String) : JoinState
|
||||
data class Failed(val message: String) : JoinState
|
||||
|
||||
/**
|
||||
* The join, or the save that followed it, could not be finished.
|
||||
*
|
||||
* [retry] is non-null for exactly one cause, and for the same reason as on
|
||||
* `ConversionState.Failed`: a [JoinViewModel.save] whose copy to the user's destination threw
|
||||
* keeps the staged file, and this is what lets the screen offer it again. Every other failure
|
||||
* leaves it null — a join that died produced no output, and a save that found the file gone
|
||||
* has nothing left to save.
|
||||
*/
|
||||
data class Failed(val message: String, val retry: PendingSave? = null) : JoinState
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged output a save would target from this state, or null when there is nothing to save.
|
||||
*
|
||||
* The join tab's half of `ConversionState.pendingSave`, and it exists for the same reason: `save`
|
||||
* and `JoinScreen`'s `CreateDocument` registration both have to answer this question, and answering
|
||||
* it twice is how a retry ends up opening the dialog with a type the finished job never chose.
|
||||
*/
|
||||
internal fun JoinState.pendingSave(): PendingSave? = when (this) {
|
||||
is JoinState.Joined -> PendingSave(staged, suggestedName, mimeType)
|
||||
is JoinState.Failed -> retry
|
||||
else -> null
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@@ -52,6 +181,15 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
app: Application,
|
||||
/** Where [reset] runs its delete. See the same parameter on `ConversionViewModel`. */
|
||||
private val cleanupDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
/**
|
||||
* Where the metadata query behind a pick runs. See the same parameter on `ConversionViewModel`.
|
||||
*
|
||||
* The join side had no such seam, and the gap was not cosmetic: the one write `onInputsPicked`
|
||||
* makes lands *after* this hop, so a test that wants to ask what happens while a pick is still
|
||||
* in flight had no way to hold one there. Issue #49's race is exactly that question, and it
|
||||
* went unasked on this side for as long as the dispatcher was a literal.
|
||||
*/
|
||||
private val pickDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : AndroidViewModel(app) {
|
||||
|
||||
private val workManager = WorkManager.getInstance(app)
|
||||
@@ -66,13 +204,28 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
private var observer: Job? = null
|
||||
private var activeWorkId: UUID? = null
|
||||
|
||||
/**
|
||||
* Who is allowed to write to this screen -- see [ScreenOwnership], which carries the rule and
|
||||
* the reason cancelling the superseded coroutine is not one.
|
||||
*
|
||||
* The convert side had issue #49 reported against it four times in two days; this side has the
|
||||
* identical shape and was never reported, because nothing was watching. Every write below that
|
||||
* lands after a suspension point is guarded: the one in [onInputsPicked] and the one in
|
||||
* [observe]. [save] is the one left out, deliberately and with the same limit its counterpart
|
||||
* in `ConversionViewModel` spells out: nothing can overwrite what it writes, but it can still
|
||||
* land on top of a [reset] taken while its copy was in flight. Which way that should go is a
|
||||
* question about the save screen rather than about this race -- issue #123.
|
||||
*/
|
||||
private val ownership = ScreenOwnership()
|
||||
|
||||
/**
|
||||
* The staged output this ViewModel is responsible for deleting.
|
||||
*
|
||||
* Held here rather than read back out of [_state] for the same reason as in
|
||||
* `ConversionViewModel`: a failed [save] lands on [JoinState.Failed], which carries a
|
||||
* message and no file, so the state machine cannot answer this on the one path that
|
||||
* most needs it.
|
||||
* `ConversionViewModel`, and still held here now that [JoinState.Failed] carries a
|
||||
* [PendingSave] after a failed [save]: that handle is a view for the screen to offer a retry
|
||||
* through, this one is the single reference [reset] deletes through, and keeping the two
|
||||
* apart is what stops a second owner of the file appearing.
|
||||
*/
|
||||
private var pendingStaged: File? = null
|
||||
|
||||
@@ -91,6 +244,10 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
* the rules about which job and why.
|
||||
*/
|
||||
private fun reattach() {
|
||||
// Read before the launch, and before the query it is about to suspend in: this is the
|
||||
// claim the answer belongs to. Reading it on the far side of the query would read whatever
|
||||
// superseded it, which is the bug rather than the check for it.
|
||||
val token = ownership.current
|
||||
viewModelScope.launch {
|
||||
val reattachment = Reattachment.choose(
|
||||
workManager.jobSnapshots(
|
||||
@@ -100,8 +257,15 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
) ?: return@launch
|
||||
|
||||
// The query suspends, so the user may have picked files or started a join in the
|
||||
// meantime. Theirs wins. No suspension point between this check and the assignment
|
||||
// below, and both run on the main dispatcher, so nothing can interleave.
|
||||
// meantime. Theirs wins.
|
||||
//
|
||||
// This catches a pick that has already *landed*, and only that. It used to claim there
|
||||
// was "no suspension point between this check and the assignment below", which was the
|
||||
// opposite of what happens: there is no assignment below, only observe(), which
|
||||
// launches a separate coroutine that cannot write until its `collect` resumes. The
|
||||
// check happens at one moment and the write lands at another, with a whole pick able
|
||||
// to fit in between -- issue #49. The token observe() carries is what holds that line;
|
||||
// see [ScreenOwnership].
|
||||
if (_state.value !is JoinState.Idle || activeWorkId != null) return@launch
|
||||
|
||||
// Joins used to stage under one constant name, so two finished joins always reported
|
||||
@@ -121,19 +285,29 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
InputFile(Uri.EMPTY, "", sizeBytes = null)
|
||||
}
|
||||
activeWorkId = reattachment.job.id
|
||||
observe(reattachment.job.id, inputs, cancelled = JoinState.Idle)
|
||||
observe(reattachment.job.id, inputs, cancelled = JoinState.Idle, token = token)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tap is the claim, which is why it is taken here rather than inside the `launch` -- and
|
||||
* above the early return, so the refusal below is covered by it too. A claim made in the
|
||||
* coroutine is only immediate while `Dispatchers.Main.immediate` happens to run it inline, and
|
||||
* a deferred claim leaves exactly the gap this closes.
|
||||
*/
|
||||
fun onInputsPicked(uris: List<Uri>) {
|
||||
val token = ownership.claim()
|
||||
if (uris.size < 2) {
|
||||
_state.value = JoinState.Failed("Pick at least two files to join.")
|
||||
_state.value = JoinState.Failed(ConcatWorker.TOO_FEW_INPUTS_MESSAGE)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val files = withContext(Dispatchers.IO) {
|
||||
val files = withContext(pickDispatcher) {
|
||||
uris.map { InputQuery.describe(getApplication(), it) }
|
||||
}
|
||||
// Guarded like every other write that lands after a hop: two picks in quick succession
|
||||
// suspend here together, and the slower one would otherwise land last.
|
||||
if (!ownership.stillHeldBy(token)) return@launch
|
||||
_state.value = JoinState.Ready(files)
|
||||
}
|
||||
}
|
||||
@@ -147,10 +321,13 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
// did answer would hand the space check a lower bound it would read as a total.
|
||||
totalBytes = InputQuery.total(inputs.map { it.sizeBytes }),
|
||||
)
|
||||
// Tapping Join claims the screen for this job, superseding any reattachment that has not
|
||||
// finished asking.
|
||||
val token = ownership.claim()
|
||||
activeWorkId = request.id
|
||||
workManager.enqueue(request)
|
||||
_state.value = JoinState.Joining(inputs)
|
||||
observe(request.id, inputs)
|
||||
observe(request.id, inputs, token = token)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,62 +335,38 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
* files, ready to join again. For one picked up by [reattach] there are no picked files —
|
||||
* what that job holds are URIs granted to a process that no longer exists — so it lands on
|
||||
* Idle rather than offering to re-join files nothing can open.
|
||||
* @param token the claim this observation belongs to. Nothing here can write until `collect`
|
||||
* has resumed with a `WorkInfo`, which is some time after the caller decided to observe, so
|
||||
* the claim is checked again at the last possible moment rather than trusted from then. See
|
||||
* [ScreenOwnership], and issue #49.
|
||||
*/
|
||||
private fun observe(id: UUID, inputs: List<InputFile>, cancelled: JoinState = JoinState.Ready(inputs)) {
|
||||
private fun observe(
|
||||
id: UUID,
|
||||
inputs: List<InputFile>,
|
||||
cancelled: JoinState = JoinState.Ready(inputs),
|
||||
token: Long,
|
||||
) {
|
||||
observer?.cancel()
|
||||
observer = viewModelScope.launch {
|
||||
workManager.getWorkInfoByIdFlow(id).collect { info ->
|
||||
if (info == null) return@collect
|
||||
_state.value = when (info.state) {
|
||||
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (info.runAttemptCount > 0) {
|
||||
JoinState.Waiting(inputs)
|
||||
} else {
|
||||
JoinState.Joining(inputs)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
||||
val strategy = info.outputData.getString(ConcatWorker.KEY_STRATEGY)
|
||||
?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
|
||||
if (path == null) {
|
||||
JoinState.Failed("Joining reported success but produced no file.")
|
||||
} else {
|
||||
val staged = File(path)
|
||||
// Take responsibility for the file at the same moment the state
|
||||
// starts referring to it, so the two cannot disagree.
|
||||
pendingStaged = staged
|
||||
JoinState.Joined(
|
||||
staged = staged,
|
||||
strategy = strategy,
|
||||
// A join enqueued before the worker reported these carries
|
||||
// neither, and the fallback is the format such a job really
|
||||
// used -- ConcatWorker.request has always defaulted to it, and
|
||||
// the join screen has never offered a choice.
|
||||
suggestedName = info.outputData
|
||||
.getString(ConcatWorker.KEY_SUGGESTED_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT),
|
||||
mimeType = info.outputData
|
||||
.getString(ConcatWorker.KEY_MIME_TYPE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.DEFAULT_FORMAT.mimeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A worker that dies before it can report anything leaves no output data at
|
||||
// all, and an exception's message can be an empty string. Both would read as
|
||||
// a failure with nothing said, so blank falls back like missing does.
|
||||
WorkInfo.State.FAILED -> JoinState.Failed(
|
||||
info.outputData.getString(ConcatWorker.KEY_ERROR)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Joining failed.",
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> cancelled
|
||||
}
|
||||
// Ahead of the `when`, not merely ahead of the assignment: the SUCCEEDED branch
|
||||
// takes ownership of the staged file, and a superseded observation must not do
|
||||
// that either.
|
||||
if (!ownership.stillHeldBy(token)) return@collect
|
||||
val next = joinStateFrom(
|
||||
JoinUpdate(
|
||||
state = info.state,
|
||||
runAttemptCount = info.runAttemptCount,
|
||||
outputData = info.outputData,
|
||||
),
|
||||
inputs = inputs,
|
||||
cancelled = cancelled,
|
||||
)
|
||||
// Read off the result rather than assigned inside the mapping -- see the same
|
||||
// three lines in ConversionViewModel for why that is the better half of the swap.
|
||||
if (next is JoinState.Joined) pendingStaged = next.staged
|
||||
_state.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,29 +382,38 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
* join offered by reattachment was last seen during a tag query that may be hours old, and
|
||||
* `cacheDir` is reclaimed by the OS and swept by this app. Without it the file's absence
|
||||
* reached the screen as a raw ENOENT path.
|
||||
*
|
||||
* Reached from [JoinState.Joined] and again from a [JoinState.Failed] an earlier save left
|
||||
* carrying its file; [pendingSave] is what makes those the same call.
|
||||
*/
|
||||
fun save(destination: Uri) {
|
||||
val joined = _state.value as? JoinState.Joined ?: return
|
||||
if (!joined.staged.isFile) {
|
||||
val pending = _state.value.pendingSave() ?: return
|
||||
if (!pending.staged.isFile) {
|
||||
// No retry handle -- the file it would offer again is the one that has gone.
|
||||
_state.value = JoinState.Failed(STAGED_FILE_GONE_MESSAGE)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
publisher.publish(joined.staged, destination)
|
||||
joined.staged.delete()
|
||||
publisher.publish(pending.staged, destination)
|
||||
pending.staged.delete()
|
||||
}
|
||||
}.onSuccess {
|
||||
// publish() already deleted it; nothing left to clean up.
|
||||
pendingStaged = null
|
||||
_state.value = JoinState.Saved(joined.suggestedName)
|
||||
_state.value = JoinState.Saved(pending.suggestedName)
|
||||
}.onFailure { e ->
|
||||
// Deliberately NOT cleared -- see the same branch in ConversionViewModel.
|
||||
// A failed save can leave the staged file as the only copy of the work, so
|
||||
// it is left for a later reset() or for the sweep to collect once its age
|
||||
// makes it certain nobody is coming back for it.
|
||||
_state.value = JoinState.Failed(e.message ?: "Could not save the file.")
|
||||
//
|
||||
// `pending` travels on the state so the screen can offer the file again rather
|
||||
// than leaving "Start over" -- which deletes it -- as the only thing on offer.
|
||||
// Passing `pending` rather than rebuilding it is what keeps a retry that fails
|
||||
// again on a carrying Failed instead of a bare one.
|
||||
_state.value = JoinState.Failed(e.message ?: SAVE_FAILED_MESSAGE, pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,8 +423,16 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
*
|
||||
* Best effort, not a guarantee: the delete is cancelled with [viewModelScope] if the
|
||||
* Activity finishes first. `OutputPublisher.sweepStaging` is the backstop.
|
||||
*
|
||||
* It deletes from a [JoinState.Failed] carrying a [PendingSave] too, deliberately and for the
|
||||
* reason `ConversionViewModel.reset` writes out: the screen offers "Try saving again" above
|
||||
* this button, so deletion is what the user chose rather than all this state could do.
|
||||
*/
|
||||
fun reset() {
|
||||
// Start over is a claim like any other. The cancel below is a request honoured at the next
|
||||
// suspension point, so a collector already on its way to a write has nothing left to
|
||||
// honour it at; the claim is what stops that write landing on top of Idle.
|
||||
ownership.claim()
|
||||
observer?.cancel()
|
||||
observer = null
|
||||
activeWorkId = null
|
||||
|
||||
@@ -15,36 +15,97 @@ package org.libremediaconverter.model
|
||||
*/
|
||||
object CodecNames {
|
||||
|
||||
fun videoFromName(name: String?): VideoCodec? = when (name?.lowercase()) {
|
||||
null, InputProbe.UNPARSEABLE -> null
|
||||
"h264", "avc", "avc1", "x264" -> VideoCodec.H264
|
||||
"hevc", "h265", "hvc1", "hev1", "x265" -> VideoCodec.H265
|
||||
"vp8" -> VideoCodec.VP8
|
||||
"vp9", "vp09" -> VideoCodec.VP9
|
||||
"av1", "av01" -> VideoCodec.AV1
|
||||
else -> null
|
||||
}
|
||||
/**
|
||||
* The video vocabulary, as data rather than a `when`.
|
||||
*
|
||||
* This is not the only place the app spells these names. `AndroidDeviceCodecs` reads the same
|
||||
* FFprobe strings to decide what the device can decode, and answers in platform MIME types,
|
||||
* which `model` cannot name without depending on Android. The two copies drifted apart:
|
||||
* `x264`, `hev1`, `x265` and `vp09` resolved here and returned null there, so the app
|
||||
* identified the codec for display and routing and then ran the device check blind, attempting
|
||||
* a hardware path it had enough information to skip (#87).
|
||||
*
|
||||
* The reason this is a map is that **a `when` cannot be enumerated**, so nothing could compare
|
||||
* the two tables. `CodecVocabularyTest` walks both key sets, so a name added to or removed
|
||||
* from one side alone now fails the build rather than waiting for a wasted transcode to show
|
||||
* it.
|
||||
*
|
||||
* Keys are lowercase; [videoFromName] lowercases before looking one up.
|
||||
*/
|
||||
internal val VIDEO_ALIASES: Map<String, VideoCodec> = mapOf(
|
||||
"h264" to VideoCodec.H264,
|
||||
"avc" to VideoCodec.H264,
|
||||
"avc1" to VideoCodec.H264,
|
||||
"x264" to VideoCodec.H264,
|
||||
"hevc" to VideoCodec.H265,
|
||||
"h265" to VideoCodec.H265,
|
||||
"hvc1" to VideoCodec.H265,
|
||||
"hev1" to VideoCodec.H265,
|
||||
"x265" to VideoCodec.H265,
|
||||
"vp8" to VideoCodec.VP8,
|
||||
"vp9" to VideoCodec.VP9,
|
||||
"vp09" to VideoCodec.VP9,
|
||||
"av1" to VideoCodec.AV1,
|
||||
"av01" to VideoCodec.AV1,
|
||||
)
|
||||
|
||||
fun audioFromName(name: String?): AudioCodec? = when (name?.lowercase()) {
|
||||
null -> null
|
||||
"aac", "mp4a", "aac_latm" -> AudioCodec.AAC
|
||||
"opus" -> AudioCodec.OPUS
|
||||
"vorbis" -> AudioCodec.VORBIS
|
||||
"mp3", "mp3float", "mpga" -> AudioCodec.MP3
|
||||
"flac" -> AudioCodec.FLAC
|
||||
"pcm", "raw", "pcm_s16le", "pcm_s24le", "pcm_f32le" -> AudioCodec.PCM
|
||||
else -> null
|
||||
}
|
||||
/**
|
||||
* The audio vocabulary, data for the same reason.
|
||||
*
|
||||
* Nothing cross-checks this one yet, and that is a gap rather than a decision: the device
|
||||
* capability check is video-only, so this module holds no second audio table to compare it
|
||||
* against. `Media3Engine.audioMimeTypeFor` is the other half, and #85 owns that file.
|
||||
*/
|
||||
internal val AUDIO_ALIASES: Map<String, AudioCodec> = mapOf(
|
||||
"aac" to AudioCodec.AAC,
|
||||
"mp4a" to AudioCodec.AAC,
|
||||
"aac_latm" to AudioCodec.AAC,
|
||||
"opus" to AudioCodec.OPUS,
|
||||
"vorbis" to AudioCodec.VORBIS,
|
||||
"mp3" to AudioCodec.MP3,
|
||||
"mp3float" to AudioCodec.MP3,
|
||||
"mpga" to AudioCodec.MP3,
|
||||
"flac" to AudioCodec.FLAC,
|
||||
"pcm" to AudioCodec.PCM,
|
||||
"raw" to AudioCodec.PCM,
|
||||
"pcm_s16le" to AudioCodec.PCM,
|
||||
"pcm_s24le" to AudioCodec.PCM,
|
||||
"pcm_f32le" to AudioCodec.PCM,
|
||||
)
|
||||
|
||||
fun videoFromName(name: String?): VideoCodec? = asCodecName(name)?.let(VIDEO_ALIASES::get)
|
||||
|
||||
fun audioFromName(name: String?): AudioCodec? = asCodecName(name)?.let(AUDIO_ALIASES::get)
|
||||
|
||||
/** Human-readable name for the source-info card. Falls back to the raw probe string. */
|
||||
fun describeVideo(name: String?): String = when {
|
||||
fun describeVideo(name: String?): String = describe(name) { videoFromName(it)?.label }
|
||||
|
||||
fun describeAudio(name: String?): String = describe(name) { audioFromName(it)?.label }
|
||||
|
||||
/**
|
||||
* Lowercases a probe string, and answers null for the two inputs that are not codec names at
|
||||
* all: absent, and the [InputProbe.UNPARSEABLE] sentinel.
|
||||
*
|
||||
* The sentinel would miss every key anyway, so naming it changes no answer. Naming it is still
|
||||
* the point: `videoFromName` excluded it explicitly and `audioFromName` did not, which read as
|
||||
* though the two disagreed about what the sentinel means — the same asymmetry as #74 one
|
||||
* function further up.
|
||||
*/
|
||||
private fun asCodecName(name: String?): String? =
|
||||
if (name == null || name == InputProbe.UNPARSEABLE) null else name.lowercase()
|
||||
|
||||
/**
|
||||
* The shared body of [describeVideo] and [describeAudio].
|
||||
*
|
||||
* They are one function apiece over one vocabulary, and they had stopped matching:
|
||||
* `describeVideo` answered "Unrecognised" for [InputProbe.UNPARSEABLE] and `describeAudio` fell
|
||||
* through to `?: name` instead. The sentinel opens with a NUL, so that fallback would have put
|
||||
* a U+0000 into a `Text` on the source-info card (#74). Sharing the arms is what stops the next
|
||||
* one being added to one side only.
|
||||
*/
|
||||
private fun describe(name: String?, label: (String) -> String?): String = when {
|
||||
name == null -> "Unknown"
|
||||
name == InputProbe.UNPARSEABLE -> "Unrecognised"
|
||||
else -> videoFromName(name)?.label ?: name
|
||||
}
|
||||
|
||||
fun describeAudio(name: String?): String = when {
|
||||
name == null -> "Unknown"
|
||||
else -> audioFromName(name)?.label ?: name
|
||||
else -> label(name) ?: name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,9 +129,25 @@ object ContainerCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.videoCodec == VideoCodec.NONE && spec.audioCodec == AudioCodec.NONE) {
|
||||
// Two faces of one rule: the output would carry no tracks at all.
|
||||
//
|
||||
// The first is visible in the spec alone — NONE on both axes. The second only emerges once
|
||||
// the spec meets the probe, because [CopyPlanner] drops a video track the *input* does not
|
||||
// have no matter which codec was named for it, so "H.265 + no audio" on an MP3 plans to
|
||||
// (Drop, Drop) exactly as "None + None" does. Asking the spec alone answered the first and
|
||||
// missed the second, and the miss was not cosmetic: `EditedMediaItem.Builder` refuses that
|
||||
// composition with IllegalStateException("Audio and video cannot both be removed"), on
|
||||
// Transformer's own thread, where the user would have seen a dead app rather than a reason.
|
||||
if (spec.audioCodec == AudioCodec.NONE && (spec.videoCodec == VideoCodec.NONE || !probe.hasVideo)) {
|
||||
return Validation.Invalid(
|
||||
"This would produce an empty file — keep at least one track.",
|
||||
if (spec.videoCodec == VideoCodec.NONE) {
|
||||
"This would produce an empty file — keep at least one track."
|
||||
} else {
|
||||
// Names both halves. "No video track" alone reads as though the video setting
|
||||
// were the only thing wrong, and the user would fix that and still be stuck.
|
||||
"This file has no video track, so turning the audio off too would produce an " +
|
||||
"empty file."
|
||||
},
|
||||
suggestions(
|
||||
// Ask for both tracks back, then let repair settle what this container and
|
||||
// this input can actually give.
|
||||
@@ -162,7 +178,12 @@ object ContainerCapabilities {
|
||||
if (!probe.hasVideo) {
|
||||
return Validation.Invalid(
|
||||
"This file has no video track to copy.",
|
||||
listOf(spec.copy(videoCodec = VideoCodec.NONE)),
|
||||
// Dropping the video is the right shape of answer, but it is only half of one:
|
||||
// `spec.copy(videoCodec = NONE)` is valid exactly when the audio axis already
|
||||
// happened to be fine, and refused otherwise — a Vorbis or PCM source into MP4,
|
||||
// an MP3 into WebM. Handing it to the shared path repairs both axes and drops
|
||||
// anything that still fails, so the chip cannot lead to a second error.
|
||||
suggestions(spec.copy(videoCodec = VideoCodec.NONE), probe, exclude = spec),
|
||||
)
|
||||
}
|
||||
val source = CodecNames.videoFromName(probe.videoCodec)
|
||||
@@ -284,8 +305,12 @@ object ContainerCapabilities {
|
||||
private fun repairVideo(spec: OutputSpec, probe: InputProbe): VideoCodec {
|
||||
val container = spec.container
|
||||
if (spec.videoCodec == VideoCodec.NONE || !container.canHoldVideo) return VideoCodec.NONE
|
||||
// There is no video track to make one out of, so naming a codec would be a suggestion
|
||||
// [CopyPlanner] drops on the floor. It also read as a non-sequitur: before this line, the
|
||||
// repair offered for an MP3 was "H.264", the first codec MP4 happens to encode.
|
||||
if (!probe.hasVideo) return VideoCodec.NONE
|
||||
|
||||
val source = CodecNames.videoFromName(probe.videoCodec).takeIf { probe.hasVideo }
|
||||
val source = CodecNames.videoFromName(probe.videoCodec)
|
||||
val copyable = source != null && accepts(container, source, CodecMode.COPY)
|
||||
|
||||
return when {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package org.libremediaconverter.ui
|
||||
|
||||
/**
|
||||
* Where a test finds each affordance on the two screens.
|
||||
*
|
||||
* Every button, picker and card in `ConverterScreen` and `JoinScreen` carries one of these through
|
||||
* `Modifier.testTag`, so a test names a symbol and never a literal. That is the whole reason the
|
||||
* table exists: `"Cancel"`, `"Start over"` and `"Save file"` are each rendered by both screens and
|
||||
* by more than one state branch, so rewording one of them would otherwise redden several
|
||||
* independent test files at once, and none of those diffs would explain why.
|
||||
*
|
||||
* Tags are applied inside `main`, never handed in by the caller. A tag a test passes down as a
|
||||
* `Modifier` proves only that the test set it -- it would stay green with the affordance's own tag
|
||||
* deleted, which is exactly the vacuous test `CLAUDE.md` records nine of.
|
||||
*
|
||||
* ### Public rather than `internal`, deliberately
|
||||
*
|
||||
* `androidTest` **is** a friend source set of `main` here: an `androidTest` file referencing the
|
||||
* `internal` `Destination.CONVERT` compiles clean through `:app:compileDebugAndroidTestKotlin`
|
||||
* under AGP 9.3.1 (measured 2026-08-24 -- nothing in the repo referenced a main `internal` from
|
||||
* `androidTest`, so the question had no in-tree answer until then). `internal` would compile today.
|
||||
*
|
||||
* It is public anyway. That friendship is AGP wiring rather than something this project states, and
|
||||
* this table is a contract read from three source sets: `main` applies the tags, `src/test` and
|
||||
* `src/androidTest` name them. Public buys no external exposure in an application module -- nothing
|
||||
* consumes it from outside -- so the durable answer costs nothing here.
|
||||
*
|
||||
* ### Invariants
|
||||
*
|
||||
* Values are distinct, which `TagTableUniquenessTest` asserts. Two affordances sharing a tag would
|
||||
* break the "resolves to exactly one node" assertion in a file nobody had touched.
|
||||
*/
|
||||
object TestTags {
|
||||
|
||||
/**
|
||||
* Affordances both screens render, under one name each.
|
||||
*
|
||||
* Shared rather than per-screen because only one screen is composed at a time -- the shell
|
||||
* swaps them -- so a tag can only ever resolve within the screen under test.
|
||||
*/
|
||||
const val CANCEL: String = "action.cancel"
|
||||
|
||||
/** Rendered by `Converted`/`Joined` and again by `Failed` on both screens. */
|
||||
const val START_OVER: String = "action.startOver"
|
||||
|
||||
const val SAVE_FILE: String = "action.saveFile"
|
||||
|
||||
/**
|
||||
* The retry a `Failed` offers after a save that threw, on both screens.
|
||||
*
|
||||
* Its own tag rather than [SAVE_FILE], because the two are different claims about the screen.
|
||||
* [SAVE_FILE] is the first attempt from a finished job; this one may appear only where a staged
|
||||
* file survived a failed save. Sharing a tag would collapse "a transcode failure offers nothing
|
||||
* to save" and "a failed save offers the file again" into one query, and that first assertion
|
||||
* is the one stopping a Save button from appearing where there is nothing to save.
|
||||
*/
|
||||
const val RETRY_SAVE: String = "action.retrySave"
|
||||
|
||||
/** `ConverterScreen`. */
|
||||
object Converter {
|
||||
const val CHOOSE_FILE: String = "converter.chooseFile"
|
||||
const val CONVERT: String = "converter.convert"
|
||||
const val CHOOSE_DIFFERENT_FILE: String = "converter.chooseDifferentFile"
|
||||
const val CONVERT_ANOTHER: String = "converter.convertAnother"
|
||||
|
||||
/** The determinate bar in `Converting`. It carries no text, so nothing else can find it. */
|
||||
const val PROGRESS: String = "converter.progress"
|
||||
|
||||
/**
|
||||
* The chip on `Converted` that says which engine ran the job and why.
|
||||
*
|
||||
* Conditional on `routeReason` being non-blank, and that condition is what the tag is for:
|
||||
* its text comes from the finished job, so a text matcher looking for it would have to
|
||||
* name a routing explanation the screen does not own.
|
||||
*/
|
||||
const val ROUTE_REASON: String = "converter.routeReason"
|
||||
|
||||
const val FILE_CARD: String = "converter.fileCard"
|
||||
const val FILE_CARD_NAME: String = "converter.fileCard.name"
|
||||
|
||||
/**
|
||||
* The byte size, or `"Size unknown"`.
|
||||
*
|
||||
* Named for bytes rather than "size" because the `IMAGE` branch also renders a row labelled
|
||||
* `Size` -- pixel dimensions -- through [detailRow], and the two mean different things.
|
||||
*/
|
||||
const val FILE_CARD_BYTES: String = "converter.fileCard.bytes"
|
||||
|
||||
/**
|
||||
* The one-line explanation that stands in for the detail rows: `"Reading…"` while the probe
|
||||
* is still running, or the unreadable-file line once it has finished and found nothing.
|
||||
* The two are mutually exclusive, so one tag covers both.
|
||||
*/
|
||||
const val FILE_CARD_NOTE: String = "converter.fileCard.note"
|
||||
|
||||
/**
|
||||
* The chip rows, not the pickers around them.
|
||||
*
|
||||
* Each tag sits on the `FlowRow` of chips, so the prose a picker renders beside it -- the
|
||||
* `"Custom — set below."` line under the formats, the tier description under the quality
|
||||
* chips -- is outside the tagged node. Tagging the picker as a whole would mean wrapping
|
||||
* three sibling emissions in a layout that does not exist today.
|
||||
*/
|
||||
const val FORMAT_CHIPS: String = "converter.formatChips"
|
||||
|
||||
const val QUALITY_CHIPS: String = "converter.qualityChips"
|
||||
const val ENGINE_CHIPS: String = "converter.engineChips"
|
||||
|
||||
/** The `Advanced` / `Hide advanced` toggle. Present whether or not the panel is open. */
|
||||
const val ADVANCED_TOGGLE: String = "converter.advanced.toggle"
|
||||
|
||||
/** The panel the toggle gates. Absent from the tree while collapsed. */
|
||||
const val ADVANCED_PANEL: String = "converter.advanced.panel"
|
||||
|
||||
/**
|
||||
* The three chip rows inside the panel, separately.
|
||||
*
|
||||
* Separately because their labels collide: `"Copy"` and `"None"` are both a `VideoCodec`
|
||||
* and an `AudioCodec`, and `"MP3"` and `"FLAC"` are both a `Container` and an `AudioCodec`,
|
||||
* so a text matcher over the open panel is ambiguous for four of the chips.
|
||||
*/
|
||||
const val ADVANCED_CONTAINER_CHIPS: String = "converter.advanced.containerChips"
|
||||
|
||||
const val ADVANCED_VIDEO_CHIPS: String = "converter.advanced.videoChips"
|
||||
const val ADVANCED_AUDIO_CHIPS: String = "converter.advanced.audioChips"
|
||||
|
||||
/** The error card. Rendered outside the panel, so it is reachable while collapsed. */
|
||||
const val VALIDATION_ERROR: String = "converter.validationError"
|
||||
|
||||
/** One detail line of the file card, by the label it renders: `Container`, `Video`, ... */
|
||||
fun detailRow(label: String): String = "converter.fileCard.row:$label"
|
||||
|
||||
/**
|
||||
* One suggested output on the validation card, by position.
|
||||
*
|
||||
* By position rather than by the text of the suggestion, because that text comes from
|
||||
* `describe`, which is itself under test -- a tag derived from it would move whenever the
|
||||
* thing it is meant to locate changed.
|
||||
*/
|
||||
fun suggestion(index: Int): String = "converter.validationError.suggestion:$index"
|
||||
}
|
||||
|
||||
/** `JoinScreen`. */
|
||||
object Join {
|
||||
const val CHOOSE_FILES: String = "join.chooseFiles"
|
||||
const val JOIN: String = "join.join"
|
||||
const val CHOOSE_DIFFERENT_FILES: String = "join.chooseDifferentFiles"
|
||||
const val JOIN_MORE: String = "join.joinMore"
|
||||
|
||||
/** The indeterminate bar in `Joining`. */
|
||||
const val PROGRESS: String = "join.progress"
|
||||
|
||||
/**
|
||||
* One picked input, by the name it displays.
|
||||
*
|
||||
* By name rather than by position, so the tag is derived from data the row already holds
|
||||
* and can stay inside `FileRow`. Passing an index down would mean the call site owned the
|
||||
* tag, and a test that supplies its own tag asserts nothing about the screen.
|
||||
*/
|
||||
fun fileRow(displayName: String): String = "join.fileRow:$displayName"
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,18 @@ private val LightColorScheme = lightColorScheme(
|
||||
* Material 3 theme.
|
||||
*
|
||||
* Dynamic color (Material You) needs API 31+; minSdk is 33, so it is available
|
||||
* unconditionally and no version guard is required. It stays switchable so users can
|
||||
* opt back to the brand palette.
|
||||
* unconditionally and no version guard is required.
|
||||
*
|
||||
* [dynamicColor] has no caller. `MainActivity` is the single call site and takes the
|
||||
* default, so the parameter is always `true`, the two dynamic branches always win, and
|
||||
* [DarkColorScheme] and [LightColorScheme] are dead: nothing in the app can opt back to the
|
||||
* brand palette. `ThemeColorSchemeTest` reaches those two branches only by passing
|
||||
* [dynamicColor] explicitly -- a test doing it, not a feature.
|
||||
*
|
||||
* That is known rather than an oversight. #68 holds the choice between adding a switch,
|
||||
* deleting the dead branches together with the template palette, and replacing that palette
|
||||
* first; it is undecided, so nothing here should be read as a promise that any of them
|
||||
* happens.
|
||||
*/
|
||||
@Composable
|
||||
fun LibreMediaConverterTheme(
|
||||
|
||||
@@ -39,7 +39,7 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
|
||||
val uris = inputData.getStringArray(KEY_INPUT_URIS)?.map(Uri::parse)
|
||||
?: return Result.failure(workDataOf(KEY_ERROR to "No input files."))
|
||||
if (uris.size < 2) {
|
||||
return Result.failure(workDataOf(KEY_ERROR to "Pick at least two files to join."))
|
||||
return Result.failure(workDataOf(KEY_ERROR to TOO_FEW_INPUTS_MESSAGE))
|
||||
}
|
||||
// Absent, not zero, when the picker could not size every input -- see the same read in
|
||||
// ConversionWorker and InputQuery for why the two are no longer one number.
|
||||
@@ -107,7 +107,7 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
|
||||
}
|
||||
FailureOutcome.FAIL -> {
|
||||
Log.e(TAG, "Joining failed.", e)
|
||||
Result.failure(workDataOf(KEY_ERROR to (e.message ?: "Joining failed.")))
|
||||
Result.failure(workDataOf(KEY_ERROR to (e.message ?: GENERIC_FAILURE_MESSAGE)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,34 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* What the user is told when a join arrives with fewer than two inputs.
|
||||
*
|
||||
* Shared with `JoinViewModel`, which refuses the same condition one layer up so the picker
|
||||
* can answer without enqueueing anything. Two copies of this sentence existed before, and
|
||||
* only the one here was pinned by a test (#139) — so the wording could drift on the screen
|
||||
* without a single test noticing, for one message the user sees from one condition.
|
||||
*
|
||||
* Here rather than in the ViewModel because the rule is the worker's: `request(...)` takes
|
||||
* a `List<Uri>` and checks nothing about its length, so this is the guard that always runs.
|
||||
*/
|
||||
const val TOO_FEW_INPUTS_MESSAGE: String = "Pick at least two files to join."
|
||||
|
||||
/**
|
||||
* The last resort when a join fails and the exception says nothing.
|
||||
*
|
||||
* Shared with `JoinViewModel`, whose `FAILED` arm falls back to the same sentence when the
|
||||
* output `Data` carries no error at all — a worker killed before it could write one. The two
|
||||
* are a chain rather than a coincidence: this is what the worker puts *in* `KEY_ERROR`, and
|
||||
* that is what the ViewModel says when `KEY_ERROR` never arrived. The user cannot tell the
|
||||
* two apart and should not have to, so they are one sentence.
|
||||
*
|
||||
* The `Log.e` above deliberately keeps its own literal. A log line has a different audience
|
||||
* and carries the exception with it; coupling it to the user-facing wording would mean
|
||||
* rewording the screen to change a log.
|
||||
*/
|
||||
const val GENERIC_FAILURE_MESSAGE: String = "Joining failed."
|
||||
|
||||
const val KEY_INPUT_URIS = "input_uris"
|
||||
const val KEY_TOTAL_BYTES = "total_bytes"
|
||||
const val KEY_FORMAT = "format"
|
||||
|
||||
@@ -313,7 +313,7 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
|
||||
}
|
||||
FailureOutcome.FAIL -> {
|
||||
Log.e(TAG, "Conversion failed.", cause)
|
||||
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
|
||||
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: GENERIC_FAILURE_MESSAGE)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +352,20 @@ class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWo
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The last resort when a conversion fails and the exception says nothing.
|
||||
*
|
||||
* Shared with `ConversionViewModel`, whose `FAILED` arm falls back to the same sentence when
|
||||
* the output `Data` carries no error — a worker killed before it could write one, which a
|
||||
* refused foreground start after a process restart produces. The two are a chain rather than
|
||||
* a coincidence: this is what goes *into* `KEY_ERROR`, and that is what is said when
|
||||
* `KEY_ERROR` never arrived. The user cannot tell those apart and should not have to.
|
||||
*
|
||||
* See [ConcatWorker.GENERIC_FAILURE_MESSAGE] for the join-side twin, and the note there
|
||||
* about why the neighbouring `Log.e` keeps its own literal.
|
||||
*/
|
||||
const val GENERIC_FAILURE_MESSAGE: String = "Conversion failed."
|
||||
|
||||
const val KEY_INPUT_URI = "input_uri"
|
||||
const val KEY_DISPLAY_NAME = "display_name"
|
||||
const val KEY_SIZE_BYTES = "size_bytes"
|
||||
|
||||
@@ -34,14 +34,20 @@ import org.robolectric.RobolectricTestRunner
|
||||
* representation survives a `Bundle` round trip. A JVM round-trip test on the
|
||||
* saver covers the representation.
|
||||
*
|
||||
* Robolectric rather than the instrumented suite, deliberately. The instrumented tests
|
||||
* cannot run on the development host at all (see CLAUDE.md), and a red test nobody can
|
||||
* execute is not a loop anyone can work in.
|
||||
* Robolectric rather than the instrumented suite, deliberately -- but not because the
|
||||
* instrumented suite is unavailable. It runs on this host for API 33-36
|
||||
* (`tools/local-emulator/run-e2e.sh`), and CI runs 33-37. The reason is cost: this test
|
||||
* needs a composition and a saved-state round trip, nothing a device supplies, and it runs
|
||||
* in the same `./gradlew` invocation as every other JVM test instead of booting an
|
||||
* emulator. A loop measured in seconds is a loop people stay inside.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AppRootRestorationTest {
|
||||
|
||||
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
|
||||
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
|
||||
// deliberate: the v2 package has no tester of its own and the two do interoperate.
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.libremediaconverter.ci
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* That a hung unit-test run still ends by itself, and still says why.
|
||||
*
|
||||
* `:app:testDebugUnitTest` had no timeout of any kind until #125 was filed. That ticket is a real
|
||||
* Java-level deadlock between Room's `TransactionExecutor` and WorkManager's `SerialExecutorImpl`,
|
||||
* reached through the WorkInfo flow the ViewModel collects, and one local run sat in it for 47
|
||||
* minutes. Nothing inside the suite could break it: the deadlock is monitor contention, which is
|
||||
* not interruptible, so it runs until something outside the JVM gives up.
|
||||
*
|
||||
* Two numbers in `app/build.gradle.kts` are what bound it now, and neither compiles, so nothing
|
||||
* else would notice their removal:
|
||||
*
|
||||
* - `timeout.set(...)` on every `Test` task, which stops the forked test JVM.
|
||||
* - the watchdog's `dumpAfterNanos`, which jstacks that JVM *before* the timeout kills it.
|
||||
*
|
||||
* The second is the one worth guarding hardest, and the one that most looks like stray config.
|
||||
* Gradle's timeout kills without a thread dump, and the jstack -- with its "Found one Java-level
|
||||
* deadlock" section naming both monitors -- is the only reason #125 could be described at all.
|
||||
* The ordering between the two numbers is what makes it work: dump first, kill second. Reverse
|
||||
* them, or delete the watchdog, and the suite still stops hanging but every hang from then on
|
||||
* reports as a bare "Timeout has been exceeded" with nothing to read. Measured against a probe
|
||||
* that hung one test: no test XML was written for the class that hung, so the hanging test itself
|
||||
* gets no attribution from the report at all.
|
||||
*
|
||||
* The range on the timeout is not decoration either, and it is the half a future edit is most
|
||||
* likely to get wrong. Below it, a healthy-but-slow runner trips the bound and a real signal
|
||||
* becomes noise people learn to re-run through; above it, CI's 30-minute job cap fires first and
|
||||
* the bound never gets to say anything.
|
||||
*
|
||||
* `ReleasePermissionTest` is the precedent and its caveat applies here too. This asserts the two
|
||||
* numbers are present, sanely sized and correctly ordered. It cannot assert that the timeout
|
||||
* fires -- that needs a hang, which is what the whole change exists to prevent. Refs #125.
|
||||
*/
|
||||
class HangBoundTest {
|
||||
|
||||
@Test
|
||||
fun `every Test task is bounded, and bounded between the slow runner and the job cap`() {
|
||||
assertTrue(
|
||||
"app/build.gradle.kts sets its Test task timeout to ${timeoutMinutes}m, which is " +
|
||||
"outside $SANE_MINUTES. Under that range a slow CI runner trips a bound meant for " +
|
||||
"deadlocks -- the slowest observed passing run of the whole invocation was 90s. " +
|
||||
"Over it, the Unit tests job's own 30-minute cap kills the job first and the " +
|
||||
"timeout never reports. `null` means the line is gone or the block was rewritten, " +
|
||||
"and without it #125's deadlock has nothing to stop it: monitor contention breaks " +
|
||||
"no interrupt, so it runs until CI gives up and reports a timeout with no cause.",
|
||||
timeoutMinutes in SANE_MINUTES,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the thread dump is taken before the timeout kills the JVM it would dump`() {
|
||||
assertTrue(
|
||||
"app/build.gradle.kts takes its hang thread dump after ${dumpAfterMinutes}m but times " +
|
||||
"the task out at ${timeoutMinutes}m, so the JVM is already dead when jstack runs " +
|
||||
"and every future hang reports as a bare `Timeout has been exceeded`. The dump " +
|
||||
"has to come first -- it is the only attribution a hanging test gets, since the " +
|
||||
"test XML never names it.",
|
||||
(dumpAfterMinutes ?: 0) < (timeoutMinutes ?: 0),
|
||||
)
|
||||
}
|
||||
|
||||
/** Minutes given to a whole `Test` task before Gradle stops the forked JVM. */
|
||||
private val timeoutMinutes: Int?
|
||||
get() = minutesIn("""timeout\.set\(Duration\.ofMinutes\((\d+)\)\)""")
|
||||
|
||||
/** Minutes the watchdog waits before jstacking the forked JVM. */
|
||||
private val dumpAfterMinutes: Int?
|
||||
get() = minutesIn("""val dumpAfterNanos = Duration\.ofMinutes\((\d+)\)""")
|
||||
|
||||
/**
|
||||
* Read out of the build script rather than from a model: the numbers live in a Kotlin DSL block
|
||||
* that no unit test can instantiate, and a scan that reports `null` when the shape changes is a
|
||||
* better trade than not checking them at all.
|
||||
*/
|
||||
private fun minutesIn(pattern: String): Int? =
|
||||
Regex(pattern).find(buildScript.readText())?.groupValues?.get(1)?.toInt()
|
||||
|
||||
/**
|
||||
* Found by walking up rather than by a fixed relative path: Gradle's working directory for the
|
||||
* unit tests is the module, but that is a default rather than a promise.
|
||||
*/
|
||||
private val buildScript: File
|
||||
get() = generateSequence(File(".").absoluteFile) { it.parentFile }
|
||||
.map { File(it, "app/build.gradle.kts") }
|
||||
.firstOrNull { it.isFile }
|
||||
?: error("could not find app/build.gradle.kts above ${File(".").absolutePath}")
|
||||
|
||||
private companion object {
|
||||
/** Above the slowest observed passing run, below the Unit tests job's `timeout-minutes`. */
|
||||
val SANE_MINUTES = 3..29
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.libremediaconverter.ci
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* That the release job still holds the one permission it needs to publish.
|
||||
*
|
||||
* `build.yml`'s `release` job declares `contents: write`, and nothing was checking it. Deleting
|
||||
* those two lines leaves actionlint clean and CodeQL silent — a *narrower* permission is not an
|
||||
* alert — and the job is `if: startsWith(github.ref, 'refs/tags/v')`, so no pull request and no
|
||||
* merge to `main` can exercise it. Measured: with the declaration removed, every gating check
|
||||
* still passes. The first thing that would notice is a release failing to publish, at the moment
|
||||
* someone is trying to cut one.
|
||||
*
|
||||
* The deletion also looks like tidying. A top-level `permissions: contents: read` now sits
|
||||
* directly above it, so a reader could reasonably take the job-level block for a duplicate. It is
|
||||
* an override, not a duplicate, and a comment saying so is not a check.
|
||||
*
|
||||
* `BackupExclusionsTest` is the precedent: a file that is configuration rather than code, load
|
||||
* bearing, and unguarded because nothing compiles it.
|
||||
*
|
||||
* **What this pins, and what it does not.** It asserts the declaration exists in the `release`
|
||||
* job's block. It cannot assert that a release actually publishes — that needs a tag push, which
|
||||
* is the thing no PR can do. So this is a tripwire against silent removal, not proof the release
|
||||
* path works.
|
||||
*/
|
||||
class ReleasePermissionTest {
|
||||
|
||||
@Test
|
||||
fun `the release job declares the write permission it needs to publish`() {
|
||||
val release = jobBlock("release")
|
||||
assertTrue(
|
||||
"build.yml's `release` job no longer declares `contents: write`. It is the only " +
|
||||
"permission that lets the job create a release, the top-level block above it is " +
|
||||
"`contents: read`, and nothing else in CI would catch this until a tag failed to " +
|
||||
"publish. If the release moved elsewhere, delete this test deliberately.",
|
||||
release.any { it.trimStart().startsWith("contents: write") },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines of one top-level job, from its ` <name>:` header to the next job at that indent.
|
||||
*
|
||||
* Line-based rather than parsed: the module has no YAML dependency, and adding one to read two
|
||||
* lines would be a worse trade than a scan that fails loudly when the shape changes.
|
||||
*/
|
||||
private fun jobBlock(name: String): List<String> {
|
||||
val lines = workflow.readLines()
|
||||
val start = lines.indexOfFirst { it == " $name:" }
|
||||
check(start >= 0) { "no ` $name:` job in ${workflow.path} — has the file been restructured?" }
|
||||
val rest = lines.drop(start + 1)
|
||||
val end = rest.indexOfFirst { it.matches(Regex("^ {2}[A-Za-z0-9_-]+:.*")) }
|
||||
return if (end < 0) rest else rest.take(end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Found by walking up rather than by a fixed relative path: Gradle's working directory for the
|
||||
* unit tests is the module, but that is a default rather than a promise.
|
||||
*/
|
||||
private val workflow: File
|
||||
get() = generateSequence(File(".").absoluteFile) { it.parentFile }
|
||||
.map { File(it, ".github/workflows/build.yml") }
|
||||
.firstOrNull { it.isFile }
|
||||
?: error("could not find .github/workflows/build.yml above ${File(".").absolutePath}")
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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.InputProbe
|
||||
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.
|
||||
*/
|
||||
/**
|
||||
* The sentinel is not just another unknown name, and the difference is the whole guard.
|
||||
*
|
||||
* `canDecode` ends `?: true` -- a name neither table knows keeps the permissive answer, because
|
||||
* the app would rather try than refuse a file it might handle. `InputProbe.UNPARSEABLE` has to
|
||||
* be the exception: the platform has *already* failed to parse the input, so there is nothing
|
||||
* for a decoder to be permissive about, and waving it through spends a Media3 attempt on a job
|
||||
* that cannot start.
|
||||
*
|
||||
* The `cinepak` line is what makes the sentinel line mean something. Without it, deleting the
|
||||
* early return leaves this test green -- both names would fall through to the same `?: true`.
|
||||
* The pair is the assertion.
|
||||
*
|
||||
* `DeviceCodecs.PERMISSIVE` carries the same rule and `ConversionRouterTest` pins its routing
|
||||
* consequence. This is the implementation that runs on a device.
|
||||
*/
|
||||
@Test
|
||||
fun `the unparseable sentinel is refused even where an unknown name is waved through`() {
|
||||
val everything = AndroidDeviceCodecs.forTesting(
|
||||
encoders = emptySet(),
|
||||
decoders = setOf("video/avc", "video/hevc"),
|
||||
)
|
||||
assertFalse(
|
||||
"the platform could not parse this input, so there is nothing to decode with",
|
||||
everything.canDecode(InputProbe.UNPARSEABLE),
|
||||
)
|
||||
assertTrue(
|
||||
"a merely unknown name still keeps the permissive answer",
|
||||
everything.canDecode("cinepak"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device without the decoder now says so for the aliases it used to wave through`() {
|
||||
val hevcOnly = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/hevc"))
|
||||
assertTrue("x265 is HEVC by another name", hevcOnly.canDecode("x265"))
|
||||
assertFalse("this device has no AVC decoder, and x264 is AVC", hevcOnly.canDecode("x264"))
|
||||
assertTrue("a name nobody knows keeps the permissive answer", hevcOnly.canDecode("cinepak"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the null policy, at the seam it exists for — #86.
|
||||
*
|
||||
* `mimeFor`'s `COPY, NONE -> null` arm carries its consequence in a comment: "Returning null
|
||||
* makes canEncode answer true, which is the right answer: a copied or absent track places no
|
||||
* demand on the hardware." That is a product decision, and until this test nothing held it. A
|
||||
* MIME appearing in that arm would make a device with no matching encoder refuse a stream copy
|
||||
* — a job that never encodes anything — and the router would send it to FFmpeg to re-mux what
|
||||
* Media3 could have re-muxed.
|
||||
*
|
||||
* The `H264` line is what makes the other two mean something: without it, a `canEncode` that
|
||||
* simply returned `true` would satisfy this test. `NONE` is asserted separately from `COPY`
|
||||
* because they are one arm today and two answers, and splitting the arm must not silently
|
||||
* halve the coverage.
|
||||
*/
|
||||
@Test
|
||||
fun `a device with no video encoder at all still permits a copied or absent track`() {
|
||||
val noEncoders = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/avc"))
|
||||
assertTrue(
|
||||
"a copied track is re-muxed, not encoded, so no encoder is required",
|
||||
noEncoders.canEncode(VideoCodec.COPY),
|
||||
)
|
||||
assertTrue("an absent track places no demand on the hardware", noEncoders.canEncode(VideoCodec.NONE))
|
||||
assertFalse(
|
||||
"this device has no AVC encoder, so an H.264 target has to be refused — without this, " +
|
||||
"a canEncode that always answered true would satisfy the two assertions above",
|
||||
noEncoders.canEncode(VideoCodec.H264),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package org.libremediaconverter.codec
|
||||
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.convert.Media3Engine
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
|
||||
/**
|
||||
* Bites on #86: a fifth `VideoCodec -> MIME` table, and nothing checking it agrees with the fourth.
|
||||
*
|
||||
* [AndroidDeviceCodecs.mimeFor] and [Media3Engine.videoMimeTypeFor] take the same enum and return a
|
||||
* MIME string, from opposite ends of one export. The first asks the device *"have you an encoder
|
||||
* for this?"*; the second tells Transformer *"produce this."* If they name different MIME types for
|
||||
* the same codec, the app checks for one encoder and then requests another — the check passes, the
|
||||
* export succeeds, and the user's H.265 file contains H.264. Both were `private` until #85 and #87
|
||||
* widened them, so this assertion could not be written before; each table had per-arm tests that
|
||||
* pinned its own answers and could not see the other side.
|
||||
*
|
||||
* **They do not agree everywhere, and must not be forced to.** Three buckets, all pinned below:
|
||||
*
|
||||
* - **H.264 and H.265** — both tables name a MIME, and it has to be the same one. This is the
|
||||
* bucket the defect lives in.
|
||||
* - **VP8, VP9 and AV1** — the device table names a real MIME, Transformer's returns null. That is
|
||||
* correct, not drift: `Transformer.setVideoMimeType` will not accept them, so the router sends
|
||||
* them to FFmpeg before Media3 is asked anything, while a device may still genuinely own a VP9
|
||||
* encoder and `canEncode` has to give a truthful answer about it. Flattening `mimeFor` to null
|
||||
* here to "make the tables agree" would make `canEncode(VP9)` answer true on hardware that has
|
||||
* no VP9 encoder. The routing half of that claim is proved in
|
||||
* `Media3EngineMimeTypesTest.the router sends exactly H264 and H265 video encodes to Media3`,
|
||||
* which drives the real router; it is not repeated here.
|
||||
* - **COPY and NONE** — neither names a MIME, because neither is encoded at all.
|
||||
*
|
||||
* The fourth bucket is asserted empty: a codec Transformer names and the device check cannot ask
|
||||
* about would mean `canEncode` waving through a target the app then really does encode.
|
||||
*
|
||||
* **Audio has no partner, and that is a gap rather than a decision.** [Media3Engine.audioMimeTypeFor]
|
||||
* is the same shape one enum over — `AudioCodec -> MIME` — but [AndroidDeviceCodecs] enumerates
|
||||
* `video/` MIME types only, so there is no device-side audio table to cross-check it against. An
|
||||
* audio encoder this device lacks is therefore not caught up front the way a video one is; the job
|
||||
* reaches Media3 and falls back after failing. Named here so the asymmetry reads as unfinished
|
||||
* rather than intended.
|
||||
*/
|
||||
@UnstableApi
|
||||
class VideoCodecMimeAgreementTest {
|
||||
|
||||
/** Both tables name a MIME. The pair has to match; this is the whole point of the file. */
|
||||
private val bothNameAMime = setOf(VideoCodec.H264, VideoCodec.H265)
|
||||
|
||||
/** Only the device table names one, because Transformer is never asked for these. */
|
||||
private val deviceOnly = setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1)
|
||||
|
||||
/** Neither names one: nothing is encoded, so there is no encoder to name. */
|
||||
private val neitherNamesOne = setOf(VideoCodec.COPY, VideoCodec.NONE)
|
||||
|
||||
/**
|
||||
* Sorts every [VideoCodec] by what the two tables actually answer, then compares the sorting
|
||||
* with the buckets documented above.
|
||||
*
|
||||
* This is what makes the agreement test below non-vacuous, and it is deliberately an exact
|
||||
* comparison in all four directions. A codec added to the enum lands in some bucket and fails
|
||||
* here rather than arriving unclassified. A table that starts returning null for everything —
|
||||
* the shape a filtered loop would pass on — empties two buckets and fails here. And a
|
||||
* *convergence* fails too: giving `videoMimeTypeFor(VP9)` a real MIME moves VP9 out of
|
||||
* `deviceOnly`, which is the point. The divergence should be deliberate and visible, so
|
||||
* changing it should require saying so in this file.
|
||||
*/
|
||||
@Test
|
||||
fun `each video codec is in the bucket the two tables actually put it in`() {
|
||||
assertEquals(
|
||||
"codecs both tables name a MIME for",
|
||||
bothNameAMime,
|
||||
VideoCodec.entries.filter { device(it) != null && transformer(it) != null }.toSet(),
|
||||
)
|
||||
assertEquals(
|
||||
"codecs only the device check names a MIME for, because Transformer will not encode them",
|
||||
deviceOnly,
|
||||
VideoCodec.entries.filter { device(it) != null && transformer(it) == null }.toSet(),
|
||||
)
|
||||
assertEquals(
|
||||
"codecs neither table names a MIME for, because nothing is encoded",
|
||||
neitherNamesOne,
|
||||
VideoCodec.entries.filter { device(it) == null && transformer(it) == null }.toSet(),
|
||||
)
|
||||
assertEquals(
|
||||
"codecs Transformer names a MIME for that the device check cannot ask about — canEncode " +
|
||||
"would answer true without looking, for a codec Media3 really is told to produce",
|
||||
emptySet<VideoCodec>(),
|
||||
VideoCodec.entries.filter { device(it) == null && transformer(it) != null }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The cross-check itself.
|
||||
*
|
||||
* Per-arm tests in either file cannot catch this: each pins its own table's answers, so a pair
|
||||
* changed in lockstep with its own expectations stays green on both sides while the two tables
|
||||
* describe different codecs.
|
||||
*/
|
||||
@Test
|
||||
fun `where both tables name a MIME they name the same one`() {
|
||||
bothNameAMime.forEach { codec ->
|
||||
val asked = device(codec)
|
||||
val requested = transformer(codec)
|
||||
assertNotNull("AndroidDeviceCodecs has no MIME to ask the device about for ${codec.label}", asked)
|
||||
assertNotNull("Media3Engine has no MIME to give Transformer for ${codec.label}", requested)
|
||||
assertEquals(
|
||||
"${codec.label}: the device is asked about $asked and Transformer is then told to " +
|
||||
"produce $requested, so the capability check answers about a codec that is not the output",
|
||||
asked,
|
||||
requested,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The documented divergence, asserted rather than described.
|
||||
*
|
||||
* Both halves matter. The null side is Media3's refusal; the non-null side is the device
|
||||
* check's genuine question, and it is the half a reader "tidying up" the disagreement would
|
||||
* delete.
|
||||
*/
|
||||
@Test
|
||||
fun `the codecs Transformer will not encode are still codecs this device may or may not have`() {
|
||||
deviceOnly.forEach { codec ->
|
||||
assertNotNull(
|
||||
"${codec.label} goes to FFmpeg, but canEncode still has to answer truthfully about " +
|
||||
"this device's encoder — a null here makes it answer true without looking",
|
||||
device(codec),
|
||||
)
|
||||
assertNull(
|
||||
"Transformer rejects ${codec.label}, so naming a MIME for it would request an export " +
|
||||
"Media3 cannot perform",
|
||||
transformer(codec),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards every comparison above against passing as `null == null`.
|
||||
*
|
||||
* `MediaFormat`'s MIME types are Java compile-time constants and are inlined, so the unit-test
|
||||
* classpath's stubbed `android.jar` never supplies them; `MimeTypes`' come from a real
|
||||
* `media3-common` jar. If either stopped holding, the buckets would collapse and this fails
|
||||
* first, with the reason. Same guard, and the same reason, as
|
||||
* `CodecVocabularyTest.the MIME constants are real strings rather than stubs`.
|
||||
*/
|
||||
@Test
|
||||
fun `both tables return real MIME strings rather than stubs`() {
|
||||
assertEquals("video/avc", AndroidDeviceCodecs.mimeFor(VideoCodec.H264))
|
||||
assertEquals("video/hevc", AndroidDeviceCodecs.mimeFor(VideoCodec.H265))
|
||||
assertEquals("video/x-vnd.on2.vp9", AndroidDeviceCodecs.mimeFor(VideoCodec.VP9))
|
||||
assertEquals("video/avc", Media3Engine.videoMimeTypeFor(VideoCodec.H264))
|
||||
assertEquals("video/hevc", Media3Engine.videoMimeTypeFor(VideoCodec.H265))
|
||||
}
|
||||
|
||||
private fun device(codec: VideoCodec): String? = AndroidDeviceCodecs.mimeFor(codec)
|
||||
|
||||
private fun transformer(codec: VideoCodec): String? = Media3Engine.videoMimeTypeFor(codec)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
|
||||
import androidx.compose.runtime.saveable.SaveableStateRegistry
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* What `AdvancedPickerTest`'s restoration test cannot see.
|
||||
*
|
||||
* `StateRestorationTester` saves into an **in-memory map**, never a `Bundle`. That is enough to
|
||||
* discriminate `rememberSaveable` from `remember`, and it is where it stops: the map holds object
|
||||
* references, so a value the platform could never parcel goes in and comes back out looking green.
|
||||
* `AppRootRestorationTest` has the same blind spot and `DestinationSaverTest` is the split it
|
||||
* prompted; this is that split for `expanded`, the only `rememberSaveable` on either screen's
|
||||
* leaves.
|
||||
*
|
||||
* ### The saved representation is not the Boolean
|
||||
*
|
||||
* `var expanded by rememberSaveable { mutableStateOf(false) }` passes no `stateSaver`, so
|
||||
* `autoSaver` saves **the `MutableState` itself**, not the `false` inside it. That works only
|
||||
* because `mutableStateOf` on Android returns a `Parcelable` implementation -- the same call on a
|
||||
* plain JVM returns one that is not. So what stands between an open panel and a rotation that
|
||||
* closes it is a platform-specific detail of a factory function nothing here names directly, and
|
||||
* an in-memory map cannot tell the two apart.
|
||||
*
|
||||
* Pinning it is the move `DestinationSaverTest` makes about names versus ordinals. Passing an
|
||||
* explicit `stateSaver` would save a bare `Boolean` instead and is a perfectly reasonable edit --
|
||||
* it is just not the one in the tree, and it should be made on purpose rather than discovered
|
||||
* after a rotation.
|
||||
*
|
||||
* ### Shared bite, stated rather than implied
|
||||
*
|
||||
* `rememberSaveable` -> `remember` empties the registry, so it reddens this file *and* the
|
||||
* restoration test in `AdvancedPickerTest`. Both failures belong in any report of that mutation.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AdvancedPanelSavedStateTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/**
|
||||
* `canBeSaved = { true }` deliberately.
|
||||
*
|
||||
* A predicate mirroring what a `Bundle` accepts would be a hand-written copy of the thing
|
||||
* under test, and a `false` from it *drops* the entry silently -- so the test would fail by
|
||||
* finding nothing saved, which is also how a `remember` regression fails. Two causes, one
|
||||
* symptom, is not a test. The type is checked on the way out instead.
|
||||
*/
|
||||
private val registry = SaveableStateRegistry(restoredValues = null, canBeSaved = { true })
|
||||
|
||||
@Test
|
||||
fun `the panel registers its open state with the registry, and nothing else`() {
|
||||
setPicker()
|
||||
|
||||
// Collapsed is a saved value, not an absent one: `rememberSaveable` registers its provider
|
||||
// on first composition, whatever the state happens to be. Exactly one, because `expanded`
|
||||
// is the only saveable in the subtree -- a second would mean something else began saving.
|
||||
assertEquals(1, savedValues().size)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
|
||||
|
||||
val saved = theOneSavedValue()
|
||||
assertTrue("saved as ${saved?.javaClass?.name}", saved is MutableState<*>)
|
||||
assertEquals(true, (saved as MutableState<*>).value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the open panel survives a real Parcel, not just an in-memory map`() {
|
||||
setPicker()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
|
||||
|
||||
val saved = theOneSavedValue()
|
||||
|
||||
// The claim the restoration test cannot make. A `MutableState` that was not `Parcelable`
|
||||
// would satisfy `StateRestorationTester` and then be dropped by the platform.
|
||||
assertTrue("saved as ${saved?.javaClass?.name}", saved is Parcelable)
|
||||
|
||||
val restored = throughARealBundle(saved as Parcelable)
|
||||
|
||||
assertTrue("restored as ${restored.javaClass.name}", restored is MutableState<*>)
|
||||
assertEquals(true, (restored as MutableState<*>).value)
|
||||
}
|
||||
|
||||
private fun setPicker() {
|
||||
composeRule.setContent {
|
||||
CompositionLocalProvider(LocalSaveableStateRegistry provides registry) {
|
||||
AdvancedPicker(
|
||||
spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC),
|
||||
validation = Validation.Valid,
|
||||
onContainer = {},
|
||||
onVideoCodec = {},
|
||||
onAudioCodec = {},
|
||||
onSuggestion = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every value the picker hands the host to persist, keys dropped -- they are positional. */
|
||||
private fun savedValues(): List<Any?> = composeRule.runOnIdle { registry.performSave().values.flatten() }
|
||||
|
||||
/**
|
||||
* The single saved value, asserted rather than assumed.
|
||||
*
|
||||
* `single()` on an empty list throws `NoSuchElementException: List is empty`, which names
|
||||
* neither the panel nor the registry -- and an empty registry is exactly how the
|
||||
* `rememberSaveable` -> `remember` regression shows up here.
|
||||
*/
|
||||
private fun theOneSavedValue(): Any? {
|
||||
val values = savedValues()
|
||||
assertEquals("the panel should register exactly one saved value", 1, values.size)
|
||||
return values.first()
|
||||
}
|
||||
|
||||
/** A write and a read through a real `Parcel`, which is what the tester's map stands in for. */
|
||||
private fun throughARealBundle(value: Parcelable): Parcelable {
|
||||
val bundle = Bundle().apply { putParcelable(KEY, value) }
|
||||
val parcel = Parcel.obtain()
|
||||
return try {
|
||||
parcel.writeBundle(bundle)
|
||||
parcel.setDataPosition(0)
|
||||
val restored = requireNotNull(parcel.readBundle(javaClass.classLoader)) {
|
||||
"the Bundle did not survive the Parcel"
|
||||
}
|
||||
requireNotNull(restored.getParcelable(KEY, Parcelable::class.java)) {
|
||||
"the saved state did not survive the Parcel"
|
||||
}
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY = "expanded"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ContainerCapabilities
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The gate over the Advanced chips, and the error card that deliberately sits outside it.
|
||||
*
|
||||
* Two defects, and they pull in opposite directions.
|
||||
*
|
||||
* The first is the chips escaping the gate, or never being reachable through it. `AdvancedPicker`
|
||||
* is the one leaf on this screen that is not stateless -- `expanded` is its own `rememberSaveable`
|
||||
* -- and Container, Video and Audio live inside `AnimatedVisibility(visible = expanded)`. Nothing
|
||||
* else on the screen hides anything, so a refactor that flattened the panel, or wired the toggle to
|
||||
* a state nobody reads, would render an app that looks reasonable in a screenshot and is wrong.
|
||||
*
|
||||
* The second is the opposite mistake, and it is the one this file exists for: **moving the
|
||||
* `ValidationError` call inside the `AnimatedVisibility`**. It is invoked after that block, so an
|
||||
* invalid spec explains itself and offers one-tap fixes *while the section is collapsed*. That is
|
||||
* the only route out of an invalid spec for a user who never opened Advanced -- and since the only
|
||||
* way to reach an invalid spec is through Advanced, hiding the way out behind the same toggle looks
|
||||
* locally sensible and is a trap. Tidying the two `if` blocks into one is a plausible edit, it
|
||||
* compiles, and until this file existed nothing went red. Every assertion about the error card here
|
||||
* therefore runs with the toggle untouched, and asserts the panel is absent in the same test, so a
|
||||
* future `expanded = true` default cannot quietly satisfy it either.
|
||||
*
|
||||
* The invalid specs come from [ContainerCapabilities.validate] rather than from a hand-built
|
||||
* [Validation.Invalid], so the messages and the suggestions are the real pairing. A hand-built one
|
||||
* would keep passing after `validate` stopped producing anything like it.
|
||||
*
|
||||
* Node location is by the three separate chip-row tags, never by text. `"Copy"` and `"None"` are
|
||||
* each both a [VideoCodec] and an [AudioCodec], and `"MP3"` and `"FLAC"` are each both a
|
||||
* [Container] and an [AudioCodec], so a text matcher over the open panel is ambiguous for four
|
||||
* chips -- which is what the separate tags are for.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AdvancedPickerTest {
|
||||
|
||||
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
|
||||
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
|
||||
// deliberate: the v2 package has no tester of its own and the two do interoperate.
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private val restoration = StateRestorationTester(composeRule)
|
||||
|
||||
private val containers = mutableListOf<Container>()
|
||||
private val videoCodecs = mutableListOf<VideoCodec>()
|
||||
private val audioCodecs = mutableListOf<AudioCodec>()
|
||||
private val applied = mutableListOf<OutputSpec>()
|
||||
|
||||
// --- the expand gate ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the three chip rows appear only while the panel is expanded`() {
|
||||
setPicker()
|
||||
|
||||
assertPanelHidden()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertExists()
|
||||
ROW_TAGS.forEach { composeRule.onNodeWithTag(it).assertExists() }
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
|
||||
|
||||
// The exit transition outlives the click, so absence has to be waited for rather than
|
||||
// asserted straight away -- unlike the initial collapsed state, which has no animation
|
||||
// in flight.
|
||||
composeRule.waitUntil { nodeCount(TestTags.Converter.ADVANCED_PANEL) == 0 }
|
||||
assertPanelHidden()
|
||||
}
|
||||
|
||||
/** The toggle is the only affordance the collapsed picker offers, so it has to say so. */
|
||||
@Test
|
||||
fun `the toggle names the direction it will move in`() {
|
||||
setPicker()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).assertTextEquals("Advanced")
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE)
|
||||
.assertTextEquals("Hide advanced")
|
||||
}
|
||||
|
||||
/**
|
||||
* The four colliding labels, one per row.
|
||||
*
|
||||
* `"Copy"` is a video codec *and* an audio codec; `"MP3"` is a container *and* an audio codec.
|
||||
* Clicking each through its own row is what proves the rows are wired to different callbacks
|
||||
* -- a picker that handed every chip to `onAudioCodec` would look identical on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `each chip row reports to its own callback, including the labels that collide`() {
|
||||
setPicker()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
|
||||
|
||||
chipIn(TestTags.Converter.ADVANCED_VIDEO_CHIPS, "Copy").performClick()
|
||||
|
||||
assertEquals(listOf(VideoCodec.COPY), videoCodecs)
|
||||
assertEquals(emptyList<AudioCodec>(), audioCodecs)
|
||||
|
||||
chipIn(TestTags.Converter.ADVANCED_AUDIO_CHIPS, "Copy").performClick()
|
||||
|
||||
assertEquals(listOf(AudioCodec.COPY), audioCodecs)
|
||||
|
||||
chipIn(TestTags.Converter.ADVANCED_CONTAINER_CHIPS, "MP3").performClick()
|
||||
|
||||
assertEquals(listOf(Container.MP3), containers)
|
||||
// Still only the one audio click. `MP3` is an AudioCodec label too, and the container row
|
||||
// must not be reporting through that callback.
|
||||
assertEquals(listOf(AudioCodec.COPY), audioCodecs)
|
||||
}
|
||||
|
||||
// --- the error card, which is outside the gate --------------------------
|
||||
|
||||
/**
|
||||
* The headline case. Dropping both tracks is reachable from the collapsed screen -- the
|
||||
* `None`/`None` pair is set inside Advanced, but the user can close it again -- and the
|
||||
* explanation has to still be there.
|
||||
*/
|
||||
@Test
|
||||
fun `an empty output explains itself while the section is collapsed`() {
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE)
|
||||
val invalid = invalidFor(spec)
|
||||
|
||||
assertEquals("This would produce an empty file — keep at least one track.", invalid.message)
|
||||
|
||||
setPicker(spec, invalid)
|
||||
|
||||
assertPanelHidden()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.VALIDATION_ERROR).assertExists()
|
||||
composeRule.onNodeWithText(invalid.message).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a codec the container cannot hold explains itself while the section is collapsed`() {
|
||||
val spec = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.OPUS)
|
||||
val invalid = invalidFor(spec)
|
||||
|
||||
assertEquals("WebM cannot hold H.264 video.", invalid.message)
|
||||
|
||||
setPicker(spec, invalid)
|
||||
|
||||
assertPanelHidden()
|
||||
composeRule.onNodeWithText(invalid.message).assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicking a suggestion, with the toggle never touched.
|
||||
*
|
||||
* The second suggestion rather than the first, and its count pinned first: with one suggestion
|
||||
* a picker that handed every chip `suggestions[0]` would pass, and `onNodeWithTag` on a
|
||||
* suggestion index that no longer exists reports an unhelpful matcher failure rather than
|
||||
* saying the list shrank.
|
||||
*/
|
||||
@Test
|
||||
fun `a suggestion chip applies its own spec without the section ever being opened`() {
|
||||
val spec = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.OPUS)
|
||||
val invalid = invalidFor(spec)
|
||||
|
||||
assertEquals(2, invalid.suggestions.size)
|
||||
val second = invalid.suggestions[1]
|
||||
|
||||
setPicker(spec, invalid)
|
||||
|
||||
assertPanelHidden()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.suggestion(1)).assertTextEquals(describe(second))
|
||||
composeRule.onNodeWithTag(TestTags.Converter.suggestion(1)).performClick()
|
||||
|
||||
assertEquals(listOf(second), applied)
|
||||
// What the chips offer is what `validate` said would work, not a repair of the test's own.
|
||||
assertTrue(
|
||||
"suggestion $second should itself validate",
|
||||
ContainerCapabilities.validate(second, PROBE).isValid,
|
||||
)
|
||||
}
|
||||
|
||||
/** A valid spec has nothing to say, collapsed or not. */
|
||||
@Test
|
||||
fun `a valid spec renders no error card`() {
|
||||
setPicker()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.VALIDATION_ERROR).assertDoesNotExist()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.VALIDATION_ERROR).assertDoesNotExist()
|
||||
}
|
||||
|
||||
// --- recreation ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `expanded` is the only `rememberSaveable` on either screen's leaves.
|
||||
*
|
||||
* `MainActivity` declares no `configChanges`, so a rotation destroys and rebuilds the whole
|
||||
* composition. A panel the user opened, set three chips in, and left open must not close
|
||||
* itself on the way back. `remember` would.
|
||||
*
|
||||
* What this cannot see is the saved *representation* -- `StateRestorationTester` saves into an
|
||||
* in-memory map rather than a `Bundle`. `AdvancedPanelSavedStateTest` covers that half.
|
||||
*/
|
||||
@Test
|
||||
fun `an open panel is still open after recreation`() {
|
||||
restoration.setContent {
|
||||
AdvancedPicker(
|
||||
spec = VALID_SPEC,
|
||||
validation = Validation.Valid,
|
||||
onContainer = {},
|
||||
onVideoCodec = {},
|
||||
onAudioCodec = {},
|
||||
onSuggestion = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertExists()
|
||||
|
||||
restoration.emulateSavedInstanceStateRestore()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertExists()
|
||||
ROW_TAGS.forEach { composeRule.onNodeWithTag(it).assertExists() }
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE)
|
||||
.assertTextEquals("Hide advanced")
|
||||
}
|
||||
|
||||
/** The default has to survive too, or the panel would spring open on every rotation. */
|
||||
@Test
|
||||
fun `a collapsed panel is still collapsed after recreation`() {
|
||||
restoration.setContent {
|
||||
AdvancedPicker(
|
||||
spec = VALID_SPEC,
|
||||
validation = Validation.Valid,
|
||||
onContainer = {},
|
||||
onVideoCodec = {},
|
||||
onAudioCodec = {},
|
||||
onSuggestion = {},
|
||||
)
|
||||
}
|
||||
|
||||
assertPanelHidden()
|
||||
|
||||
restoration.emulateSavedInstanceStateRestore()
|
||||
|
||||
assertPanelHidden()
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private fun setPicker(spec: OutputSpec = VALID_SPEC, validation: Validation = Validation.Valid) {
|
||||
composeRule.setContent {
|
||||
AdvancedPicker(
|
||||
spec = spec,
|
||||
validation = validation,
|
||||
onContainer = { containers += it },
|
||||
onVideoCodec = { videoCodecs += it },
|
||||
onAudioCodec = { audioCodecs += it },
|
||||
onSuggestion = { applied += it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole panel, by every tag it owns, so a partial escape counts as a failure. */
|
||||
private fun assertPanelHidden() {
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertDoesNotExist()
|
||||
ROW_TAGS.forEach { composeRule.onNodeWithTag(it).assertDoesNotExist() }
|
||||
}
|
||||
|
||||
private fun nodeCount(tag: String) = composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().size
|
||||
|
||||
private fun chipIn(rowTag: String, label: String) =
|
||||
composeRule.onNode(hasText(label) and hasAnyAncestor(hasTestTag(rowTag)))
|
||||
|
||||
private fun invalidFor(spec: OutputSpec): Validation.Invalid {
|
||||
val validation = ContainerCapabilities.validate(spec, PROBE)
|
||||
return validation as? Validation.Invalid
|
||||
?: throw AssertionError("$spec was expected to be invalid, but validate said $validation")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ROW_TAGS = listOf(
|
||||
TestTags.Converter.ADVANCED_CONTAINER_CHIPS,
|
||||
TestTags.Converter.ADVANCED_VIDEO_CHIPS,
|
||||
TestTags.Converter.ADVANCED_AUDIO_CHIPS,
|
||||
)
|
||||
|
||||
val VALID_SPEC = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC)
|
||||
|
||||
/** An ordinary H.264/AAC MP4, so the suggestions have a real source to repair towards. */
|
||||
val PROBE = InputProbe(
|
||||
videoCodec = "h264",
|
||||
audioCodec = "aac",
|
||||
durationMs = 90_000,
|
||||
container = Container.MP4,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Every answer [conversionStateFrom] can give, chosen rather than stumbled into.
|
||||
*
|
||||
* ## What this revises
|
||||
*
|
||||
* The mapping is not cold code and never was: `ConversionViewModel$observe$1$1` reported 28 covered
|
||||
* lines before this file existed, because every test that drives a real worker runs it. What no
|
||||
* test did was **choose which arm it took**. A real worker reaches a terminal state with
|
||||
* well-formed output, so `SUCCEEDED`-with-a-path and `FAILED`-with-a-message were the only arms any
|
||||
* test had ever produced — the other six ran never.
|
||||
*
|
||||
* A `grep` for `WorkInfo.State.` across the JVM suite makes that look untrue: all six constants are
|
||||
* there. They are in `ReattachmentTest`, driven into **`Reattachment.choose`** — a different
|
||||
* function that encodes the same enqueued-means-retry rule. So that rule had a test in one of its
|
||||
* two homes, and the copy the user's screen reads had none.
|
||||
*
|
||||
* ## Why the seam, and why these assertions
|
||||
*
|
||||
* `WorkManager.getInstance` is called in the ViewModel's constructor and `observe` is private, so
|
||||
* nothing could hand this a chosen `WorkInfo`. Cutting the `when` out as a pure function over
|
||||
* [ConversionUpdate] is the answer #141 took for `MediaProbe`, and `JobSnapshot` beside
|
||||
* `Reattachment.choose` is the same shape again.
|
||||
*
|
||||
* The assertions are on the whole state, not on its type. `Converting(input, 40)` and
|
||||
* `Converting(input, 0)` are both `Converting`, and a mapping that dropped the progress read would
|
||||
* pass any test that only asked which class came back.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConversionStateMappingTest {
|
||||
|
||||
// --- running ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a running job reports the progress it published`() {
|
||||
// The percent is read from `progress`, not from `outputData`, and not from the settings.
|
||||
// A mapping that returned Converting(input, 0) for every RUNNING would leave the bar
|
||||
// pinned at zero for the whole conversion.
|
||||
val state = map(WorkInfo.State.RUNNING, progress = 40)
|
||||
|
||||
assertEquals(ConversionState.Converting(INPUT, 40), state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running job with no published progress reports zero rather than failing`() {
|
||||
// getInt's default. A worker that has started but not yet called setProgress is ordinary,
|
||||
// and must not read as an error.
|
||||
val state = map(WorkInfo.State.RUNNING, progress = null)
|
||||
|
||||
assertEquals(ConversionState.Converting(INPUT, 0), state)
|
||||
}
|
||||
|
||||
// --- enqueued: the rule that had a test only in its other home ----------
|
||||
|
||||
@Test
|
||||
fun `an enqueued job that has already run is waiting to retry`() {
|
||||
val state = map(WorkInfo.State.ENQUEUED, runAttemptCount = 1)
|
||||
|
||||
assertEquals(
|
||||
"an ENQUEUED after a run is a pending retry, which the user is told about",
|
||||
ConversionState.Waiting(INPUT),
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an enqueued job that has never run is simply starting`() {
|
||||
// The other side, and the reason the test above is not enough on its own: a mapping that
|
||||
// ignored runAttemptCount and always answered Waiting would pass that one and fail this.
|
||||
val state = map(WorkInfo.State.ENQUEUED, runAttemptCount = 0)
|
||||
|
||||
assertEquals(ConversionState.Converting(INPUT, 0), state)
|
||||
}
|
||||
|
||||
// --- succeeded ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a success that named no file is a failure, not an empty success`() {
|
||||
// The job said it finished and named nothing. There is no file to offer, so `Converted`
|
||||
// would put a Save button over a path that does not exist.
|
||||
val state = map(WorkInfo.State.SUCCEEDED, data = Data.EMPTY)
|
||||
|
||||
assertEquals(ConversionState.Failed(SUCCEEDED_WITHOUT_A_FILE_MESSAGE), state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success carries the worker's own name and type, not the current settings`() {
|
||||
val state = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to "/cache/conversions/out.mkv",
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mkv",
|
||||
ConversionWorker.KEY_MIME_TYPE to "video/x-matroska",
|
||||
ConversionWorker.KEY_ENGINE_USED to "FFMPEG",
|
||||
ConversionWorker.KEY_ROUTE_REASON to "container needs FFmpeg",
|
||||
),
|
||||
)
|
||||
|
||||
val converted = state as ConversionState.Converted
|
||||
assertEquals("holiday.mkv", converted.suggestedName)
|
||||
assertEquals("video/x-matroska", converted.mimeType)
|
||||
assertEquals("FFMPEG", converted.engineUsed)
|
||||
assertEquals("container needs FFmpeg", converted.routeReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success from older work falls back to the current settings for name and type`() {
|
||||
// WorkManager keeps finished work about a week, so a job enqueued before the worker
|
||||
// reported these is ordinary for a few days rather than a corner case.
|
||||
val state = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(ConversionWorker.KEY_OUTPUT_PATH to "/cache/conversions/out.mp4"),
|
||||
)
|
||||
|
||||
val converted = state as ConversionState.Converted
|
||||
assertEquals(FALLBACK_SPEC.mimeType, converted.mimeType)
|
||||
assertEquals(
|
||||
ConversionWorker.outputNameFor(INPUT.displayName, FALLBACK_SPEC),
|
||||
converted.suggestedName,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank name or type falls back the same way a missing one does`() {
|
||||
// A blank string is not an answer. Without takeIf, the save dialog opens named "" and
|
||||
// registered for a MIME type of "", which no provider will accept.
|
||||
val state = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to "/cache/conversions/out.mp4",
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to "",
|
||||
ConversionWorker.KEY_MIME_TYPE to " ",
|
||||
),
|
||||
)
|
||||
|
||||
val converted = state as ConversionState.Converted
|
||||
assertEquals(FALLBACK_SPEC.mimeType, converted.mimeType)
|
||||
assertEquals(
|
||||
ConversionWorker.outputNameFor(INPUT.displayName, FALLBACK_SPEC),
|
||||
converted.suggestedName,
|
||||
)
|
||||
}
|
||||
|
||||
// --- failed -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a failure carries the reason the worker gave`() {
|
||||
val state = map(
|
||||
WorkInfo.State.FAILED,
|
||||
data = workDataOf(ConversionWorker.KEY_ERROR to "Not enough free space to convert."),
|
||||
)
|
||||
|
||||
assertEquals(ConversionState.Failed("Not enough free space to convert."), state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure with nothing said still says something`() {
|
||||
// A worker killed before it could write output data leaves none at all -- a refused
|
||||
// foreground start after a process restart is one way. Failed("") would render as a blank
|
||||
// error card.
|
||||
val state = map(WorkInfo.State.FAILED, data = Data.EMPTY)
|
||||
|
||||
assertEquals(ConversionState.Failed(ConversionWorker.GENERIC_FAILURE_MESSAGE), state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure whose message is blank falls back like a missing one`() {
|
||||
val state = map(
|
||||
WorkInfo.State.FAILED,
|
||||
data = workDataOf(ConversionWorker.KEY_ERROR to " "),
|
||||
)
|
||||
|
||||
assertEquals(ConversionState.Failed(ConversionWorker.GENERIC_FAILURE_MESSAGE), state)
|
||||
}
|
||||
|
||||
// --- cancelled and blocked ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a cancellation lands wherever the caller said it should`() {
|
||||
// Not a fixed state: a conversion started here goes back to Ready with the picked file,
|
||||
// while one picked up by reattach goes to Idle, because the URI that job holds belongs to
|
||||
// a process that no longer exists. `observe`'s KDoc is where that distinction is set.
|
||||
val toReady = map(WorkInfo.State.CANCELLED, cancelled = ConversionState.Ready(INPUT))
|
||||
val toIdle = map(WorkInfo.State.CANCELLED, cancelled = ConversionState.Idle)
|
||||
|
||||
assertEquals(ConversionState.Ready(INPUT), toReady)
|
||||
assertEquals(ConversionState.Idle, toIdle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blocked job looks like one that is starting`() {
|
||||
// BLOCKED is a job waiting on a prerequisite. There is nothing useful to say about it that
|
||||
// differs from "starting", and inventing a state for it would put a word on screen the
|
||||
// user cannot act on.
|
||||
val state = map(WorkInfo.State.BLOCKED)
|
||||
|
||||
assertEquals(ConversionState.Converting(INPUT, 0), state)
|
||||
}
|
||||
|
||||
private fun map(
|
||||
state: WorkInfo.State,
|
||||
progress: Int? = null,
|
||||
runAttemptCount: Int = 0,
|
||||
data: Data = Data.EMPTY,
|
||||
cancelled: ConversionState = ConversionState.Ready(INPUT),
|
||||
): ConversionState = conversionStateFrom(
|
||||
ConversionUpdate(
|
||||
state = state,
|
||||
// Modelled on the call site, which reads `getInt(KEY_PROGRESS, 0)` -- so "no progress
|
||||
// published" is the default reaching the mapping, not a null it has to handle.
|
||||
progressPercent = progress ?: 0,
|
||||
runAttemptCount = runAttemptCount,
|
||||
outputData = data,
|
||||
),
|
||||
input = INPUT,
|
||||
cancelled = cancelled,
|
||||
fallbackSpec = FALLBACK_SPEC,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val INPUT = InputFile(Uri.parse("content://test/holiday.mov"), "holiday.mov", 4096L)
|
||||
val FALLBACK_SPEC = OutputFormat.MP4_H265.spec
|
||||
}
|
||||
}
|
||||
@@ -93,8 +93,12 @@ class ConversionViewModelCleanupTest {
|
||||
assertTrue("a failed save must not destroy the only copy", staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
|
||||
// Failed carries no file reference at all, so this only works because the handle is
|
||||
// a ViewModel field rather than something read back out of the state machine.
|
||||
// The handle is a ViewModel field rather than something read back out of the state
|
||||
// machine, and stays one now that a save-failed `Failed` also carries a `PendingSave`:
|
||||
// that is a view for the screen to offer a retry through, never a second owner of the
|
||||
// file. This delete goes through the field, which is what keeps a state that is dropped
|
||||
// rather than read from taking the only reference with it. What the state carries, and
|
||||
// what the screen then does with it, are `FailedSaveRetryTest`'s.
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
|
||||
+32
-27
@@ -2,14 +2,15 @@ package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -18,8 +19,6 @@ import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* That a probe which throws leaves a screen the user can act on, not a dead coroutine.
|
||||
@@ -87,19 +86,35 @@ class ConversionViewModelProbeFailureTest {
|
||||
* is out of memory" into "this video looks unreadable" and let the app carry on in a
|
||||
* state it cannot honour — which is the regression a blanket `catch (Throwable)` would
|
||||
* have introduced, and the reason this defect was left open rather than fixed carelessly.
|
||||
*
|
||||
* **The error itself is what is asserted here, and that is what the `pickDispatcher` seam
|
||||
* bought.** With the hop hard-coded to `Dispatchers.IO` this was impossible: the throw
|
||||
* happened on a pool thread some time after this method had returned, so all a test could do
|
||||
* was infer it from a card that never filled in — which is also what a probe returning null
|
||||
* would look like. Worse, the escaped error went into kotlinx-coroutines-test's process-wide
|
||||
* collector and was rethrown at whichever `runTest` started next, which is a *different*
|
||||
* Compose class between runs of identical code. Putting the pick on [Dispatchers.Unconfined]
|
||||
* runs it inline, inside a `runTest` whose scope owns the collector's callback: the error is
|
||||
* handed to this test and consumed, rather than stored for a stranger.
|
||||
*
|
||||
* Note where it surfaces — at the end of `runTest`, not inside `onInputPicked`. `launch`
|
||||
* gives an escaped error to the handler chain and never to its caller, so nothing can catch
|
||||
* it at the call itself. This is as close as the coroutine machinery allows, and unlike the
|
||||
* old assertion it is the real [OutOfMemoryError] instance.
|
||||
*/
|
||||
@Test
|
||||
fun `an OutOfMemoryError is not swallowed`() {
|
||||
ConversionDependencies.probe = { _, _ -> throw OutOfMemoryError("Failed to allocate 512 MB") }
|
||||
// Unconfined for the pick, so the whole of onInputPicked runs inline on this thread and
|
||||
// has thrown before runTest can leave the scope that has to receive the error.
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined, Dispatchers.Unconfined)
|
||||
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(INPUT)
|
||||
val escaped = assertThrows(OutOfMemoryError::class.java) { runTest { viewModel.onInputPicked(INPUT) } }
|
||||
|
||||
// The observable difference, and the reason this is asserted on state rather than on a
|
||||
// caught throwable: the probe hop is on Dispatchers.IO, so an error that escapes lands
|
||||
// on that thread's handler rather than at this call. What must not happen is the card
|
||||
// filling in with an "unreadable" verdict the app would then act on.
|
||||
val settled = settle(viewModel)
|
||||
assertEquals("Failed to allocate 512 MB", escaped.message)
|
||||
// The other half of the contract, unchanged: an OOM is about the process, so the card is
|
||||
// left as it was rather than filled in with a verdict the app would then act on.
|
||||
val settled = viewModel.state.value
|
||||
// `sizeBytes = null`, not `0L`: no provider is registered for this authority, so the
|
||||
// metadata query returns nothing and the descriptor cannot be opened either. That is the
|
||||
// unknown, and it stopped being spelled the same way as "empty" -- see [InputQuery].
|
||||
@@ -125,30 +140,20 @@ class ConversionViewModelProbeFailureTest {
|
||||
private fun pickedProbe(): InputProbe? {
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(INPUT)
|
||||
// The predicate is the guard, and it is the only one needed. It requires `Ready`, so a
|
||||
// pick that ended in `Failed` never satisfies it and `awaitState` fails on its timeout
|
||||
// naming what it was waiting for -- "Ready with a probe" -- which says more than a
|
||||
// separate assertion could. A `ready as? ConversionState.Failed` check used to sit here
|
||||
// and was dead: `Ready` and `Failed` are sibling subtypes of one sealed interface, so
|
||||
// the cast was always null and the assertNull could never fire. Measured, not assumed --
|
||||
// flipping it to assertNotNull failed all three callers of this helper.
|
||||
val ready = awaitState(viewModel.state, "Ready with a probe") {
|
||||
it is ConversionState.Ready && it.input.probe != null
|
||||
}
|
||||
assertNull("nothing here should reach a terminal failure", (ready as? ConversionState.Failed))
|
||||
return (ready as ConversionState.Ready).input.probe
|
||||
}
|
||||
|
||||
/**
|
||||
* Pumps the looper the way [awaitState] does, but for a fixed span and without requiring
|
||||
* anything to happen — here "the pick never came back" is the expected outcome, so there
|
||||
* is no predicate to wait on.
|
||||
*/
|
||||
private fun settle(viewModel: ConversionViewModel): ConversionState {
|
||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SETTLE_MS)
|
||||
while (System.nanoTime() < deadline) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
return viewModel.state.value
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val INPUT: Uri = Uri.parse("content://test/holiday.mp4")
|
||||
const val SETTLE_MS = 500L
|
||||
const val POLL_MS = 5L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
|
||||
/**
|
||||
* The four pure helpers behind the converter screen's prose, pinned at the points where they
|
||||
* change what they say.
|
||||
*
|
||||
* No Compose rule and no Robolectric: these are `String` in, `String` out, and running them under a
|
||||
* device sandbox would buy nothing while hiding the boundaries in a rendered tree.
|
||||
*
|
||||
* The defect each group bites on:
|
||||
*
|
||||
* - **[formatBytes] picks a unit by comparing against three thresholds.** Every one of them is a
|
||||
* `>=`, and a `>` would move a file sitting exactly on a boundary into the unit below -- `1 GB`
|
||||
* shown as `1000.0 MB`. Only a value *on* the threshold can tell the two apart, so each of the
|
||||
* three is asserted at the boundary and one below it. The unit prefixes are decimal, matching
|
||||
* what the file manager and the provider report, not powers of two.
|
||||
* - **[formatDuration] has no hours field.** An hour-long recording reads `60:00`, and that is the
|
||||
* contract rather than an oversight -- the row is a length, not a clock. Pinned so that adding
|
||||
* hours is a deliberate change with a red test in front of it instead of a silent reformat.
|
||||
* - **[describe] builds the suggestion-chip label out of up to three parts**, and the parts are
|
||||
* conditional: [VideoCodec.NONE] and [AudioCodec.NONE] drop out entirely, so an image output
|
||||
* with neither track has to render as the container alone rather than as a container followed
|
||||
* by a dangling separator.
|
||||
* - **[EnginePreference] carries no `label` property**, unlike every other enum the screen
|
||||
* renders; its three display strings live in a `when` in the screen file. Adding a constant is
|
||||
* caught by the compiler because that `when` is exhaustive, but nothing stops two constants
|
||||
* being given the same string, which is what the distinctness assertion is for.
|
||||
*/
|
||||
class ConverterFormattersTest {
|
||||
|
||||
@Test
|
||||
fun `bytes below a kilobyte are counted exactly`() {
|
||||
assertEquals("0 B", formatBytes(0))
|
||||
assertEquals("1 B", formatBytes(1))
|
||||
assertEquals("999 B", formatBytes(999))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each unit starts exactly on its threshold rather than one byte past it`() {
|
||||
assertEquals("1 kB", formatBytes(1_000))
|
||||
assertEquals("1.0 MB", formatBytes(1_000_000))
|
||||
assertEquals("1.0 GB", formatBytes(1_000_000_000))
|
||||
}
|
||||
|
||||
/**
|
||||
* One byte below each threshold, which is the half a `>=` to `>` change leaves alone. Both
|
||||
* halves are needed: the boundary values alone would still pass if the comparison let
|
||||
* everything through.
|
||||
*/
|
||||
@Test
|
||||
fun `a value just below a threshold stays in the smaller unit`() {
|
||||
assertEquals("999 B", formatBytes(999))
|
||||
assertEquals("1000 kB", formatBytes(999_999))
|
||||
assertEquals("1000.0 MB", formatBytes(999_999_999))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real file size reads as one decimal place`() {
|
||||
assertEquals("12.3 MB", formatBytes(12_345_678))
|
||||
assertEquals("1.5 GB", formatBytes(1_500_000_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration is minutes and zero-padded seconds`() {
|
||||
assertEquals("0:00", formatDuration(0))
|
||||
assertEquals("0:01", formatDuration(1_000))
|
||||
assertEquals("0:59", formatDuration(59_000))
|
||||
assertEquals("1:00", formatDuration(60_000))
|
||||
assertEquals("1:30", formatDuration(90_000))
|
||||
}
|
||||
|
||||
/** Sub-second remainders are dropped rather than rounded up into the next second. */
|
||||
@Test
|
||||
fun `a partial second does not become a whole one`() {
|
||||
assertEquals("0:00", formatDuration(999))
|
||||
assertEquals("0:59", formatDuration(59_999))
|
||||
}
|
||||
|
||||
/** No hours field, deliberately: an hour is `60:00` and two hours are `120:00`. */
|
||||
@Test
|
||||
fun `an hour and beyond keeps counting in minutes`() {
|
||||
assertEquals("60:00", formatDuration(3_600_000))
|
||||
assertEquals("61:01", formatDuration(3_661_000))
|
||||
assertEquals("120:00", formatDuration(7_200_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a spec with both tracks names the container and joins the two codecs`() {
|
||||
assertEquals(
|
||||
"MP4 · H.264 + AAC",
|
||||
describe(OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track set to none is left out instead of being named none`() {
|
||||
assertEquals(
|
||||
"MP3 · MP3",
|
||||
describe(OutputSpec(Container.MP3, VideoCodec.NONE, AudioCodec.MP3)),
|
||||
)
|
||||
assertEquals(
|
||||
"MP4 · H.264",
|
||||
describe(OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.NONE)),
|
||||
)
|
||||
}
|
||||
|
||||
/** An image output has neither track, so there is nothing for the separator to separate. */
|
||||
@Test
|
||||
fun `a spec with no tracks at all is the container alone, with no trailing separator`() {
|
||||
assertEquals("GIF", describe(OutputSpec(Container.GIF, VideoCodec.NONE, AudioCodec.NONE)))
|
||||
assertEquals(
|
||||
"PNG frames",
|
||||
describe(OutputSpec(Container.IMAGE_SEQUENCE, VideoCodec.NONE, AudioCodec.NONE)),
|
||||
)
|
||||
}
|
||||
|
||||
/** `Copy` is a codec here, not the absence of one, so a remux describes both tracks. */
|
||||
@Test
|
||||
fun `a remux names copy on both tracks rather than dropping them`() {
|
||||
assertEquals(
|
||||
"Matroska · Copy + Copy",
|
||||
describe(OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each engine preference has the wording the chips show`() {
|
||||
assertEquals("Automatic", EnginePreference.AUTO.label())
|
||||
assertEquals("Prefer hardware", EnginePreference.PREFER_HARDWARE.label())
|
||||
assertEquals("Force software", EnginePreference.FORCE_SOFTWARE.label())
|
||||
}
|
||||
|
||||
/**
|
||||
* Two constants sharing a label would render as two identical chips, one of which the user
|
||||
* could not choose deliberately. The exhaustive `when` cannot catch that; this does.
|
||||
*/
|
||||
@Test
|
||||
fun `no two engine preferences render the same chip`() {
|
||||
val labels = EnginePreference.entries.map { it.label() }
|
||||
|
||||
assertEquals(EnginePreference.entries.size, labels.toSet().size)
|
||||
assertEquals(emptyList<String>(), labels.filter { it.isBlank() })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.InputKind
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Each leaf of the converter screen renders, and each tag it claims resolves to exactly one node.
|
||||
*
|
||||
* The defect this bites on is a tag that is not where the table says it is: dropped by a refactor
|
||||
* that rewrote a `Modifier` chain, applied to the wrong one of two siblings, or duplicated onto a
|
||||
* leaf that is rendered twice. None of that is visible at compile time -- a `testTag` is a string
|
||||
* handed to a modifier -- and none of it shows up in the app either, because nothing but a test
|
||||
* ever reads one.
|
||||
*
|
||||
* It has to be caught here rather than by the children that consume the tags. R38.2, R38.3 and
|
||||
* R38.4 all *begin* by locating a node through one of these, so a tag that had quietly moved would
|
||||
* surface as three unrelated PRs failing on a line their own diffs do not touch. Counting the nodes
|
||||
* rather than asserting existence is deliberate: `onNodeWithTag` on two matches throws about
|
||||
* ambiguity in one place and passes in another, so "exactly one" is the property worth pinning.
|
||||
*
|
||||
* Deliberately *not* the state matrix. Which affordances each `ConversionState` renders is R38.6,
|
||||
* and it needs the state seam R38.5 extracts -- the branch buttons tagged in this change (Convert,
|
||||
* Cancel, Save file, Start over, ...) therefore have no bite yet, which the PR body records.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterLeafTagsTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private fun assertResolvesToOneNode(tag: String) {
|
||||
composeRule.onAllNodesWithTag(tag).assertCountEquals(1)
|
||||
}
|
||||
|
||||
private fun input(sizeBytes: Long? = 12_345_678L, probe: InputProbe? = VIDEO_PROBE) = InputFile(
|
||||
uri = Uri.parse("content://test/clip.mkv"),
|
||||
displayName = "clip.mkv",
|
||||
sizeBytes = sizeBytes,
|
||||
probe = probe,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the format picker tags its chip row`() {
|
||||
composeRule.setContent { FormatPicker(OutputFormat.MP4_H264) {} }
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.FORMAT_CHIPS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the quality picker tags its chip row`() {
|
||||
composeRule.setContent { QualityPicker(QualityTier.FAST) {} }
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.QUALITY_CHIPS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the engine picker tags its chip row`() {
|
||||
composeRule.setContent { EnginePicker(EnginePreference.AUTO) {} }
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.ENGINE_CHIPS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the advanced picker tags its toggle, which is all it renders while collapsed`() {
|
||||
setAdvancedPicker()
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.ADVANCED_TOGGLE)
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel and its three rows only exist once the toggle has been clicked, which is R38.4's
|
||||
* subject. Expanding is the only way to reach the tags at all, so the smoke test has to do it.
|
||||
*/
|
||||
@Test
|
||||
fun `expanding the advanced picker tags the panel and each of its three chip rows`() {
|
||||
setAdvancedPicker()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.ADVANCED_PANEL)
|
||||
assertResolvesToOneNode(TestTags.Converter.ADVANCED_CONTAINER_CHIPS)
|
||||
assertResolvesToOneNode(TestTags.Converter.ADVANCED_VIDEO_CHIPS)
|
||||
assertResolvesToOneNode(TestTags.Converter.ADVANCED_AUDIO_CHIPS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the validation card tags itself and every suggestion on it`() {
|
||||
composeRule.setContent {
|
||||
ValidationError(
|
||||
Validation.Invalid(
|
||||
message = "WebM cannot hold H.264 video.",
|
||||
suggestions = listOf(
|
||||
OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC),
|
||||
OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS),
|
||||
),
|
||||
),
|
||||
) {}
|
||||
}
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.VALIDATION_ERROR)
|
||||
assertResolvesToOneNode(TestTags.Converter.suggestion(0))
|
||||
assertResolvesToOneNode(TestTags.Converter.suggestion(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the file card tags itself, its name and its size line`() {
|
||||
composeRule.setContent { FileCard(input()) }
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.FILE_CARD)
|
||||
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_NAME)
|
||||
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_BYTES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Both writers of the note line get their own case. They are two separate `Text` calls in two
|
||||
* branches that share one tag, so a test of either alone would leave the other unguarded.
|
||||
*/
|
||||
@Test
|
||||
fun `the file card tags the note it shows while the probe is still running`() {
|
||||
composeRule.setContent { FileCard(input(probe = null)) }
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_NOTE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the file card tags the note it shows when nothing could read the file`() {
|
||||
composeRule.setContent { FileCard(input(probe = InputProbe(kind = InputKind.UNPARSEABLE))) }
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.FILE_CARD_NOTE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a detail row tags itself with the label it renders`() {
|
||||
composeRule.setContent { DetailRow("Container", "Matroska") }
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.detailRow("Container"))
|
||||
}
|
||||
|
||||
/** The rows the file card builds carry the same per-label tags, one per row it renders. */
|
||||
@Test
|
||||
fun `the file card's detail rows are each tagged by their own label`() {
|
||||
composeRule.setContent { FileCard(input()) }
|
||||
|
||||
assertResolvesToOneNode(TestTags.Converter.detailRow("Container"))
|
||||
assertResolvesToOneNode(TestTags.Converter.detailRow("Video"))
|
||||
assertResolvesToOneNode(TestTags.Converter.detailRow("Audio"))
|
||||
assertResolvesToOneNode(TestTags.Converter.detailRow("Length"))
|
||||
}
|
||||
|
||||
private fun setAdvancedPicker() {
|
||||
composeRule.setContent {
|
||||
AdvancedPicker(
|
||||
spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC),
|
||||
validation = Validation.Valid,
|
||||
onContainer = {},
|
||||
onVideoCodec = {},
|
||||
onAudioCodec = {},
|
||||
onSuggestion = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val VIDEO_PROBE = InputProbe(
|
||||
videoCodec = "video/avc",
|
||||
audioCodec = "audio/mp4a-latm",
|
||||
durationMs = 90_000,
|
||||
kind = InputKind.VIDEO,
|
||||
container = Container.MKV,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Each picker lights the chip it was handed and reports the constant that was pressed.
|
||||
*
|
||||
* The defect this bites on is a picker that renders perfectly and answers wrongly. All three are
|
||||
* the same dozen lines with a different enum substituted, so the failure mode is a copy-paste that
|
||||
* survives review: an `onClick` that closes over the picker's `selected` parameter instead of the
|
||||
* chip's own entry hands back one constant no matter which chip was tapped, and an inverted
|
||||
* `entry == selected` lights every chip except the right one. Neither throws, neither changes the
|
||||
* set of labels on screen, and a test that only asserted "the callback ran" would pass over both.
|
||||
*
|
||||
* Clicking every chip in turn and comparing the whole recorded list against `entries` is what makes
|
||||
* the constant load-bearing rather than the click count -- a hardcoded `onSelect` fires the same
|
||||
* number of times as a correct one. Selection is asserted over every chip for the same reason: the
|
||||
* one that should be lit proves nothing on its own, because `!=` lights it too whenever the enum
|
||||
* has exactly one entry, and lights all its siblings whenever it has more.
|
||||
*
|
||||
* Labels come from `OutputFormat.label` and `QualityTier.label`; [label], which the screen owns
|
||||
* because `EnginePreference` carries no label of its own, supplies the third set. Retyping any of
|
||||
* them here would turn a rename into a red test that named the wrong cause.
|
||||
*
|
||||
* Not covered, deliberately: the `"Output format"`, `"Quality"` and `"Engine"` headings, which are
|
||||
* untagged `Text` calls with no enum behind them and no behaviour to bite on.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterPickerSelectionTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/**
|
||||
* The chip carrying [label] inside the row tagged [rowTag].
|
||||
*
|
||||
* By ancestor rather than by direct child: how many semantics nodes Material 3 puts between a
|
||||
* `FlowRow` and its chips is that library's business, and a matcher that assumed "one" would
|
||||
* break on an upgrade that changed nothing this test is about.
|
||||
*/
|
||||
private fun chipIn(rowTag: String, label: String): SemanticsNodeInteraction =
|
||||
composeRule.onNode(hasAnyAncestor(hasTestTag(rowTag)) and hasText(label))
|
||||
|
||||
private fun assertOnlySelected(rowTag: String, labels: List<String>, selected: String?) {
|
||||
labels.forEach { label ->
|
||||
val chip = chipIn(rowTag, label)
|
||||
if (label == selected) chip.assertIsSelected() else chip.assertIsNotSelected()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the format picker lights the selected format and no other`() {
|
||||
composeRule.setContent { FormatPicker(OutputFormat.WEBM_VP9) {} }
|
||||
|
||||
assertOnlySelected(
|
||||
rowTag = TestTags.Converter.FORMAT_CHIPS,
|
||||
labels = OutputFormat.entries.map { it.label },
|
||||
selected = OutputFormat.WEBM_VP9.label,
|
||||
)
|
||||
}
|
||||
|
||||
/** A spec no preset can express lights nothing, which is what the custom line stands in for. */
|
||||
@Test
|
||||
fun `the format picker lights nothing when the spec is custom`() {
|
||||
composeRule.setContent { FormatPicker(null) {} }
|
||||
|
||||
assertOnlySelected(
|
||||
rowTag = TestTags.Converter.FORMAT_CHIPS,
|
||||
labels = OutputFormat.entries.map { it.label },
|
||||
selected = null,
|
||||
)
|
||||
composeRule.onNodeWithText(CUSTOM_SPEC_NOTE).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selected format hides the custom line`() {
|
||||
composeRule.setContent { FormatPicker(OutputFormat.MP3) {} }
|
||||
|
||||
composeRule.onNodeWithText(CUSTOM_SPEC_NOTE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking a format chip reports that format`() {
|
||||
val picked = mutableListOf<OutputFormat>()
|
||||
composeRule.setContent { FormatPicker(null) { picked += it } }
|
||||
|
||||
OutputFormat.entries.forEach { chipIn(TestTags.Converter.FORMAT_CHIPS, it.label).performClick() }
|
||||
|
||||
assertEquals(OutputFormat.entries.toList(), picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the quality picker lights the selected tier and no other`() {
|
||||
composeRule.setContent { QualityPicker(QualityTier.BEST) {} }
|
||||
|
||||
assertOnlySelected(
|
||||
rowTag = TestTags.Converter.QUALITY_CHIPS,
|
||||
labels = QualityTier.entries.map { it.label },
|
||||
selected = QualityTier.BEST.label,
|
||||
)
|
||||
}
|
||||
|
||||
/** The line under the chips describes what was chosen, not whichever tier was written first. */
|
||||
@Test
|
||||
fun `the quality picker explains the tier that is selected`() {
|
||||
composeRule.setContent { QualityPicker(QualityTier.BEST) {} }
|
||||
|
||||
composeRule.onNodeWithText(QualityTier.BEST.description).assertExists()
|
||||
composeRule.onNodeWithText(QualityTier.FAST.description).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking a quality chip reports that tier`() {
|
||||
val picked = mutableListOf<QualityTier>()
|
||||
composeRule.setContent { QualityPicker(QualityTier.FAST) { picked += it } }
|
||||
|
||||
QualityTier.entries.forEach { chipIn(TestTags.Converter.QUALITY_CHIPS, it.label).performClick() }
|
||||
|
||||
assertEquals(QualityTier.entries.toList(), picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the engine picker lights the selected preference and no other`() {
|
||||
composeRule.setContent { EnginePicker(EnginePreference.FORCE_SOFTWARE) {} }
|
||||
|
||||
assertOnlySelected(
|
||||
rowTag = TestTags.Converter.ENGINE_CHIPS,
|
||||
labels = EnginePreference.entries.map { it.label() },
|
||||
selected = EnginePreference.FORCE_SOFTWARE.label(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking an engine chip reports that preference`() {
|
||||
val picked = mutableListOf<EnginePreference>()
|
||||
composeRule.setContent { EnginePicker(EnginePreference.AUTO) { picked += it } }
|
||||
|
||||
EnginePreference.entries.forEach { chipIn(TestTags.Converter.ENGINE_CHIPS, it.label()).performClick() }
|
||||
|
||||
assertEquals(EnginePreference.entries.toList(), picked)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Copied byte for byte out of `ConverterScreen.kt` -- it holds a U+2014 em dash, which
|
||||
* retyped as ASCII would match nothing and fail as "no node found" rather than as a reword.
|
||||
*/
|
||||
const val CUSTOM_SPEC_NOTE: String = "Custom — set below."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The seam carries a `ConversionState` in and an action back out.
|
||||
*
|
||||
* The defect this bites on is the extraction having quietly stopped being an extraction: a
|
||||
* `ConverterScreenContent` that ignores the `state` it was handed, or renders the finished job's
|
||||
* affordances without wiring them to the callbacks the entry point supplies. Neither shows up at
|
||||
* compile time -- an unread parameter compiles, and a `Button` whose `onClick` does nothing is a
|
||||
* valid `Button` -- and neither is visible from the leaf tests, which compose `FileCard`,
|
||||
* `AdvancedPicker` and the pickers directly and never see a state at all.
|
||||
*
|
||||
* **Both assertions were unreachable before R38.5**, which is the point of the ticket rather than
|
||||
* a remark about it. `ConversionState.Converted` is produced only by a `ConversionWorker` run that
|
||||
* has already succeeded, so no test can drive a real `ConversionViewModel` into it: it would need
|
||||
* a `WorkManager`, a media probe, a staged output file and a completed job. Handing the state in
|
||||
* is the only way to ask what the screen does with it.
|
||||
*
|
||||
* Deliberately not the state matrix. Which affordances each of the six `ConversionState`s renders
|
||||
* is R38.6 (#62); this file asserts only that the injection point exists and works in both
|
||||
* directions, so the two PRs cannot collide over the same cases.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterScreenContentTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||
private val savedAs = mutableListOf<String>()
|
||||
|
||||
@Test
|
||||
fun `a converted job renders the save button`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* The direction that did not exist before this change.
|
||||
*
|
||||
* Asserting the *name* rather than just that something was called: the suggested name comes
|
||||
* from the job -- `ConversionWorker.KEY_SUGGESTED_NAME` -- and is what the save dialog opens
|
||||
* with, so a Save button wired to the wrong branch's state would hand over the wrong one and
|
||||
* a bare "was called" check would stay green.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping save hands back the name the finished job chose`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("holiday.mp4"), savedAs)
|
||||
}
|
||||
|
||||
/**
|
||||
* `staged` names a file that does not exist, on purpose.
|
||||
*
|
||||
* The branch renders `formatBytes(s.staged.length())`, and `length()` answers `0L` for a
|
||||
* missing path rather than throwing, so the size line reads `0 B` and no temporary folder is
|
||||
* needed. `routeReason` stays blank, which is what keeps the routing chip out of the tree --
|
||||
* that chip is R38.6's case, not this file's.
|
||||
*/
|
||||
private fun converted() = ConversionState.Converted(
|
||||
input = InputFile(
|
||||
uri = Uri.parse("content://test/holiday.mkv"),
|
||||
displayName = "holiday.mkv",
|
||||
sizeBytes = 12_345_678L,
|
||||
),
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
suggestedName = "holiday.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: ConversionState) {
|
||||
composeRule.setContent {
|
||||
ConverterScreenContent(
|
||||
state = state,
|
||||
settings = ConversionSettings(),
|
||||
validation = Validation.Valid,
|
||||
actions = ConverterActions(
|
||||
onPickInput = {},
|
||||
onPreset = {},
|
||||
onContainer = {},
|
||||
onVideoCodec = {},
|
||||
onAudioCodec = {},
|
||||
onSuggestion = {},
|
||||
onQuality = {},
|
||||
onEnginePreference = {},
|
||||
onConvert = {},
|
||||
onCancel = {},
|
||||
onSave = { suggestedName -> savedAs += suggestedName },
|
||||
onReset = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Every `ConversionState` renders its own affordances, and only its own.
|
||||
*
|
||||
* The defect this bites on is a `when` arm that has drifted from the state it names: a button
|
||||
* offered in a state where it cannot work, a state's own data never reaching the node that is
|
||||
* supposed to display it, or an affordance wired to the wrong callback. None of that is a compile
|
||||
* error -- every arm of the `when` returns `Unit`, so an arm can render anything at all -- and none
|
||||
* of it is visible from the leaf tests, which compose `FileCard`, `AdvancedPicker` and the three
|
||||
* pickers directly and never see a `ConversionState`.
|
||||
*
|
||||
* The arm most worth guarding is `Ready`'s `enabled = validation.isValid`. The Advanced picker
|
||||
* deliberately lets an impossible container / codec combination be selected -- `AdvancedPicker`'s
|
||||
* KDoc says teaching the constraint beats hiding it -- so that single expression is the only thing
|
||||
* standing between an invalid spec and a job that cannot succeed. `enabled = true` compiles, renders
|
||||
* an identical screen apart from one colour, and passes every other test in this suite.
|
||||
*
|
||||
* Callbacks are asserted by **identity, over the whole log**: [fired] records all twelve of them and
|
||||
* each assertion compares the complete list against one expected entry. A bare "the callback ran"
|
||||
* check stays green when an arm fires the right callback for the wrong reason, and a check on one
|
||||
* callback alone stays green when an arm fires two.
|
||||
*
|
||||
* ### Not asserted here, so that each is a decision rather than an omission
|
||||
*
|
||||
* - **`Failed`'s error colour.** #62's table asks for the message "in the error colour". Compose
|
||||
* publishes no text colour to the semantics tree -- there is no `SemanticsProperties` entry for
|
||||
* it -- so it is unobservable from a JVM test, the same limit `FileCardTest` records for
|
||||
* `HorizontalDivider`. The message text itself is asserted; the colour would need a screenshot.
|
||||
* - **The three `assertDoesNotExist` checks on [TestTags.Converter.FILE_CARD] are compile-guarded,
|
||||
* not guarded by this file.** `Idle` is a `data object`, `Saved` carries a `displayName`, and
|
||||
* `Failed` carries a message and -- after a failed save only -- the staged file it left behind;
|
||||
* none of the three has an `input`, so `FileCard(s.input)` does not compile in those arms. The
|
||||
* lines stay because they state the intent cheaply, but they are not what stops a `FileCard`
|
||||
* appearing there and this file does not claim they are.
|
||||
* - **Which constant each chip hands back** belongs to `ConverterPickerSelectionTest`, and **what
|
||||
* the file card says about an unknown size** to `FileCardTest`. This file asserts that `Ready`
|
||||
* puts those leaves on screen at all, not what they then do.
|
||||
* - **The suggested name `Converted` hands to the save dialog** is pinned by
|
||||
* `ConverterScreenContentTest`; repeating it here would be a second copy of one assertion.
|
||||
* - **`ConverterScreen`'s permission dance.** `requestNotifications` calls `convert()` on both grant
|
||||
* and deny, deliberately -- the KDoc explains that the foreground service runs either way -- and
|
||||
* it lives in the entry point, above the seam this file composes.
|
||||
* - **`is ConversionState.Idle -> Unit` in the nested `when`.** The outer `when` peels `Idle` off
|
||||
* first, so that arm is permanently unreachable and no test can reach it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterStateAffordancesTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/**
|
||||
* Every callback the screen fired, in order, tagged with the value it carried.
|
||||
*
|
||||
* All twelve are recorded rather than only the one under test, so an assertion can be
|
||||
* `assertEquals(listOf("cancel"), fired)` -- which says "this one and nothing else".
|
||||
*/
|
||||
private val fired = mutableListOf<String>()
|
||||
|
||||
// -------------------------------------------------------------------- Idle
|
||||
|
||||
@Test
|
||||
fun `an idle screen offers the prompt and the picker, and nothing to act on yet`() {
|
||||
setContent(ConversionState.Idle)
|
||||
|
||||
composeRule.onNodeWithText("Pick a file to convert.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertDoesNotExist()
|
||||
// Compile-guarded rather than guarded here -- `Idle` has no `input`. See the class KDoc.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* No [performScrollTo] on this one, unlike every other click below. `Idle` is the centred
|
||||
* branch outside the `verticalScroll` column, so it has no scrollable ancestor to scroll in.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping choose file on an idle screen asks for a file and does nothing else`() {
|
||||
setContent(ConversionState.Idle)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
|
||||
assertEquals(listOf("pickInput"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- Ready
|
||||
|
||||
/**
|
||||
* All four pickers, the card above them and both buttons below, in one assertion each.
|
||||
*
|
||||
* A superset of #62's "all five pickers": which four or five of these count as a picker is not
|
||||
* worth arguing about, so the case names everything the arm emits.
|
||||
*/
|
||||
@Test
|
||||
fun `a picked file offers its card, all four pickers and both buttons`() {
|
||||
setContent(ConversionState.Ready(input()))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FORMAT_CHIPS).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.QUALITY_CHIPS).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ENGINE_CHIPS).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE).assertExists()
|
||||
}
|
||||
|
||||
/** The card is handed `s.input`, so the name on it is how the state is shown to have arrived. */
|
||||
@Test
|
||||
fun `the file card on a picked file names the file that was picked`() {
|
||||
setContent(ConversionState.Ready(input()))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertTextEquals("holiday.mkv")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `convert is offered for a spec that can be produced`() {
|
||||
setContent(ConversionState.Ready(input()), validation = Validation.Valid)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping convert starts the job and does nothing else`() {
|
||||
setContent(ConversionState.Ready(input()), validation = Validation.Valid)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("convert"), fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bite named in #62. Reverting `enabled = validation.isValid` to `enabled = true` reddens
|
||||
* exactly this case, and nothing else in the repository.
|
||||
*/
|
||||
@Test
|
||||
fun `convert is withheld for a spec that cannot be produced`() {
|
||||
setContent(ConversionState.Ready(input()), validation = INVALID)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
/** The other button on the arm goes back to the picker rather than starting anything. */
|
||||
@Test
|
||||
fun `tapping choose a different file asks for a file rather than converting`() {
|
||||
setContent(ConversionState.Ready(input()))
|
||||
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE)
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
assertEquals(listOf("pickInput"), fired)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- Converting
|
||||
|
||||
/**
|
||||
* Two independent readings of the same `percent`, on purpose.
|
||||
*
|
||||
* The heading is a string and the bar is a float, and the arm computes them from the state
|
||||
* separately -- `"${s.percent}%"` against `s.percent / 100f`. A hardcoded bar and a hardcoded
|
||||
* heading are different mistakes, so neither assertion covers the other.
|
||||
*/
|
||||
@Test
|
||||
fun `a running job reports how far it has got, in words and on the bar`() {
|
||||
setContent(ConversionState.Converting(input(), percent = 42))
|
||||
|
||||
composeRule.onNodeWithText("Converting… 42%").assertExists()
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.PROGRESS)
|
||||
.assertRangeInfoEquals(ProgressBarRangeInfo(0.42f, 0f..1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running job offers cancel and not start over`() {
|
||||
setContent(ConversionState.Converting(input(), percent = 42))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping cancel on a running job cancels it and does nothing else`() {
|
||||
setContent(ConversionState.Converting(input(), percent = 42))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), fired)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- Waiting
|
||||
|
||||
/**
|
||||
* The second bite named in #62. Deleting the `Cancel` button from the `Waiting` arm reddens
|
||||
* this case and the one below it.
|
||||
*
|
||||
* The paragraph is asserted in full rather than by a fragment because it is the only thing the
|
||||
* arm renders besides the card and the button, and because its wording is the arm's whole
|
||||
* job -- `FailureOutcome` records that two different causes land here and the state cannot tell
|
||||
* them apart, so the text has to cover both. A reword should redden one test, and this is it.
|
||||
*/
|
||||
@Test
|
||||
fun `a paused job explains why and still offers cancel`() {
|
||||
setContent(ConversionState.Waiting(input()))
|
||||
|
||||
composeRule.onNodeWithText(PAUSED_PARAGRAPH).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping cancel on a paused job cancels it and does nothing else`() {
|
||||
setContent(ConversionState.Waiting(input()))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), fired)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Converted
|
||||
|
||||
@Test
|
||||
fun `a finished job offers save and start over, and no longer offers cancel`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over on a finished job resets and does not save`() {
|
||||
setContent(converted())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip carries the job's own explanation, so its text is the assertion rather than its
|
||||
* presence: a chip showing the engine name, or the previous job's reason, would still exist.
|
||||
*/
|
||||
@Test
|
||||
fun `a finished job shows the routing decision the job reported`() {
|
||||
setContent(converted(routeReason = "Software — the MKV input needed a re-encode"))
|
||||
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.ROUTE_REASON)
|
||||
.assertTextEquals("Software — the MKV input needed a re-encode")
|
||||
}
|
||||
|
||||
/** The other side of the `isNotBlank` guard, which is unguarded without a case of its own. */
|
||||
@Test
|
||||
fun `a finished job that reported no routing decision shows no chip`() {
|
||||
setContent(converted(routeReason = ""))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.ROUTE_REASON).assertDoesNotExist()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- Saved
|
||||
|
||||
@Test
|
||||
fun `a saved file names itself and offers another conversion`() {
|
||||
setContent(ConversionState.Saved(displayName = "holiday.mp4"))
|
||||
|
||||
composeRule.onNodeWithText("Saved holiday.mp4.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT_ANOTHER).assertExists()
|
||||
// Compile-guarded rather than guarded here -- `Saved` has no `input`. See the class KDoc.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping convert another after a save resets and does nothing else`() {
|
||||
setContent(ConversionState.Saved(displayName = "holiday.mp4"))
|
||||
|
||||
composeRule
|
||||
.onNodeWithTag(TestTags.Converter.CONVERT_ANOTHER)
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Failed
|
||||
|
||||
/**
|
||||
* The message is the arm's only output that carries information, and it comes from the state.
|
||||
* An arm rendering a fixed apology would look right and say nothing, which is why the assertion
|
||||
* is on the text handed in rather than on a node existing.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed job renders the reason it was given and offers a restart`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
|
||||
composeRule.onNodeWithText("Ran out of space while writing the output.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
|
||||
// Compile-guarded rather than guarded here -- `Failed` has no `input`. See the class KDoc.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* **The assertion that bounds #30's whole change**, and the one worth breaking things to keep.
|
||||
*
|
||||
* A transcode that died staged nothing, so its `Failed` carries no [PendingSave] and there is
|
||||
* nothing for a save dialog to be handed. Making the retry unconditional -- or making the
|
||||
* `WorkInfo.State.FAILED` arm of `ConversionViewModel.observe` carry a handle it has no file
|
||||
* for -- puts a button on screen that can only fail, and this is what notices.
|
||||
*/
|
||||
@Test
|
||||
fun `a transcode failure offers no way to save`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over after a failure resets and does nothing else`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- Failed, carrying a file
|
||||
|
||||
/**
|
||||
* The defect in #30, stated as what the branch must render.
|
||||
*
|
||||
* `save()` keeps the staged file on a failure deliberately -- it can be the only copy of an
|
||||
* hour of transcoding -- and before this the only control here was "Start over", wired to
|
||||
* `reset()`, which deletes exactly that file. Both buttons, not one: the restart has to stay
|
||||
* reachable, because leaving a full-size file in cache is the outcome it exists to avoid.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed save offers the file again as well as a restart`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* The name, not just that something fired: it comes from the finished job, and a retry wired to
|
||||
* a literal or to the picker's current guess would hand the dialog a name the job never chose.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping try saving again hands back the name the job chose`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("save:holiday.mp4"), fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start over from here still resets, and resetting still deletes -- see `reset()`'s KDoc for
|
||||
* why that is acceptable now and was not before. What it must not do is save on the way past.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping start over after a failed save resets and does not save`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Harness
|
||||
|
||||
private fun input() = InputFile(
|
||||
uri = Uri.parse("content://test/holiday.mkv"),
|
||||
displayName = "holiday.mkv",
|
||||
sizeBytes = 12_345_678L,
|
||||
)
|
||||
|
||||
/**
|
||||
* `staged` names a path that does not exist, deliberately: `File.length()` answers `0L` for a
|
||||
* missing file rather than throwing, so the size line reads `0 B` and no temporary folder is
|
||||
* needed to render the arm.
|
||||
*/
|
||||
/**
|
||||
* A `Failed` an earlier save left carrying its file, which is the only way [retry] is non-null.
|
||||
*
|
||||
* The same missing `staged` path as [converted], and for the same reason: this arm renders no
|
||||
* size line at all, so nothing here ever touches the filesystem.
|
||||
*/
|
||||
private fun failedSave() = ConversionState.Failed(
|
||||
message = "There was not enough room on the destination.",
|
||||
retry = PendingSave(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
suggestedName = "holiday.mp4",
|
||||
mimeType = "video/mp4",
|
||||
),
|
||||
)
|
||||
|
||||
private fun converted(routeReason: String = "") = ConversionState.Converted(
|
||||
input = input(),
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
routeReason = routeReason,
|
||||
suggestedName = "holiday.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: ConversionState, validation: Validation = Validation.Valid) {
|
||||
composeRule.setContent {
|
||||
ConverterScreenContent(
|
||||
state = state,
|
||||
settings = ConversionSettings(),
|
||||
validation = validation,
|
||||
actions = ConverterActions(
|
||||
onPickInput = { fired += "pickInput" },
|
||||
onPreset = { fired += "preset:$it" },
|
||||
onContainer = { fired += "container:$it" },
|
||||
onVideoCodec = { fired += "videoCodec:$it" },
|
||||
onAudioCodec = { fired += "audioCodec:$it" },
|
||||
onSuggestion = { fired += "suggestion:$it" },
|
||||
onQuality = { fired += "quality:$it" },
|
||||
onEnginePreference = { fired += "engine:$it" },
|
||||
onConvert = { fired += "convert" },
|
||||
onCancel = { fired += "cancel" },
|
||||
onSave = { fired += "save:$it" },
|
||||
onReset = { fired += "reset" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
/**
|
||||
* A spec no container can hold, with somewhere to go instead.
|
||||
*
|
||||
* Built here rather than run through `ContainerCapabilities` because what makes a spec
|
||||
* invalid is that class's subject; all this arm needs is a `Validation` that answers
|
||||
* `isValid == false`.
|
||||
*/
|
||||
val INVALID = Validation.Invalid(
|
||||
message = "WebM cannot hold H.264 video.",
|
||||
suggestions = listOf(OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC)),
|
||||
)
|
||||
|
||||
/** Copied from the `Waiting` arm, where it is written as two concatenated fragments. */
|
||||
const val PAUSED_PARAGRAPH =
|
||||
"Paused. Android limits background media processing, so this will " +
|
||||
"resume automatically — keeping the app open helps it along."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinState
|
||||
import org.libremediaconverter.join.JoinViewModel
|
||||
import org.libremediaconverter.join.pendingSave
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A failed save has to leave the file *offerable*, not merely undeleted.
|
||||
*
|
||||
* The defect is #30, and both halves of it were already written down in `main`. `save()`'s
|
||||
* `onFailure` kept the staged file on purpose -- "deleting here would destroy the work to tidy up
|
||||
* a cache directory" -- and then handed the screen a `Failed` carrying a message and nothing else,
|
||||
* so the single control that branch rendered was "Start over", wired to `reset()`, which deletes
|
||||
* exactly that file. The intent and the affordance disagreed, and the affordance won.
|
||||
*
|
||||
* `ConversionViewModelCleanupTest` already pins the *keeping*: after a failed save the file is
|
||||
* still on disk and nothing has been discarded. It stays green with the state carrying nothing,
|
||||
* because it reads the filesystem rather than the state. This file asserts the other half -- that
|
||||
* the handle reaches the state a screen can read -- and the negative that bounds it: a failure
|
||||
* with nothing staged behind it must not sprout a save button.
|
||||
*
|
||||
* Both ViewModels in one class, following `MissingStagedFileTest`. They are separate state
|
||||
* machines that can each hold a staged file at once, but this defect and its fix are the same
|
||||
* shape in both, and splitting them would put the two halves of one invariant in two files.
|
||||
*
|
||||
* ### Not asserted here, so each is a decision rather than an omission
|
||||
*
|
||||
* - **That the destination received the bytes.** [RecordingPublisher.publish] is a stub, which is
|
||||
* the only way to make a save fail deterministically -- and making it fail is what every case
|
||||
* here needs. `OutputPublisherPublishTest` owns what a real publish writes.
|
||||
* - **The screen's two buttons.** `ConverterStateAffordancesTest` and `JoinStateAffordancesTest`
|
||||
* own what each state renders; this file owns what each state carries.
|
||||
* - **`ConverterScreen`'s `destinationMime` line itself.** It lives in the entry point, above the
|
||||
* `ScreenContent` seam, and reaching it needs a real ViewModel inside a composition. What it
|
||||
* reads -- `pendingSave()?.mimeType` -- is asserted directly instead, which is why that
|
||||
* derivation was moved out of the entry point in the first place.
|
||||
* - **Picking a new input while a `Failed` carries a file.** `onInputPicked` overwrites the state
|
||||
* without discarding, from `Converted` exactly as much as from a carrying `Failed`, and neither
|
||||
* branch renders a picker. It is a pre-existing path this change neither opens nor widens: the
|
||||
* carried handle is a view of `pendingStaged`, never a second owner of the file.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FailedSaveRetryTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
// MediaProbe spawns FFprobe, whose loader throws with no native library present.
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
|
||||
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Convert
|
||||
|
||||
/**
|
||||
* The bite named in #30's fix. Emitting a plain `Failed` from `save()`'s `onFailure` -- which
|
||||
* is what `main` did -- reddens this case on the null handle, and nothing else in the suite.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed save leaves the staged file offerable, not merely undeleted`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
val failed = viewModel.state.value as ConversionState.Failed
|
||||
val retry = failed.retry
|
||||
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
|
||||
assertEquals("the retry must name the file the conversion actually produced", staged, retry?.staged)
|
||||
// Both from the job's own output Data rather than from the pickers, so a retry opens the
|
||||
// same dialog the first attempt did.
|
||||
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
|
||||
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
|
||||
assertTrue("a failed save must not destroy the only copy", staged.exists())
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of carrying the handle: the second attempt is a real save, not a new job.
|
||||
*
|
||||
* `publishFailure` is cleared between the two calls, so one `RecordingPublisher` plays both a
|
||||
* full destination and an empty one -- which is exactly the user's situation.
|
||||
*/
|
||||
@Test
|
||||
fun `retrying a failed save publishes the file and leaves nothing staged`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
publisher.publishFailure = null
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val saved = awaitState(viewModel.state, "Saved") { it is ConversionState.Saved }
|
||||
assertEquals(SUGGESTED_NAME, (saved as ConversionState.Saved).displayName)
|
||||
assertFalse("a successful retry should have removed the staged file", staged.exists())
|
||||
// Nothing to collect afterwards: the retry published it, so reset() has no work left.
|
||||
viewModel.reset()
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
/**
|
||||
* The second failure must not eat the file the first one kept.
|
||||
*
|
||||
* A `Failed` built fresh from `e.message` alone would drop the handle here while every other
|
||||
* assertion in this file stayed green -- the file is still on disk, and the first failure
|
||||
* already proved the state can carry it.
|
||||
*/
|
||||
@Test
|
||||
fun `a retry that fails again still carries the file rather than dropping it`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume still full")
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
// Waited for by the *second* message rather than by `is Failed`: the state was already
|
||||
// Failed when the retry started, so the type alone would be satisfied before it ran.
|
||||
val failed = awaitState(viewModel.state, "the second failure") {
|
||||
it is ConversionState.Failed && it.message == "destination volume still full"
|
||||
} as ConversionState.Failed
|
||||
assertEquals("the second failure must offer the same file the first one did", staged, failed.retry?.staged)
|
||||
assertTrue(staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Start over" still deletes, and that is the decision `reset()`'s KDoc records: acceptable
|
||||
* only because "Try saving again" is on screen beside it. Exactly once, through the publisher.
|
||||
*/
|
||||
@Test
|
||||
fun `start over from a failed save discards the carried file exactly once`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
/**
|
||||
* A retry meets the same existence check the first attempt did, so a file collected by the
|
||||
* sweep or by the OS in between is reported as a sentence rather than as a raw ENOENT path.
|
||||
* And the state that reports it carries nothing: there is no file left to offer.
|
||||
*/
|
||||
@Test
|
||||
fun `a retry whose staged file has gone says so and offers nothing further`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
assertTrue("the fixture must start with a real staged file", staged.delete())
|
||||
|
||||
publisher.publishFailure = null
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val failed = viewModel.state.value as ConversionState.Failed
|
||||
assertEquals(STAGED_FILE_GONE_MESSAGE, failed.message)
|
||||
assertNull("a file that has gone cannot be offered again", failed.retry)
|
||||
}
|
||||
|
||||
/**
|
||||
* The negative that bounds the whole change, and the reason `retry` is nullable.
|
||||
*
|
||||
* A transcode that died staged nothing, so there is no file to hand back -- and a `Failed`
|
||||
* that carried one anyway would put a save button on a screen with nothing to save. Driven
|
||||
* through a worker that really fails rather than by constructing the state, because the line
|
||||
* under test is the `WorkInfo.State.FAILED` arm of `observe`.
|
||||
*/
|
||||
@Test
|
||||
fun `a transcode failure carries nothing to save`() {
|
||||
installFailingTestWorkManager(app, workDataOf(ConversionWorker.KEY_ERROR to "The encoder gave up."))
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mp4"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
|
||||
viewModel.convert()
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed } as ConversionState.Failed
|
||||
assertEquals("The encoder gave up.", failed.message)
|
||||
assertNull("a transcode failure has nothing staged, so it must offer no save", failed.retry)
|
||||
assertNull("and nothing for the save dialog to open with either", failed.pendingSave())
|
||||
}
|
||||
|
||||
/**
|
||||
* What the save dialog reopens with, which is the entry point's only reader of this state.
|
||||
*
|
||||
* The pickers are moved *after* the job finishes, which is what makes this bite: a retry that
|
||||
* asked the current settings would offer `audio/mpeg` for a file the job wrote as MP4. The
|
||||
* same gap is permanent for a reattached job, whose spec was never in these settings at all.
|
||||
*/
|
||||
@Test
|
||||
fun `a retry offers the type the job chose, not the one the pickers now show`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
viewModel.setPreset(OutputFormat.MP3)
|
||||
|
||||
assertEquals(
|
||||
"the fixture needs the pickers to disagree with the job",
|
||||
"audio/mpeg",
|
||||
viewModel.settings.value.spec.mimeType,
|
||||
)
|
||||
assertEquals(JOB_MIME_TYPE, viewModel.state.value.pendingSave()?.mimeType)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- Join
|
||||
|
||||
@Test
|
||||
fun `a failed join save leaves the staged file offerable, not merely undeleted`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
val failed = viewModel.state.value as JoinState.Failed
|
||||
val retry = failed.retry
|
||||
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
|
||||
assertEquals(staged, retry?.staged)
|
||||
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
|
||||
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
|
||||
assertTrue(staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrying a failed join save publishes the file and leaves nothing staged`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
publisher.publishFailure = null
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val saved = awaitState(viewModel.state, "Saved") { it is JoinState.Saved }
|
||||
assertEquals(SUGGESTED_NAME, (saved as JoinState.Saved).displayName)
|
||||
assertFalse(staged.exists())
|
||||
viewModel.reset()
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join retry that fails again still carries the file rather than dropping it`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume still full")
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
// By the second message, not by `is Failed` -- see the converter case above.
|
||||
val failed = awaitState(viewModel.state, "the second failure") {
|
||||
it is JoinState.Failed && it.message == "destination volume still full"
|
||||
} as JoinState.Failed
|
||||
assertEquals(staged, failed.retry?.staged)
|
||||
assertTrue(staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start over from a failed join save discards the carried file exactly once`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(JoinState.Idle, viewModel.state.value)
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join failure carries nothing to save`() {
|
||||
installFailingTestWorkManager(app, workDataOf(ConcatWorker.KEY_ERROR to "The files could not be joined."))
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
|
||||
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
|
||||
viewModel.join()
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed } as JoinState.Failed
|
||||
assertEquals("The files could not be joined.", failed.message)
|
||||
assertNull("a join failure has nothing staged, so it must offer no save", failed.retry)
|
||||
assertNull(failed.pendingSave())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Harness
|
||||
|
||||
/**
|
||||
* A ViewModel driven to `Converted` and then through a save that threw.
|
||||
*
|
||||
* The WorkManager is installed here rather than in `@Before`, because two cases in this class
|
||||
* need one whose workers fail instead.
|
||||
*/
|
||||
private fun failedSaveViewModel(): ConversionViewModel {
|
||||
installTestWorkManager(app, conversionOutput())
|
||||
// Unconfined so reset()'s delete runs inline instead of on a real IO thread.
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
viewModel.convert()
|
||||
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume full")
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Failed") { it is ConversionState.Failed }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
/** The join tab's equivalent, driven to `Joined` and then through a save that threw. */
|
||||
private fun failedJoinSaveViewModel(): JoinViewModel {
|
||||
installTestWorkManager(app, joinOutput())
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
|
||||
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
viewModel.join()
|
||||
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume full")
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Failed") { it is JoinState.Failed }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
/**
|
||||
* The output `Data` a finished conversion reports.
|
||||
*
|
||||
* The name and type are set rather than left out, so the assertions above are about what the
|
||||
* *job* chose. Both ViewModels fall back to a derivation when they are missing, and a fixture
|
||||
* that omitted them would be asserting the fallback while looking like it asserted the job.
|
||||
*
|
||||
* Spelled out per worker rather than shared with [joinOutput], even though the two constants
|
||||
* hold the same strings today. A test that leaned on that would be asserting a coincidence.
|
||||
*/
|
||||
private fun conversionOutput() = workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConversionWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
/** The output `Data` a finished join reports. See [conversionOutput]. */
|
||||
private fun joinOutput() = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConcatWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConcatWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
|
||||
|
||||
const val SUGGESTED_NAME = "holiday.mp4"
|
||||
|
||||
/** What the job wrote. [OutputFormat.MP3]'s `audio/mpeg` is what the pickers move to. */
|
||||
const val JOB_MIME_TYPE = "video/mp4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ProviderInfo
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Content providers more than one test needs, and the registration dance they all repeat.
|
||||
*
|
||||
* Only that. A stub that serves one test stays in that test, next to the assertion it exists for —
|
||||
* the rule `work/WorkerStubs.kt` states, and the reason `UnreliableOutputStream` is still private to
|
||||
* `OutputPublisherPublishTest`.
|
||||
*
|
||||
* These started life inside `OutputPublisherPublishTest`, which is the only thing that needed a
|
||||
* provider at all. They moved here when `InputQuery`'s cursor reads turned out to need the same
|
||||
* provider answering *badly* — see [RowShape].
|
||||
*/
|
||||
|
||||
internal const val DOCUMENTS_AUTHORITY = "org.libremediaconverter.test.documents"
|
||||
internal const val PLAIN_AUTHORITY = "org.libremediaconverter.test.plain"
|
||||
|
||||
/**
|
||||
* How [FakeSafProvider] answers a metadata query.
|
||||
*
|
||||
* A provider is another app. It can be uninstalled, revoke its grant, crash, or simply answer
|
||||
* something the caller did not expect — and "answered something unexpected" is not one case but
|
||||
* several, which is why this is an enum rather than a boolean.
|
||||
*
|
||||
* The distinction that matters most to callers is **null versus missing versus zero versus
|
||||
* negative**. `InputQuery` exists to stop the last three being conflated: `hasSpaceFor(0)` is only
|
||||
* "is there 128 MB free", so a size nobody could determine must not arrive as `0`, and
|
||||
* `OutputPublisher.destinationIsKnownEmpty` must answer `false` — never "empty, go ahead and
|
||||
* delete" — for every one of them.
|
||||
*
|
||||
* Column-level granularity is deliberate. `OutputPublisher` reads only `SIZE`; `InputQuery` reads
|
||||
* both, and reaches a different answer depending on which one is bad.
|
||||
*/
|
||||
internal enum class RowShape {
|
||||
/** What a healthy provider answers: the file's real name and real length. */
|
||||
NORMAL,
|
||||
|
||||
/** A row is present and its `DISPLAY_NAME` cell is null. */
|
||||
NULL_DISPLAY_NAME,
|
||||
|
||||
/** A row is present and its `SIZE` cell is null. */
|
||||
NULL_SIZE,
|
||||
|
||||
/** The cursor carries no `DISPLAY_NAME` column at all — `getColumnIndex` gives `-1`. */
|
||||
NO_DISPLAY_NAME_COLUMN,
|
||||
|
||||
/** The cursor carries no `SIZE` column at all — `getColumnIndex` gives `-1`. */
|
||||
NO_SIZE_COLUMN,
|
||||
|
||||
/**
|
||||
* A size of `-1`.
|
||||
*
|
||||
* Not a corrupt provider: it is what anything without a fixed length reports — a pipe, or a
|
||||
* provider streaming its answer — and it is a third way of saying "unknown", distinct from a
|
||||
* null cell and from a missing column.
|
||||
*/
|
||||
NEGATIVE_SIZE,
|
||||
|
||||
/**
|
||||
* A cursor with the right columns and no rows in it.
|
||||
*
|
||||
* Distinct from returning `null`, which is what a provider that does not recognise the URI
|
||||
* does. Both mean "no answer", and code that treats one as an answer and the other as an
|
||||
* absence is wrong about one of them.
|
||||
*/
|
||||
NO_ROWS,
|
||||
|
||||
/**
|
||||
* The query itself throws.
|
||||
*
|
||||
* A resolver call is a call into another app, and that app can have been uninstalled, revoked
|
||||
* its grant, or simply crashed. `InputQuery.firstRow`'s KDoc is explicit that "a file picker is
|
||||
* not a place to bring the process down from", so this is the shape that proves the guard is
|
||||
* one.
|
||||
*/
|
||||
QUERY_THROWS,
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in for the provider behind a SAF destination.
|
||||
*
|
||||
* It answers only what its callers ask of a document -- how many bytes are already there, what it
|
||||
* is called, and delete it -- backed by a real file so the assertions are about the filesystem
|
||||
* rather than about a mock's call log alone. The rest of the `ContentProvider` surface is stubbed.
|
||||
*
|
||||
* Writing is deliberately NOT routed through it. Robolectric's `ShadowContentResolver`
|
||||
* consults its registered-stream map before it reaches any provider, which is what lets a
|
||||
* test hand out a stream that writes some bytes and then fails -- a condition a real provider
|
||||
* cannot be asked to produce on demand.
|
||||
*/
|
||||
internal open class FakeSafProvider : ContentProvider() {
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor? {
|
||||
if (rowShape == RowShape.QUERY_THROWS) throw SecurityException("provider revoked the grant")
|
||||
val file = backingFile(uri)
|
||||
if (!file.exists()) return null
|
||||
return MatrixCursor(columnsFor(rowShape)).apply {
|
||||
if (rowShape != RowShape.NO_ROWS) addRow(cellsFor(rowShape, file))
|
||||
}
|
||||
}
|
||||
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
||||
if (method != METHOD_DELETE_DOCUMENT) return null
|
||||
val target = extras?.getParcelable(EXTRA_URI, Uri::class.java) ?: return null
|
||||
deleteRequests += target
|
||||
deleteFailure?.let { throw it }
|
||||
backingFile(target).delete()
|
||||
return Bundle()
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri) = "video/mp4"
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
companion object {
|
||||
// DocumentsContract.METHOD_DELETE_DOCUMENT and EXTRA_URI are hidden from the public
|
||||
// SDK, so they cannot be referenced. These are the wire names
|
||||
// DocumentsContract.deleteDocument() actually sends, which is what a provider sees.
|
||||
const val METHOD_DELETE_DOCUMENT = "android:deleteDocument"
|
||||
const val EXTRA_URI = "uri"
|
||||
|
||||
/** Where the "documents" really live. Set per test to a Robolectric temp path. */
|
||||
lateinit var root: File
|
||||
|
||||
/** Every delete this provider was asked for, in order. Empty is an assertion too. */
|
||||
val deleteRequests = mutableListOf<Uri>()
|
||||
|
||||
/** Armed by the test that needs the cleanup itself to fail. */
|
||||
var deleteFailure: RuntimeException? = null
|
||||
|
||||
/**
|
||||
* How the next query answers. [reset] puts it back to [RowShape.NORMAL], so a test that
|
||||
* does not care never has to think about it.
|
||||
*/
|
||||
var rowShape: RowShape = RowShape.NORMAL
|
||||
|
||||
fun backingFile(uri: Uri) = File(root, uri.lastPathSegment.orEmpty())
|
||||
|
||||
fun reset(directory: File) {
|
||||
root = directory
|
||||
deleteRequests.clear()
|
||||
deleteFailure = null
|
||||
rowShape = RowShape.NORMAL
|
||||
}
|
||||
|
||||
private fun columnsFor(shape: RowShape): Array<String> = when (shape) {
|
||||
RowShape.NO_DISPLAY_NAME_COLUMN -> arrayOf(OpenableColumns.SIZE)
|
||||
RowShape.NO_SIZE_COLUMN -> arrayOf(OpenableColumns.DISPLAY_NAME)
|
||||
else -> arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||
}
|
||||
|
||||
private fun cellsFor(shape: RowShape, file: File): Array<Any?> = when (shape) {
|
||||
RowShape.NO_DISPLAY_NAME_COLUMN -> arrayOf(file.length())
|
||||
RowShape.NO_SIZE_COLUMN -> arrayOf<Any?>(file.name)
|
||||
RowShape.NULL_DISPLAY_NAME -> arrayOf(null, file.length())
|
||||
RowShape.NULL_SIZE -> arrayOf(file.name, null)
|
||||
RowShape.NEGATIVE_SIZE -> arrayOf(file.name, UNKNOWN_LENGTH)
|
||||
else -> arrayOf(file.name, file.length())
|
||||
}
|
||||
|
||||
/** What `statSize` reports for anything without a fixed length. See [RowShape.NEGATIVE_SIZE]. */
|
||||
private const val UNKNOWN_LENGTH = -1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same provider, registered WITHOUT the documents-provider intent filter.
|
||||
*
|
||||
* A separate class because the package manager keys providers by component name, so two
|
||||
* authorities need two components. It exists to prove the guard is a guard: a content URI
|
||||
* from something that is not a documents provider must not be handed to `deleteDocument`.
|
||||
*/
|
||||
internal class FakePlainProvider : FakeSafProvider()
|
||||
|
||||
/**
|
||||
* Stands [provider] up on [authority] so `contentResolver` and the package manager both know it.
|
||||
*
|
||||
* `isDocumentUri()` does not look at the URI alone: it asks the package manager whether anything
|
||||
* answers `ACTION_DOCUMENTS_PROVIDER` for that authority. Registering the provider with the
|
||||
* resolver is not enough, which is the whole reason [asDocumentsProvider] is a parameter rather
|
||||
* than always true — the negative case is a test.
|
||||
*/
|
||||
internal fun registerProvider(
|
||||
context: Context,
|
||||
provider: Class<out FakeSafProvider>,
|
||||
authority: String,
|
||||
asDocumentsProvider: Boolean,
|
||||
) {
|
||||
val info = ProviderInfo().apply {
|
||||
this.authority = authority
|
||||
packageName = context.packageName
|
||||
name = provider.name
|
||||
exported = true
|
||||
grantUriPermissions = true
|
||||
}
|
||||
Robolectric.buildContentProvider(provider).create(info)
|
||||
|
||||
val packageManager = shadowOf(context.packageManager)
|
||||
packageManager.addOrUpdateProvider(info)
|
||||
if (asDocumentsProvider) {
|
||||
packageManager.addIntentFilterForProvider(
|
||||
ComponentName(context.packageName, provider.name),
|
||||
IntentFilter(DocumentsContract.PROVIDER_INTERFACE),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.InputKind
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* What the source-info card says when it does not know something.
|
||||
*
|
||||
* The defect is a card that invents an answer instead of admitting it has none. Two of them are
|
||||
* live here and neither had a test before this file:
|
||||
*
|
||||
* - **`InputFile.sizeBytes` is nullable and the card is the reader that has to say so in words.**
|
||||
* `sizeBytes` used to be `0L` for "nobody told me", and [UnknownInputSizeTest] records what that
|
||||
* cost at the space check. The card is the other reader, and its failure mode is the mirror
|
||||
* image: hand the null to `formatBytes` and it renders `"0 B"` -- a measurement, shown to the
|
||||
* user, that no provider ever made. It renders **independently of the probe**, which is why the
|
||||
* same assertion appears twice below, with the probe present and absent. That independence is
|
||||
* the contract; a test covering only the probed case would leave the branch a user actually hits
|
||||
* first -- the card is on screen before the probe finishes -- unguarded.
|
||||
* - **The codec rows degrade in words too.** `CodecNames.describeVideo`/`describeAudio` answer
|
||||
* `"Unknown"` for a codec nothing named, the `VIDEO` branch answers `"No audio track"` for a file
|
||||
* with no audio, and the two `> 0` guards drop the dimension and length rows rather than printing
|
||||
* `0` and `0:00`. Each of those has a case below on **both** sides of the guard, because a test
|
||||
* of the present side alone stays green with the guard deleted.
|
||||
*
|
||||
* ### What cannot be asserted here, so that it is a decision rather than an omission
|
||||
*
|
||||
* The `probe == null` branch exits before `HorizontalDivider`, and **the divider's absence is not
|
||||
* observable from a test**: Material 3 renders it as a `Box` with no semantics modifier, so it
|
||||
* contributes no node to the semantics tree at all. What is asserted instead is everything the
|
||||
* divider precedes -- no detail row for any label the four kind branches can emit -- plus the
|
||||
* card's child count, which pins "these three texts and nothing else" without having to enumerate.
|
||||
*
|
||||
* The early exit itself is enforced by the compiler rather than by this file, which the PR body
|
||||
* records: deleting `return@Column` un-smart-casts `probe`, and the `probe.kind` below it stops
|
||||
* compiling. The mutation that reddens the test here is the compilable form of that regression --
|
||||
* defaulting the null away with `?: InputProbe()` and letting the kind rows render.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FileCardTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `a file no provider could measure says so in words rather than showing a zero`() {
|
||||
setFileCard(input(sizeBytes = null, probe = VIDEO_PROBE))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_BYTES)
|
||||
.assertTextEquals("Size unknown")
|
||||
}
|
||||
|
||||
/**
|
||||
* The same line, with no probe at all. Separate from the case above rather than folded into
|
||||
* it because `setContent` may only be called once per rule, and because two independent reds
|
||||
* are the evidence that the size line does not depend on the probe.
|
||||
*/
|
||||
@Test
|
||||
fun `the size line says the same thing while the probe is still running`() {
|
||||
setFileCard(input(sizeBytes = null, probe = null))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_BYTES)
|
||||
.assertTextEquals("Size unknown")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a size that was reported is formatted rather than replaced by the unknown line`() {
|
||||
setFileCard(input(sizeBytes = 12_345_678L, probe = VIDEO_PROBE))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertTextEquals("clip.mkv")
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_BYTES).assertTextEquals("12.3 MB")
|
||||
}
|
||||
|
||||
/**
|
||||
* The note and the emptiness are one behaviour, so they are one test: a regression that keeps
|
||||
* the note but renders the rows anyway would leave a note-only test green.
|
||||
*/
|
||||
@Test
|
||||
fun `while the probe is still running the card shows the reading note and nothing else`() {
|
||||
setFileCard(input(probe = null))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NOTE)
|
||||
.assertTextEquals("Reading…")
|
||||
assertNoDetailRows()
|
||||
// Name, size, note. Catches a row whose label is not in EVERY_ROW_LABEL as well.
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).onChildren().assertCountEquals(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file nothing could read gets the explanatory line instead of unknown codecs`() {
|
||||
setFileCard(input(probe = InputProbe(kind = InputKind.UNPARSEABLE)))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NOTE)
|
||||
.assertTextEquals("Could not identify this file. It will be converted with FFmpeg.")
|
||||
assertNoDetailRows()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image gets its type and its pixel dimensions`() {
|
||||
setFileCard(input(probe = InputProbe(kind = InputKind.IMAGE, width = 1920, height = 1080)))
|
||||
|
||||
assertRow("Type", "Image")
|
||||
assertRow("Size", "1920×1080")
|
||||
}
|
||||
|
||||
/** The `width > 0` guard, from the side that would print `0×0` if it were dropped. */
|
||||
@Test
|
||||
fun `an image whose dimensions nothing reported gets the type row alone`() {
|
||||
setFileCard(input(probe = InputProbe(kind = InputKind.IMAGE)))
|
||||
|
||||
assertRow("Type", "Image")
|
||||
assertNoRow("Size")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio-only file says it has no video track rather than leaving the row blank`() {
|
||||
setFileCard(
|
||||
input(
|
||||
probe = InputProbe(
|
||||
audioCodec = "aac",
|
||||
hasVideo = false,
|
||||
durationMs = 90_000,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
container = Container.MP3,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertRow("Container", Container.MP3.label)
|
||||
assertRow("Video", "No video track")
|
||||
assertRow("Audio", AudioCodec.AAC.label)
|
||||
assertRow("Length", "1:30")
|
||||
assertNoRow("Type")
|
||||
assertNoRow("Size")
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the audio branch can fail to know, at once: no container, no codec name, no
|
||||
* duration. Each degrades in its own words, and the length row disappears rather than
|
||||
* claiming `0:00`.
|
||||
*/
|
||||
@Test
|
||||
fun `an audio-only file nothing else could describe degrades one row at a time`() {
|
||||
setFileCard(input(probe = InputProbe(hasVideo = false, kind = InputKind.AUDIO_ONLY)))
|
||||
|
||||
assertRow("Container", "Unknown")
|
||||
assertRow("Video", "No video track")
|
||||
assertRow("Audio", "Unknown")
|
||||
assertNoRow("Length")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a video file composes its codec with its dimensions on one row`() {
|
||||
setFileCard(input(probe = VIDEO_PROBE))
|
||||
|
||||
assertRow("Container", Container.MP4.label)
|
||||
assertRow("Video", "${VideoCodec.H264.label} · 1920×1080")
|
||||
assertRow("Audio", AudioCodec.AAC.label)
|
||||
assertRow("Length", "1:30")
|
||||
}
|
||||
|
||||
/**
|
||||
* `"No audio track"` rather than `describeAudio(null)`'s `"Unknown"`. The video branch knows
|
||||
* the difference between a track it could not name and a track that is not there; the audio
|
||||
* branch above cannot, because a file with no audio is not audio-only.
|
||||
*/
|
||||
@Test
|
||||
fun `a video file with no audio track says so instead of naming an unknown codec`() {
|
||||
setFileCard(input(probe = VIDEO_PROBE.copy(audioCodec = null)))
|
||||
|
||||
assertRow("Audio", "No audio track")
|
||||
}
|
||||
|
||||
/** Both `> 0` guards on the video branch, plus the codec name nothing supplied. */
|
||||
@Test
|
||||
fun `a video file missing its codec, dimensions and duration omits them rather than faking them`() {
|
||||
setFileCard(
|
||||
input(
|
||||
probe = VIDEO_PROBE.copy(
|
||||
videoCodec = null,
|
||||
width = 0,
|
||||
height = 0,
|
||||
durationMs = 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertRow("Video", "Unknown")
|
||||
assertNoRow("Length")
|
||||
}
|
||||
|
||||
/**
|
||||
* The row is one node, not a label node beside a value node. A test matching on `"Container"`
|
||||
* alone would pass against either shape.
|
||||
*/
|
||||
@Test
|
||||
fun `a detail row renders its label and its value as a single node`() {
|
||||
composeRule.setContent { DetailRow("Container", "Matroska") }
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.detailRow("Container"))
|
||||
.assertTextEquals("Container: Matroska")
|
||||
}
|
||||
|
||||
private fun setFileCard(input: InputFile) = composeRule.setContent { FileCard(input) }
|
||||
|
||||
private fun input(sizeBytes: Long? = 12_345_678L, probe: InputProbe? = VIDEO_PROBE) = InputFile(
|
||||
uri = Uri.parse("content://test/clip.mkv"),
|
||||
displayName = "clip.mkv",
|
||||
sizeBytes = sizeBytes,
|
||||
probe = probe,
|
||||
)
|
||||
|
||||
private fun assertRow(label: String, value: String) {
|
||||
composeRule.onNodeWithTag(TestTags.Converter.detailRow(label))
|
||||
.assertTextEquals("$label: $value")
|
||||
}
|
||||
|
||||
private fun assertNoRow(label: String) {
|
||||
composeRule.onNodeWithTag(TestTags.Converter.detailRow(label)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun assertNoDetailRows() = EVERY_ROW_LABEL.forEach(::assertNoRow)
|
||||
|
||||
private companion object {
|
||||
/** Every label the four kind branches can emit, so absence can be asserted exhaustively. */
|
||||
val EVERY_ROW_LABEL = listOf("Container", "Video", "Audio", "Length", "Type", "Size")
|
||||
|
||||
val VIDEO_PROBE = InputProbe(
|
||||
videoCodec = "h264",
|
||||
audioCodec = "aac",
|
||||
durationMs = 90_000,
|
||||
kind = InputKind.VIDEO,
|
||||
container = Container.MP4,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What [InputQuery] makes of a metadata row.
|
||||
*
|
||||
* ## Why this is a separate file from `UnknownInputSizeTest`
|
||||
*
|
||||
* That test drives the case where **no provider is registered** — the query returns null and
|
||||
* `measure()` answers instead — and it drives it thoroughly. What it never does is hand `InputQuery`
|
||||
* a row. Before this file, nothing did: `firstRow`'s body, `displayNameOrNull` and `sizeOrNull` had
|
||||
* never executed in the JVM suite, so every branch inside them was untested.
|
||||
*
|
||||
* ## What is actually being pinned
|
||||
*
|
||||
* Not "does it read a cursor" — that would pass against almost any implementation. The rule is that
|
||||
* **a size nobody could determine must not arrive as a number**, and there are four separate ways a
|
||||
* provider fails to determine one: a null cell, a missing column, a negative value, and no row at
|
||||
* all. `InputQuery`'s KDoc states the stake:
|
||||
*
|
||||
* > a worker's input `Data` carries the size the *picker* found … `hasSpaceFor(0)` is only "is there
|
||||
* > 128 MB free".
|
||||
*
|
||||
* So each of those four must produce `null`, and `null` specifically — not `0`, not `-1`. A test
|
||||
* that asserted only "not the file's length" would pass on `0`, which is the exact conflation the
|
||||
* class exists to end.
|
||||
*
|
||||
* ## Why every fall-through lands on null here
|
||||
*
|
||||
* [FakeSafProvider] does not implement `openFile`, so `measure()` cannot answer for these URIs
|
||||
* either. That is deliberate: it isolates the cursor half. The other direction — the cursor says
|
||||
* nothing and `measure()` succeeds — is `UnknownInputSizeTest`'s
|
||||
* `a picked file no provider describes is measured rather than reported as empty`, and is not
|
||||
* repeated here.
|
||||
*
|
||||
* ## What the mutations say, including the one that does not bite
|
||||
*
|
||||
* Measured against `MatrixCursor`, which is what these tests drive:
|
||||
*
|
||||
* | call on a null cell | result |
|
||||
* |---|---|
|
||||
* | `getString` | returns `null` |
|
||||
* | `getLong` | returns **`0`** |
|
||||
*
|
||||
* That second row is why `sizeOrNull`'s `!isNull(it)` guard is load-bearing and why these tests
|
||||
* bite: remove it and a null size arrives as `0`, a real number indistinguishable from an empty
|
||||
* file, which is the precise conflation this class exists to end. Removing it reddens
|
||||
* `a null size is unknown rather than zero`. Removing the trailing `takeIf { it >= 0 }` reddens
|
||||
* `a negative size is unknown rather than reported`.
|
||||
*
|
||||
* **Named exemption: `displayNameOrNull`'s `!isNull(it)` guard is not pinned by anything here, and
|
||||
* cannot be.** `getString` returns null for a null cell, so the fallback applies with or without
|
||||
* the guard — removing it leaves every test in this file green. The guard is not redundant in
|
||||
* production: `Cursor.getString`'s contract states that whether it throws on a null column is
|
||||
* *implementation-defined*, and a real `ContentProvider` is free to throw where `MatrixCursor`
|
||||
* returns null. It should stay. It simply cannot be falsified with this cursor, and saying so is
|
||||
* better than implying `a null display name falls back without disturbing the size` covers it —
|
||||
* that test pins the behaviour, not the guard.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class InputQueryCursorTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var uri: Uri
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
FakeSafProvider.reset(File(context.cacheDir, "picked").apply { mkdirs() })
|
||||
registerProvider(context, FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
|
||||
uri = Uri.parse("content://$DOCUMENTS_AUTHORITY/document/holiday.mp4")
|
||||
FakeSafProvider.backingFile(uri).writeBytes(ByteArray(PAYLOAD_BYTES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider that answers properly supplies both the name and the size`() {
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals("holiday.mp4", described.displayName)
|
||||
assertEquals(PAYLOAD_BYTES.toLong(), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null display name falls back without disturbing the size`() {
|
||||
FakeSafProvider.rowShape = RowShape.NULL_DISPLAY_NAME
|
||||
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
|
||||
// The two columns are read independently. A provider that cannot name the file can still
|
||||
// size it, and losing the size here would be a bug the name assertion alone would miss.
|
||||
assertEquals(PAYLOAD_BYTES.toLong(), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor with no display name column falls back rather than throwing`() {
|
||||
// getColumnIndex returns -1 rather than throwing, so the `it >= 0` guard is the only thing
|
||||
// between this and an IllegalArgumentException out of getString.
|
||||
FakeSafProvider.rowShape = RowShape.NO_DISPLAY_NAME_COLUMN
|
||||
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
|
||||
assertEquals(PAYLOAD_BYTES.toLong(), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null size is unknown rather than zero`() {
|
||||
FakeSafProvider.rowShape = RowShape.NULL_SIZE
|
||||
|
||||
assertNull(unknownSizeMessage("a null cell"), InputQuery.sizeOf(context, uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor with no size column is unknown rather than zero`() {
|
||||
FakeSafProvider.rowShape = RowShape.NO_SIZE_COLUMN
|
||||
|
||||
assertNull(unknownSizeMessage("a missing column"), InputQuery.sizeOf(context, uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negative size is unknown rather than reported`() {
|
||||
// What anything without a fixed length reports -- a pipe, or a provider streaming its
|
||||
// answer. Passing -1 through would be worse than passing 0: hasSpaceFor compares it
|
||||
// against free space, so it would read as "needs less than nothing".
|
||||
FakeSafProvider.rowShape = RowShape.NEGATIVE_SIZE
|
||||
|
||||
assertNull(unknownSizeMessage("a negative size"), InputQuery.sizeOf(context, uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor with no rows is unknown rather than zero`() {
|
||||
// Distinct from the provider returning null, which UnknownInputSizeTest covers. A cursor
|
||||
// that exists and holds nothing still has to reach the same answer.
|
||||
FakeSafProvider.rowShape = RowShape.NO_ROWS
|
||||
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
|
||||
assertNull(unknownSizeMessage("an empty cursor"), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider that throws is survived rather than propagated`() {
|
||||
// The guard firstRow's KDoc exists for: "a resolver call is a call into another app ... and
|
||||
// a file picker is not a place to bring the process down from". Without the runCatching,
|
||||
// this SecurityException reaches the caller and takes the pick with it.
|
||||
FakeSafProvider.rowShape = RowShape.QUERY_THROWS
|
||||
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
|
||||
assertNull(unknownSizeMessage("a provider that threw"), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join total is unknown when any one input could not be sized`() {
|
||||
// The consequence the four cases above exist for, asserted once at the place it lands.
|
||||
// Summing the inputs that did answer would produce a lower bound indistinguishable from a
|
||||
// real total, which is what the space check cannot tell apart.
|
||||
FakeSafProvider.rowShape = RowShape.NULL_SIZE
|
||||
val unsizable = InputQuery.sizeOf(context, uri)
|
||||
FakeSafProvider.rowShape = RowShape.NORMAL
|
||||
val sizable = InputQuery.sizeOf(context, uri)
|
||||
|
||||
assertEquals(PAYLOAD_BYTES.toLong(), sizable)
|
||||
assertNull(unsizable)
|
||||
assertNull("one unknown input makes the whole total unknown", InputQuery.total(listOf(sizable, unsizable)))
|
||||
}
|
||||
|
||||
private fun unknownSizeMessage(cause: String) =
|
||||
"$cause means nobody could size the file; that must be null, not 0 -- hasSpaceFor(0) is only a headroom check"
|
||||
|
||||
private companion object {
|
||||
const val PAYLOAD_BYTES = 4096
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.AudioPlan
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.CopyPlanner
|
||||
import org.libremediaconverter.model.InputKind
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.VideoPlan
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
/**
|
||||
* What happens when Media3 refuses the export before it starts.
|
||||
*
|
||||
* `EditedMediaItem.Builder` rejects a composition with both tracks removed —
|
||||
* checkState("Audio and video cannot both be removed") — and [Media3Engine] builds it on its own
|
||||
* HandlerThread. That build used to sit *between* two narrow `runCatching` blocks, one around
|
||||
* `buildTransformer` and one around `start`, so the exception escaped `handler.post`'s body: it
|
||||
* reached the thread's uncaught handler, which on Android takes the process down, and the
|
||||
* continuation was left unresumed either way.
|
||||
*
|
||||
* Robolectric runs the real [android.os.HandlerThread] and the real Media3 builders, so the whole
|
||||
* sequence happens here — the engine really posts, really builds, and really throws. What it cannot
|
||||
* reproduce is the *consequence* of an escaped throw: a JVM background thread dying is not process
|
||||
* death. So the assertion is on the half that is observable everywhere and is the half that
|
||||
* matters to the user — the suspension is resolved, with the reason, rather than left hanging.
|
||||
* `Media3EngineTest.aPlanThatRemovesBothTracksFailsInsteadOfKillingTheProcess` is the same case on
|
||||
* a device.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class Media3EngineEmptyCompositionTest {
|
||||
|
||||
@Test
|
||||
fun `a plan that removes both tracks fails the job instead of escaping the handler thread`() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val engine = Media3Engine(context)
|
||||
val request = ConversionRequest(
|
||||
spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
probe = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
),
|
||||
)
|
||||
|
||||
// Asserted rather than assumed: ConversionRequest's default probe says hasVideo = true, and
|
||||
// with it this same spec plans to (Encode, Drop), nothing throws, and the test would pass
|
||||
// over a code path it never entered.
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
|
||||
val failure = try {
|
||||
runCatching {
|
||||
runBlocking {
|
||||
withTimeout(TIMEOUT_MS) {
|
||||
engine.transcode(Uri.parse("file:///dev/null"), File(context.cacheDir, "empty.mp4"), request) {}
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
} finally {
|
||||
engine.close()
|
||||
}
|
||||
|
||||
// Both halves are load-bearing, and the second is not pedantry: withTimeout raises
|
||||
// TimeoutCancellationException, and `java.util.concurrent.CancellationException` *extends*
|
||||
// IllegalStateException — so testing only the first would call an unresumed continuation a
|
||||
// pass. This assertion was written that way, and the mutation is what found it.
|
||||
assertFalse(
|
||||
"the continuation was never resumed — the failure escaped instead of being reported: $failure",
|
||||
failure is CancellationException,
|
||||
)
|
||||
assertTrue(
|
||||
"the builder's refusal must surface as a failed job; got $failure",
|
||||
failure is IllegalStateException,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Short on purpose. Nothing is decoded, encoded or muxed on this path — the builder refuses
|
||||
* the input outright — so anything approaching this is a hang, which is the failure mode
|
||||
* this test is looking for.
|
||||
*/
|
||||
const val TIMEOUT_MS = 10_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.AudioPlan
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionPlan
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.ConversionRouter
|
||||
import org.libremediaconverter.model.CopyPlanner
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.VideoPlan
|
||||
|
||||
/**
|
||||
* Guards [Media3Engine]'s two enum-to-MIME tables and the claims written above them.
|
||||
*
|
||||
* The defect: neither table was exercised at all, so nothing stood between a wrong entry and the
|
||||
* user's file. Point `H265` at `VIDEO_H264` and every hardware HEVC export writes H.264 into a
|
||||
* file the user asked to be H.265 — Transformer does exactly as told, the export succeeds, and
|
||||
* the only symptom is a codec nobody chose.
|
||||
*
|
||||
* Worse, one arm carried an assertion instead of a value:
|
||||
*
|
||||
* ```
|
||||
* // Never reached: only an Encode plan consults this, and COPY/NONE are not Encode.
|
||||
* ```
|
||||
*
|
||||
* That is a claim about *callers* parked in a branch of a callee. It happens to be true, and
|
||||
* nothing whatsoever checked it, so it would have gone on reading as true after it stopped being.
|
||||
*
|
||||
* Three kinds of test, because arm-by-arm equality alone would only pin today's answers:
|
||||
*
|
||||
* 1. Every arm of both tables, nulls included.
|
||||
* 2. The "never reached" claim, proved over every plan [CopyPlanner] can produce.
|
||||
* 3. The tables against [ConversionRouter]'s actual decisions rather than against its codec sets —
|
||||
* the comments claim behaviour ("the router routes them to FFmpeg"), and a set can be right
|
||||
* while the rule that reads it is wrong.
|
||||
*
|
||||
* A JVM test rather than an instrumented one: both tables take an enum and return a constant.
|
||||
*/
|
||||
@UnstableApi
|
||||
class Media3EngineMimeTypesTest {
|
||||
|
||||
@Test
|
||||
fun `every video codec maps to the MIME type Transformer will be given`() {
|
||||
assertEquals(
|
||||
"EXPECTED_VIDEO_MIME must name every VideoCodec, so a new one cannot arrive untested",
|
||||
VideoCodec.entries.toSet(),
|
||||
EXPECTED_VIDEO_MIME.keys,
|
||||
)
|
||||
VideoCodec.entries.forEach { codec ->
|
||||
assertEquals(
|
||||
"videoMimeTypeFor(${codec.label})",
|
||||
EXPECTED_VIDEO_MIME.getValue(codec),
|
||||
Media3Engine.videoMimeTypeFor(codec),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every audio codec maps to the MIME type Transformer will be given`() {
|
||||
assertEquals(
|
||||
"EXPECTED_AUDIO_MIME must name every AudioCodec, so a new one cannot arrive untested",
|
||||
AudioCodec.entries.toSet(),
|
||||
EXPECTED_AUDIO_MIME.keys,
|
||||
)
|
||||
AudioCodec.entries.forEach { codec ->
|
||||
assertEquals(
|
||||
"audioMimeTypeFor(${codec.label})",
|
||||
EXPECTED_AUDIO_MIME.getValue(codec),
|
||||
Media3Engine.audioMimeTypeFor(codec),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "never reached" claim, proved rather than repeated.
|
||||
*
|
||||
* [Media3Engine] asks these tables only for `plan.video as? VideoPlan.Encode`, and every plan
|
||||
* it sees comes from [CopyPlanner]. So the claim reduces to a property of the planner: over
|
||||
* every spec it can be handed, an `Encode` never carries `COPY` or `NONE`. That holds because
|
||||
* both codecs are answered before the `Encode` branch, and the fallback draws from
|
||||
* `ContainerCapabilities.encodableVideo`, which contains neither — but this asserts it instead
|
||||
* of trusting the reading.
|
||||
*
|
||||
* The counters are not decoration. `(plan.video as? VideoPlan.Encode)?.let { ... }` asserts
|
||||
* nothing at all for a `Drop` or `Copy` plan, so a sweep that stopped producing `Encode` plans
|
||||
* would stay green while checking nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `no plan CopyPlanner can produce carries COPY or NONE inside an Encode`() {
|
||||
var videoEncodes = 0
|
||||
var audioEncodes = 0
|
||||
everyPlan().forEach { (spec, probe, plan) ->
|
||||
(plan.video as? VideoPlan.Encode)?.let {
|
||||
videoEncodes++
|
||||
assertTrue(
|
||||
"CopyPlanner produced VideoPlan.Encode(${it.codec}) for $spec against $probe",
|
||||
it.codec != VideoCodec.COPY && it.codec != VideoCodec.NONE,
|
||||
)
|
||||
}
|
||||
(plan.audio as? AudioPlan.Encode)?.let {
|
||||
audioEncodes++
|
||||
assertTrue(
|
||||
"CopyPlanner produced AudioPlan.Encode(${it.codec}) for $spec against $probe",
|
||||
it.codec != AudioCodec.COPY && it.codec != AudioCodec.NONE,
|
||||
)
|
||||
}
|
||||
}
|
||||
assertTrue("the sweep produced no video Encode plan, so it asserted nothing", videoEncodes > 0)
|
||||
assertTrue("the sweep produced no audio Encode plan, so it asserted nothing", audioEncodes > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The video table's other claim: VP8, VP9 and AV1 targets "never reach here".
|
||||
*
|
||||
* Asked of the router rather than of its private codec set, so the rule is what is under test.
|
||||
*/
|
||||
@Test
|
||||
fun `the router sends exactly H264 and H265 video encodes to Media3`() {
|
||||
val onMedia3 = REAL_VIDEO_CODECS.filter { engineForVideoEncode(it) == Engine.MEDIA3 }
|
||||
assertEquals(listOf(VideoCodec.H264, VideoCodec.H265), onMedia3)
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio table's sibling claim, and where it turned out to be incomplete.
|
||||
*
|
||||
* The comment named MP3 and FLAC. One rule — `audioEncode !in MEDIA3_AUDIO` — diverts Vorbis
|
||||
* by exactly the same logic, so three of the six encodable codecs never reach the table, not
|
||||
* two. Asserted as the whole set rather than as two memberships, which is what makes the
|
||||
* omission visible.
|
||||
*/
|
||||
@Test
|
||||
fun `the router keeps MP3 FLAC and Vorbis audio encodes off Media3`() {
|
||||
val onMedia3 = REAL_AUDIO_CODECS.filter { engineForAudioEncode(it) == Engine.MEDIA3 }
|
||||
assertEquals(listOf(AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.PCM), onMedia3)
|
||||
}
|
||||
|
||||
/**
|
||||
* The binding that makes the two halves above one test rather than two coincidences.
|
||||
*
|
||||
* A codec the router starts sending to Media3 must have a MIME type here, or Transformer is
|
||||
* left to pick its own and the user gets a codec they did not choose.
|
||||
*/
|
||||
@Test
|
||||
fun `every codec the router sends to Media3 has a MIME type`() {
|
||||
REAL_VIDEO_CODECS.filter { engineForVideoEncode(it) == Engine.MEDIA3 }.forEach { codec ->
|
||||
assertNotNull(
|
||||
"${codec.label} is routed to Media3 but videoMimeTypeFor returns null",
|
||||
Media3Engine.videoMimeTypeFor(codec),
|
||||
)
|
||||
}
|
||||
REAL_AUDIO_CODECS.filter { engineForAudioEncode(it) == Engine.MEDIA3 }.forEach { codec ->
|
||||
assertNotNull(
|
||||
"${codec.label} is routed to Media3 but audioMimeTypeFor returns null",
|
||||
Media3Engine.audioMimeTypeFor(codec),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reverse direction, which holds for video and not for audio.
|
||||
*
|
||||
* Every video codec the router withholds has a null entry, so that table is exactly the set of
|
||||
* codecs Media3 is asked to encode. Audio has one entry more than the router will ever use:
|
||||
* `VORBIS -> AUDIO_VORBIS` is correct and unreachable. Pinned deliberately — if a routing
|
||||
* change makes Vorbis live, this is the test that says the arm above stopped being dead.
|
||||
*/
|
||||
@Test
|
||||
fun `Vorbis is the one MIME type the router never asks for`() {
|
||||
REAL_VIDEO_CODECS.filter { engineForVideoEncode(it) == Engine.FFMPEG }.forEach { codec ->
|
||||
assertEquals(
|
||||
"${codec.label} never reaches Media3, so it must not name a MIME type",
|
||||
null,
|
||||
Media3Engine.videoMimeTypeFor(codec),
|
||||
)
|
||||
}
|
||||
val namedButUnrouted = REAL_AUDIO_CODECS
|
||||
.filter { Media3Engine.audioMimeTypeFor(it) != null }
|
||||
.filter { engineForAudioEncode(it) == Engine.FFMPEG }
|
||||
assertEquals(listOf(AudioCodec.VORBIS), namedButUnrouted)
|
||||
assertEquals(MimeTypes.AUDIO_VORBIS, Media3Engine.audioMimeTypeFor(AudioCodec.VORBIS))
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a video-only re-encode to [codec] and reports the engine chosen.
|
||||
*
|
||||
* `mpeg2video` is the load-bearing detail: [CopyPlanner] upgrades a request to a stream copy
|
||||
* when the source codec matches, and a `Copy` plan would answer a different question. A name
|
||||
* `CodecNames` cannot resolve forces an `Encode` for every codec, which the assertion pins so
|
||||
* that a planner change cannot quietly turn this sweep into a sweep of `Copy` plans.
|
||||
*/
|
||||
private fun engineForVideoEncode(codec: VideoCodec): Engine {
|
||||
val request = ConversionRequest(
|
||||
spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE),
|
||||
probe = InputProbe(videoCodec = "mpeg2video", container = Container.MKV),
|
||||
)
|
||||
assertEquals(
|
||||
"this request no longer plans a video Encode, so its engine says nothing about $codec",
|
||||
VideoPlan.Encode(codec),
|
||||
CopyPlanner.plan(request.spec, request.probe).video,
|
||||
)
|
||||
return ConversionRouter.route(request, DeviceCodecs.PERMISSIVE).engine
|
||||
}
|
||||
|
||||
/** The audio counterpart. `ac3` is unresolvable for the same reason `mpeg2video` is. */
|
||||
private fun engineForAudioEncode(codec: AudioCodec): Engine {
|
||||
val request = ConversionRequest(
|
||||
spec = OutputSpec(Container.MP4, VideoCodec.NONE, codec),
|
||||
probe = InputProbe(audioCodec = "ac3", hasVideo = false, container = Container.MKV),
|
||||
)
|
||||
assertEquals(
|
||||
"this request no longer plans an audio Encode, so its engine says nothing about $codec",
|
||||
AudioPlan.Encode(codec),
|
||||
CopyPlanner.plan(request.spec, request.probe).audio,
|
||||
)
|
||||
return ConversionRouter.route(request, DeviceCodecs.PERMISSIVE).engine
|
||||
}
|
||||
|
||||
private fun everyPlan(): List<Triple<OutputSpec, InputProbe, ConversionPlan>> =
|
||||
ALL_SPECS.flatMap { spec -> PROBES.map { Triple(spec, it, CopyPlanner.plan(spec, it)) } }
|
||||
|
||||
private companion object {
|
||||
|
||||
/** Every arm of `videoMimeTypeFor`, including the ones the tests above prove unreachable. */
|
||||
val EXPECTED_VIDEO_MIME: Map<VideoCodec, String?> = mapOf(
|
||||
VideoCodec.H264 to MimeTypes.VIDEO_H264,
|
||||
VideoCodec.H265 to MimeTypes.VIDEO_H265,
|
||||
VideoCodec.VP8 to null,
|
||||
VideoCodec.VP9 to null,
|
||||
VideoCodec.AV1 to null,
|
||||
// Unreachable, and asserted anyway: the proof lives in another test, and a reader
|
||||
// deleting these would leave the arms themselves unexercised.
|
||||
VideoCodec.COPY to null,
|
||||
VideoCodec.NONE to null,
|
||||
)
|
||||
|
||||
val EXPECTED_AUDIO_MIME: Map<AudioCodec, String?> = mapOf(
|
||||
AudioCodec.AAC to MimeTypes.AUDIO_AAC,
|
||||
AudioCodec.OPUS to MimeTypes.AUDIO_OPUS,
|
||||
AudioCodec.VORBIS to MimeTypes.AUDIO_VORBIS,
|
||||
AudioCodec.PCM to MimeTypes.AUDIO_RAW,
|
||||
AudioCodec.MP3 to null,
|
||||
AudioCodec.FLAC to null,
|
||||
AudioCodec.COPY to null,
|
||||
AudioCodec.NONE to null,
|
||||
)
|
||||
|
||||
/** Codecs a user can actually ask to be produced: `COPY` and `NONE` are instructions. */
|
||||
val REAL_VIDEO_CODECS = VideoCodec.entries - VideoCodec.COPY - VideoCodec.NONE
|
||||
val REAL_AUDIO_CODECS = AudioCodec.entries - AudioCodec.COPY - AudioCodec.NONE
|
||||
|
||||
/** Every output a spec can name — 15 containers by 7 video codecs by 8 audio codecs. */
|
||||
val ALL_SPECS: List<OutputSpec> = Container.entries.flatMap { container ->
|
||||
VideoCodec.entries.flatMap { video ->
|
||||
AudioCodec.entries.map { audio -> OutputSpec(container, video, audio) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Inputs chosen to reach each of [CopyPlanner]'s branches. */
|
||||
val PROBES = listOf(
|
||||
// Nothing known about the source at all.
|
||||
InputProbe(),
|
||||
// Identified, and the container changes: the copy upgrade applies.
|
||||
InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV),
|
||||
// Identified, container unchanged: the copy upgrade deliberately does not apply.
|
||||
InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MP4),
|
||||
// Copyable but not encodable by either engine — the fallback's reason for existing.
|
||||
InputProbe(videoCodec = "av1", audioCodec = "flac", container = Container.MKV),
|
||||
// Real codecs this app cannot name, so a copy is never proven safe.
|
||||
InputProbe(videoCodec = "mpeg2video", audioCodec = "ac3", container = Container.AVI),
|
||||
// The platform extractor could not open it.
|
||||
InputProbe(videoCodec = InputProbe.UNPARSEABLE),
|
||||
// Audio only.
|
||||
InputProbe(videoCodec = null, audioCodec = "opus", hasVideo = false, container = Container.OGG),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The image-demuxer rule, which looks arbitrary until it is read as a suffix.
|
||||
*
|
||||
* `MediaProbe.classify` asks [MediaProbe.isImageFormat] before anything else, so this one boolean
|
||||
* overrides everything both probes found: true and the source-info card says "Image" and a size,
|
||||
* false and it says container, codec and length. Neither mistake fails loudly.
|
||||
*
|
||||
* The rule has two halves and they are not the same shape. `image2` is a whole format name —
|
||||
* FFprobe reports it for a numbered image sequence — while the piped demuxers are named one per
|
||||
* image codec, so `_pipe` has to be matched as a *suffix*: `png_pipe`, `jpeg_pipe`, `webp_pipe`
|
||||
* and some thirty more. Widening that suffix to a substring is the tempting simplification and it
|
||||
* is wrong, because `yuv4mpegpipe` is raw video.
|
||||
*
|
||||
* The image names were measured rather than recalled. `ffprobe -show_entries format=format_name`
|
||||
* reports `png_pipe` for a `.png`, `jpeg_pipe` for a `.jpg`, `yuv4mpegpipe` for a `.y4m`, and
|
||||
* `image2` only when that demuxer is named explicitly. The container names come from
|
||||
* [MediaProbeFormatTest], and the case and spacing variants are synthetic — those exercise the
|
||||
* normalisation rather than anything FFprobe emits.
|
||||
*
|
||||
* One real format name is deliberately not asserted either way. `image2pipe` gets a false answer
|
||||
* here, being neither `image2` nor a `_pipe` suffix, and that is inert rather than a latent bug:
|
||||
* FFprobe only selects it when the demuxer is named with `-f image2pipe`, while `probeWithFFprobe`
|
||||
* forces no format at all, so a picked image arrives as `png_pipe` or its own codec's equivalent.
|
||||
* Pinning today's answer for a name this app cannot receive would be a test about FFmpeg's command
|
||||
* line rather than about this rule.
|
||||
*/
|
||||
class MediaProbeImageFormatTest {
|
||||
|
||||
@Test
|
||||
fun `a numbered image sequence is an image`() {
|
||||
assertIsImage("image2")
|
||||
}
|
||||
|
||||
/** What a picked PNG or JPEG actually reports, and the reason the suffix rule exists. */
|
||||
@Test
|
||||
fun `the per-codec piped demuxers are images`() {
|
||||
assertIsImage("png_pipe")
|
||||
assertIsImage("jpeg_pipe")
|
||||
assertIsImage("webp_pipe")
|
||||
}
|
||||
|
||||
/**
|
||||
* The half that a substring match would break.
|
||||
*
|
||||
* `yuv4mpegpipe` contains `pipe` and is not an image: it is raw uncompressed video, and
|
||||
* describing it as an image would hide its codec, its size and its length from the card while
|
||||
* leaving the file perfectly convertible.
|
||||
*/
|
||||
@Test
|
||||
fun `a format that merely contains pipe is not an image`() {
|
||||
assertNotImage("yuv4mpegpipe")
|
||||
}
|
||||
|
||||
/** The ordinary media containers, which is what the false answer is mostly for. */
|
||||
@Test
|
||||
fun `a real container is not an image`() {
|
||||
assertNotImage("mov,mp4,m4a,3gp,3g2,mj2")
|
||||
assertNotImage("matroska,webm")
|
||||
assertNotImage("mp3")
|
||||
}
|
||||
|
||||
/**
|
||||
* FFprobe names every format sharing the demuxer, so the entry that matters can be anywhere in
|
||||
* the list — and the padding and case are normalised the same way [MediaProbe.containerFrom]
|
||||
* normalises them.
|
||||
*/
|
||||
@Test
|
||||
fun `an image entry is found anywhere in the list, whatever its spacing or case`() {
|
||||
assertIsImage("PNG_PIPE")
|
||||
assertIsImage(" image2 ")
|
||||
assertIsImage("something_else, tiff_pipe")
|
||||
}
|
||||
|
||||
/** Nothing to go on is not an image; the card falls back to describing an unknown container. */
|
||||
@Test
|
||||
fun `an empty format name is not an image`() {
|
||||
assertNotImage("")
|
||||
}
|
||||
|
||||
private fun assertIsImage(formatName: String) =
|
||||
assertTrue("isImageFormat(\"$formatName\")", MediaProbe.isImageFormat(formatName))
|
||||
|
||||
private fun assertNotImage(formatName: String) =
|
||||
assertFalse("isImageFormat(\"$formatName\")", MediaProbe.isImageFormat(formatName))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.media.MediaFormat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The MIME -> short codec name table, which nothing downstream would notice going wrong.
|
||||
*
|
||||
* `MediaExtractor` answers in platform MIME spellings; the router, the copy planner and the
|
||||
* source-info card all speak FFmpeg's short names. [MediaProbe.shortName] is the one place those
|
||||
* two vocabularies meet, and most of its arms are translations rather than trimming — `video/avc`
|
||||
* is `h264`, `audio/mp4a-latm` is `aac`, `video/x-vnd.on2.vp9` is `vp9`.
|
||||
*
|
||||
* So a dropped or mistyped arm does not throw. It falls through to `substringAfter('/')` and
|
||||
* reports a different, entirely plausible-looking string. `CodecNames` carries alias lists that
|
||||
* happen to rescue some of those (`avc`, `av01`, `raw`) and not others (`mp4a-latm`,
|
||||
* `x-vnd.on2.vp9`), which is exactly why leaning on the rescue is not a plan: an unrecognised
|
||||
* codec is how a stream-copyable file quietly becomes a re-encode, and how the card ends up naming
|
||||
* a codec no user has heard of. This table is the only place those arms are pinned.
|
||||
*
|
||||
* A plain JVM test rather than Robolectric: `MediaFormat.MIMETYPE_*` are Java compile-time String
|
||||
* constants, so this test and `MediaProbe` alike carry the literals in their own bytecode and the
|
||||
* framework class is never loaded.
|
||||
*
|
||||
* Every case names its MIME in the failure message, because the MIME is the thing that has to be
|
||||
* looked up when one of these goes red.
|
||||
*/
|
||||
class MediaProbeMimeNamesTest {
|
||||
|
||||
@Test
|
||||
fun `an AVC track is reported as h264, which is what everything downstream calls it`() {
|
||||
assertShortName("h264", MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||
}
|
||||
|
||||
/** On2's vendor MIME looks nothing like the codec name FFmpeg and the router use. */
|
||||
@Test
|
||||
fun `the VP8 and VP9 vendor MIMEs are reported without their vendor prefix`() {
|
||||
assertShortName("vp8", MediaFormat.MIMETYPE_VIDEO_VP8)
|
||||
assertShortName("vp9", MediaFormat.MIMETYPE_VIDEO_VP9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AV1 and MPEG-4 are reported by codec name rather than by MIME spelling`() {
|
||||
assertShortName("av1", MediaFormat.MIMETYPE_VIDEO_AV1)
|
||||
assertShortName("mpeg4", MediaFormat.MIMETYPE_VIDEO_MPEG4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an AAC track is reported as aac, not as the mp4a-latm its MIME says`() {
|
||||
assertShortName("aac", MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uncompressed audio is reported as pcm, which is not what its MIME says either`() {
|
||||
assertShortName("pcm", MediaFormat.MIMETYPE_AUDIO_RAW)
|
||||
}
|
||||
|
||||
/**
|
||||
* Four arms produce exactly what the fallback would produce anyway.
|
||||
*
|
||||
* `video/hevc` -> `hevc`, `audio/opus` -> `opus`, `audio/flac` -> `flac`,
|
||||
* `audio/vorbis` -> `vorbis`: for these the `when` arm and `substringAfter('/')` agree, so
|
||||
* deleting the arm changes no observable behaviour and no test can catch it. That is a
|
||||
* property of the code rather than a gap here, and it is reported as such rather than dressed
|
||||
* up as coverage. The assertions still earn their place — they pin the promise the router is
|
||||
* given (`hevc`, whatever the MIME happens to spell) against a later edit that changes the
|
||||
* mapping rather than deleting it.
|
||||
*/
|
||||
@Test
|
||||
fun `the arms whose MIME subtype already is the short name still map to it`() {
|
||||
assertShortName("hevc", MediaFormat.MIMETYPE_VIDEO_HEVC)
|
||||
assertShortName("opus", MediaFormat.MIMETYPE_AUDIO_OPUS)
|
||||
assertShortName("flac", MediaFormat.MIMETYPE_AUDIO_FLAC)
|
||||
assertShortName("vorbis", MediaFormat.MIMETYPE_AUDIO_VORBIS)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback, which is what makes an unlisted codec describable at all.
|
||||
*
|
||||
* These are real `MediaFormat` MIMEs with no arm of their own. Dropping the subtype is the
|
||||
* right guess far more often than reporting the whole MIME would be — FFprobe calls the first
|
||||
* of these `ac3` too.
|
||||
*/
|
||||
@Test
|
||||
fun `a MIME with no arm of its own falls back to its subtype`() {
|
||||
assertShortName("ac3", MediaFormat.MIMETYPE_AUDIO_AC3)
|
||||
assertShortName("mpeg2", MediaFormat.MIMETYPE_VIDEO_MPEG2)
|
||||
assertShortName("dolby-vision", MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION)
|
||||
}
|
||||
|
||||
/**
|
||||
* The surprising half of `substringAfter`'s contract, pinned deliberately.
|
||||
*
|
||||
* With no `/` in the string it returns the whole input rather than the empty string. Today's
|
||||
* callers gate on a `video/` or `audio/` prefix so they cannot reach this, but "report what
|
||||
* you were given" rather than "report nothing" is what would keep a malformed MIME visible on
|
||||
* the card instead of blank.
|
||||
*/
|
||||
@Test
|
||||
fun `a MIME with no subtype separator is reported unchanged`() {
|
||||
assertShortName("weird", "weird")
|
||||
assertShortName("", "")
|
||||
}
|
||||
|
||||
private fun assertShortName(expected: String, mime: String) =
|
||||
assertEquals("shortName(\"$mime\")", expected, MediaProbe.shortName(mime))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.media.MediaFormat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Reading Int track properties out of a `MediaFormat`, which is a heterogeneous map.
|
||||
*
|
||||
* [MediaProbe.intOr] guards two different failures with one expression, and only one of them is
|
||||
* obvious. A key the format does not carry is the easy half. The other is a key it *does* carry
|
||||
* with a value of another type: `getInteger` casts rather than coerces, so a frame rate stored as
|
||||
* a Float answers with a `ClassCastException`. `probeForConcat` reads `KEY_FRAME_RATE`, which the
|
||||
* platform accepts either way, and its `catch` sits outside the track loop — so without the
|
||||
* `runCatching` one oddly-typed field would discard the codec and dimensions already read from
|
||||
* that file and the join would re-encode for no reason.
|
||||
*
|
||||
* Robolectric rather than a plain JVM test, unlike the two sibling `MediaProbe` helper tests: this
|
||||
* one needs a real `MediaFormat` instance, not just its compile-time String constants.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MediaProbeTrackFieldsTest {
|
||||
|
||||
@Test
|
||||
fun `a property the format carries as an Int is read`() {
|
||||
val format = videoFormat()
|
||||
|
||||
assertEquals(1920, with(MediaProbe) { format.intOr(MediaFormat.KEY_WIDTH) })
|
||||
assertEquals(1080, with(MediaProbe) { format.intOr(MediaFormat.KEY_HEIGHT) })
|
||||
}
|
||||
|
||||
/**
|
||||
* A track that simply does not say. `MediaExtractor` omits `KEY_FRAME_RATE` for plenty of real
|
||||
* files, and 0 is what `ConcatPlanner` reads as "cannot prove a match".
|
||||
*/
|
||||
@Test
|
||||
fun `a key the format does not carry gives the fallback`() {
|
||||
val format = videoFormat()
|
||||
|
||||
assertEquals(0, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE) })
|
||||
assertEquals(-1, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE, -1) })
|
||||
}
|
||||
|
||||
/**
|
||||
* The premise of the `runCatching`, pinned against the platform rather than assumed.
|
||||
*
|
||||
* If `getInteger` coerced a Float instead of throwing, the guard below would be testing
|
||||
* nothing at all — so the throw is asserted directly first.
|
||||
*/
|
||||
@Test
|
||||
fun `getInteger refuses a Float rather than coercing it`() {
|
||||
val format = videoFormat()
|
||||
format.setFloat(MediaFormat.KEY_FRAME_RATE, NON_INTEGRAL_FRAME_RATE)
|
||||
|
||||
val thrown = runCatching { format.getInteger(MediaFormat.KEY_FRAME_RATE) }.exceptionOrNull()
|
||||
|
||||
assertTrue("expected getInteger to refuse a Float, got $thrown", thrown is ClassCastException)
|
||||
}
|
||||
|
||||
/** And that refusal is answered with the fallback, not passed on to the caller. */
|
||||
@Test
|
||||
fun `a frame rate the format carries as a Float gives the fallback rather than throwing`() {
|
||||
val format = videoFormat()
|
||||
format.setFloat(MediaFormat.KEY_FRAME_RATE, NON_INTEGRAL_FRAME_RATE)
|
||||
|
||||
assertEquals(0, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE) })
|
||||
assertEquals(-1, with(MediaProbe) { format.intOr(MediaFormat.KEY_FRAME_RATE, -1) })
|
||||
}
|
||||
|
||||
private fun videoFormat(): MediaFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 1920, 1080)
|
||||
|
||||
private companion object {
|
||||
/** NTSC's 30000/1001, the frame rate that cannot be stored as an Int in the first place. */
|
||||
const val NON_INTEGRAL_FRAME_RATE = 29.97f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.media.MediaFormat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The rules `MediaProbe` applies to a set of track formats.
|
||||
*
|
||||
* ## Why this exists, and what it revises
|
||||
*
|
||||
* Issue #84 classified `probeWithExtractor` and `probeForConcat` as device-bound and explicitly not
|
||||
* a gap:
|
||||
*
|
||||
* > These are exercised by `RemuxTest`, `ConcatEngineTest` and `RealMediaBenchmark` in
|
||||
* > `androidTest` … **Do not read their 0% as untested.**
|
||||
*
|
||||
* That was right about the measurement boundary and right about FFprobe. It was not right that
|
||||
* these are only orchestration. The track walk is a **branch matrix**, and `androidTest` reaches it
|
||||
* only through whatever the committed fixtures happen to contain — so none of the rules below is
|
||||
* *chosen* by any test there. A fixture with two video tracks, a track that omits its duration, or
|
||||
* an audio-before-video ordering is not something a device test would produce on purpose.
|
||||
*
|
||||
* The seam is the answer #133 preferred over driving `ShadowMediaExtractor`: the walk is a pure
|
||||
* function over `List<MediaFormat>`, and what is left needing a device — `setDataSource`,
|
||||
* `getTrackFormat`, `release` — is the thin edge `androidTest` should be covering. This is the
|
||||
* `work/FailureOutcome.kt` pattern `CLAUDE.md` names.
|
||||
*
|
||||
* `MediaFormat` is a real one throughout, not a stub. `MediaProbeTrackFieldsTest` records why that
|
||||
* matters: it is a heterogeneous map whose getters throw rather than coerce, and a hand-rolled
|
||||
* double would not reproduce that.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MediaProbeTrackWalkTest {
|
||||
|
||||
// --- extractedFrom: the conversion flow's read ---------------------------
|
||||
|
||||
@Test
|
||||
fun `the first video track wins when a file carries two`() {
|
||||
// `video == null` is the entire guard. A file with two video tracks must report the first,
|
||||
// because that is the one an engine will transcode -- and the width and height must come
|
||||
// from the same track, not be mixed across them.
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1920, height = 1080),
|
||||
video(MediaFormat.MIMETYPE_VIDEO_HEVC, width = 640, height = 480),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", extracted.videoCodec)
|
||||
assertEquals(1920, extracted.width)
|
||||
assertEquals(1080, extracted.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first audio track wins when a file carries two`() {
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_OPUS),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("aac", extracted.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duration is the longest track, not the first or the last`() {
|
||||
// A file whose audio outlasts its video is ordinary. Taking the video's length would cut
|
||||
// the progress bar short; taking the last track's would be right only by accident of order.
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, durationUs = 10_000_000),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC, durationUs = 12_500_000),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_OPUS, durationUs = 1_000_000),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(12_500L, extracted.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track that does not declare its duration contributes nothing to it`() {
|
||||
// MediaExtractor omits KEY_DURATION for plenty of real tracks -- MediaProbeTrackFieldsTest
|
||||
// records the same for KEY_FRAME_RATE. Reading a key that is absent is what containsKey
|
||||
// stands between us and.
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC, durationUs = 7_000_000),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(7_000L, extracted.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declaring audio before video changes nothing`() {
|
||||
// Track order is a property of the container, not of the content. Both orderings have to
|
||||
// reach the same answer or the same file remuxed twice would probe differently.
|
||||
val videoFirst = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1280, height = 720),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
|
||||
),
|
||||
)
|
||||
val audioFirst = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1280, height = 720),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(videoFirst.videoCodec, audioFirst.videoCodec)
|
||||
assertEquals(videoFirst.audioCodec, audioFirst.audioCodec)
|
||||
assertEquals(videoFirst.width, audioFirst.width)
|
||||
assertEquals(videoFirst.height, audioFirst.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track that is neither audio nor video is ignored`() {
|
||||
// Subtitle and timed-metadata tracks are common in MKV and MP4. Neither prefix matches, so
|
||||
// neither slot is filled -- and, importantly, a subtitle track must not be mistaken for the
|
||||
// absence of an audio track by some later `else`.
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
MediaFormat().apply { setString(MediaFormat.KEY_MIME, "text/vtt") },
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", extracted.videoCodec)
|
||||
assertNull(extracted.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no tracks reports nothing rather than zero-width video`() {
|
||||
val extracted = MediaProbe.extractedFrom(emptyList())
|
||||
|
||||
assertNull(extracted.videoCodec)
|
||||
assertNull(extracted.audioCodec)
|
||||
assertEquals(0L, extracted.durationMs)
|
||||
assertEquals(0, extracted.width)
|
||||
assertEquals(0, extracted.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio-only file reports no video codec at all`() {
|
||||
// The distinction MediaProbe.classify turns into InputKind.AUDIO_ONLY, and the reason
|
||||
// `hasVideo` exists: an audio file and a corrupt file must not look alike.
|
||||
val extracted = MediaProbe.extractedFrom(listOf(audio(MediaFormat.MIMETYPE_AUDIO_AAC)))
|
||||
|
||||
assertNull(extracted.videoCodec)
|
||||
assertEquals("aac", extracted.audioCodec)
|
||||
assertEquals(0, extracted.width)
|
||||
}
|
||||
|
||||
// --- concatInputFrom: the join flow's read -------------------------------
|
||||
|
||||
@Test
|
||||
fun `the join read takes frame rate from the first video track`() {
|
||||
val input = MediaProbe.concatInputFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1920, height = 1080, frameRate = 30),
|
||||
video(MediaFormat.MIMETYPE_VIDEO_HEVC, width = 640, height = 480, frameRate = 60),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", input.videoCodec)
|
||||
assertEquals("aac", input.audioCodec)
|
||||
assertEquals(1920, input.width)
|
||||
assertEquals(1080, input.height)
|
||||
assertEquals(30, input.frameRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a video track with no declared frame rate reports zero rather than guessing`() {
|
||||
// ConcatPlanner treats 0 as "cannot prove a match" and re-encodes. A guessed 30 would read
|
||||
// as agreement and produce a stream copy of clips that do not actually match -- the failure
|
||||
// its KDoc says the whole flow is arranged to avoid.
|
||||
val input = MediaProbe.concatInputFrom(listOf(video(MediaFormat.MIMETYPE_VIDEO_AVC)))
|
||||
|
||||
assertEquals(0, input.frameRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no tracks joins as entirely unknown`() {
|
||||
val input = MediaProbe.concatInputFrom(emptyList())
|
||||
|
||||
assertNull(input.videoCodec)
|
||||
assertNull(input.audioCodec)
|
||||
assertEquals(0, input.width)
|
||||
assertEquals(0, input.height)
|
||||
assertEquals(0, input.frameRate)
|
||||
}
|
||||
|
||||
private fun video(
|
||||
mime: String,
|
||||
width: Int = 1920,
|
||||
height: Int = 1080,
|
||||
durationUs: Long? = null,
|
||||
frameRate: Int? = null,
|
||||
): MediaFormat = MediaFormat.createVideoFormat(mime, width, height).apply {
|
||||
durationUs?.let { setLong(MediaFormat.KEY_DURATION, it) }
|
||||
frameRate?.let { setInteger(MediaFormat.KEY_FRAME_RATE, it) }
|
||||
}
|
||||
|
||||
private fun audio(mime: String, durationUs: Long? = null): MediaFormat =
|
||||
MediaFormat.createAudioFormat(mime, SAMPLE_RATE, CHANNELS).apply {
|
||||
durationUs?.let { setLong(MediaFormat.KEY_DURATION, it) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SAMPLE_RATE = 48_000
|
||||
const val CHANNELS = 2
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ProviderInfo
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -20,7 +10,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
@@ -31,90 +20,8 @@ import java.io.OutputStream
|
||||
/** What a destination volume says when it fills up mid-write. */
|
||||
private const val NO_SPACE = "No space left on device"
|
||||
|
||||
private const val DOCUMENTS_AUTHORITY = "org.libremediaconverter.test.documents"
|
||||
private const val PLAIN_AUTHORITY = "org.libremediaconverter.test.plain"
|
||||
|
||||
/**
|
||||
* A stand-in for the provider behind a SAF destination.
|
||||
*
|
||||
* It answers only what `publish()` asks of a destination -- how many bytes are already there,
|
||||
* and delete it -- backed by a real file so the assertions are about the filesystem rather
|
||||
* than about a mock's call log alone. The rest of the `ContentProvider` surface is stubbed.
|
||||
*
|
||||
* Writing is deliberately NOT routed through it. Robolectric's `ShadowContentResolver`
|
||||
* consults its registered-stream map before it reaches any provider, which is what lets a
|
||||
* test hand out a stream that writes some bytes and then fails -- the condition this whole
|
||||
* file exists for, and one a real provider cannot be asked to produce on demand.
|
||||
*/
|
||||
internal open class FakeSafProvider : ContentProvider() {
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor? {
|
||||
val file = backingFile(uri)
|
||||
if (!file.exists()) return null
|
||||
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply {
|
||||
addRow(arrayOf<Any?>(file.name, file.length()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
||||
if (method != METHOD_DELETE_DOCUMENT) return null
|
||||
val target = extras?.getParcelable(EXTRA_URI, Uri::class.java) ?: return null
|
||||
deleteRequests += target
|
||||
deleteFailure?.let { throw it }
|
||||
backingFile(target).delete()
|
||||
return Bundle()
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri) = "video/mp4"
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
companion object {
|
||||
// DocumentsContract.METHOD_DELETE_DOCUMENT and EXTRA_URI are hidden from the public
|
||||
// SDK, so they cannot be referenced. These are the wire names
|
||||
// DocumentsContract.deleteDocument() actually sends, which is what a provider sees.
|
||||
const val METHOD_DELETE_DOCUMENT = "android:deleteDocument"
|
||||
const val EXTRA_URI = "uri"
|
||||
|
||||
/** Where the "documents" really live. Set per test to a Robolectric temp path. */
|
||||
lateinit var root: File
|
||||
|
||||
/** Every delete this provider was asked for, in order. Empty is an assertion too. */
|
||||
val deleteRequests = mutableListOf<Uri>()
|
||||
|
||||
/** Armed by the test that needs the cleanup itself to fail. */
|
||||
var deleteFailure: RuntimeException? = null
|
||||
|
||||
fun backingFile(uri: Uri) = File(root, uri.lastPathSegment.orEmpty())
|
||||
|
||||
fun reset(directory: File) {
|
||||
root = directory
|
||||
deleteRequests.clear()
|
||||
deleteFailure = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same provider, registered WITHOUT the documents-provider intent filter.
|
||||
*
|
||||
* A separate class because the package manager keys providers by component name, so two
|
||||
* authorities need two components. It exists to prove the guard is a guard: a content URI
|
||||
* from something that is not a documents provider must not be handed to `deleteDocument`.
|
||||
*/
|
||||
internal class FakePlainProvider : FakeSafProvider()
|
||||
/** How far a failing copy gets before the volume "fills up". Any value below the payload does. */
|
||||
private const val PARTIAL_BYTES = 512
|
||||
|
||||
/**
|
||||
* A sink that behaves like a volume filling up.
|
||||
@@ -171,6 +78,8 @@ class OutputPublisherPublishTest {
|
||||
|
||||
private val payload = ByteArray(8192) { (it % 251).toByte() }
|
||||
|
||||
/** How far a failing copy gets before the volume "fills up". Any value below the payload does. */
|
||||
|
||||
private val documentUri: Uri = Uri.parse("content://$DOCUMENTS_AUTHORITY/document/holiday.mp4")
|
||||
private val plainUri: Uri = Uri.parse("content://$PLAIN_AUTHORITY/document/holiday_plain.mp4")
|
||||
private val deadUri: Uri = Uri.parse("content://org.libremediaconverter.nonexistent/document/gone.mp4")
|
||||
@@ -179,8 +88,8 @@ class OutputPublisherPublishTest {
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
FakeSafProvider.reset(File(context.cacheDir, "destinations").apply { mkdirs() })
|
||||
register(FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
|
||||
register(FakePlainProvider::class.java, PLAIN_AUTHORITY, asDocumentsProvider = false)
|
||||
registerProvider(context, FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
|
||||
registerProvider(context, FakePlainProvider::class.java, PLAIN_AUTHORITY, asDocumentsProvider = false)
|
||||
|
||||
// SAF's CreateDocument contract hands back a document that already exists and is
|
||||
// empty, so that is the state every destination starts in here.
|
||||
@@ -299,6 +208,76 @@ class OutputPublisherPublishTest {
|
||||
assertEquals(emptyList<Uri>(), FakeSafProvider.deleteRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a destination whose size cannot be determined is never deleted`() {
|
||||
// The three short-circuits in destinationIsKnownEmpty, and the reason its KDoc gives for
|
||||
// each of them answering false:
|
||||
//
|
||||
// "this decides whether a delete is allowed and 'I could not tell' must never authorise
|
||||
// one."
|
||||
//
|
||||
// The contrast is `a copy that fails partway leaves nothing at the destination` above: a
|
||||
// provider that *does* say zero gets the delete. These say nothing, so they must not.
|
||||
// Getting this backwards costs the user a file they already had, on a save that failed.
|
||||
//
|
||||
// Named exemption: of the three conjuncts, `size >= 0` cannot be falsified behaviourally.
|
||||
// Measured -- getColumnIndex returns -1 for an absent column, and isNull(-1) throws
|
||||
// CursorIndexOutOfBoundsException, which the surrounding runCatching already turns into
|
||||
// `?: false`. So relaxing it to `size >= -1` leaves this test green: same answer, reached
|
||||
// by the exception path instead. The guard should stay -- control flow through an exception
|
||||
// is worse than a comparison, and another Cursor implementation need not throw -- but no
|
||||
// assertion here pins it, and saying so beats implying the missing-column case covers it.
|
||||
// `!row.isNull(size)` and `row.moveToFirst()` do both bite.
|
||||
listOf(
|
||||
RowShape.NO_SIZE_COLUMN to "a cursor with no SIZE column",
|
||||
RowShape.NULL_SIZE to "a cursor whose SIZE cell is null",
|
||||
RowShape.NO_ROWS to "a cursor holding no rows",
|
||||
// The third case the KDoc names -- "a resolver call that throws" -- and the one the
|
||||
// list was missing. It reaches `?: false` through `runCatching` rather than through a
|
||||
// cursor answer, so it is the only one of the four that proves the catch is load
|
||||
// bearing: a provider that revokes its grant between the picker and the write must not
|
||||
// have its document deleted on the way out.
|
||||
RowShape.QUERY_THROWS to "a provider that throws out of query",
|
||||
).forEach { (shape, description) ->
|
||||
FakeSafProvider.deleteRequests.clear()
|
||||
FakeSafProvider.backingFile(documentUri).writeBytes(ByteArray(0))
|
||||
FakeSafProvider.rowShape = shape
|
||||
failMidCopy(documentUri, afterBytes = PARTIAL_BYTES)
|
||||
|
||||
assertThrows(IOException::class.java) { publisher.publish(staged, documentUri) }
|
||||
|
||||
assertEquals(
|
||||
"$description must not authorise a delete",
|
||||
emptyList<Uri>(),
|
||||
FakeSafProvider.deleteRequests,
|
||||
)
|
||||
assertTrue(
|
||||
"$description must leave the destination where it was",
|
||||
FakeSafProvider.backingFile(documentUri).exists(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider that declines by returning null fails with the destination named`() {
|
||||
// openOutputStream has two ways of refusing, and only one of them is otherwise reachable.
|
||||
// `a destination the provider will not open...` above drives the throwing one -- a provider
|
||||
// that has gone away. This is the other: a provider that is present, answers, and hands
|
||||
// back null. Without the `?: error(...)` that becomes an NPE inside `use`, which reaches
|
||||
// the user as "Conversion failed." with a null message.
|
||||
val nullOpening = object : OutputPublisher(context) {
|
||||
override fun openDestination(destination: Uri): OutputStream? = null
|
||||
}
|
||||
|
||||
val failure = runCatching { nullOpening.publish(staged, documentUri) }.exceptionOrNull()
|
||||
|
||||
assertTrue("a null stream must not appear to succeed, got $failure", failure != null)
|
||||
assertTrue(
|
||||
"the failure must name the destination rather than being a bare NPE; got ${failure?.message}",
|
||||
failure?.message?.contains("Could not open destination for writing") == true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy that succeeds delivers every byte and deletes nothing`() {
|
||||
shadowOf(context.contentResolver).registerOutputStreamSupplier(documentUri) {
|
||||
@@ -326,28 +305,4 @@ class OutputPublisherPublishTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun register(provider: Class<out FakeSafProvider>, authority: String, asDocumentsProvider: Boolean) {
|
||||
val info = ProviderInfo().apply {
|
||||
this.authority = authority
|
||||
packageName = context.packageName
|
||||
name = provider.name
|
||||
exported = true
|
||||
grantUriPermissions = true
|
||||
}
|
||||
Robolectric.buildContentProvider(provider).create(info)
|
||||
|
||||
// isDocumentUri() does not look at the URI alone: it asks the package manager whether
|
||||
// anything answers ACTION_DOCUMENTS_PROVIDER for that authority. Registering the
|
||||
// provider with the resolver is not enough, which is the whole reason the negative
|
||||
// case above can exist.
|
||||
val packageManager = shadowOf(context.packageManager)
|
||||
packageManager.addOrUpdateProvider(info)
|
||||
if (asDocumentsProvider) {
|
||||
packageManager.addIntentFilterForProvider(
|
||||
ComponentName(context.packageName, provider.name),
|
||||
IntentFilter(DocumentsContract.PROVIDER_INTERFACE),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -18,20 +20,26 @@ import java.util.UUID
|
||||
* the actual filesystem — the same calls `reset()` makes, without needing a ViewModel (both
|
||||
* of those construct a `WorkManager`, which is not initialised on the JVM classpath).
|
||||
*
|
||||
* The instrumented suite cannot run on the development host, so this is the only place the
|
||||
* "Start over leaks a full-size copy" defect can be caught before CI.
|
||||
* The instrumented suite could also catch the "Start over leaks a full-size copy" defect --
|
||||
* it runs on this host for API 33-36 (`tools/local-emulator/run-e2e.sh`) and on CI for
|
||||
* 33-37. Here rather than there because a real `cacheDir` is all the defect needs, and
|
||||
* finding it costs an emulator boot there and a few seconds here.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class OutputPublisherStagingTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var cacheDir: File
|
||||
private lateinit var publisher: OutputPublisher
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
cacheDir = context.cacheDir
|
||||
publisher = OutputPublisher(context)
|
||||
// Held as a field rather than a local: the race test below builds an anonymous
|
||||
// OutputPublisher, and inside that `object` expression a bare `context` resolves to the
|
||||
// superclass's own constructor property, which is not initialised at the super call.
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
cacheDir = app.cacheDir
|
||||
publisher = OutputPublisher(app)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -97,4 +105,103 @@ class OutputPublisherStagingTest {
|
||||
|
||||
publisher.sweepStaging()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sweep tolerates a staging path that is not a directory`() {
|
||||
// The other half of `listFiles() ?: return`, and not the same as the case above: a missing
|
||||
// directory is created by `stagingDir`'s own mkdirs() and lists as empty. Only a path that
|
||||
// cannot be a directory makes listFiles() answer null, and a sweep that dereferenced that
|
||||
// would take the app down on a launch rather than on a conversion -- AppStartSweepTest is
|
||||
// where this runs from.
|
||||
val stagingPath = stagingPathAsRegularFile()
|
||||
|
||||
publisher.sweepStaging()
|
||||
|
||||
assertTrue("the sweep must not have replaced the fixture", stagingPath.isFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes `cacheDir/conversions` a regular file, which is the whole precondition of the test
|
||||
* above -- and does it in a loop, because a single delete-then-write loses a race that CI
|
||||
* caught and this machine does not reproduce.
|
||||
*
|
||||
* `LibreMediaConverterApp.onCreate` ends with
|
||||
* `appScope.launch { OutputPublisher(...).sweepStaging() }` on `Dispatchers.IO`, and
|
||||
* `sweepStaging` reads `stagingDir`, whose getter calls `mkdirs()`. Robolectric instantiates
|
||||
* the application for every test that asks for one, so that background `mkdirs()` is in flight
|
||||
* across the whole suite, on a thread the paused main looper does not control. Between deleting
|
||||
* this path and writing it there is a window where the path does not exist and that `mkdirs()`
|
||||
* can win, which is `FileNotFoundException: ... (Is a directory)` out of `writeBytes` -- run
|
||||
* 33069641674 on #149, once, against 468 tests that pass here.
|
||||
*
|
||||
* Retrying closes it rather than narrowing it, because the race is not symmetric: `mkdirs()`
|
||||
* fails on an existing regular file, so the invariant only has to survive being *established*.
|
||||
* Once a write lands, nothing in the suite can turn this back into a directory.
|
||||
*
|
||||
* The wider problem -- application-scope IO work racing every Robolectric test that shares
|
||||
* `cacheDir` -- is #159, and is deliberately not fixed here.
|
||||
*/
|
||||
private fun stagingPathAsRegularFile(): File {
|
||||
val stagingPath = File(cacheDir, "conversions")
|
||||
repeat(FIXTURE_ATTEMPTS) {
|
||||
if (stagingPath.isFile) return stagingPath
|
||||
stagingPath.deleteRecursively()
|
||||
runCatching { stagingPath.writeBytes(ByteArray(FIXTURE_BYTES)) }
|
||||
}
|
||||
check(stagingPath.isFile) {
|
||||
"the fixture needs $stagingPath to be a regular file and it is a directory; " +
|
||||
"something recreated it $FIXTURE_ATTEMPTS times -- see #159"
|
||||
}
|
||||
return stagingPath
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file that stops being collectable between the listing and the delete survives`() {
|
||||
// The race the second timestamp read exists for, and the only branch of it that had never
|
||||
// run. The comment in sweepStaging states the cost precisely: a worker resumed by
|
||||
// WorkManager -- in this same process -- could have started writing this very file, and
|
||||
// unlinking an inode a running job still holds open ends with the job reporting success for
|
||||
// a path that no longer exists.
|
||||
//
|
||||
// So: a file old enough to collect at listing time, touched to now before the delete is
|
||||
// reached. StagingSweep.collectable already said yes; isCollectable has to say no.
|
||||
val orphan = publisher.createStagingFile(
|
||||
StagingNames.forJob(UUID.randomUUID(), "mp4"),
|
||||
).apply { writeBytes(ByteArray(4096)) }
|
||||
assertTrue(orphan.setLastModified(System.currentTimeMillis() - StagingSweep.GRACE_PERIOD_MS - 60_000))
|
||||
|
||||
// Touched *after* the snapshot is taken, which is the only window that reaches the
|
||||
// re-read. Doing it around listFiles() instead changes what StagingSweep.collectable is
|
||||
// given, so the file is never proposed for deletion and the guard is never exercised --
|
||||
// measured, and the reason the seam sits where it does.
|
||||
val racing = object : OutputPublisher(app) {
|
||||
override fun snapshot(listing: Array<File>): List<StagingSweep.Entry> =
|
||||
super.snapshot(listing).also { orphan.setLastModified(System.currentTimeMillis()) }
|
||||
}
|
||||
|
||||
racing.sweepStaging()
|
||||
|
||||
assertTrue(
|
||||
"a file a live job started writing after the listing must not be unlinked",
|
||||
orphan.exists(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discarding a file with no parent at all is refused`() {
|
||||
// A relative name has no parent directory, so `staged.parentFile` is null. The handle
|
||||
// reaches the ViewModel as a path string out of WorkInfo.outputData and is turned straight
|
||||
// into a File, so this is not a shape the caller can rule out -- and the guard has to
|
||||
// answer false rather than dereference it.
|
||||
val parentless = File("holiday.mp4")
|
||||
assertNull("the fixture is supposed to have no parent", parentless.parentFile)
|
||||
|
||||
assertFalse("a file with no parent is not in staging", publisher.discardStaged(parentless))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Enough to outlast a burst of application-scope sweeps; one attempt is what CI lost. */
|
||||
const val FIXTURE_ATTEMPTS = 50
|
||||
const val FIXTURE_BYTES = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* A dispatcher that holds a pick in flight until the test lets it finish.
|
||||
*
|
||||
* Issue #49 is about what a ViewModel does *while* a pick is between the tap and the write it
|
||||
* eventually makes. Both ViewModels put a blocking hop there — the metadata query, and on the
|
||||
* convert side the probe as well — and both hops go through an injectable dispatcher. Handing
|
||||
* them this one turns "the pick has been made but has not landed yet" from a window a test has
|
||||
* to race into a state it can simply sit in.
|
||||
*
|
||||
* Nothing here is a fake pick. The real `InputQuery.describe` still runs, on this thread,
|
||||
* whenever [runAll] is called; the only thing under the test's control is *when*.
|
||||
*
|
||||
* Confined to the thread that drives the test. Both ViewModels reach `withContext(pickDispatcher)`
|
||||
* from a coroutine on the main dispatcher, so [dispatch] is only ever called from there — the
|
||||
* queue is concurrent anyway, because a dispatcher that quietly dropped a block from another
|
||||
* thread would fail as a hang rather than as an assertion.
|
||||
*/
|
||||
class ParkedPickDispatcher : CoroutineDispatcher() {
|
||||
|
||||
private val parked = ConcurrentLinkedQueue<Runnable>()
|
||||
|
||||
/**
|
||||
* How many blocks are waiting.
|
||||
*
|
||||
* Asserted on before the interesting part of a test, because "the pick was in flight" is a
|
||||
* premise rather than a detail: a zero here means the pick had already landed and whatever
|
||||
* the test went on to prove was proved about a different situation.
|
||||
*/
|
||||
val parkedCount: Int get() = parked.size
|
||||
|
||||
override fun dispatch(context: CoroutineContext, block: Runnable) {
|
||||
parked += block
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes everything parked, oldest first, and hands it to the caller to run.
|
||||
*
|
||||
* What [runAll] cannot express: two picks are two hops through this dispatcher, and the defect
|
||||
* they can produce is the *first* one finishing last. Running them in the order they arrived
|
||||
* is the one order in which nothing goes wrong, so a test has to be able to choose.
|
||||
*/
|
||||
fun takeParked(): List<Runnable> = generateSequence { parked.poll() }.toList()
|
||||
|
||||
/**
|
||||
* Runs everything parked, and everything that parks as a result.
|
||||
*
|
||||
* The loop is not defensive: `ConversionViewModel.onInputPicked` makes two hops through this
|
||||
* dispatcher — the metadata query, then the probe — and the second is only enqueued once the
|
||||
* first has run. Draining once would leave the probe parked for the rest of the process.
|
||||
*/
|
||||
fun runAll() {
|
||||
while (true) {
|
||||
val next = parked.poll() ?: return
|
||||
next.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The half of issue #49 that is not about reattachment at all.
|
||||
*
|
||||
* `onInputPicked` makes two writes and both of them land after a hop off the main thread, so both
|
||||
* belong to whichever pick was in flight rather than to whichever pick the user last made. Nothing
|
||||
* was enforcing that. Two taps in quick succession — an easy thing to do while a `content://`
|
||||
* metadata query is slow — put the loser's file on screen if its query happened to come back
|
||||
* second, which is the same defect the ticket reported against reattachment with a different
|
||||
* coroutine on the losing side.
|
||||
*
|
||||
* Both cases below were measured rather than assumed: deleting either guard turns the matching
|
||||
* test red, and deleting the probe one turns nine other tests red with it. Neither was ever
|
||||
* reported, because a pick that loses to another pick still shows *a* file the user chose -- which
|
||||
* is what made it worth closing alongside #49 rather than leaving as a second thing to find.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PickOwnershipTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var parkedPick: ParkedPickDispatcher
|
||||
private lateinit var viewModel: ConversionViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
||||
ConversionDependencies.probe = { _, _ -> PROBE }
|
||||
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
|
||||
parkedPick = ParkedPickDispatcher()
|
||||
viewModel = ConversionViewModel(app, pickDispatcher = parkedPick)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Two taps, and the first one's metadata query is the slow one.
|
||||
*
|
||||
* The order is chosen rather than raced: both queries are parked, and this runs the second
|
||||
* before the first. Without an ownership check the straggler writes last and the screen ends
|
||||
* up showing a file the user moved off two taps ago.
|
||||
*/
|
||||
@Test
|
||||
fun `the slower of two picks does not land on top of the faster one`() {
|
||||
viewModel.onInputPicked(FIRST)
|
||||
viewModel.onInputPicked(SECOND)
|
||||
|
||||
val queries = parkedPick.takeParked()
|
||||
assertEquals("both picks should be in flight", 2, queries.size)
|
||||
// The second pick's query comes back first; the first pick's is the straggler.
|
||||
queries[1].run()
|
||||
queries[0].run()
|
||||
|
||||
val current = viewModel.state.value
|
||||
assertEquals(
|
||||
"a pick the user has already replaced took the screen: $current",
|
||||
SECOND,
|
||||
(current as ConversionState.Ready).input.uri,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The second of `onInputPicked`'s two writes, which lands a whole probe later.
|
||||
*
|
||||
* The probe hop is a native process spawn, so it is the longest gap in a pick and the easiest
|
||||
* one to pick again during. This used to be guarded by comparing URIs against the state, which
|
||||
* answers a narrower question than the one that matters — it cannot tell a second pick of the
|
||||
* same file from the first, and it reads a state that a later claim may not have written yet,
|
||||
* which is exactly this case: the newer pick has claimed the screen but its own query has not
|
||||
* come back, so the state still names the older file and the comparison waves it through.
|
||||
*/
|
||||
@Test
|
||||
fun `a probe from a pick the user has moved off does not fill the card in`() {
|
||||
viewModel.onInputPicked(FIRST)
|
||||
parkedPick.takeParked().single().run()
|
||||
assertEquals(FIRST, (viewModel.state.value as ConversionState.Ready).input.uri)
|
||||
|
||||
// The user picks again while the first pick is still probing.
|
||||
viewModel.onInputPicked(SECOND)
|
||||
val pending = parkedPick.takeParked()
|
||||
assertEquals("the first probe and the second query should both be waiting", 2, pending.size)
|
||||
pending[0].run()
|
||||
|
||||
assertNull(
|
||||
"a probe belonging to a pick the user replaced must not reach the card",
|
||||
(viewModel.state.value as ConversionState.Ready).input.probe,
|
||||
)
|
||||
|
||||
// And the pick that did win still fills its own card in, probe included.
|
||||
pending[1].run()
|
||||
parkedPick.runAll()
|
||||
val settled = viewModel.state.value as ConversionState.Ready
|
||||
assertEquals(SECOND, settled.input.uri)
|
||||
assertNotNull("the winning pick's own probe still has to land", settled.input.probe)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val FIRST: Uri = Uri.fromFile(File("/tmp/first.mp4"))
|
||||
val SECOND: Uri = Uri.fromFile(File("/tmp/second.mp4"))
|
||||
val PROBE = InputProbe(videoCodec = "h264")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Issue #49, on the JVM and without the race.
|
||||
*
|
||||
* `ReattachOnLaunchTest.doesNotOverwriteAPickTheUserHasAlreadyMade` has been catching this on
|
||||
* devices since 2026-08-24 — four occurrences, spread across API 33, 35 and 36, which is what
|
||||
* ruled out an emulator-image quirk. Every one was on attempt 1 and every one passed on re-run,
|
||||
* which is why it was read as flaky infrastructure for two days. It is not. The assertion it fails
|
||||
* on is `expected null, but was:<Converted>`: a finished job from an earlier session taking a
|
||||
* screen the user had already picked a file on.
|
||||
*
|
||||
* The defect is a check-then-act whose act is deferred into another coroutine. `reattach()` reads
|
||||
* `_state.value` and then calls `observe()`, which *launches* a collector that has to suspend on
|
||||
* `getWorkInfoByIdFlow(...).collect` before it can write anything. So the check happens at one
|
||||
* moment and the write lands at another:
|
||||
*
|
||||
* 1. `init` starts the tag query and suspends in it.
|
||||
* 2. The user picks a file; `onInputPicked` suspends in its metadata query.
|
||||
* 3. The query comes back. `_state.value` is still `Idle` — step 2 has not written yet — so the
|
||||
* guard passes and an observation of the old job is launched.
|
||||
* 4. The pick lands. `Ready(picked)`. The user owns the screen.
|
||||
* 5. The observation's first `WorkInfo` arrives and writes `Converted(yesterday)` over it.
|
||||
*
|
||||
* The comment above that guard claimed "no suspension point between this check and the assignment
|
||||
* below, so nothing can interleave". There is no assignment below, and the check and the write
|
||||
* are in different coroutines.
|
||||
*
|
||||
* [ReattachGuardsTest] covers the case where the pick has already *landed*, which the plain guard
|
||||
* does catch. This covers the one where it is still in flight, which it does not.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ReattachmentOwnershipTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var workManager: WorkManager
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
|
||||
staged = publisher.createStagingFile("holiday_converted.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath))
|
||||
workManager = WorkManager.getInstance(app)
|
||||
// The situation reattachment exists for: a conversion that finished in a process that is
|
||||
// gone, with its output still in the cache and nothing in the UI holding its id.
|
||||
workManager.enqueue(
|
||||
ConversionWorker.request(
|
||||
inputUri = Uri.parse("content://test/holiday.mp4"),
|
||||
displayName = "holiday.mp4",
|
||||
sizeBytes = 4_096L,
|
||||
),
|
||||
).result.get()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* The race, made into a state the test can sit in rather than one it has to catch.
|
||||
*
|
||||
* The pick is parked on a dispatcher this test owns, so it stays in flight — issued, not yet
|
||||
* written — for as long as the assertions need it to be. Everything else is production: a
|
||||
* real `WorkManager` holding a real finished job, the real `reattach`, the real `observe`.
|
||||
*
|
||||
* Determinism comes from where Robolectric leaves the main looper. `reattach`'s tag query hops
|
||||
* to a real [kotlinx.coroutines.Dispatchers.IO] thread, so its continuation can only come back
|
||||
* as a message posted to the main looper — and that looper is paused, so it cannot run until
|
||||
* something pumps it. `onInputPicked` is an ordinary synchronous call from this thread. The
|
||||
* pick is therefore *always* issued before the guard runs; none of it is left to timing, which
|
||||
* is the whole point of writing it here rather than relying on the rare device sighting.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversion found while the user was picking never reaches the screen`() {
|
||||
val picked = Uri.fromFile(File(app.cacheDir, "beach.mp4").apply { writeBytes(ByteArray(2048)) })
|
||||
val parkedPick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = parkedPick)
|
||||
viewModel.onInputPicked(picked)
|
||||
|
||||
assertEquals(
|
||||
"the pick must still be in flight, or this proves something about a different situation",
|
||||
1,
|
||||
parkedPick.parkedCount,
|
||||
)
|
||||
|
||||
// The control, and the reason this test does not rest on a settle window being long
|
||||
// enough. A second ViewModel with nothing to supersede it reattaches to the same job
|
||||
// through the same code; when it has arrived, the whole query-guard-observe-write path has
|
||||
// demonstrably run to completion. `viewModel` started its own reattachment first, so it
|
||||
// has had at least as long. Waiting on this rather than on a sleep is what makes the
|
||||
// assertion below "it did not happen" rather than "it had not happened yet".
|
||||
reattachmentHasRunToCompletion()
|
||||
|
||||
val current = viewModel.state.value
|
||||
assertTrue("reattachment took the screen from the user: $current", current is ConversionState.Idle)
|
||||
|
||||
// And the pick, when it lands, is what stays there.
|
||||
parkedPick.runAll()
|
||||
val ready = awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
assertEquals(picked, (ready as ConversionState.Ready).input.uri)
|
||||
reattachmentHasRunToCompletion()
|
||||
assertEquals("the user's pick must survive a late reattachment", ready, viewModel.state.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the contract: a reattachment nobody has superseded still takes the screen.
|
||||
*
|
||||
* Without this, dropping every reattachment on the floor would pass the test above. Same job,
|
||||
* same WorkManager, same production path — only the pick is missing.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversion nobody has superseded still reaches the screen`() {
|
||||
val converted = awaitState(ConversionViewModel(app).state, "Converted") {
|
||||
it is ConversionState.Converted
|
||||
}
|
||||
|
||||
assertEquals(staged.absolutePath, (converted as ConversionState.Converted).staged.absolutePath)
|
||||
assertEquals("holiday.mp4", converted.input.displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a throwaway ViewModel through a whole reattachment, and returns once it has landed.
|
||||
*
|
||||
* [awaitState] pumps the main looper, which is what runs every reattachment continuation
|
||||
* waiting on it — this one's, and the one belonging to the ViewModel under test, which was
|
||||
* posted earlier and therefore runs first.
|
||||
*/
|
||||
private fun reattachmentHasRunToCompletion() {
|
||||
awaitState(ConversionViewModel(app).state, "Converted") { it is ConversionState.Converted }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinState
|
||||
import org.libremediaconverter.join.JoinViewModel
|
||||
import org.libremediaconverter.join.joinActions
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* That each affordance is wired to the ViewModel method it is named after.
|
||||
*
|
||||
* ## What this covers that no other test can
|
||||
*
|
||||
* `ConverterScreenContentTest`, `ConverterStateAffordancesTest` and `JoinScreenContentTest` all
|
||||
* drive the **stateless** content composables, which build their own `ConverterActions`. So the
|
||||
* wiring — the list of `viewModel::` references the stateful outer hands down — was seen by nothing
|
||||
* in the suite.
|
||||
*
|
||||
* ## The hazard is narrower than "seventeen bindings", and this says so
|
||||
*
|
||||
* #156 was filed claiming a transposition of any two bindings would survive the suite. That is not
|
||||
* true, and it was worth checking rather than testing on the assumption:
|
||||
*
|
||||
* | swap | result |
|
||||
* |---|---|
|
||||
* | `onVideoCodec` ↔ `onAudioCodec` | **rejected by the compiler** |
|
||||
* | `onCancel` ↔ `onReset` | **compiles** |
|
||||
*
|
||||
* Every typed binding — container, both codecs, preset, suggestion, quality, engine preference —
|
||||
* takes a distinct parameter type, so the compiler is already the test. Writing assertions for
|
||||
* those would be theatre.
|
||||
*
|
||||
* **The `() -> Unit` bindings are the real gap**, because they are interchangeable to the compiler:
|
||||
* two on the converter screen (`onCancel`, `onReset`) and three on the join screen (`onJoin`,
|
||||
* `onCancel`, `onReset`). A Cancel that discards the finished file, or a Join that cancels, is a
|
||||
* one-character mistake that ships.
|
||||
*
|
||||
* ## How they are told apart
|
||||
*
|
||||
* By effect, not by a recording double. `reset()` sets the state to `Idle`; `cancel()` with no
|
||||
* active job leaves it alone (`ConversionViewModel.cancel` is `activeWorkId?.let(...)`, and
|
||||
* `SettingsEditsTest` pins that). Driving each from a non-`Idle` state is therefore enough to say
|
||||
* which one ran.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ScreenWiringTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
||||
ConversionDependencies.probe = { _, _ -> org.libremediaconverter.model.InputProbe() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
// --- the converter screen ----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `Start over resets, and Cancel does not`() {
|
||||
// The transposition that compiles. If onReset were bound to cancel, this stays on Ready.
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
|
||||
val actions = converterActions(viewModel, onPickInput = {}, onConvert = {}, onSave = {})
|
||||
viewModel.onInputPicked(INPUT_URI)
|
||||
pick.runAll()
|
||||
assertNotEquals(
|
||||
"the fixture needs a non-Idle state or neither action is observable",
|
||||
ConversionState.Idle,
|
||||
viewModel.state.value,
|
||||
)
|
||||
|
||||
actions.onReset()
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancel leaves the picked file on screen`() {
|
||||
// The other half. Without it, a wiring with BOTH actions bound to reset passes the test
|
||||
// above -- and that is exactly what a copy-paste of the wrong line produces.
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
|
||||
val actions = converterActions(viewModel, onPickInput = {}, onConvert = {}, onSave = {})
|
||||
viewModel.onInputPicked(INPUT_URI)
|
||||
pick.runAll()
|
||||
val before = viewModel.state.value
|
||||
|
||||
actions.onCancel()
|
||||
|
||||
assertEquals(
|
||||
"Cancel must not throw away the pick the way Start over does",
|
||||
before,
|
||||
viewModel.state.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each settings affordance reaches the setting it is named after`() {
|
||||
// The typed bindings. The compiler already rejects a transposition among these, so this is
|
||||
// not that assertion -- it is the cheaper one that each is bound to *something*, and that a
|
||||
// binding dropped to `{}` during an edit would be caught.
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
|
||||
val actions = converterActions(viewModel, onPickInput = {}, onConvert = {}, onSave = {})
|
||||
|
||||
actions.onPreset(OutputFormat.WEBM_VP9)
|
||||
assertEquals(OutputFormat.WEBM_VP9.spec, viewModel.settings.value.spec)
|
||||
|
||||
actions.onContainer(Container.MKV)
|
||||
assertEquals(Container.MKV, viewModel.settings.value.spec.container)
|
||||
|
||||
actions.onVideoCodec(VideoCodec.H264)
|
||||
assertEquals(VideoCodec.H264, viewModel.settings.value.spec.videoCodec)
|
||||
|
||||
actions.onAudioCodec(AudioCodec.FLAC)
|
||||
assertEquals(AudioCodec.FLAC, viewModel.settings.value.spec.audioCodec)
|
||||
|
||||
actions.onQuality(QualityTier.BEST)
|
||||
assertEquals(QualityTier.BEST, viewModel.settings.value.quality)
|
||||
|
||||
actions.onEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
||||
assertEquals(EnginePreference.FORCE_SOFTWARE, viewModel.settings.value.enginePreference)
|
||||
|
||||
actions.onSuggestion(OutputFormat.MP4_H264.spec)
|
||||
assertEquals(OutputFormat.MP4_H264.spec, viewModel.settings.value.spec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the launcher-backed actions are the ones the screen supplies`() {
|
||||
// Not wired to the ViewModel at all, deliberately -- they need an ActivityResultLauncher.
|
||||
// Asserted so that a later edit routing one of them at the ViewModel is noticed.
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
|
||||
val called = mutableListOf<String>()
|
||||
val actions = converterActions(
|
||||
viewModel,
|
||||
onPickInput = { called += "pick" },
|
||||
onConvert = { called += "convert" },
|
||||
onSave = { called += "save:$it" },
|
||||
)
|
||||
|
||||
actions.onPickInput()
|
||||
actions.onConvert()
|
||||
actions.onSave("holiday.mp4")
|
||||
|
||||
assertEquals(listOf("pick", "convert", "save:holiday.mp4"), called)
|
||||
}
|
||||
|
||||
// --- the join screen, where three are interchangeable -------------------
|
||||
|
||||
@Test
|
||||
fun `Start over resets the join, and Cancel does not`() {
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = JoinViewModel(app, pickDispatcher = pick)
|
||||
val actions = joinActions(viewModel, onPickInputs = {}, onSave = {})
|
||||
viewModel.onInputsPicked(TWO_INPUTS)
|
||||
pick.runAll()
|
||||
assertTrue(
|
||||
"the fixture needs a non-Idle state: ${viewModel.state.value}",
|
||||
viewModel.state.value !is JoinState.Idle,
|
||||
)
|
||||
|
||||
actions.onReset()
|
||||
|
||||
assertEquals(JoinState.Idle, viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancel leaves the picked files on screen`() {
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = JoinViewModel(app, pickDispatcher = pick)
|
||||
val actions = joinActions(viewModel, onPickInputs = {}, onSave = {})
|
||||
viewModel.onInputsPicked(TWO_INPUTS)
|
||||
pick.runAll()
|
||||
val before = viewModel.state.value
|
||||
|
||||
actions.onCancel()
|
||||
|
||||
assertEquals(before, viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Join starts the job rather than cancelling or resetting it`() {
|
||||
// The third of the join screen's interchangeable trio, and the one whose transposition is
|
||||
// worst: a Join button bound to cancel does nothing at all, which reads as a dead button.
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = JoinViewModel(app, pickDispatcher = pick)
|
||||
val actions = joinActions(viewModel, onPickInputs = {}, onSave = {})
|
||||
viewModel.onInputsPicked(TWO_INPUTS)
|
||||
pick.runAll()
|
||||
|
||||
actions.onJoin()
|
||||
|
||||
assertTrue(
|
||||
"Join must leave Ready for a running state, not sit still and not go Idle: " +
|
||||
"${viewModel.state.value}",
|
||||
viewModel.state.value is JoinState.Joining || viewModel.state.value is JoinState.Joined,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val INPUT_URI: Uri = Uri.parse("content://test/holiday.mov")
|
||||
val TWO_INPUTS = listOf(
|
||||
Uri.parse("content://test/a.mp4"),
|
||||
Uri.parse("content://test/b.mp4"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* The seven one-line edits the settings sheet makes, and what each one leaves alone.
|
||||
*
|
||||
* ## Why these needed a file of their own
|
||||
*
|
||||
* `setPreset` was covered. The six beside it — `setContainer`, `setVideoCodec`, `setAudioCodec`,
|
||||
* `applySuggestion`, `setQuality`, `setEnginePreference` — and `cancel()` had **no coverage at
|
||||
* all**, which is the tell: they are reachable from the JVM suite by exactly the route `setPreset`
|
||||
* already takes, and nothing had asked.
|
||||
*
|
||||
* ## What is actually being asserted
|
||||
*
|
||||
* Not "the setter sets something". Each of these copies into a nested `OutputSpec`, so the failure
|
||||
* worth catching is **a setter that writes the right value into the wrong field, or that rebuilds
|
||||
* the spec and silently discards the other two**. So every test here asserts the field it changed
|
||||
* *and* that the rest of the spec survived — a `setContainer` implemented as
|
||||
* `it.copy(spec = OutputFormat.MP4_H265.spec.copy(container = container))` would pass a test that
|
||||
* only checked the container.
|
||||
*
|
||||
* `ConverterScreenContentTest` cannot cover this: it builds `ConverterActions` itself and never
|
||||
* touches the ViewModel. That the *screen* calls these is #156's, and neither implies the other.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SettingsEditsTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var viewModel: ConversionViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
viewModel = ConversionViewModel(app)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing a preset replaces the whole spec`() {
|
||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
||||
|
||||
assertEquals(OutputFormat.WEBM_VP9.spec, viewModel.settings.value.spec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the container leaves both codecs alone`() {
|
||||
// Moved off the default spec first, and that is load-bearing rather than tidiness. The
|
||||
// default IS `OutputFormat.MP4_H265.spec`, so a `setContainer` that rebuilt the spec from
|
||||
// that preset instead of from the current one produced an identical answer and the
|
||||
// mutation went green. Editing the codecs away from the default first is what makes
|
||||
// "the other two survived" an assertion rather than a coincidence.
|
||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setContainer(Container.MKV)
|
||||
|
||||
val after = viewModel.settings.value.spec
|
||||
assertEquals(Container.MKV, after.container)
|
||||
assertEquals("the video codec is not the container's to change", before.videoCodec, after.videoCodec)
|
||||
assertEquals("the audio codec is not the container's to change", before.audioCodec, after.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the video codec leaves the container and the audio codec alone`() {
|
||||
// The transposition this guards against is real: setVideoCodec and setAudioCodec take
|
||||
// different enum types, but a copy(...) naming the wrong field compiles wherever the types
|
||||
// happen to line up, and the picker would silently set the other one.
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setVideoCodec(VideoCodec.VP9)
|
||||
|
||||
val after = viewModel.settings.value.spec
|
||||
assertEquals(VideoCodec.VP9, after.videoCodec)
|
||||
assertEquals(before.container, after.container)
|
||||
assertEquals(before.audioCodec, after.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the audio codec leaves the container and the video codec alone`() {
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setAudioCodec(AudioCodec.OPUS)
|
||||
|
||||
val after = viewModel.settings.value.spec
|
||||
assertEquals(AudioCodec.OPUS, after.audioCodec)
|
||||
assertEquals(before.container, after.container)
|
||||
assertEquals(before.videoCodec, after.videoCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applying a suggestion replaces the spec without disturbing quality or engine`() {
|
||||
// A suggestion comes from ContainerCapabilities when the current spec is invalid, so it is
|
||||
// a whole spec by construction. What it must not do is reset the two settings beside it.
|
||||
viewModel.setQuality(QualityTier.BEST)
|
||||
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
||||
|
||||
viewModel.applySuggestion(OutputFormat.MKV_H264.spec)
|
||||
|
||||
val settings = viewModel.settings.value
|
||||
assertEquals(OutputFormat.MKV_H264.spec, settings.spec)
|
||||
assertEquals(QualityTier.BEST, settings.quality)
|
||||
assertEquals(EnginePreference.FORCE_SOFTWARE, settings.enginePreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the quality leaves the spec and the engine preference alone`() {
|
||||
// Both neighbours are moved off their defaults first. Asserting against AUTO -- which is
|
||||
// what `ConversionSettings` starts with -- let a `setQuality` that also reset the engine
|
||||
// preference to AUTO pass, because the reset and the survival looked identical.
|
||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
||||
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setQuality(QualityTier.BEST)
|
||||
|
||||
val settings = viewModel.settings.value
|
||||
assertEquals(QualityTier.BEST, settings.quality)
|
||||
assertEquals(before, settings.spec)
|
||||
assertEquals(
|
||||
"quality is not the engine preference's to change",
|
||||
EnginePreference.FORCE_SOFTWARE,
|
||||
settings.enginePreference,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the engine preference leaves the spec and the quality alone`() {
|
||||
// Off the defaults for the same reason as the test above: QualityTier.FAST is the starting
|
||||
// value, so asserting it here would have been satisfied by a reset as readily as by a
|
||||
// survival.
|
||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
||||
viewModel.setQuality(QualityTier.BEST)
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
||||
|
||||
val settings = viewModel.settings.value
|
||||
assertEquals(EnginePreference.FORCE_SOFTWARE, settings.enginePreference)
|
||||
assertEquals(before, settings.spec)
|
||||
assertEquals(
|
||||
"the engine preference is not the quality's to change",
|
||||
QualityTier.BEST,
|
||||
settings.quality,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing past every preset leaves no matching preset`() {
|
||||
// `matchingPreset` is what the settings sheet reads to decide whether to show a preset as
|
||||
// selected or to say "Custom". Editing one field of a preset must drop it out of the list
|
||||
// rather than leaving the old one highlighted.
|
||||
viewModel.setPreset(OutputFormat.MP4_H265)
|
||||
assertEquals(OutputFormat.MP4_H265, viewModel.settings.value.matchingPreset)
|
||||
|
||||
viewModel.setAudioCodec(AudioCodec.FLAC)
|
||||
|
||||
assertNull(
|
||||
"an edited spec is no longer any preset, and the sheet says Custom",
|
||||
viewModel.settings.value.matchingPreset,
|
||||
)
|
||||
assertNotEquals(OutputFormat.MP4_H265.spec, viewModel.settings.value.spec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling with no active job does nothing rather than throwing`() {
|
||||
// `activeWorkId?.let(...)` -- the null side. A user can reach Cancel through a state that
|
||||
// has already finished, and taking the app down for it would be worse than doing nothing.
|
||||
viewModel.cancel()
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,28 @@ class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for a worker that died, reporting [outputData] on the way out.
|
||||
*
|
||||
* The counterpart to [SucceedingWorkerFactory], and needed for the same reason: the real workers
|
||||
* cannot run on the JVM, so the only way to ask what a ViewModel does with a `FAILED` `WorkInfo` is
|
||||
* to produce one. A `Result.failure` carrying `KEY_ERROR` is exactly what both real workers report
|
||||
* when their engine gives up, and it is the one path where nothing has ever been staged.
|
||||
*
|
||||
* `runAttemptCount` is irrelevant here: `Result.failure` is terminal, so WorkManager does not retry
|
||||
* it and the state goes straight to `Failed` rather than through `Waiting`.
|
||||
*/
|
||||
class FailingWorkerFactory(private val outputData: Data) : WorkerFactory() {
|
||||
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker = object : Worker(appContext, workerParameters) {
|
||||
override fun doWork(): Result = Result.failure(outputData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a synchronous test WorkManager whose workers succeed with [outputData].
|
||||
*
|
||||
@@ -89,6 +111,16 @@ class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
|
||||
*/
|
||||
fun installTestWorkManager(context: Context, outputData: Data): SucceedingWorkerFactory {
|
||||
val factory = SucceedingWorkerFactory(outputData)
|
||||
installWorkManager(context, factory)
|
||||
return factory
|
||||
}
|
||||
|
||||
/** Installs a synchronous test WorkManager whose workers fail, reporting [outputData]. */
|
||||
fun installFailingTestWorkManager(context: Context, outputData: Data) {
|
||||
installWorkManager(context, FailingWorkerFactory(outputData))
|
||||
}
|
||||
|
||||
private fun installWorkManager(context: Context, factory: WorkerFactory) {
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(
|
||||
context,
|
||||
Configuration.Builder()
|
||||
@@ -98,7 +130,6 @@ fun installTestWorkManager(context: Context, outputData: Data): SucceedingWorker
|
||||
.setWorkerFactory(factory)
|
||||
.build(),
|
||||
)
|
||||
return factory
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.ConcatPlanner
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* A clip in a join that nothing could read, from the probe all the way to the strategy.
|
||||
*
|
||||
* Both halves of this are covered already, and separately: `MediaProbeTrackWalkTest` pins what
|
||||
* `concatInputFrom` makes of a track list, and `ConcatPlannerTest`'s
|
||||
* `an unknown codec is not treated as a match` pins what the planner does with a hand-built
|
||||
* `ConcatInput(video = null)`. **Nothing spanned the two**, and the span is the load-bearing part:
|
||||
* the planner's safety rests on the probe really producing that shape, and the hand-built fixture
|
||||
* would go on passing if it stopped.
|
||||
*
|
||||
* Measured rather than asserted: mutating `concatInputFrom`'s initial `video` to a non-null
|
||||
* placeholder leaves `ConcatPlannerTest` green and turns this red.
|
||||
*
|
||||
* ## The asymmetry this protects
|
||||
*
|
||||
* `ConcatPlanner` guards its video check against a null codec (`ConcatStrategy.kt:51`) and its
|
||||
* audio check not at all (`:54`). **That is correct, not an oversight.** `MediaProbe.shortName`
|
||||
* returns a non-null `String`, so in `concatInputFrom` a null `audioCodec` means the track is
|
||||
* *absent* — and two clips with no audio genuinely do match. A null `videoCodec` carries both
|
||||
* meanings, absent or unreadable, which is why only that one is guarded.
|
||||
*
|
||||
* So the audio check is safe *because* the video guard fires first on a clip nothing could read.
|
||||
* Nothing wrote that coupling down and nothing held it.
|
||||
*
|
||||
* ## What this deliberately does not cover
|
||||
*
|
||||
* `probeForConcat`'s `catch` arm (`MediaProbe.kt:300-302`). It is **not reachable on the JVM**:
|
||||
* Robolectric's `MediaExtractor` never throws from `setDataSource`, measured across an
|
||||
* unregistered `content://` authority, a missing `file://`, a file of garbage bytes and an `http://`
|
||||
* URL — all four returned normally with `trackCount = 0`. So the failure arrives here as an empty
|
||||
* track list rather than as an exception, which reaches the same `ConcatInput(null, null, 0, 0, 0)`
|
||||
* by the other road. The catch stays device-only, and this file does not pretend otherwise.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class UnreadableJoinInputTest {
|
||||
|
||||
@Test
|
||||
fun `a clip nothing could read probes as unknown, and an unknown clip is re-encoded`() {
|
||||
val unreadable = MediaProbe.probeForConcat(RuntimeEnvironment.getApplication(), UNREADABLE)
|
||||
|
||||
assertNull("an unreadable clip proves nothing about its video codec", unreadable.videoCodec)
|
||||
assertNull("nor about its audio codec", unreadable.audioCodec)
|
||||
assertEquals("nor about its dimensions", 0, unreadable.width)
|
||||
assertEquals(0, unreadable.height)
|
||||
assertEquals(0, unreadable.frameRate)
|
||||
|
||||
assertEquals(
|
||||
"a clip nothing could read is not evidence of a match with anything",
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(unreadable, unreadable)),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** `content://` so the probe takes the SAF branch a real pick takes. Nothing answers it. */
|
||||
val UNREADABLE: Uri = Uri.parse("content://test/vanished.mp4")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The join screen's one leaf renders, and tags itself with the file it is showing.
|
||||
*
|
||||
* `FileRow` is the only place on either screen where the same leaf is rendered more than once at a
|
||||
* time -- one row per picked input -- so it is the only tag that cannot be a constant. It is
|
||||
* derived from `displayName`, inside `FileRow` itself, and that is the part worth a test: a row
|
||||
* that took its tag from the call site would let R38.7 pass a tag in and assert nothing, which is
|
||||
* the vacuous shape `CLAUDE.md` records nine of in one review.
|
||||
*
|
||||
* Two rows are rendered here rather than one, because a tag derived from the wrong thing -- a
|
||||
* constant, an index the row does not have -- would still resolve to one node with a single input
|
||||
* on screen.
|
||||
*
|
||||
* Deliberately not the state matrix: which affordances each `JoinState` renders is R38.7.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinLeafTagsTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private fun input(displayName: String) = InputFile(
|
||||
uri = Uri.parse("content://test/$displayName"),
|
||||
displayName = displayName,
|
||||
sizeBytes = 4_000_000L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `each file row is tagged with the name it displays`() {
|
||||
composeRule.setContent {
|
||||
FileRow(input("first.mp4"))
|
||||
FileRow(input("second.mp4"))
|
||||
}
|
||||
|
||||
composeRule.onAllNodesWithTag(TestTags.Join.fileRow("first.mp4")).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(TestTags.Join.fileRow("second.mp4")).assertCountEquals(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.ParkedPickDispatcher
|
||||
import org.libremediaconverter.convert.RecordingPublisher
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* `PickOwnershipTest`'s case on the join side.
|
||||
*
|
||||
* `onInputsPicked` makes one write and it lands after a hop off the main thread, so it belongs to
|
||||
* whichever pick was in flight rather than to whichever set of files the user last chose. Two
|
||||
* selections in quick succession — likelier here than on the convert side, since a join picks
|
||||
* several files at a time and the metadata query is per file — put the loser's files on screen if
|
||||
* its query came back second.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinPickOwnershipTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var parkedPick: ParkedPickDispatcher
|
||||
private lateinit var viewModel: JoinViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
|
||||
parkedPick = ParkedPickDispatcher()
|
||||
viewModel = JoinViewModel(app, pickDispatcher = parkedPick)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Two selections, with the first one's metadata query the slow one.
|
||||
*
|
||||
* The order is chosen rather than raced: both queries are parked, and this runs the second
|
||||
* before the first.
|
||||
*/
|
||||
@Test
|
||||
fun `the slower of two selections does not land on top of the faster one`() {
|
||||
viewModel.onInputsPicked(FIRST)
|
||||
viewModel.onInputsPicked(SECOND)
|
||||
|
||||
val queries = parkedPick.takeParked()
|
||||
assertEquals("both selections should be in flight", 2, queries.size)
|
||||
// The second selection's query comes back first; the first one's is the straggler.
|
||||
queries[1].run()
|
||||
queries[0].run()
|
||||
|
||||
val current = viewModel.state.value
|
||||
assertEquals(
|
||||
"a selection the user has already replaced took the screen: $current",
|
||||
SECOND,
|
||||
(current as JoinState.Ready).inputs.map { it.uri },
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val FIRST = listOf(
|
||||
Uri.parse("content://test/first-a.mp4"),
|
||||
Uri.parse("content://test/first-b.mp4"),
|
||||
)
|
||||
val SECOND = listOf(
|
||||
Uri.parse("content://test/second-a.mp4"),
|
||||
Uri.parse("content://test/second-b.mp4"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.ParkedPickDispatcher
|
||||
import org.libremediaconverter.convert.RecordingPublisher
|
||||
import org.libremediaconverter.convert.awaitState
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Issue #49 on the join side, where nothing was watching for it.
|
||||
*
|
||||
* `reattach()` checks that the screen is still free, and then hands the answer to `observe()`,
|
||||
* which writes from a *different* coroutine that has to suspend on `collect` before it can write
|
||||
* anything at all. So the check happens at one moment and the write lands at another, with a
|
||||
* whole pick able to fit in between:
|
||||
*
|
||||
* 1. `init` starts the tag query and suspends in it.
|
||||
* 2. The user picks files; `onInputsPicked` suspends in its metadata query.
|
||||
* 3. The query comes back. The screen is still `Idle` — step 2 has not written yet — so the
|
||||
* guard passes and an observation of the old job is launched.
|
||||
* 4. The pick lands. `Ready(picked)`. The user owns the screen.
|
||||
* 5. The observation's first `WorkInfo` arrives and writes `Joined(yesterday's file)` over it.
|
||||
*
|
||||
* The comment above that guard used to say "no suspension point between this check and the
|
||||
* assignment below, so nothing can interleave". There is no assignment below, and the two lines
|
||||
* are in different coroutines.
|
||||
*
|
||||
* The convert side has been failing this on CI for two days — four occurrences across three API
|
||||
* levels, each read as flaky infrastructure. `JoinViewModel` has the identical shape and no test
|
||||
* at all, which is why this one was written before the fix rather than after it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinReattachmentOwnershipTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var workManager: WorkManager
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
|
||||
staged = publisher.createStagingFile("joined-yesterday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
installTestWorkManager(
|
||||
app,
|
||||
workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
|
||||
),
|
||||
)
|
||||
workManager = WorkManager.getInstance(app)
|
||||
// The situation reattachment exists for: a join that finished in a process that is gone,
|
||||
// with its output still in the cache and nothing in the UI holding its id.
|
||||
workManager.enqueue(
|
||||
ConcatWorker.request(
|
||||
inputs = listOf(
|
||||
Uri.parse("content://test/yesterday-a.mp4"),
|
||||
Uri.parse("content://test/yesterday-b.mp4"),
|
||||
),
|
||||
totalBytes = 8_192L,
|
||||
),
|
||||
).result.get()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* The race, made into a state the test can sit in rather than one it has to catch.
|
||||
*
|
||||
* The pick is parked on a dispatcher this test owns, so it is in flight — issued, not yet
|
||||
* written — for as long as the assertions need it to be. Everything else is real: a real
|
||||
* `WorkManager` holding a real finished job, the production `reattach`, the production
|
||||
* `observe`.
|
||||
*
|
||||
* Determinism comes from where Robolectric leaves the main looper. `reattach`'s tag query
|
||||
* hops to a real [kotlinx.coroutines.Dispatchers.IO] thread, so its continuation can only
|
||||
* come back as a message posted to the main looper — and that looper is paused, so it cannot
|
||||
* run until something pumps it. `onInputsPicked` is an ordinary synchronous call from this
|
||||
* thread. The pick is therefore always issued before the guard runs, with nothing left to
|
||||
* timing.
|
||||
*/
|
||||
@Test
|
||||
fun `a join found while the user was picking never reaches the screen`() {
|
||||
val parkedPick = ParkedPickDispatcher()
|
||||
val viewModel = JoinViewModel(app, pickDispatcher = parkedPick)
|
||||
viewModel.onInputsPicked(PICKED)
|
||||
|
||||
assertEquals(
|
||||
"the pick must still be in flight, or this proves something about a different situation",
|
||||
1,
|
||||
parkedPick.parkedCount,
|
||||
)
|
||||
|
||||
// The control, and the reason this test does not rest on a settle window being long
|
||||
// enough. A second ViewModel with nothing to supersede it reattaches to the same job
|
||||
// through the same code; when it has arrived, the whole query-guard-observe-write path
|
||||
// has demonstrably run to completion. `viewModel` started its own reattachment first, so
|
||||
// it has had at least as long. Waiting on this rather than on a sleep is what makes the
|
||||
// assertion below "it did not happen" instead of "it had not happened yet".
|
||||
reattachmentHasRunToCompletion()
|
||||
|
||||
val current = viewModel.state.value
|
||||
assertTrue("reattachment took the screen from the user: $current", current is JoinState.Idle)
|
||||
|
||||
// And the pick, when it lands, is what stays there.
|
||||
parkedPick.runAll()
|
||||
val ready = awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
assertEquals(PICKED, (ready as JoinState.Ready).inputs.map { it.uri })
|
||||
reattachmentHasRunToCompletion()
|
||||
assertEquals("the user's pick must survive a late reattachment", ready, viewModel.state.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the contract: a reattachment nobody has superseded still takes the screen.
|
||||
*
|
||||
* Without this, dropping every reattachment on the floor would pass the test above. It is the
|
||||
* same job, the same WorkManager and the same production path — only the pick is missing.
|
||||
*/
|
||||
@Test
|
||||
fun `a join nobody has superseded still reaches the screen`() {
|
||||
val joined = awaitState(JoinViewModel(app).state, "Joined") { it is JoinState.Joined }
|
||||
|
||||
assertEquals(staged.absolutePath, (joined as JoinState.Joined).staged.absolutePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a throwaway ViewModel through a whole reattachment, and returns once it has landed.
|
||||
*
|
||||
* [awaitState] pumps the main looper, which is what runs every reattachment continuation
|
||||
* waiting on it — this one's and the one belonging to the ViewModel under test, which was
|
||||
* posted earlier and therefore runs first.
|
||||
*/
|
||||
private fun reattachmentHasRunToCompletion() {
|
||||
awaitState(JoinViewModel(app).state, "Joined") { it is JoinState.Joined }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PICKED = listOf(
|
||||
Uri.parse("content://test/clip-one.mp4"),
|
||||
Uri.parse("content://test/clip-two.mp4"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The join screen's half of the same seam, and the same two directions.
|
||||
*
|
||||
* The defect is the one `ConverterScreenContentTest` describes -- a content composable that
|
||||
* ignores the state handed to it, or renders the finished job's affordances unwired -- and it has
|
||||
* to be asked separately here because the two screens share no code. `JoinScreen` and
|
||||
* `ConverterScreen` were extracted in the same commit by the same hand, which is exactly the
|
||||
* circumstance in which one of them gets the wiring right and the other does not.
|
||||
*
|
||||
* `JoinState.Joined` is unreachable through a real `JoinViewModel` for the same reason
|
||||
* `ConversionState.Converted` is: only a `ConcatWorker` run that has already succeeded produces
|
||||
* one, carrying the strategy it chose and the name it picked.
|
||||
*
|
||||
* `JoinScreenKt` is the honest remaining coverage gap on this repo, and closing it is R38.7 (#63),
|
||||
* not this file. Which affordances each `JoinState` renders belongs there; this asserts only that
|
||||
* the injection point exists.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinScreenContentTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||
private val savedAs = mutableListOf<String>()
|
||||
|
||||
@Test
|
||||
fun `a finished join renders the save button`() {
|
||||
setContent(joined())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping save hands back the name the finished join chose`() {
|
||||
setContent(joined())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("joined.mp4"), savedAs)
|
||||
}
|
||||
|
||||
/** `staged` names a missing file deliberately -- see the same helper on the converter side. */
|
||||
private fun joined() = JoinState.Joined(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
strategy = ConcatStrategy.STREAM_COPY,
|
||||
suggestedName = "joined.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: JoinState) {
|
||||
composeRule.setContent {
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = JoinActions(
|
||||
onPickInputs = {},
|
||||
onJoin = {},
|
||||
onCancel = {},
|
||||
onSave = { suggestedName -> savedAs += suggestedName },
|
||||
onReset = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.convert.PendingSave
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Every `JoinState` renders its own affordances, wired to its own callback.
|
||||
*
|
||||
* The defect is a branch of `JoinScreenContent`'s `when` that reads the wrong thing: a count taken
|
||||
* from a literal rather than from `inputs`, a strategy line that describes the other strategy, a
|
||||
* button wired to the neighbouring branch's callback, a `Failed` that drops the message it carries.
|
||||
* None of that is visible at compile time -- every branch of the `when` type-checks against the
|
||||
* same `JoinScreenContent` signature -- and none of it is visible from the leaf tests either, which
|
||||
* compose `FileRow` on its own and never see a state.
|
||||
*
|
||||
* `JoinScreenContentTest` deliberately asks only whether the seam exists, using `Joined`. This is
|
||||
* the matrix behind it: seven states, each pinned to what it lets the user do next.
|
||||
*
|
||||
* ### Two assertions here that nothing else in the suite makes
|
||||
*
|
||||
* **Order.** A join is the one flow where the order of the inputs is the content of the output --
|
||||
* the empty state promises "in the order you want them" -- so the rows are read back sorted by
|
||||
* their position on screen and compared as a list, not as a set. `JoinLeafTagsTest` proves a row
|
||||
* tags itself with the file it shows; nothing proved the rows come out in the order they went in.
|
||||
*
|
||||
* **Indeterminate.** The join progress bar carries no percentage, on purpose: FFmpeg reports
|
||||
* progress against one input's duration, which means nothing across a concatenation. The converter
|
||||
* screen's bar is determinate, so "it has a progress bar" is the assertion that would not notice a
|
||||
* fabricated percentage arriving here.
|
||||
*
|
||||
* ### Not asserted here, deliberately
|
||||
*
|
||||
* `JoinState.Joined.mimeType` is not rendered by this composable at all -- it is read by the entry
|
||||
* point, to open the save dialog with a type that matches the finished job. The colour of the
|
||||
* `Failed` message is `MaterialTheme.colorScheme.error`, which is theme lookup rather than state
|
||||
* logic, so it is left to the eye. The `is JoinState.Idle -> Unit` arm inside the scrolling branch
|
||||
* is unreachable by construction: the outer `when` peels `Idle` off first.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinStateAffordancesTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/** Which callback the screen invoked, in order, with what it passed. Empty until one fires. */
|
||||
private val events = mutableListOf<String>()
|
||||
|
||||
@Test
|
||||
fun `the empty state asks for files in order and offers the picker`() {
|
||||
setContent(JoinState.Idle)
|
||||
|
||||
composeRule.onNodeWithText("Pick two or more files to join, in the order you want them.").assertExists()
|
||||
// No `performScrollTo` on this one: `Idle` is the centred branch, outside the scrolling
|
||||
// column every other state renders into, so there is nothing to scroll.
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).performClick()
|
||||
|
||||
assertEquals(listOf("pickInputs"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows come out in the order the inputs went in.
|
||||
*
|
||||
* Sorted by position rather than trusting the order `fetchSemanticsNodes` happens to return, so
|
||||
* the assertion is about what the user sees down the screen. Three inputs, with names whose
|
||||
* alphabetical order is not their picked order, so a list that had been sorted anywhere on the
|
||||
* way through would not be able to pass this.
|
||||
*/
|
||||
@Test
|
||||
fun `the picked inputs are listed in the order they were picked`() {
|
||||
val picked = listOf("intro.mp4", "middle.mp4", "outro.mp4")
|
||||
setContent(JoinState.Ready(inputs = picked.map(::input)))
|
||||
|
||||
val topToBottom = composeRule.onAllNodes(isFileRow)
|
||||
.fetchSemanticsNodes()
|
||||
.sortedBy { it.positionInRoot.y }
|
||||
.map { it.config[SemanticsProperties.TestTag] }
|
||||
|
||||
assertEquals(picked.map(TestTags.Join::fileRow), topToBottom)
|
||||
}
|
||||
|
||||
/**
|
||||
* Three inputs, not two: two is the minimum a join accepts, so a button that had been
|
||||
* hardcoded to the smallest legal join would still read correctly with two on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `the join button counts the files it will join`() {
|
||||
setContent(JoinState.Ready(inputs = listOf(input("intro.mp4"), input("middle.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN).assertTextEquals("Join 3 files")
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("join"), events)
|
||||
}
|
||||
|
||||
/** `Ready` is the one working state that still offers the picker, to replace the selection. */
|
||||
@Test
|
||||
fun `a ready join can be repicked`() {
|
||||
setContent(JoinState.Ready(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_DIFFERENT_FILES).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("pickInputs"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running join names the count and shows a bar with no percentage`() {
|
||||
setContent(JoinState.Joining(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithText("Joining 2 files…").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Join.PROGRESS).assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The paragraph is byte-identical to the converter screen's, which is the point of asserting
|
||||
* the whole of it rather than a fragment: the two branches were worded together, and a reword
|
||||
* that lands on one screen only is the failure this notices.
|
||||
*/
|
||||
@Test
|
||||
fun `a paused join explains itself and still offers cancel`() {
|
||||
setContent(JoinState.Waiting(inputs = listOf(input("intro.mp4"), input("outro.mp4"))))
|
||||
|
||||
composeRule.onNodeWithText(PAUSED_PARAGRAPH).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.CANCEL).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("cancel"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stream copied join says nothing was re-encoded`() {
|
||||
setContent(joined(ConcatStrategy.STREAM_COPY))
|
||||
|
||||
composeRule.onNodeWithText(STREAM_COPY_EXPLANATION).assertExists()
|
||||
composeRule.onNodeWithText(REENCODE_EXPLANATION).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the pair. Asserting the absence of the stream-copy line as well, because a
|
||||
* branch that had collapsed to one answer would still render *an* explanation.
|
||||
*/
|
||||
@Test
|
||||
fun `a re-encoded join says the files differed`() {
|
||||
setContent(joined(ConcatStrategy.REENCODE))
|
||||
|
||||
composeRule.onNodeWithText(REENCODE_EXPLANATION).assertExists()
|
||||
composeRule.onNodeWithText(STREAM_COPY_EXPLANATION).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** The size comes from the staged file, which is missing here, so `length()` answers `0L`. */
|
||||
@Test
|
||||
fun `a finished join reports the size of what it produced`() {
|
||||
setContent(joined(ConcatStrategy.STREAM_COPY))
|
||||
|
||||
composeRule.onNodeWithText("Joined — 0 MB.").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished join offers save and start over, and they are not the same button`() {
|
||||
setContent(joined(ConcatStrategy.STREAM_COPY))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("save:joined.mp4", "reset"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a saved join names the file and offers to join more`() {
|
||||
setContent(JoinState.Saved(displayName = "holiday-joined.mp4"))
|
||||
|
||||
composeRule.onNodeWithText("Saved holiday-joined.mp4.").assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN_MORE).assertTextEquals("Join more")
|
||||
composeRule.onNodeWithTag(TestTags.Join.JOIN_MORE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The message is the whole content of this state -- it is the only thing that says why the job
|
||||
* stopped -- and it arrives as a string the failure produced, so a branch that rendered a fixed
|
||||
* apology instead would look correct on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed join renders the message it carries`() {
|
||||
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
|
||||
|
||||
composeRule.onNodeWithText("The second file has no audio track, so joining stopped.").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed join offers start over`() {
|
||||
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The negative that bounds #30 on this screen. A join that died staged nothing, so its `Failed`
|
||||
* carries no [PendingSave] and there is nothing a save dialog could be handed. A retry button
|
||||
* rendered unconditionally here could only fail, and this is what notices.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed join offers no way to save`() {
|
||||
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* #30 on this screen: `save()` keeps the staged file when the copy out throws, and until this
|
||||
* branch grew a second button the only control it rendered was "Start over" -- `reset()`, which
|
||||
* deletes exactly that file. Both, not one: the restart still has to be reachable.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed join save offers the file again as well as a restart`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
}
|
||||
|
||||
/** The name comes from the job, so a retry wired to a literal would hand back the wrong one. */
|
||||
@Test
|
||||
fun `tapping try saving again hands back the name the join chose`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("save:joined.mp4"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over after a failed join save resets and does not save`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), events)
|
||||
}
|
||||
|
||||
/** Anything `FileRow` tagged, whichever file it is showing. The prefix comes from the table. */
|
||||
private val isFileRow = SemanticsMatcher("is a join file row") { node ->
|
||||
node.config.getOrNull(SemanticsProperties.TestTag)?.startsWith(TestTags.Join.fileRow("")) == true
|
||||
}
|
||||
|
||||
private fun input(displayName: String) = InputFile(
|
||||
uri = Uri.parse("content://test/$displayName"),
|
||||
displayName = displayName,
|
||||
sizeBytes = 4_000_000L,
|
||||
)
|
||||
|
||||
/**
|
||||
* A `Failed` an earlier save left carrying its file, which is the only way `retry` is non-null.
|
||||
* `staged` names a missing file for the same reason [joined] does -- this arm reads no length.
|
||||
*/
|
||||
private fun failedSave() = JoinState.Failed(
|
||||
message = "There was not enough room on the destination.",
|
||||
retry = PendingSave(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
suggestedName = "joined.mp4",
|
||||
mimeType = "video/mp4",
|
||||
),
|
||||
)
|
||||
|
||||
/** `staged` names a missing file deliberately -- see the same helper in `JoinScreenContentTest`. */
|
||||
private fun joined(strategy: ConcatStrategy) = JoinState.Joined(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
strategy = strategy,
|
||||
suggestedName = "joined.mp4",
|
||||
mimeType = "video/mp4",
|
||||
)
|
||||
|
||||
private fun setContent(state: JoinState) {
|
||||
composeRule.setContent {
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = JoinActions(
|
||||
onPickInputs = { events += "pickInputs" },
|
||||
onJoin = { events += "join" },
|
||||
onCancel = { events += "cancel" },
|
||||
onSave = { suggestedName -> events += "save:$suggestedName" },
|
||||
onReset = { events += "reset" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Byte-identical to the converter screen's, and split the same way `main` splits it. */
|
||||
const val PAUSED_PARAGRAPH =
|
||||
"Paused. Android limits background media processing, so this will " +
|
||||
"resume automatically — keeping the app open helps it along."
|
||||
|
||||
const val STREAM_COPY_EXPLANATION =
|
||||
"Files matched, so they were joined without " +
|
||||
"re-encoding — no quality loss."
|
||||
|
||||
const val REENCODE_EXPLANATION =
|
||||
"Files differed in format, so they were re-encoded " +
|
||||
"to match."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Every answer [joinStateFrom] can give, chosen rather than stumbled into.
|
||||
*
|
||||
* The join-side twin of `ConversionStateMappingTest`, and the argument is the same one: the mapping
|
||||
* ran on every test that drove a real `ConcatWorker`, but a real worker only ever reaches a terminal
|
||||
* state with well-formed output, so five arms had never been *chosen* by anything.
|
||||
*
|
||||
* ## The one that is not just coverage
|
||||
*
|
||||
* `an unknown strategy name is read as a re-encode rather than thrown` covers a real defect this
|
||||
* seam exposed. The line it replaces was:
|
||||
*
|
||||
* ```kotlin
|
||||
* .getString(ConcatWorker.KEY_STRATEGY)?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
|
||||
* ```
|
||||
*
|
||||
* `valueOf` throws on a name this build does not define, and this runs inside a `viewModelScope`
|
||||
* collect with no handler — so it does not become a `Failed` state, it takes the process down.
|
||||
* `ConcatWorker.kt` had already made this exact change for `KEY_FORMAT` and written down why; the
|
||||
* matching read on this side had not been changed with it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinStateMappingTest {
|
||||
|
||||
@Test
|
||||
fun `a running join is joining`() {
|
||||
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.RUNNING))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blocked join looks like one that is starting`() {
|
||||
// Folded into the RUNNING arm deliberately: a job waiting on a prerequisite is nothing the
|
||||
// user can act on, and a separate word for it would be noise.
|
||||
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.BLOCKED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an enqueued join that has already run is waiting to retry`() {
|
||||
assertEquals(JoinState.Waiting(INPUTS), map(WorkInfo.State.ENQUEUED, runAttemptCount = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an enqueued join that has never run is simply starting`() {
|
||||
// The other side. Without it, a mapping that ignored runAttemptCount passes the test above.
|
||||
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.ENQUEUED, runAttemptCount = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success that named no file is a failure, not an empty success`() {
|
||||
assertEquals(
|
||||
JoinState.Failed(JOINED_WITHOUT_A_FILE_MESSAGE),
|
||||
map(WorkInfo.State.SUCCEEDED, data = Data.EMPTY),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success carries the strategy the worker actually used`() {
|
||||
// Not cosmetic: the join screen tells the user whether their files were stream-copied or
|
||||
// re-encoded, which is the difference between lossless and lossy.
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
|
||||
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
|
||||
),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatStrategy.STREAM_COPY, joined.strategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown strategy name is read as a re-encode rather than thrown`() {
|
||||
// The defect. A build that added a third strategy leaves finished joins in the queue naming
|
||||
// it, and WorkManager keeps those about a week -- the premise WorkerEnumFallbackTest and
|
||||
// JobTags are both written on. With `valueOf` this throws IllegalArgumentException inside a
|
||||
// viewModelScope collect that has no handler, so it is not a Failed state, it is a crash.
|
||||
//
|
||||
// REENCODE rather than STREAM_COPY because it is the conservative answer: describing an
|
||||
// unknown join as lossless would be a claim the app cannot support.
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
|
||||
ConcatWorker.KEY_STRATEGY to "SMART_CONCAT_V2",
|
||||
),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatStrategy.REENCODE, joined.strategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success with no strategy at all falls back the same way`() {
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4"),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatStrategy.REENCODE, joined.strategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success from older work falls back to the format such a job really used`() {
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4"),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT), joined.suggestedName)
|
||||
assertEquals(ConcatWorker.DEFAULT_FORMAT.mimeType, joined.mimeType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank name or type falls back the same way a missing one does`() {
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
|
||||
ConcatWorker.KEY_SUGGESTED_NAME to "",
|
||||
ConcatWorker.KEY_MIME_TYPE to " ",
|
||||
),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT), joined.suggestedName)
|
||||
assertEquals(ConcatWorker.DEFAULT_FORMAT.mimeType, joined.mimeType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure carries the reason the worker gave`() {
|
||||
assertEquals(
|
||||
JoinState.Failed("Not enough free space to join these files."),
|
||||
map(
|
||||
WorkInfo.State.FAILED,
|
||||
data = workDataOf(
|
||||
ConcatWorker.KEY_ERROR to "Not enough free space to join these files.",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure with nothing said still says something`() {
|
||||
assertEquals(
|
||||
JoinState.Failed(ConcatWorker.GENERIC_FAILURE_MESSAGE),
|
||||
map(WorkInfo.State.FAILED, data = Data.EMPTY),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure whose message is blank falls back like a missing one`() {
|
||||
assertEquals(
|
||||
JoinState.Failed(ConcatWorker.GENERIC_FAILURE_MESSAGE),
|
||||
map(WorkInfo.State.FAILED, data = workDataOf(ConcatWorker.KEY_ERROR to " ")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancellation lands wherever the caller said it should`() {
|
||||
// A join started here goes back to Ready with the picked files; one picked up by reattach
|
||||
// goes to Idle, because those URIs belong to a process that no longer exists.
|
||||
assertEquals(
|
||||
JoinState.Ready(INPUTS),
|
||||
map(WorkInfo.State.CANCELLED, cancelled = JoinState.Ready(INPUTS)),
|
||||
)
|
||||
assertEquals(JoinState.Idle, map(WorkInfo.State.CANCELLED, cancelled = JoinState.Idle))
|
||||
}
|
||||
|
||||
private fun map(
|
||||
state: WorkInfo.State,
|
||||
runAttemptCount: Int = 0,
|
||||
data: Data = Data.EMPTY,
|
||||
cancelled: JoinState = JoinState.Ready(INPUTS),
|
||||
): JoinState = joinStateFrom(
|
||||
JoinUpdate(state = state, runAttemptCount = runAttemptCount, outputData = data),
|
||||
inputs = INPUTS,
|
||||
cancelled = cancelled,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val INPUTS = listOf(
|
||||
InputFile(Uri.parse("content://test/a.mp4"), "a.mp4", 1024L),
|
||||
InputFile(Uri.parse("content://test/b.mp4"), "b.mp4", 2048L),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.RecordingPublisher
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* The two layers that refuse a short join, refusing it with one sentence.
|
||||
*
|
||||
* ## Why this is not "assert a constant equals itself"
|
||||
*
|
||||
* `ConcatWorker` and `JoinViewModel` both reject a join of fewer than two files, and before #158
|
||||
* each carried **its own copy of the literal**. Only the worker's was pinned — by `RefusedJobTest`,
|
||||
* added in #139 — so the wording on the screen could drift away from the wording in the job with no
|
||||
* test saying anything, for one message the user sees from one condition.
|
||||
*
|
||||
* Sharing a constant makes them agree by construction. What it does *not* do is prove that both
|
||||
* layers still reach it: a refactor that stops `JoinViewModel` refusing at all, or that gives it a
|
||||
* different message, passes any test that only reads `TOO_FEW_INPUTS_MESSAGE`. So each layer is
|
||||
* driven for real here — the ViewModel through `onInputsPicked`, the worker through `doWork` — and
|
||||
* the assertion is that the two answers are **the same string**, taken from two running layers
|
||||
* rather than from one declaration.
|
||||
*
|
||||
* That is the shape `CLAUDE.md` asks for: revert the sharing and this goes red, because the two
|
||||
* sites drift the moment they are allowed to.
|
||||
*
|
||||
* ## Scope
|
||||
*
|
||||
* The arity guard's own behaviour on the ViewModel side — that it refuses one file, that it accepts
|
||||
* two, that it claims ownership first — is #155's, and this deliberately does not duplicate it.
|
||||
* This file is about the *agreement between layers*, which is what #158 changed.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SharedFailureMessagesTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var viewModel: JoinViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
viewModel = JoinViewModel(app)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both layers refuse a one-file join with the same sentence`() {
|
||||
// The ViewModel, refusing before anything is enqueued.
|
||||
viewModel.onInputsPicked(listOf(ONE_FILE))
|
||||
val fromScreen = (viewModel.state.value as JoinState.Failed).message
|
||||
|
||||
// The worker, refusing a job that reached the queue anyway -- which it can, because
|
||||
// ConcatWorker.request(...) takes a List<Uri> and checks nothing about its length.
|
||||
val result = runBlocking { worker(ONE_FILE).doWork() }
|
||||
val fromJob = (result as ListenableWorker.Result.Failure)
|
||||
.outputData.getString(ConcatWorker.KEY_ERROR)
|
||||
|
||||
assertEquals(
|
||||
"the screen and the job must say the same thing about the same refusal",
|
||||
fromScreen,
|
||||
fromJob,
|
||||
)
|
||||
// And that the shared sentence is the one either layer would have written on its own,
|
||||
// rather than both having drifted together to something else.
|
||||
assertEquals(ConcatWorker.TOO_FEW_INPUTS_MESSAGE, fromScreen)
|
||||
}
|
||||
|
||||
private fun worker(vararg inputs: Uri): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to inputs.map(Uri::toString).toTypedArray(),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to 1024L,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).build()
|
||||
|
||||
private companion object {
|
||||
val ONE_FILE: Uri = Uri.parse("content://test/holiday.mp4")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ import org.junit.Test
|
||||
* `OutputFormat` used to be twelve hand-picked triples, and its KDoc defended that on the grounds
|
||||
* that a closed set was what made routing decidable. Opening it up moves that burden here, so this
|
||||
* is where decidability now has to be proven.
|
||||
*
|
||||
* That includes what a refusal offers instead. `Validation.Invalid` promises every suggestion is
|
||||
* itself valid and names this class as the proof, so a branch that assembles its own suggestion
|
||||
* list rather than going through `suggestions()` is only checked here if some row happens to reach
|
||||
* it — which is how a dead-end chip survived two widenings of that table.
|
||||
*/
|
||||
class ContainerCapabilitiesTest {
|
||||
|
||||
@@ -20,6 +25,44 @@ class ContainerCapabilitiesTest {
|
||||
container = Container.MP4,
|
||||
)
|
||||
|
||||
/**
|
||||
* An MP3, and the reason several rules below need a second probe.
|
||||
*
|
||||
* `hasVideo = false` is the load-bearing field. Every rule that reads only the spec answers the
|
||||
* same for this input as for a video file, which is exactly how a spec naming a video codec was
|
||||
* called valid for a file with no video track to put in it.
|
||||
*/
|
||||
private val mp3Source = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
container = Container.MP3,
|
||||
)
|
||||
|
||||
/**
|
||||
* An audio-only input carrying a codec MP4 has no place for at all.
|
||||
*
|
||||
* Vorbis lives in Ogg and Matroska; MP4 carries AAC, MP3, Opus and FLAC. That gap is what turns
|
||||
* a suggestion which merely drops the video track into a second refusal.
|
||||
*/
|
||||
private val vorbisSource = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "vorbis",
|
||||
hasVideo = false,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
container = Container.OGG,
|
||||
)
|
||||
|
||||
/** The same shape, for the other codec MP4 refuses. One case is a coincidence; two is the rule. */
|
||||
private val pcmSource = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "pcm_s16le",
|
||||
hasVideo = false,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
container = Container.WAV,
|
||||
)
|
||||
|
||||
// --- copy and encode are different questions ----------------------------
|
||||
|
||||
/**
|
||||
@@ -82,20 +125,56 @@ class ContainerCapabilitiesTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** A suggestion that is itself invalid is worse than no suggestion. */
|
||||
/**
|
||||
* A suggestion that is itself invalid is worse than no suggestion.
|
||||
*
|
||||
* Only a branch that assembles its own suggestion list can break that promise: [suggestions]
|
||||
* ends by filtering on `validate(...).isValid`, so everything routed through it is valid by
|
||||
* construction. Those branches are what this table has to cover — the image output, and copy
|
||||
* the video from a file that has none, which built its list by hand and came back refused for
|
||||
* a Vorbis or PCM source into MP4 and an MP3 into WebM. The Advanced picker showed a one-tap
|
||||
* fix that led straight to a second error, through two widenings of this table that never
|
||||
* reached the branch.
|
||||
*/
|
||||
@Test
|
||||
fun `every suggestion is itself valid`() {
|
||||
val broken = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC)
|
||||
val result = ContainerCapabilities.validate(broken, h264Source)
|
||||
val cases = listOf(
|
||||
OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC) to h264Source,
|
||||
// The audio-only input. Every rejection it can reach used to hand back `None + None`
|
||||
// — a spec validation refuses in the next breath — because these branches built their
|
||||
// suggestion by hand instead of going through the repair-and-filter path.
|
||||
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE) to mp3Source,
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE) to mp3Source,
|
||||
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE) to mp3Source,
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.AAC) to mp3Source,
|
||||
// Copy-the-video-from-a-file-with-no-video, the last branch that built its offer by
|
||||
// hand. It escaped the five rows above because `spec.copy(videoCodec = NONE)` is valid
|
||||
// exactly when the audio axis happens to be fine — true for the AAC and MP3 sources
|
||||
// used there, false for any audio the target container cannot carry.
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY) to vorbisSource,
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY) to pcmSource,
|
||||
OutputSpec(Container.WEBM, VideoCodec.COPY, AudioCodec.COPY) to mp3Source,
|
||||
// The same branch with audio the container *can* hold, which is the half that already
|
||||
// worked and must keep working: the repair here is a copy, so the offer is the very
|
||||
// spec the caller handed to `suggestions`. It survives only because the exclusion is
|
||||
// against what the user asked for rather than against the repair.
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY) to mp3Source,
|
||||
// The one branch that still builds its list by hand, so that it is asserted rather
|
||||
// than merely reasoned about: an image container takes `None + None` and nothing else,
|
||||
// which makes its single offer valid by construction.
|
||||
OutputSpec(Container.GIF, VideoCodec.H264, AudioCodec.AAC) to h264Source,
|
||||
)
|
||||
|
||||
val invalid = result as? Validation.Invalid
|
||||
?: throw AssertionError("expected H.264 in WebM to be rejected")
|
||||
assertTrue("no alternatives offered", invalid.suggestions.isNotEmpty())
|
||||
invalid.suggestions.forEach { suggestion ->
|
||||
assertTrue(
|
||||
"suggested $suggestion is itself invalid",
|
||||
ContainerCapabilities.validate(suggestion, h264Source).isValid,
|
||||
)
|
||||
cases.forEach { (spec, probe) ->
|
||||
val invalid = ContainerCapabilities.validate(spec, probe) as? Validation.Invalid
|
||||
?: throw AssertionError("expected $spec to be rejected")
|
||||
assertTrue("no alternatives offered for $spec on $probe", invalid.suggestions.isNotEmpty())
|
||||
invalid.suggestions.forEach { suggestion ->
|
||||
assertTrue(
|
||||
"suggested $suggestion for $spec on $probe is itself invalid",
|
||||
ContainerCapabilities.validate(suggestion, probe).isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +206,117 @@ class ContainerCapabilitiesTest {
|
||||
assertTrue((result as Validation.Invalid).suggestions.isNotEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule, seen only against the probe.
|
||||
*
|
||||
* A video codec named for a file with no video track is dropped, not encoded — so
|
||||
* MP4/H.265/None on an MP3 empties the output exactly as None/None does. Reading the spec
|
||||
* alone answered "valid" because the spec names a video codec, and the job went to Media3,
|
||||
* where `EditedMediaItem.Builder` refuses a composition with both tracks removed by throwing
|
||||
* on Transformer's own HandlerThread.
|
||||
*/
|
||||
@Test
|
||||
fun `a video codec named for a file with no video track and no audio is refused`() {
|
||||
ContainerCapabilities.encodableVideo(Container.MP4).forEach { codec ->
|
||||
val spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE)
|
||||
val result = ContainerCapabilities.validate(spec, mp3Source)
|
||||
|
||||
assertFalse(
|
||||
"MP4/${codec.label}/None on an audio-only input plans to (Drop, Drop) and would " +
|
||||
"produce an empty file; it must be refused. Got $result",
|
||||
result.isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal is only worth having if it leads somewhere.
|
||||
*
|
||||
* The COPY form of this was already refused, but its one hand-built suggestion was
|
||||
* `None + None` — which validation refuses in the next breath, so the Advanced picker offered
|
||||
* a one-tap fix that fixed nothing. Every face of the rule now goes through the shared
|
||||
* suggestion path, so the offer keeps the one track the input actually has.
|
||||
*/
|
||||
@Test
|
||||
fun `refusing an empty output still offers a way to keep the audio`() {
|
||||
listOf(VideoCodec.H265, VideoCodec.H264, VideoCodec.COPY, VideoCodec.NONE).forEach { codec ->
|
||||
val spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE)
|
||||
val invalid = ContainerCapabilities.validate(spec, mp3Source) as? Validation.Invalid
|
||||
?: throw AssertionError("expected MP4/${codec.label}/None to be rejected")
|
||||
|
||||
assertTrue(
|
||||
"a refusal with no way out is a dead end in the Advanced picker",
|
||||
invalid.suggestions.isNotEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
"every suggestion must keep a track, got ${invalid.suggestions}",
|
||||
invalid.suggestions.all { it.audioCodec != AudioCodec.NONE },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A repair must not name a track the input does not have.
|
||||
*
|
||||
* `repairVideo` used to fall through to "the first codec this container can encode" whenever
|
||||
* nothing else fitted, and for an MP3 that produced the non-sequitur `MP4 · H.264 · Copy`.
|
||||
* It validated, so nothing caught it — but [CopyPlanner] drops that video track anyway, which
|
||||
* makes the codec in the offer a fiction.
|
||||
*/
|
||||
@Test
|
||||
fun `a repair for a file with no video track never names a video codec`() {
|
||||
listOf(
|
||||
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE),
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE),
|
||||
).forEach { spec ->
|
||||
val invalid = ContainerCapabilities.validate(spec, mp3Source) as Validation.Invalid
|
||||
invalid.suggestions.forEach {
|
||||
assertEquals(
|
||||
"offering ${it.videoCodec.label} for a file with no video track is a fiction; " +
|
||||
"CopyPlanner drops it. Suggested $it for $spec",
|
||||
VideoCodec.NONE,
|
||||
it.videoCodec,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule stated as the property it is, over the whole matrix.
|
||||
*
|
||||
* A plan of (Drop, Drop) is precisely the composition `EditedMediaItem.Builder` refuses to
|
||||
* build, so no non-image spec that reaches it may be called valid. Sweeping every container ×
|
||||
* codec × codec against both probes is what stops the next container or codec from
|
||||
* reintroducing the gap on an axis nobody thought to write a case for.
|
||||
*
|
||||
* Image outputs are exempt and deliberately so: GIF and PNG frames carry no codecs at all, and
|
||||
* `None + None` is the only spec they accept — but they never reach Media3, because the router
|
||||
* sends every image output to FFmpeg.
|
||||
*/
|
||||
@Test
|
||||
fun `no valid non-image spec plans to remove both tracks`() {
|
||||
val specs = Container.entries
|
||||
.filterNot { it == Container.GIF || it == Container.IMAGE_SEQUENCE }
|
||||
.flatMap { container -> VideoCodec.entries.map { container to it } }
|
||||
.flatMap { (container, video) -> AudioCodec.entries.map { OutputSpec(container, video, it) } }
|
||||
val cases = specs.flatMap { spec -> listOf(h264Source, mp3Source).map { spec to it } }
|
||||
|
||||
val empties = cases.filter { (spec, probe) ->
|
||||
val plan = CopyPlanner.plan(spec, probe)
|
||||
plan.video == VideoPlan.Drop && plan.audio == AudioPlan.Drop
|
||||
}
|
||||
|
||||
assertTrue("the sweep found nothing to check — the filter has gone wrong", empties.isNotEmpty())
|
||||
empties.forEach { (spec, probe) ->
|
||||
assertFalse(
|
||||
"$spec on $probe plans to (Drop, Drop) — an empty file, and the composition " +
|
||||
"Media3 cannot build — so it must not validate",
|
||||
ContainerCapabilities.validate(spec, probe).isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying is offered as the fix when the codec is right but unencodable`() {
|
||||
val av1Source = InputProbe(videoCodec = "av1", audioCodec = "aac", container = Container.MKV)
|
||||
@@ -168,4 +358,213 @@ class ContainerCapabilitiesTest {
|
||||
assertEquals(emptyList<VideoCodec>(), ContainerCapabilities.encodableVideo(container))
|
||||
}
|
||||
}
|
||||
|
||||
// --- the audio axis -----------------------------------------------------
|
||||
//
|
||||
// Every rule below has a video twin already tested above. The two halves of `validate` were
|
||||
// written together and only one of them was ever checked, so these are deliberately shaped like
|
||||
// their twins rather than as a fresh idea about what to assert.
|
||||
|
||||
@Test
|
||||
fun `an unidentifiable source audio codec cannot be copied`() {
|
||||
// The audio twin of `an unidentifiable source codec cannot be copied`. Never guess: a copy
|
||||
// of an unidentified codec is how you ship a file that does not play.
|
||||
val unknownAudio = InputProbe(videoCodec = "h264", audioCodec = null, container = Container.MP4)
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.COPY)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, unknownAudio) as? Validation.Invalid
|
||||
?: throw AssertionError("copying an unidentified audio codec must be refused")
|
||||
|
||||
assertTrue(invalid.message, invalid.message.contains("could not be identified"))
|
||||
assertEverySuggestionValid(invalid, unknownAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying an audio codec the container cannot hold is refused`() {
|
||||
// MP4 carries AAC, MP3, Opus and FLAC. Vorbis lives in Ogg and Matroska, so a stream copy
|
||||
// out of a Vorbis source into MP4 has nowhere to put the track.
|
||||
val vorbisAudio = InputProbe(videoCodec = "h264", audioCodec = "vorbis", container = Container.MKV)
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.COPY)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, vorbisAudio) as? Validation.Invalid
|
||||
?: throw AssertionError("Vorbis copied into MP4 must be refused")
|
||||
|
||||
assertEquals("MP4 cannot hold Vorbis audio.", invalid.message)
|
||||
assertEverySuggestionValid(invalid, vorbisAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio codec the container cannot hold is refused on the encode path too`() {
|
||||
// WAV carries PCM and nothing else. The twin is `H265 in AVI is refused`.
|
||||
val spec = OutputSpec(Container.WAV, VideoCodec.NONE, AudioCodec.AAC)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, mp3Source) as? Validation.Invalid
|
||||
?: throw AssertionError("AAC in WAV must be refused")
|
||||
|
||||
assertEquals("WAV cannot hold AAC audio.", invalid.message)
|
||||
assertEverySuggestionValid(invalid, mp3Source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio codec this app cannot encode is refused, and copying is offered instead`() {
|
||||
// Matroska carries Vorbis; nothing here encodes it. The refusal has to say so *and* say
|
||||
// what would work, which is the audio twin of `copying is offered as the fix when the codec
|
||||
// is right but unencodable`.
|
||||
val spec = OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.VORBIS)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, h264Source) as? Validation.Invalid
|
||||
?: throw AssertionError("encoding Vorbis must be refused")
|
||||
|
||||
assertEquals(
|
||||
"This app cannot encode Vorbis audio. It can still be copied from a Vorbis source.",
|
||||
invalid.message,
|
||||
)
|
||||
assertEverySuggestionValid(invalid, h264Source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying a video codec the container cannot hold is refused`() {
|
||||
// Not the audio axis, but the one video refusal with no test: AVI predates H.265, so a
|
||||
// stream copy out of an HEVC source into AVI has nowhere to put the track. `H265 in AVI is
|
||||
// refused` covers the matrix; this covers what validate() does with it.
|
||||
val h265Source = InputProbe(videoCodec = "hevc", audioCodec = "mp3", container = Container.MP4)
|
||||
val spec = OutputSpec(Container.AVI, VideoCodec.COPY, AudioCodec.MP3)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, h265Source) as? Validation.Invalid
|
||||
?: throw AssertionError("H.265 copied into AVI must be refused")
|
||||
|
||||
assertEquals("AVI cannot hold H.265 video.", invalid.message)
|
||||
assertEverySuggestionValid(invalid, h265Source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no audio track is accepted by every container in both modes`() {
|
||||
// The audio twin of VideoCodec.NONE -> true. A container that refused "no audio" would make
|
||||
// every video-only output invalid.
|
||||
Container.entries.forEach { container ->
|
||||
listOf(CodecMode.COPY, CodecMode.ENCODE).forEach { mode ->
|
||||
assertTrue(
|
||||
"$container should accept no audio track ($mode)",
|
||||
ContainerCapabilities.accepts(container, AudioCodec.NONE, mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The video twin of `no audio track is accepted by every container in both modes`.
|
||||
*
|
||||
* Dead in production today, and deliberately so: every caller guards `NONE` before asking the
|
||||
* matrix, so nothing reaches this arm through the app. **The asymmetry is the argument, not the
|
||||
* reachability** -- its audio counterpart at the top of the same `when` has had a dedicated
|
||||
* test since #136, and one of a matched pair being covered is how a later reader concludes the
|
||||
* other was considered and exempted. It was not; it was simply missed.
|
||||
*
|
||||
* Not the same shape as the two `COPY -> error(...)` arms, which `docs/coverage-read-findings.md`
|
||||
* records as a named exemption (F4). Those are guards that must not be provokable. This is a
|
||||
* documented answer -- "no video track fits anywhere" -- and an answer is a thing to pin.
|
||||
*/
|
||||
@Test
|
||||
fun `no video track is accepted by every container in both modes`() {
|
||||
Container.entries.forEach { container ->
|
||||
listOf(CodecMode.COPY, CodecMode.ENCODE).forEach { mode ->
|
||||
assertTrue(
|
||||
"$container should accept no video track ($mode)",
|
||||
ContainerCapabilities.accepts(container, VideoCodec.NONE, mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggestion that keeps the codec the user asked for, rather than falling back to the
|
||||
* container's first encodable one.
|
||||
*
|
||||
* `repairVideo`'s third arm -- "the request is not a copy, and this container can encode it" --
|
||||
* is the one that preserves intent, and it was the only arm of the four nothing reached. The
|
||||
* property test above executes `repairVideo` on every case it walks and lands elsewhere each
|
||||
* time: an explicit COPY that works, a source the container can carry untouched, or no video
|
||||
* track at all.
|
||||
*
|
||||
* The route is indirect because it is the only one the app has. VP9 into WebM is a perfectly
|
||||
* good video request; what makes it invalid is the *audio* -- WebM carries Opus and Vorbis, not
|
||||
* AAC. So `validateAudio` refuses, `suggestions` looks for a container that can hold what was
|
||||
* asked for, and MP4 can encode VP9. The suggestion has to come back carrying VP9: swapping to
|
||||
* the container's first encodable codec would discard the choice the user made.
|
||||
*/
|
||||
@Test
|
||||
fun `a repaired suggestion keeps the video codec the user chose`() {
|
||||
val invalid = ContainerCapabilities.validate(
|
||||
OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.AAC),
|
||||
h264Source,
|
||||
)
|
||||
|
||||
assertTrue("WebM cannot hold AAC, so this spec is invalid", invalid is Validation.Invalid)
|
||||
val suggestions = (invalid as Validation.Invalid).suggestions
|
||||
assertTrue(
|
||||
"expected a suggestion that still encodes VP9, got $suggestions",
|
||||
suggestions.any { it.videoCodec == VideoCodec.VP9 },
|
||||
)
|
||||
assertEverySuggestionValid(invalid, h264Source)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback in `firstContainerHolding`: when the input's own container cannot hold the
|
||||
* codec the user asked for, any container that can will do.
|
||||
*
|
||||
* The preferred half -- "the container the input already uses" -- is what every other case
|
||||
* reaches, because they all start from a file whose own container carries the codec in
|
||||
* question. The elvis after it had never run.
|
||||
*
|
||||
* AVI is the input that makes it run: AVI predates H.265 and has no mapping for it, so asking
|
||||
* an AVI for H.265 is refused, and the container the input already uses cannot be part of the
|
||||
* answer. Without the fallback the only candidates left are AVI itself and the container
|
||||
* holding the *source* codec -- also AVI -- so the refusal still offers something, but what it
|
||||
* offers is H.264: the app quietly declines the codec the user asked for instead of moving them
|
||||
* to a container that supports it.
|
||||
*
|
||||
* That is why this asserts the codec survives rather than that the list is non-empty. A
|
||||
* non-empty assertion passes with the fallback deleted -- measured, not assumed.
|
||||
*/
|
||||
@Test
|
||||
fun `an input whose container cannot hold the requested codec is moved, not downgraded`() {
|
||||
val aviSource = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.AVI)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(
|
||||
OutputSpec(Container.AVI, VideoCodec.H265, AudioCodec.AAC),
|
||||
aviSource,
|
||||
)
|
||||
|
||||
assertTrue("AVI has no mapping for H.265", invalid is Validation.Invalid)
|
||||
val suggestions = (invalid as Validation.Invalid).suggestions
|
||||
assertTrue(
|
||||
"expected a container that can actually hold H.265, got $suggestions",
|
||||
suggestions.any { it.videoCodec == VideoCodec.H265 },
|
||||
)
|
||||
assertEverySuggestionValid(invalid, aviSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolving audio COPY before asking the matrix is required`() {
|
||||
// The audio twin of `resolving COPY before asking the matrix is required`, and the reason is
|
||||
// identical: silently answering "false" would refuse a perfectly good remux.
|
||||
runCatching { ContainerCapabilities.accepts(Container.MP4, AudioCodec.COPY, CodecMode.COPY) }
|
||||
.onSuccess { throw AssertionError("expected audio COPY to be rejected by the matrix") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every alternative a refusal offers has to be one the same input could actually take.
|
||||
*
|
||||
* `Validation.Invalid` promises exactly this and names this class as the proof. The global
|
||||
* property test walks the presets; these paths reach `suggestions()` through `validateAudio`,
|
||||
* which no preset does.
|
||||
*/
|
||||
private fun assertEverySuggestionValid(invalid: Validation.Invalid, probe: InputProbe) {
|
||||
invalid.suggestions.forEach {
|
||||
assertTrue(
|
||||
"suggestion $it is itself invalid, so the chip leads to a second error",
|
||||
ContainerCapabilities.validate(it, probe).isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +350,34 @@ class ConversionRouterTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `Media3Engine` still needs a guard of its own.
|
||||
*
|
||||
* `ContainerCapabilities.validate` now refuses "a video codec with the audio off" for an input
|
||||
* with no video track, so neither the picker nor `ConversionWorker` will start one. Routing is
|
||||
* a separate question and still answers MEDIA3 — nothing about a dropped track makes the job
|
||||
* un-hardware-able — so a request that skips validation, from a direct
|
||||
* `ConversionWorker.request(...)` or a job queued before the settings changed, arrives at the
|
||||
* engine with a plan Media3 cannot build. That has to fail the job, not the process.
|
||||
*/
|
||||
@Test
|
||||
fun `a plan that drops both tracks still routes to media3`() {
|
||||
val audioOnly = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
)
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE)
|
||||
|
||||
val plan = CopyPlanner.plan(spec, audioOnly)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
|
||||
assertEquals(Engine.MEDIA3, route(spec, probe = audioOnly).engine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio-only formats are flagged as such`() {
|
||||
assertEquals(true, OutputFormat.MP3.isAudioOnly)
|
||||
|
||||
@@ -146,6 +146,33 @@ class CopyPlannerTest {
|
||||
assertTrue("copying the only track is still a remux", plan.isPureRemux)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one plan `Media3Engine` cannot be handed.
|
||||
*
|
||||
* `EditedMediaItem.Builder` refuses a composition with both tracks removed —
|
||||
* checkState("Audio and video cannot both be removed") — and this is how an ordinary-looking
|
||||
* spec reaches it: a video codec named for a file that has no video, with the audio switched
|
||||
* off. Neither half is unusual on its own, which is why validation could read the spec, see a
|
||||
* video codec, and call it fine.
|
||||
*/
|
||||
@Test
|
||||
fun `an audio-only source with the audio dropped removes both tracks`() {
|
||||
val audioOnly = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
)
|
||||
val plan = CopyPlanner.plan(
|
||||
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
audioOnly,
|
||||
)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
assertTrue("an empty plan is not a remux", !plan.isPureRemux)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying one track and encoding the other is not a pure remux`() {
|
||||
val plan = CopyPlanner.plan(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.libremediaconverter.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* No two entries of [TestTags] may share a value.
|
||||
*
|
||||
* A duplicated value is the one mistake this table invites -- the constants are added in blocks of
|
||||
* near-identical lines, and a copy-paste that keeps the old string still compiles, still reads
|
||||
* correctly at the call site, and still passes every test in the file that placed it. It surfaces
|
||||
* later, in someone else's PR, as an affordance that "resolves to exactly one node" finding two,
|
||||
* with nothing in that diff to explain it.
|
||||
*
|
||||
* Read by reflection rather than from a hand-written list, because a hand-written list would be a
|
||||
* second copy of the table with the same copy-paste failure in it.
|
||||
*/
|
||||
class TagTableUniquenessTest {
|
||||
|
||||
private fun tagsIn(vararg holders: Class<*>): List<String> = holders.flatMap { holder ->
|
||||
holder.declaredFields
|
||||
.filter { it.type == String::class.java }
|
||||
.map { it.get(null) as String }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every tag constant has its own value`() {
|
||||
val tags = tagsIn(
|
||||
TestTags::class.java,
|
||||
TestTags.Converter::class.java,
|
||||
TestTags.Join::class.java,
|
||||
)
|
||||
|
||||
// Without this the check would pass on an empty list, which is what a reflection call
|
||||
// that stopped finding the constants would hand it.
|
||||
assertTrue("reflection found only ${tags.size} tag constants, so it is not reading the table", tags.size > 20)
|
||||
assertEquals(emptyList<String>(), tags.groupBy { it }.filterValues { it.size > 1 }.keys.toList())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.libremediaconverter.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The theme has to resolve the scheme its arguments name, and `ThemeKt` had no test at all --
|
||||
* 23 lines, none of them covered, which is how #68 was found.
|
||||
*
|
||||
* Only two of the four branches in [LibreMediaConverterTheme]'s `when` are reachable from the
|
||||
* app. `MainActivity` is the single call site and passes no arguments, so `dynamicColor` is
|
||||
* always `true` and the live choice is between the dynamic dark and dynamic light schemes.
|
||||
* Those two are what ships, and asserting on them survives whichever way #68 is decided.
|
||||
*
|
||||
* **The other two branches have no caller.** `dynamicColor = false` is passed below by this
|
||||
* test and by nothing else in `app/src`, so the coverage it produces is not evidence that a
|
||||
* switch exists -- misreading it that way is the whole reason #68 was filed. #68 is the open
|
||||
* decision about whether one ever will exist.
|
||||
*
|
||||
* What the assertions distinguish the branches on was measured under Robolectric `sdk=36`
|
||||
* rather than assumed. The dynamic palette resolves to the platform's own default there --
|
||||
* dark background `#121318` against light `#FAF8FF`, dark primary `#B0C6FF` -- and that is a
|
||||
* different hue from the brand palette's [Purple80] / [Purple40]. A dynamic scheme reads the
|
||||
* device, so those exact values belong to the Robolectric stub and to no particular phone,
|
||||
* which is why the live-branch tests compare the two resolved schemes against each other
|
||||
* instead of hard-coding either one.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ThemeColorSchemeTest {
|
||||
|
||||
// The **v2** rule (`androidx.compose.ui.test.junit4.v2`), as everywhere else in this
|
||||
// source set.
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/**
|
||||
* Every scheme the `when` can produce, read out of [MaterialTheme] inside the content
|
||||
* lambda -- the only place that shows what the theme actually chose, rather than what the
|
||||
* caller hoped for.
|
||||
*
|
||||
* All four are resolved in one composition because `setContent` may be called once per
|
||||
* test, and a comparison needs at least two of them.
|
||||
*/
|
||||
private fun resolveAll(): Schemes {
|
||||
lateinit var dynamicDark: ColorScheme
|
||||
lateinit var dynamicLight: ColorScheme
|
||||
lateinit var brandDark: ColorScheme
|
||||
lateinit var brandLight: ColorScheme
|
||||
composeRule.setContent {
|
||||
LibreMediaConverterTheme(darkTheme = true) { dynamicDark = MaterialTheme.colorScheme }
|
||||
LibreMediaConverterTheme(darkTheme = false) { dynamicLight = MaterialTheme.colorScheme }
|
||||
LibreMediaConverterTheme(darkTheme = true, dynamicColor = false) {
|
||||
brandDark = MaterialTheme.colorScheme
|
||||
}
|
||||
LibreMediaConverterTheme(darkTheme = false, dynamicColor = false) {
|
||||
brandLight = MaterialTheme.colorScheme
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
return Schemes(dynamicDark, dynamicLight, brandDark, brandLight)
|
||||
}
|
||||
|
||||
private class Schemes(
|
||||
val dynamicDark: ColorScheme,
|
||||
val dynamicLight: ColorScheme,
|
||||
val brandDark: ColorScheme,
|
||||
val brandLight: ColorScheme,
|
||||
)
|
||||
|
||||
/**
|
||||
* The live branches, and the one assertion that catches them being swapped: both dynamic
|
||||
* schemes come from the same device palette, so they are similar enough that identity or a
|
||||
* bare inequality would prove nothing. Background luminance is not similar -- it is the
|
||||
* thing dark mode is for.
|
||||
*/
|
||||
@Test
|
||||
fun `dark mode resolves a darker scheme than light mode`() {
|
||||
val schemes = resolveAll()
|
||||
|
||||
val dark = schemes.dynamicDark.background.luminance()
|
||||
val light = schemes.dynamicLight.background.luminance()
|
||||
assertTrue(
|
||||
"darkTheme = true should resolve the dynamic dark scheme, whose background " +
|
||||
"luminance ($dark) is below the light scheme's ($light)",
|
||||
dark < light,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the two dark branches ran, and which of the two light ones: the scheme a live
|
||||
* call resolves is the dynamic one, not the brand palette sitting next to it.
|
||||
*/
|
||||
@Test
|
||||
fun `the live branches take the dynamic palette rather than the brand one`() {
|
||||
val schemes = resolveAll()
|
||||
|
||||
assertNotEquals(
|
||||
"the default dynamicColor = true should not resolve the brand dark palette",
|
||||
Purple80,
|
||||
schemes.dynamicDark.primary,
|
||||
)
|
||||
assertNotEquals(
|
||||
"the default dynamicColor = true should not resolve the brand light palette",
|
||||
Purple40,
|
||||
schemes.dynamicLight.primary,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The two dead branches. Nothing in `app/src` passes `dynamicColor = false`; this test
|
||||
* does it directly, because the parameter is public, and that is the only way either
|
||||
* branch runs. Covered so a later decision on #68 starts from a tested `when` -- not
|
||||
* because the brand palette is reachable in the app.
|
||||
*/
|
||||
@Test
|
||||
fun `the brand palette branches run only when dynamicColor is passed explicitly`() {
|
||||
val schemes = resolveAll()
|
||||
|
||||
assertEquals(Purple80, schemes.brandDark.primary)
|
||||
assertEquals(Purple40, schemes.brandLight.primary)
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,6 @@ import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* That a refused foreground-service start does not end the job.
|
||||
@@ -125,6 +122,40 @@ class DeniedForegroundStartTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join denied past the attempt bound fails with a message the user can act on`() {
|
||||
// The join twin of the conversion case above. ConcatWorker reaches the same FailureOutcome
|
||||
// through its own `when`, and that arm was the only one of its three with no test -- so a
|
||||
// join that gave up silently, or gave up with an empty Data, would have looked identical to
|
||||
// one that retried.
|
||||
val worker = concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS)
|
||||
|
||||
val result = runBlocking { worker.doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(
|
||||
workDataOf(ConcatWorker.KEY_ERROR to FailureOutcome.FOREGROUND_DENIED_MESSAGE),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join that gives up collects the partial it had already staged`() {
|
||||
// The delete lives on ConcatWorker's `catch (e: Throwable)` path, which every give-up goes
|
||||
// through. Written first so a missing delete cannot pass by asking whether a file nobody
|
||||
// wrote is absent.
|
||||
concatStagedFile().writeBytes(ByteArray(PARTIAL_BYTES))
|
||||
|
||||
runBlocking { concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS).doWork() }
|
||||
|
||||
assertEquals(
|
||||
"a join that gave up must not orphan what it staged",
|
||||
emptyList<String>(),
|
||||
stagedNames(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun conversionWorker(runAttemptCount: Int = 0): ConversionWorker =
|
||||
TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
@@ -141,18 +172,22 @@ class DeniedForegroundStartTest {
|
||||
.setForegroundUpdater(DenyingForegroundUpdater)
|
||||
.build()
|
||||
|
||||
private fun concatWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
private fun concatWorker(runAttemptCount: Int = 0): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "content://test/second.mp4"),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
|
||||
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
|
||||
ConcatWorker.KEY_FORMAT to CONCAT_FORMAT.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
runAttemptCount = runAttemptCount,
|
||||
).setId(CONCAT_ID)
|
||||
.setForegroundUpdater(DenyingForegroundUpdater)
|
||||
.build()
|
||||
|
||||
/** The staging path the join will compute, asked for rather than spelled out here. */
|
||||
private fun concatStagedFile(): File =
|
||||
publisher.createStagingFile(StagingNames.forJob(CONCAT_ID, CONCAT_FORMAT.extension))
|
||||
|
||||
/** The staging path the worker will compute, asked for rather than spelled out here. */
|
||||
private fun stagedFile(): File = publisher.createStagingFile(StagingNames.forJob(CONVERSION_ID, SPEC.extension))
|
||||
|
||||
@@ -164,6 +199,7 @@ class DeniedForegroundStartTest {
|
||||
const val INPUT_BYTES = 1024L
|
||||
const val PARTIAL_BYTES = 2048
|
||||
val SPEC = OutputFormat.MP4_H265.spec
|
||||
val CONCAT_FORMAT = OutputFormat.MP4_H264
|
||||
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000001")
|
||||
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000002")
|
||||
}
|
||||
@@ -182,18 +218,3 @@ private object DenyingForegroundUpdater : ForegroundUpdater {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An already-failed future, written out rather than pulled from a futures library.
|
||||
*
|
||||
* `await()` takes the `isDone` fast path and unwraps the `ExecutionException`, which is what puts
|
||||
* the platform's own exception in front of the worker's catch rather than a wrapper.
|
||||
*/
|
||||
private class FailedFuture(private val failure: Throwable) : ListenableFuture<Void> {
|
||||
override fun addListener(listener: Runnable, executor: Executor): Unit = executor.execute(listener)
|
||||
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
|
||||
override fun isCancelled(): Boolean = false
|
||||
override fun isDone(): Boolean = true
|
||||
override fun get(): Void = throw ExecutionException(failure)
|
||||
override fun get(timeout: Long, unit: TimeUnit): Void = throw ExecutionException(failure)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.content.pm.ServiceInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* [ConversionForegroundType.current] answers differently on each of the three API regimes, and
|
||||
* until this file only one of them was ever executed.
|
||||
*
|
||||
* `app/src/test/resources/robolectric.properties` pins the whole JVM suite to `sdk=36`, so every
|
||||
* Robolectric test that reaches a `ForegroundInfo` takes the `mediaProcessing` arm and no other.
|
||||
* The 33 and 34 arms were cold: 3 lines and 3 of 4 branches, measured on `main` at `d354f64`.
|
||||
*
|
||||
* **The instrumented test is not a substitute, and the reason is specific.**
|
||||
* `ConversionWorkerTest.foregroundTypeMatchesTheRunningApiLevel` asserts against whichever API the
|
||||
* leg happens to be — one arm per leg, never the other two — and the legs that would cover 33 and
|
||||
* 34 are the ones issue #122 wedges. `docs/coverage-read-findings.md` records an API 33 run that
|
||||
* reported `received: 60` and `failed: unknown`: the regime *was* exercised, and that leg could
|
||||
* not have said so if it had broken. Four `@Config` classes here pin all three arms
|
||||
* deterministically, in the same `./gradlew` invocation as everything else.
|
||||
*
|
||||
* `minSdk` is 33, so none of these is dead code — each is a device someone is running the app on.
|
||||
*
|
||||
* **SDK 35 is in the list for the boundary, not for the answer.** It shares its answer with 36,
|
||||
* which would make it look redundant. It is not: relaxing `>= VANILLA_ICE_CREAM` to `>` is invisible
|
||||
* at every level except exactly 35, so without this class that mutation survives the suite.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class ForegroundTypeApi33Test {
|
||||
|
||||
/**
|
||||
* Zero rather than a named constant because there is no constant to name: API 33 does not
|
||||
* require a type, and `mediaProcessing` does not exist here to pass. `ForegroundInfo` reads 0
|
||||
* as "no type at all", which is what this regime wants.
|
||||
*/
|
||||
@Test
|
||||
fun `api 33 asks for no foreground service type`() {
|
||||
assertEquals(0, ConversionForegroundType.current())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 34 makes a type mandatory and still has no `mediaProcessing`, so `dataSync` is the only
|
||||
* sensible fit. See [ForegroundTypeApi33Test] for why this file exists.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ForegroundTypeApi34Test {
|
||||
|
||||
@Test
|
||||
fun `api 34 falls back to dataSync, the only type that fits`() {
|
||||
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ConversionForegroundType.current())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first level with `mediaProcessing`, and therefore the one that tells `>=` from `>`.
|
||||
* See [ForegroundTypeApi33Test].
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class ForegroundTypeApi35Test {
|
||||
|
||||
@Test
|
||||
fun `api 35 is the first level that takes mediaProcessing`() {
|
||||
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, ConversionForegroundType.current())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The level the rest of the suite runs at, asserted here rather than assumed — it is the one arm
|
||||
* that was already covered, and leaving it out would make this file look like it is about the old
|
||||
* levels rather than about all three regimes. See [ForegroundTypeApi33Test].
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class ForegroundTypeApi36Test {
|
||||
|
||||
@Test
|
||||
fun `api 36 keeps mediaProcessing`() {
|
||||
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, ConversionForegroundType.current())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -125,6 +126,39 @@ class JobSnapshotsTest {
|
||||
assertEquals(newer.absolutePath, Reattachment.choose(snapshots)?.job?.outputPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* A job in the tag query that never recorded an output path at all.
|
||||
*
|
||||
* Distinct from the three cases above, which all *have* a path and differ in what it names. A
|
||||
* job still running, or one that finished without writing its result key, carries no path at
|
||||
* all -- and `getWorkInfosByTagFlow` returns it alongside the finished ones, because the tag is
|
||||
* the worker class and every attempt ever enqueued carries it.
|
||||
*
|
||||
* The guard is the `?.` in `path?.let(::File)`. Without it the null goes straight into a `File`
|
||||
* constructor. What this pins is the consequence rather than the null check: such a job must
|
||||
* not be offered as a result, so `Reattachment.choose` has to walk past it to the job that
|
||||
* really produced a file. Choosing it would put a Converted screen in front of the user with a
|
||||
* Save button that has nothing to save.
|
||||
*/
|
||||
@Test
|
||||
fun `a job that recorded no output path is not offered as a result`() {
|
||||
val real = stagedFile("real.mp4", bytes = 4096)
|
||||
finishedWithOutput(real)
|
||||
finishedWithNoOutput()
|
||||
|
||||
val snapshots = snapshots()
|
||||
|
||||
assertEquals("both jobs carry the tag, so both come back", 2, snapshots.size)
|
||||
val silent = snapshots.single { it.outputPath == null }
|
||||
assertFalse("no path means no output, not an empty one", silent.outputExists)
|
||||
assertEquals("and no time either, for the same reason", 0L, silent.outputModifiedAt)
|
||||
assertEquals(
|
||||
"the reattachment has to walk past it to the job that really produced a file",
|
||||
real.absolutePath,
|
||||
Reattachment.choose(snapshots)?.job?.outputPath,
|
||||
)
|
||||
}
|
||||
|
||||
private fun snapshots(): List<JobSnapshot> = runBlocking {
|
||||
workManager.jobSnapshots(
|
||||
tag = ConversionWorker::class.java.name,
|
||||
@@ -152,6 +186,11 @@ class JobSnapshotsTest {
|
||||
).result.get()
|
||||
}
|
||||
|
||||
/** A job that carries the tag and no result key -- still running, or finished without one. */
|
||||
private fun finishedWithNoOutput() {
|
||||
workManager.enqueue(OneTimeWorkRequestBuilder<ConversionWorker>().build()).result.get()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Two fixed moments a day apart, so the ordering is stated rather than raced for. */
|
||||
const val OLDER_MS = 1_700_000_000_000L
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.ContainerCapabilities
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Jobs the worker refuses before it converts anything, and what it says about them.
|
||||
*
|
||||
* Two exits, both cold before this file, and both reachable for the same underlying reason: **a job
|
||||
* does not have to come from the picker.** WorkManager keeps queued and finished work for about a
|
||||
* week, so a downgrade or a rollback hands this build a job enqueued by another one — the premise
|
||||
* `WorkerEnumFallbackTest` and `JobTags` are both written on — and `ConversionWorker.request(...)`
|
||||
* is callable directly.
|
||||
*
|
||||
* What makes these worth their own file rather than another case in an existing one is that both
|
||||
* are about the *message*. A refusal that fails with empty output `Data` renders the UI's generic
|
||||
* "Conversion failed." with nothing else to say, which is the defect shape `DeniedForegroundStartTest`
|
||||
* records from the device pass. Asserting the verdict alone would pass against exactly that.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class RefusedJobTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: OutputPublisher
|
||||
private lateinit var engine: RefusingTranscoder
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = AlwaysRoomPublisher(app)
|
||||
engine = RefusingTranscoder()
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
ConversionDependencies.software = { engine }
|
||||
// Neither test is about probing or about this machine's codecs; both would otherwise decide
|
||||
// the outcome for reasons no assertion mentions. See WorkerCancellationTest's setUp.
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a job with no input URI fails with a message rather than a bare failure`() {
|
||||
val result = runBlocking { workerWithout(ConversionWorker.KEY_INPUT_URI).doWork() }
|
||||
|
||||
// `Failure.equals` compares output data, so this pins the message and the verdict together.
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(workDataOf(ConversionWorker.KEY_ERROR to "No input file.")),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a job with no input URI stages nothing`() {
|
||||
// The URI read is the first thing doWork does -- above the space check, above the staging
|
||||
// name, above the try. A refusal there must not have reserved anything.
|
||||
runBlocking { workerWithout(ConversionWorker.KEY_INPUT_URI).doWork() }
|
||||
|
||||
assertEquals("a job refused for having no input must not stage a file", emptyList<String>(), stagedNames())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a spec the picker would never have allowed is refused with the reason`() {
|
||||
// WAV carries PCM and nothing else. The picker cannot produce this combination today, which
|
||||
// is exactly why the worker checks: the job can arrive from a queue written before the
|
||||
// settings changed, or from a direct request(...) call.
|
||||
val expected = ContainerCapabilities.validate(REFUSED_SPEC, InputProbe()) as? Validation.Invalid
|
||||
?: throw AssertionError("the fixture spec is supposed to be invalid; ContainerCapabilities disagrees")
|
||||
|
||||
val result = runBlocking { worker(REFUSED_SPEC).doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(workDataOf(ConversionWorker.KEY_ERROR to expected.message)),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused spec never reaches an engine`() {
|
||||
// The half that says it failed *before* converting rather than during. Without this, a
|
||||
// worker that ran the job and then reported the validation message would pass the test
|
||||
// above -- and would have spent the user's battery on a file it was going to refuse.
|
||||
runBlocking { worker(REFUSED_SPEC).doWork() }
|
||||
|
||||
assertTrue("a refused spec must be refused before any engine runs", engine.invocations.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a valid spec is not refused`() {
|
||||
// The control. Every assertion above is about a refusal, so without this they would all
|
||||
// still pass against a worker that refused everything.
|
||||
val result = runBlocking { worker(OutputFormat.MP4_H265.spec).doWork() }
|
||||
|
||||
assertEquals(ListenableWorker.Result.success(), stripOutput(result))
|
||||
assertEquals(listOf(OutputFormat.MP4_H265.spec), engine.invocations)
|
||||
}
|
||||
|
||||
// --- the same refusal, on the join side ----------------------------------
|
||||
|
||||
@Test
|
||||
fun `a join of a single file is refused with a message rather than joined`() {
|
||||
// The arm beside it -- a job with no URI array at all -- is covered on the device by
|
||||
// `UnopenableUriTest.aJoinWithNoInputArrayFailsWithAMessage`. This one was covered by
|
||||
// nothing in either source set, which a coverage report cannot say because it cannot see
|
||||
// androidTest: the two arms are adjacent lines and only one of them had a test.
|
||||
//
|
||||
// Reachable for the reason this file's header gives, plus one of its own: `request(...)`
|
||||
// takes a `List<Uri>` and checks nothing about its length, so a single-item join is a
|
||||
// well-formed call, not a corrupted queue entry.
|
||||
val result = runBlocking { joinWorker(INPUT).doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(
|
||||
workDataOf(ConcatWorker.KEY_ERROR to ConcatWorker.TOO_FEW_INPUTS_MESSAGE),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join of two files is not refused for its count`() {
|
||||
// The control, and the half that makes the test above bite on the boundary rather than on
|
||||
// the message: without it, `uris.size < 3` passes everything here.
|
||||
//
|
||||
// It refuses the space instead of letting the job run, because the next thing past the
|
||||
// count guard is `ConcatEngine`, which is native -- `NamingPublisher`'s KDoc records that
|
||||
// no JVM test gets past it. A refusal with the *space* message is proof that execution
|
||||
// reached line 57, which is proof it got past line 42, and it costs no engine to say so.
|
||||
val noRoom = NamingPublisher(app).apply { refuseSpace = true }
|
||||
ConversionDependencies.publisher = { noRoom }
|
||||
|
||||
val result = runBlocking { joinWorker(INPUT, SECOND_INPUT).doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(
|
||||
workDataOf(ConcatWorker.KEY_ERROR to "Not enough free space to join these files."),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
/** [ListenableWorker.Result.Success] compares its output data, which these tests do not pin. */
|
||||
private fun stripOutput(result: ListenableWorker.Result): ListenableWorker.Result =
|
||||
if (result is ListenableWorker.Result.Success) ListenableWorker.Result.success() else result
|
||||
|
||||
private fun worker(spec: OutputSpec): ConversionWorker = build(
|
||||
workDataOf(
|
||||
ConversionWorker.KEY_INPUT_URI to INPUT.toString(),
|
||||
ConversionWorker.KEY_DISPLAY_NAME to DISPLAY_NAME,
|
||||
ConversionWorker.KEY_SIZE_BYTES to INPUT_BYTES,
|
||||
ConversionWorker.KEY_CONTAINER to spec.container.name,
|
||||
ConversionWorker.KEY_VIDEO_CODEC to spec.videoCodec.name,
|
||||
ConversionWorker.KEY_AUDIO_CODEC to spec.audioCodec.name,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* The ordinary input `Data`, less one key.
|
||||
*
|
||||
* Built by removal rather than by spelling out a shorter map, so the test cannot drift into
|
||||
* omitting something else as well and passing for a reason it does not name.
|
||||
*/
|
||||
private fun workerWithout(key: String): ConversionWorker {
|
||||
val full = OutputFormat.MP4_H265.spec
|
||||
val entries = mapOf(
|
||||
ConversionWorker.KEY_INPUT_URI to INPUT.toString(),
|
||||
ConversionWorker.KEY_DISPLAY_NAME to DISPLAY_NAME,
|
||||
ConversionWorker.KEY_SIZE_BYTES to INPUT_BYTES,
|
||||
ConversionWorker.KEY_CONTAINER to full.container.name,
|
||||
ConversionWorker.KEY_VIDEO_CODEC to full.videoCodec.name,
|
||||
ConversionWorker.KEY_AUDIO_CODEC to full.audioCodec.name,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
|
||||
) - key
|
||||
return build(Data.Builder().putAll(entries).build())
|
||||
}
|
||||
|
||||
private fun build(data: Data): ConversionWorker =
|
||||
TestListenableWorkerBuilder<ConversionWorker>(context = app, inputData = data, runAttemptCount = 0)
|
||||
.setId(JOB_ID)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* A join job carrying [inputs], a declared total, and a format.
|
||||
*
|
||||
* The total is declared so `hasRoomFor` takes its `hasSpaceFor` branch: the other branch is
|
||||
* `hasSpaceForUnknownSize`, which `NamingPublisher` does not override and which would measure
|
||||
* this machine's real disk.
|
||||
*/
|
||||
private fun joinWorker(vararg inputs: Uri): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to inputs.map(Uri::toString).toTypedArray(),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES * inputs.size,
|
||||
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID).build()
|
||||
|
||||
private fun stagedNames(): List<String> =
|
||||
publisher.createStagingFile("anything").parentFile?.listFiles().orEmpty().map { it.name }.sorted()
|
||||
|
||||
private companion object {
|
||||
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
|
||||
const val DISPLAY_NAME = "holiday.mp4"
|
||||
const val INPUT_BYTES = 1024L
|
||||
|
||||
/** A join needs two, and "two" is the boundary the count guard is about. */
|
||||
val SECOND_INPUT: Uri = Uri.parse("file:///tmp/holiday-2.mp4")
|
||||
|
||||
/** WAV carries PCM and nothing else, so AAC in WAV has nowhere to go. */
|
||||
val REFUSED_SPEC = OutputSpec(
|
||||
org.libremediaconverter.model.Container.WAV,
|
||||
VideoCodec.NONE,
|
||||
AudioCodec.AAC,
|
||||
)
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000005")
|
||||
}
|
||||
}
|
||||
|
||||
/** An engine that records what it was asked for and writes an output, so a success is a success. */
|
||||
private class RefusingTranscoder : SoftwareTranscoder {
|
||||
|
||||
/** Every spec that actually reached an engine. Empty is the assertion for a refused job. */
|
||||
val invocations = mutableListOf<OutputSpec>()
|
||||
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
invocations += request.spec
|
||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val OUTPUT_BYTES = 512
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.ForegroundUpdater
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
@@ -18,6 +22,7 @@ import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.convert.StagingNames
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
@@ -108,6 +113,54 @@ class WorkerCancellationTest {
|
||||
assertEquals("a failed attempt must not leave its partial behind", emptyList<String>(), stagedNames())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled join propagates instead of being turned into a Result`() {
|
||||
val thrown = runCatching { runBlocking { concatWorker().doWork() } }.exceptionOrNull()
|
||||
|
||||
assertTrue(
|
||||
"cancellation must leave doWork as cancellation, not as a Result; got $thrown",
|
||||
thrown is CancellationException,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled join still deletes the partial it had already staged`() {
|
||||
// Written first, so a missing delete cannot pass by asking whether a file nobody wrote is
|
||||
// absent -- the same reason PartialThenFailingTranscoder writes before it throws.
|
||||
concatStagedFile().writeBytes(ByteArray(PARTIAL_STAGED_BYTES))
|
||||
|
||||
runCatching { runBlocking { concatWorker().doWork() } }
|
||||
|
||||
assertEquals("a cancelled join must not leave its partial behind", emptyList<String>(), stagedNames())
|
||||
}
|
||||
|
||||
/**
|
||||
* A join whose foreground start is cancelled rather than denied.
|
||||
*
|
||||
* The conversion twin cancels *inside the engine*, which is the honest shape there because
|
||||
* `ConversionDependencies` has a seam for it. `ConcatWorker` calls `ConcatEngine` directly and
|
||||
* has no such seam -- it is native, and nothing here gets past it -- so the cancellation is
|
||||
* injected at the only other point inside the `try`: `setForeground`. That is not a contrivance.
|
||||
* A job cancelled while WorkManager is promoting it to the foreground is precisely when the
|
||||
* window is open, and what is being tested is the `catch` arm, which cannot tell where in the
|
||||
* `try` the cancellation came from.
|
||||
*/
|
||||
private fun concatWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "file:///tmp/second.mp4"),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
|
||||
ConcatWorker.KEY_FORMAT to CONCAT_FORMAT.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(CONCAT_ID)
|
||||
.setForegroundUpdater(CancellingForegroundUpdater)
|
||||
.build()
|
||||
|
||||
/** The staging path the join will compute, asked for rather than spelled out here. */
|
||||
private fun concatStagedFile(): File =
|
||||
publisher.createStagingFile(StagingNames.forJob(CONCAT_ID, CONCAT_FORMAT.extension))
|
||||
|
||||
/**
|
||||
* A worker routed to the software engine, which is [failure] and nothing else.
|
||||
*
|
||||
@@ -142,7 +195,10 @@ class WorkerCancellationTest {
|
||||
const val DISPLAY_NAME = "holiday.mp4"
|
||||
const val INPUT_BYTES = 1024L
|
||||
val SPEC = OutputFormat.MP4_H265.spec
|
||||
val CONCAT_FORMAT = OutputFormat.MP4_H264
|
||||
const val PARTIAL_STAGED_BYTES = 2048
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000003")
|
||||
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000004")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,3 +223,19 @@ private class PartialThenFailingTranscoder(private val failure: () -> Nothing) :
|
||||
const val PARTIAL_BYTES = 2048
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for a job cancelled while WorkManager is promoting it to the foreground.
|
||||
*
|
||||
* The mechanism `DeniedForegroundStartTest` documents, carrying a different exception:
|
||||
* `WorkForegroundUpdater` propagates whatever the future failed with, and
|
||||
* `ListenableFuture.await()` unwraps the `ExecutionException`, so the worker meets a bare
|
||||
* `CancellationException` exactly where a real cancellation would put one.
|
||||
*/
|
||||
private object CancellingForegroundUpdater : ForegroundUpdater {
|
||||
override fun setForegroundAsync(
|
||||
context: Context,
|
||||
id: UUID,
|
||||
foregroundInfo: ForegroundInfo,
|
||||
): ListenableFuture<Void> = FailedFuture(CancellationException("cancelled while going foreground"))
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
@@ -109,6 +110,50 @@ class WorkerEnumFallbackTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a container this build does not define falls back to the default spec`() {
|
||||
assertFallsBackToDefault(container = "HOLOTAPE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a video codec this build does not define falls back to the default spec`() {
|
||||
assertFallsBackToDefault(video = "H267")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio codec this build does not define falls back to the default spec`() {
|
||||
assertFallsBackToDefault(audio = "SUPER_AAC")
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a job whose spec is [NOT_THE_FALLBACK] on every axis but the one named, and asserts the
|
||||
* whole spec came back as [DEFAULT_SPEC].
|
||||
*
|
||||
* **The baseline is the point.** `readSpec` returns the *entire* fallback spec the moment any
|
||||
* one axis fails to resolve, so a test starting from `MP4_H265` -- which is itself the fallback
|
||||
* -- could not tell a worker that read the spec correctly from one that gave up on it. Starting
|
||||
* from MKV/H.264 makes the difference visible on two axes at once.
|
||||
*
|
||||
* Asserting the spec that *ran*, rather than only that a `Result` came back, is the other half:
|
||||
* the defect these three are written for threw out of `doWork` entirely, so "a Result at all"
|
||||
* would pass against a fallback to something arbitrary.
|
||||
*/
|
||||
private fun assertFallsBackToDefault(
|
||||
container: String = NOT_THE_FALLBACK.container.name,
|
||||
video: String = NOT_THE_FALLBACK.videoCodec.name,
|
||||
audio: String = NOT_THE_FALLBACK.audioCodec.name,
|
||||
) {
|
||||
val transcoder = RequestRecordingTranscoder()
|
||||
ConversionDependencies.software = { transcoder }
|
||||
|
||||
val result = runBlocking {
|
||||
conversionWorker(container = container, video = video, audio = audio).doWork()
|
||||
}
|
||||
|
||||
assertEquals(ListenableWorker.Result.success(), stripOutput(result))
|
||||
assertEquals(listOf(DEFAULT_SPEC), transcoder.specs)
|
||||
}
|
||||
|
||||
/** [ListenableWorker.Result.Success] compares its output data, which these tests do not pin. */
|
||||
private fun stripOutput(result: ListenableWorker.Result): ListenableWorker.Result =
|
||||
if (result is ListenableWorker.Result.Success) ListenableWorker.Result.success() else result
|
||||
@@ -116,15 +161,18 @@ class WorkerEnumFallbackTest {
|
||||
private fun conversionWorker(
|
||||
quality: String = QualityTier.FAST.name,
|
||||
preference: String = EnginePreference.FORCE_SOFTWARE.name,
|
||||
container: String = SPEC.container.name,
|
||||
video: String = SPEC.videoCodec.name,
|
||||
audio: String = SPEC.audioCodec.name,
|
||||
): ConversionWorker = TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConversionWorker.KEY_INPUT_URI to INPUT.toString(),
|
||||
ConversionWorker.KEY_DISPLAY_NAME to DISPLAY_NAME,
|
||||
ConversionWorker.KEY_SIZE_BYTES to INPUT_BYTES,
|
||||
ConversionWorker.KEY_CONTAINER to SPEC.container.name,
|
||||
ConversionWorker.KEY_VIDEO_CODEC to SPEC.videoCodec.name,
|
||||
ConversionWorker.KEY_AUDIO_CODEC to SPEC.audioCodec.name,
|
||||
ConversionWorker.KEY_CONTAINER to container,
|
||||
ConversionWorker.KEY_VIDEO_CODEC to video,
|
||||
ConversionWorker.KEY_AUDIO_CODEC to audio,
|
||||
ConversionWorker.KEY_QUALITY to quality,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to preference,
|
||||
),
|
||||
@@ -146,6 +194,12 @@ class WorkerEnumFallbackTest {
|
||||
const val DISPLAY_NAME = "holiday.mp4"
|
||||
const val INPUT_BYTES = 1024L
|
||||
val SPEC = OutputFormat.MP4_H265.spec
|
||||
|
||||
/** What `readSpec` returns when any axis fails to resolve. */
|
||||
val DEFAULT_SPEC = OutputFormat.MP4_H265.spec
|
||||
|
||||
/** A spec that differs from [DEFAULT_SPEC] on container *and* video codec. See the helper. */
|
||||
val NOT_THE_FALLBACK = OutputFormat.MKV_H264.spec
|
||||
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000021")
|
||||
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000022")
|
||||
}
|
||||
@@ -156,6 +210,9 @@ private class RequestRecordingTranscoder : SoftwareTranscoder {
|
||||
|
||||
val qualities = mutableListOf<QualityTier>()
|
||||
|
||||
/** The spec each run was asked for. Which one ran is what the three readSpec tests assert. */
|
||||
val specs = mutableListOf<OutputSpec>()
|
||||
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
@@ -164,6 +221,7 @@ private class RequestRecordingTranscoder : SoftwareTranscoder {
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
qualities += request.quality
|
||||
specs += request.spec
|
||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.content.Context
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import java.io.File
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Scaffolding more than one worker test needs.
|
||||
@@ -68,3 +72,25 @@ object WritingTranscoder : SoftwareTranscoder {
|
||||
|
||||
private const val OUTPUT_BYTES = 512
|
||||
}
|
||||
|
||||
/**
|
||||
* An already-failed future, written out rather than pulled from a futures library.
|
||||
*
|
||||
* `await()` takes the `isDone` fast path and unwraps the `ExecutionException`, which is what puts
|
||||
* the original exception in front of the worker's `catch` rather than a wrapper. That is the whole
|
||||
* mechanism behind driving a `ForegroundUpdater` to fail: `WorkForegroundUpdater` propagates
|
||||
* whatever the future failed with rather than swallowing it, so `setForeground()` throws exactly
|
||||
* what is handed here.
|
||||
*
|
||||
* Shared because two tests inject two different failures through it -- a denied foreground start
|
||||
* and a cancellation -- and Kotlin will not take two file-private top-level classes of one name in
|
||||
* one package.
|
||||
*/
|
||||
internal class FailedFuture(private val failure: Throwable) : ListenableFuture<Void> {
|
||||
override fun addListener(listener: Runnable, executor: Executor): Unit = executor.execute(listener)
|
||||
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
|
||||
override fun isCancelled(): Boolean = false
|
||||
override fun isDone(): Boolean = true
|
||||
override fun get(): Void = throw ExecutionException(failure)
|
||||
override fun get(timeout: Long, unit: TimeUnit): Void = throw ExecutionException(failure)
|
||||
}
|
||||
|
||||
+667
-132
@@ -1,127 +1,597 @@
|
||||
# API 37 is not tested in CI: a crash in Google's `android-37.0` emulator image
|
||||
# API 37 on the emulator: a guest gralloc bug that only the host GL renderer triggers
|
||||
|
||||
**Status:** open upstream, worked around by removing API 37 from the E2E matrix.
|
||||
The app itself is verified good on real API 37 hardware — this is an emulator bug only.
|
||||
**Last verified:** 2026-08-21, against emulator `37.1.11.0` and system image revision 6
|
||||
**Status:** the bug is real and still open upstream, but the previous diagnosis in this file was
|
||||
wrong about its most important detail. **The renderer decides whether API 37 boots**, and once it
|
||||
boots, disabling SystemUI collapses the crash rate far enough to run a suite —
|
||||
`tools/local-emulator/run-e2e.sh 37` gets through the whole instrumented suite and comes back with
|
||||
**2 failures, 0 errors and the two by-design skips** (measured 49 / 2 / 0 / 2 at `22c7914`, where
|
||||
the suite was 49 tests — [Reading these totals](#reading-these-totals) before comparing any total
|
||||
with another). The crashes do not stop outright, and the two failures are real; both are quantified
|
||||
below. CI now takes API 37 as two jobs — a gating leg and an advisory one for those two
|
||||
failures — see [So should CI take API 37?](#so-should-ci-take-api-37).
|
||||
**Last verified:** 2026-08-22, emulator `37.1.11.0` (build 15917651), Fedora 44,
|
||||
against system images `android-37.0` rev 6 **and** `android-37.1` rev 8.
|
||||
|
||||
`minSdk` is 33 and `targetSdk` is 37, and the E2E matrix in
|
||||
[`status_check.yml`](../.github/workflows/status_check.yml) runs API 33 through 36.
|
||||
API 37 is deliberately absent. This is why.
|
||||
## The correction
|
||||
|
||||
## Summary
|
||||
This file previously said, under "What was ruled out":
|
||||
|
||||
The `android-37.0` emulator system image crashes `surfaceflinger` in a loop. The app
|
||||
under test never gets a working framework, so every instrumented test fails regardless
|
||||
of what the app does. The bug is in the emulator image, not in this project.
|
||||
> **GPU mode.** Both `swiftshader_indirect` and `host` crash, with the same assertion and
|
||||
> the same frames. The crash is in the gralloc mapper, below the renderer.
|
||||
|
||||
The crash is an assertion inside the emulator's own gralloc implementation:
|
||||
**That is wrong.** The mapper is below the renderer, but *whether the mapper's bad path is
|
||||
reached* is not. Re-measured on 2026-08-22, seven runs, one variable at a time:
|
||||
|
||||
| # | system image | `-gpu` | GLES the emulator chose | booted? | surfaceflinger aborts |
|
||||
|---|---|---|---|---|---|
|
||||
| r01 | `android-37.0` rev 6 | `host` | host (Mesa Iris Xe) | **no**, 422 s | 71, looping |
|
||||
| r02 | `android-37.1` rev 8 | `host` | host (Mesa Iris Xe) | **no**, 362 s | 65, looping |
|
||||
| r03 | `android-37.0` rev 6 | `swangle_indirect` | ANGLE | **yes, 85 s** | 1 |
|
||||
| r04 | `android-37.0` rev 6 | `host` + `-feature -GLDMA,-GLDMA2,-GLDirectMem` | host | **no**, 363 s | 57, looping |
|
||||
| r05 | `android-37.0` rev 6 | `angle_indirect` | ANGLE | **yes, 112 s** | 2 |
|
||||
| r06 | `android-37.1` rev 8 | `swangle_indirect` | ANGLE | **yes, 285 s** | 23 |
|
||||
| r07 | `android-37.0` rev 6 | `host` + `-feature -HostComposition` | host | **no**, wedged adb at 208 s | not readable |
|
||||
|
||||
The discriminator is exact across the **six runs that reported**: a run boots if and only if the
|
||||
emulator log says something other than `gles_mode_selected:host`. r07 is excluded on purpose — it
|
||||
wedged adb at 208 s and is recorded below as inconclusive rather than ruled out, and a row this
|
||||
page calls inconclusive cannot also be counted as evidence. Excluding it costs nothing: r07 is a
|
||||
`host` row, so the discriminator predicts it would not boot, and confirming a prediction with the
|
||||
one run whose evidence did not come back would add no information either way.
|
||||
|
||||
One caveat about how independent those rows are, because the table flatters itself. `-gpu
|
||||
angle_indirect` (r05) and `-gpu swangle_indirect` (r03) both logged `gles_mode_selected:swangle`
|
||||
and both reported the same adapter, differing only in the Vulkan backend beneath
|
||||
(`vulkan_mode_selected:lavapipe` against `swiftshader`). So they are closer to one GLES path
|
||||
reached two ways than to two renderers agreeing — note that at API 33–36
|
||||
[`docs/local-emulator.md`](local-emulator.md) records `angle_indirect` resolving to ANGLE on
|
||||
*llvmpipe*, a genuinely different adapter, which it did not do here. What is 7-for-7 is the
|
||||
host-GLES-versus-not split, not "two independent renderers both work".
|
||||
|
||||
```
|
||||
# r01, r02, r04, r07 -- never boots
|
||||
INFO | emuglConfig_init: vulkan_mode_selected:host gles_mode_selected:host
|
||||
INFO | Graphics Adapter Android Emulator OpenGL ES Translator (Mesa Intel(R) Iris(R) Xe Graphics (TGL GT2))
|
||||
|
||||
# r03, r05, r06 -- boots
|
||||
INFO | emuglConfig_init: vulkan_mode_selected:swiftshader gles_mode_selected:swangle
|
||||
INFO | Graphics Adapter Android Emulator OpenGL ES Translator (ANGLE (Google, Vulkan 1.2.0
|
||||
| (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver-5.0.0))
|
||||
```
|
||||
|
||||
### Why the wrong claim looked right
|
||||
|
||||
It rested on two samples of two different things, and neither of them was ANGLE.
|
||||
|
||||
- The **local** `swiftshader_indirect` sample was void. On this workstation *every*
|
||||
SwiftShader-GLES launch segfaults the host emulator before the guest matters at all —
|
||||
SELinux denies `execheap` to SwiftShader's Reactor JIT. That is
|
||||
[`docs/local-emulator.md`](local-emulator.md), and it was not yet understood when this file
|
||||
was written. So "`swiftshader_indirect` crashes" was true, for an entirely unrelated reason,
|
||||
and told you nothing about the gralloc assertion.
|
||||
- The **CI** sample was one `swiftshader_indirect` run on a GPU-less `ubuntu-latest`, and the
|
||||
**local** sample was one `-gpu host` run. Two renderers, one measurement each, and the pair
|
||||
written up as "both GPU modes".
|
||||
|
||||
`angle_indirect` and `swangle_indirect` — the two modes that work — had never been tried on
|
||||
API 37. Neither had a second system image.
|
||||
|
||||
The lesson is the same one `docs/local-emulator.md` ends on, which makes it worth repeating:
|
||||
"both backends fail" is a claim about a matrix, and a matrix needs cells, not inference. Two
|
||||
observations of two different configurations do not establish anything about a third.
|
||||
|
||||
## What the bug actually is
|
||||
|
||||
`surfaceflinger` aborts inside the emulator's own gralloc mapper:
|
||||
|
||||
```
|
||||
Executable: /system/bin/surfaceflinger
|
||||
signal 6 (SIGABRT), code -1 (SI_QUEUE), tid: RegionSampling
|
||||
Abort message: 'Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma'
|
||||
|
||||
#03 mapper.ranchu.so GoldfishMapper::readFromHost(cb_handle_t const&) const
|
||||
#04 mapper.ranchu.so GoldfishMapper::GoldfishMapper()::'lambda'(...)::__invoke
|
||||
#05 libui.so android::Gralloc5Mapper::lock(...)
|
||||
#06 libui.so android::GraphicBufferMapper::lock(...)
|
||||
#07 libui.so android::GraphicBuffer::lockAsync(...)
|
||||
#08 libui.so android::GraphicBuffer::lock(...)
|
||||
#09 surfaceflinger android::RegionSamplingThread::threadMain()
|
||||
#03 /vendor/lib64/hw/mapper.ranchu.so GoldfishMapper::readFromHost(cb_handle_t const&) const+543
|
||||
#04 /vendor/lib64/hw/mapper.ranchu.so GoldfishMapper::GoldfishMapper()::'lambda'(...)::__invoke+704
|
||||
#05 /system/lib64/libui.so android::Gralloc5Mapper::lock(...)+63
|
||||
#06 /system/lib64/libui.so android::GraphicBufferMapper::lock(...)+198
|
||||
#07 /system/lib64/libui.so android::GraphicBuffer::lockAsync(...)+545
|
||||
#08 /system/lib64/libui.so android::GraphicBuffer::lock(...)+67
|
||||
#09 /system/bin/surfaceflinger android::RegionSamplingThread::threadMain()+2571
|
||||
```
|
||||
|
||||
`RegionSamplingThread` is SystemUI's navigation-bar luma sampling. It calls
|
||||
`GraphicBuffer::lock`, which routes into `GoldfishMapper::readFromHost`, which asserts
|
||||
that the host has *not* negotiated the `ReadColorBufferDma` capability. On this image
|
||||
the host has, so the assertion fails and `surfaceflinger` aborts. It restarts and
|
||||
aborts again.
|
||||
`RegionSamplingThread` is SystemUI's nav-bar luma sampling. It locks a `GraphicBuffer` for CPU
|
||||
read; that routes through the Gralloc5 mapper into `GoldfishMapper::readFromHost`, which is the
|
||||
*non-DMA* readback path and asserts that the host has not negotiated `ReadColorBufferDma`. The
|
||||
host always has, so the assert fires whenever that path is taken.
|
||||
|
||||
## Impact
|
||||
Two facts pin down what "always" means:
|
||||
|
||||
The failure surfaces in two different ways depending on how far the job gets, which is
|
||||
why it took several rounds to identify:
|
||||
- **The capability is negotiated regardless of renderer.** The evidence is the aborts
|
||||
themselves: the assertion that fires is `!hasReadColorBufferDma`, and it fires under ANGLE
|
||||
(r03/r05/r06) as well as under the host translator — just far less often. That is a direct
|
||||
observation of the guest having negotiated DMA readback under both, and it stands alone.
|
||||
(Supporting only, and weaker than it first looks: `ANDROID_EMU_read_color_buffer_dma` appears
|
||||
in exactly one file in the SDK, `emulator/lib64/libgfxstream_backend.so`, which every `-gpu`
|
||||
mode goes through. A string search establishes where the extension is implemented, not that
|
||||
it is negotiated on every path.)
|
||||
- **It is not gated by any feature flag the emulator exposes.** See the ruled-out list below.
|
||||
|
||||
| Guest RAM | Where it dies | What CI reports |
|
||||
|---|---|---|
|
||||
| 1536 MB | during APK install | `Unknown failure: cmd: Can't find service: package` |
|
||||
| 2560 MB | during the test run | `There were failing tests` — all of them |
|
||||
So the renderer does not decide whether the guest *believes* DMA readback exists. It decides how
|
||||
often `RegionSamplingThread` ends up in `readFromHost` — which under the host GL translator is
|
||||
constantly, and under ANGLE is occasionally.
|
||||
|
||||
At 2560 MB the install succeeds and the tests actually execute, then fail wholesale.
|
||||
The first failure in the report is misleading:
|
||||
### Why one abort takes down the whole device
|
||||
|
||||
`surfaceflinger` is a critical service. When it dies, `init` kills the framework with it:
|
||||
|
||||
```
|
||||
kotlin.UninitializedPropertyAccessException: lateinit property output has not
|
||||
been initialized
|
||||
at Media3EngineTest.tearDown(Media3EngineTest.kt:53)
|
||||
|
||||
java.lang.IllegalStateException: WorkManager is not initialized properly.
|
||||
You have explicitly disabled WorkManagerInitializer in your manifest, ...
|
||||
08-22 21:40:28.253 I/init: Sending SIGKILL to service 'zygote' (pid 470) process group...
|
||||
08-22 21:40:28.260 I/init: Service 'zygote' (pid 470) received SIGKILL
|
||||
```
|
||||
|
||||
Neither is a real defect in this project. `tearDown` throws because `setUp` never got
|
||||
far enough to assign `output`, and WorkManager's `InitializationProvider` never runs
|
||||
because content-provider installation fails on a framework whose `surfaceflinger` is
|
||||
crash-looping. The same tests pass at API 33, 34, 35, and 36 in the same CI run, and
|
||||
the first `surfaceflinger` abort is timestamped *before* the test results are reported.
|
||||
|
||||
This is not inference. The full suite was run against a physical API 37 device and
|
||||
passed — see [Verified on real API 37 hardware](#verified-on-real-api-37-hardware)
|
||||
below. `ConversionWorkerTest` and `ConcatWorkerTest`, which drive a real WorkManager
|
||||
round trip and are among the tests that failed this way in CI, both pass there.
|
||||
Everything above zygote goes with it, which is why the symptoms look nothing like a graphics
|
||||
bug. Under `-gpu host` the cycle repeats every five to seven seconds forever and
|
||||
`sys.boot_completed` is never set. Under ANGLE the aborts are sparse enough that the boot
|
||||
usually completes between them — but they do not stop, and each one is a framework restart.
|
||||
That is the difference between "boots" and "is usable", and it is the reason this is not simply
|
||||
fixed by changing the renderer. See [Can the suite run on it?](#can-the-suite-run-on-it) below.
|
||||
|
||||
## Environment
|
||||
|
||||
Reproduced identically in two unrelated environments, so it is not specific to a host
|
||||
GPU, driver, or CI runner.
|
||||
|
||||
| | GitHub Actions | Local workstation |
|
||||
|---|---|---|
|
||||
| Host | `ubuntu-latest`, no GPU | Fedora, Intel Iris Xe (TGL GT2) |
|
||||
| Host | `ubuntu-latest`, no GPU | Fedora 44, Intel Iris Xe (TGL GT2), kernel `7.1.8-200.fc44` |
|
||||
| Emulator | `37.1.11.0` (build 15917651) | `37.1.11.0` (build 15917651) |
|
||||
| GPU mode | `swiftshader_indirect` | `host` |
|
||||
| Result | boots, aborts during tests | aborts before boot completes |
|
||||
| GPU mode measured | `swiftshader_indirect` | `host`, `angle_indirect`, `swangle_indirect` |
|
||||
|
||||
System image: `system-images;android-37.0;google_apis;x86_64`, `Pkg.Revision=6`,
|
||||
`AndroidVersion.ApiLevel=37.0`, `AndroidVersion.ExtensionLevel=22`
|
||||
Images, both reproducing it:
|
||||
|
||||
```
|
||||
Build fingerprint: google/sdk_gphone64_x86_64/emu64xa:17/CE2A.260420.019/15611780:userdebug/dev-keys
|
||||
Kernel Release: 6.12.58-android16-6-gccafb60de224-ab14828483
|
||||
system-images;android-37.0;google_apis;x86_64 Pkg.Revision=6 ApiLevel=37.0 ExtensionLevel=22
|
||||
fingerprint google/sdk_gphone64_x86_64/emu64xa:17/CE2A.260420.019/15611780:userdebug/dev-keys
|
||||
system-images;android-37.1;google_apis_ps16k;x86_64 Pkg.Revision=8 ApiLevel=37.1 ExtensionLevel=23
|
||||
ro.build.version.codename=REL (a release image, not a preview)
|
||||
```
|
||||
|
||||
**Note the `ps16k` in the second one — it is not optional, and it is why the 37.1 result is
|
||||
interpretable.** From API 37.1 onward Google ships *only* 16 KB-page x86_64 images; there is no
|
||||
plain `google_apis` variant to pick. `sdkmanager --list` for 37.1 and 37.2-beta* offers nothing
|
||||
but `google_apis_ps16k` and `google_apis_playstore_ps16k`. That makes page-size alignment a
|
||||
prerequisite rather than a detail: a `.so` that is not 16 KB aligned will not load on such a
|
||||
guest, and the resulting failure looks like an app bug. Checked before the first `ps16k` boot,
|
||||
using the same test `build.yml` applies to release APKs — all 20 libraries in the committed
|
||||
`bin/ffmpeg-kit-next-8.1.1.aar`, both ABIs, report `0x4000`:
|
||||
|
||||
```
|
||||
$ for f in jni/*/*.so; do readelf -lW "$f" | awk '$1=="LOAD"{print $NF}' | sort -u; done
|
||||
0x4000 (x20: libavcodec, libavdevice, libavfilter, libavformat, libavutil,
|
||||
libc++_shared, libffmpegkit, libffmpegkit_abidetect, libswresample, libswscale
|
||||
-- arm64-v8a and x86_64)
|
||||
```
|
||||
|
||||
So when `android-37.1` reproduced the abort, that was the gralloc bug and not a page-size
|
||||
mismatch. `image_pkg_for_api` in `tools/local-emulator/run-e2e.sh` encodes the `ps16k` tag for
|
||||
37.1; if this ever fails after an FFmpeg rebuild, re-run the alignment check first.
|
||||
|
||||
## What was ruled out, and how
|
||||
|
||||
Each of these was tested rather than reasoned about, because the first three attempts
|
||||
at this bug were plausible fixes that turned out to address earlier, unrelated failures.
|
||||
**A newer system image.** This file's own revisit trigger was "a new `android-37.0` system image
|
||||
revision ships (this was revision 6)". That trigger was written too narrowly and would never have
|
||||
fired: `android-37.0` is *still* revision 6, but Google shipped a whole new minor level.
|
||||
`android-37.1` `google_apis_ps16k` revision 8 — a `REL` build, not a beta — was installed and
|
||||
tested (r02, r06) and **behaves identically**: same assertion, same frames, never boots under
|
||||
`-gpu host`, and *worse* under ANGLE (23 aborts to `37.0`'s 1). `android-37.2-beta3` exists too
|
||||
but was not needed; two independent images agreeing settles it, and a beta could not be used by
|
||||
CI anyway.
|
||||
|
||||
**Guest memory.** The emulator raises an undersized guest to a minimum on its own, but
|
||||
only for API levels it recognises, and it does not recognise `"37.0"`. API 33 bumps to
|
||||
2048 MB and 34/35/36 to 2560 MB, while API 37 logged no bump at all and ran at the
|
||||
`pixel_6` default of 1536 MB. Setting `ram-size: 2560M` explicitly fixed that asymmetry
|
||||
and did change the outcome — the job got past install and into the test run — but it is
|
||||
not the underlying bug. At the moment of failure the guest reported `MemTotal 2527392
|
||||
kB` with `MemAvailable 1507104 kB`: 1.5 GB free, and no OOM kills.
|
||||
**An ATD image.** Still does not exist for API 37. `sdkmanager --list` offers `aosp_atd` and
|
||||
`google_atd` for API 30 through 36 and nothing above:
|
||||
|
||||
**GPU mode.** Both `swiftshader_indirect` and `host` crash, with the same assertion and
|
||||
the same frames. The crash is in the gralloc mapper, below the renderer.
|
||||
```
|
||||
system-images;android-36;google_atd;x86_64 | 1 | Google APIs ATD Intel x86_64 Atom System Image
|
||||
(no android-37 ATD of any kind)
|
||||
```
|
||||
|
||||
**Disabling the DMA feature.** `GLDMA` is the host feature that most plausibly backs the
|
||||
guest's `hasReadColorBufferDma`. Launching with `-feature -GLDMA` was accepted by the
|
||||
emulator — the log confirms `Feature 'GLDMA' (51) is overridden to 'disabled'` — and
|
||||
`surfaceflinger` still aborted 13 times and the device never finished booting. Whatever
|
||||
sets that guest capability, it is not this flag.
|
||||
For API 37 the only x86_64 images are `google_apis`, `google_apis_playstore`, their `ps16k`
|
||||
16 KB-page variants, and Wear OS. Check again when revisiting.
|
||||
|
||||
**An ATD image.** `google_atd` / `aosp_atd` images are built for automated testing and
|
||||
ship without the SystemUI package set, which is what drives `RegionSamplingThread` in
|
||||
the first place. That would likely sidestep the bug class entirely, but **no ATD image
|
||||
exists for `android-37.0`** — only `google_apis`, `google_apis_playstore`, the `ps16k`
|
||||
16 KB-page variants, and Wear OS. Check again when revisiting; if an ATD image appears,
|
||||
try it before anything else here.
|
||||
**The DMA feature flags.** `GLDMA` alone was ruled out previously; `GLDMA2` and `GLDirectMem`
|
||||
were not, and the per-image `advancedFeatures.ini` turns all three on. Disabling all three
|
||||
together (r04) is accepted by the emulator and changes nothing:
|
||||
|
||||
```
|
||||
INFO | Feature 'GLDMA' (51) is overridden to 'disabled'
|
||||
INFO | Feature 'GLDMA2' (52) is overridden to 'disabled'
|
||||
INFO | Feature 'GLDirectMem' (53) is overridden to 'disabled'
|
||||
... 57 surfaceflinger aborts, device never boots
|
||||
```
|
||||
|
||||
**Host composition.** `-feature -HostComposition` (r07) was the best remaining guess at what
|
||||
forces the readback. It did not help; it made things worse, wedging adb entirely at 208 s so the
|
||||
crash buffer could not even be read. Recorded as inconclusive rather than ruled out, because no
|
||||
evidence came back from it.
|
||||
|
||||
**Guest feature negotiation differing from API 36.** It does not. The image-level
|
||||
`advancedFeatures.ini` for `android-37.0` is byte-identical to `android-36`'s except for one
|
||||
unrelated line:
|
||||
|
||||
```
|
||||
$ diff android-36/google_apis/x86_64/advancedFeatures.ini android-37.0/google_apis/x86_64/advancedFeatures.ini
|
||||
+QemuCameraSensorOrientation = on
|
||||
```
|
||||
|
||||
`GLDMA`, `GLDMA2`, `GLDirectMem`, `GrallocSync`, `HostComposition` and `YUVCache` are on in
|
||||
both. API 36 boots and passes. So nothing about the host/guest feature handshake changed — the
|
||||
regression is in the guest's Gralloc5 mapper or in what API 37's `RegionSamplingThread` asks of
|
||||
it, not in what the emulator advertises.
|
||||
|
||||
**Guest memory.** Ruled out previously and not revisited; every run above used
|
||||
`hw.ramSize=2560`, the same value the E2E matrix pins, and none of them OOMed.
|
||||
|
||||
**A host-side crash.** Not this bug, and worth stating because the other emulator failure on this
|
||||
workstation *is* host-side. Every run above left `coredumpctl` empty and produced zero
|
||||
`avc: denied` lines, and the qemu process was still alive at the end of the ones that never
|
||||
booted (`emulator_alive=yes`). The host emulator is fine; the guest is not.
|
||||
|
||||
## Can the suite run on it?
|
||||
|
||||
**Almost, and less so than it was.** `tools/local-emulator/run-e2e.sh 37` runs the whole suite
|
||||
locally. Measured at `22c7914`: **49 tests, 2 failures, 0 errors, 2 skipped** — 45 passed, the two
|
||||
`Media3EngineTest` failures dissected below, and the two `assumeTrue` skips every level has. It
|
||||
costs two deviations from how every other level is run, and both are worth understanding before
|
||||
trusting the leg.
|
||||
|
||||
**That was the high-water mark.** On 2026-08-24 a test that touches system UI joined the suite,
|
||||
and the level stopped *finishing* rather than merely failing two —
|
||||
[see below](#something-does-depend-on-system-ui-now-and-it-is-excluded-rather-than-trusted).
|
||||
Two `Media3EngineTest` failures is what **CI's gating leg** expects, because it filters on
|
||||
`notAnnotation`; a local `run-e2e.sh 37` does not filter and sees more.
|
||||
|
||||
Two things about that total before it is compared with anything. It is the size of the suite on
|
||||
the checkout that ran, not a property of API 37 — `app/src/androidTest` held 49 `@Test` methods at
|
||||
`22c7914`, and a newer checkout reports its own count; see
|
||||
[Reading these totals](#reading-these-totals). And **the Pixel has never run 49**: its green run
|
||||
was 40 / 0 / 0 / 2 at `edd6385`, the same suite nine tests earlier. What compares across the two
|
||||
is two failures against none, and the same two skips — not the totals.
|
||||
|
||||
The same numbers and the same two test names came back twice, which is real corroboration — but
|
||||
by two different routes, and only one of them is the harness. The first was driven by hand
|
||||
(`pm disable-user`, then several minutes of incidental framework restarts, then `e2e-run.sh`
|
||||
directly); the second went through `disable_region_sampling`'s `stop; start`. **The harness path
|
||||
itself has one green measurement.** What would make this routine is a second consecutive
|
||||
`run-e2e.sh 37` whose only failures are the same two.
|
||||
|
||||
### Booting is not the same as being usable
|
||||
|
||||
Changing the renderer gets the device to `sys.boot_completed=1`, and that is all it gets you. The
|
||||
aborts do not stop, and each one is a framework restart. A five-minute test run does not survive
|
||||
that. What it looks like from Gradle:
|
||||
|
||||
```
|
||||
Shell command failed (1): rm -rf "/sdcard/Android/media/org.libremediaconverter/..."
|
||||
rm: ...: Transport endpoint is not connected
|
||||
Starting 0 tests on lmc_e2e_api37(AVD) - 17
|
||||
Shell command failed (20): am get-current-user
|
||||
cmd: Can't find service: activity
|
||||
Device emulator-5572 failed to uninstall test APK org.libremediaconverter.
|
||||
[cmd: Can't find service: package]
|
||||
Test run failed to complete. No test results.
|
||||
onError: commandError=false message=INSTRUMENTATION_ABORTED: System has crashed.
|
||||
```
|
||||
|
||||
Measured idle rate on `android-37.0` under `swangle_indirect`: **10 aborts in 150 s, then 11 more
|
||||
in the next 150 s**. Steady, not a start-up transient.
|
||||
|
||||
### The fix is to remove the region-sampling listener, not to survive it
|
||||
|
||||
`RegionSamplingThread` exists only because SystemUI registers a nav-bar luma-sampling listener.
|
||||
Take SystemUI away and the thread is never started, so the mapper's bad path is never called:
|
||||
|
||||
```
|
||||
$ adb shell pm disable-user --user 0 com.android.systemui
|
||||
Package com.android.systemui new state: disabled-user
|
||||
|
||||
=== aborts at start of measurement: 36
|
||||
=== idle 180s with SystemUI disabled ===
|
||||
=== aborts after: 36 NEW IN WINDOW: 0
|
||||
--- services still up? ---
|
||||
activity Service activity: found
|
||||
package Service package: found
|
||||
window Service window: found
|
||||
```
|
||||
|
||||
**Zero in 180 s, against 10–11 per 150 s.** That is the strongest evidence that region sampling
|
||||
is the dominant trigger, and it is worth recording even by someone who never wants the workaround.
|
||||
It does not establish it as the *only* trigger: the paragraph below has an abort surviving the
|
||||
disable, and nothing measured here says whether that residue is a second caller of the readback
|
||||
path or a disable that did not fully take.
|
||||
|
||||
Do not read that as "the crashes stop", though, because the harness path does not reproduce a
|
||||
clean zero. Its own post-disable check on the run recorded below printed
|
||||
|
||||
```
|
||||
quiet check: 1 new surfaceflinger aborts in 45 s (want 0)
|
||||
surfaceflinger hasReadColorBufferDma aborts: 4 (whole run)
|
||||
```
|
||||
|
||||
So what is reliably achieved is a **rate collapse** — from roughly one abort every fourteen
|
||||
seconds to one every forty-five — which a 47-second Gradle run survives and a five-minute one
|
||||
might not. The 180-second zero above is one measurement on a device that had been up for twelve
|
||||
minutes and had already cycled its framework several times. The harness prints the quiet-check
|
||||
delta on every run precisely so this is visible rather than assumed.
|
||||
|
||||
One ordering detail cost a whole run and is now encoded in `disable_region_sampling`: by the time
|
||||
`sys.boot_completed` flips, SystemUI has **already registered**, and `pm disable-user` does not
|
||||
retract an existing registration — it only stops the package being started again. Disabling it
|
||||
and proceeding straight to the tests fails exactly as before. The harness therefore does
|
||||
`stop; start` afterwards, so the framework that comes back never starts SystemUI at all.
|
||||
|
||||
### The two deviations, stated plainly
|
||||
|
||||
1. **The renderer is ANGLE, not the host GPU.** Shared with nothing else in the matrix — API
|
||||
33–36 run `-gpu host` locally, and CI runs `swiftshader_indirect`.
|
||||
2. **SystemUI is disabled.** The API 37 leg does not run the same device configuration as any
|
||||
other leg or as the Pixel. It was defensible here because nothing in this suite touched
|
||||
system UI — Media3, FFmpeg and WorkManager tests — and because the alternative is no local
|
||||
API 37 coverage at all. **Anything that ever does depend on system UI must not trust this
|
||||
leg.** Something now does; see the section below.
|
||||
|
||||
### Something does depend on system UI now, and half of it is excluded
|
||||
|
||||
Added 2026-08-24, and the first entry on this page that is not a codec.
|
||||
|
||||
`SafPickerRoundTripTest` drives the real system file picker and rotates the display. Both reach
|
||||
the gralloc mapper — DocumentsUI is another app's windows, and a rotation rebuilds every surface
|
||||
on screen — and **disabling SystemUI does not help**, because it removes the *idle* trigger
|
||||
(RegionSamplingThread's nav-bar luma sampling) and not this one.
|
||||
|
||||
Measured one method per fresh emulator, `android-37.0`, `swangle_indirect`, SystemUI disabled and
|
||||
verified quiet — separately, because inferring the second from the first is the mistake this
|
||||
page's opening correction is about:
|
||||
|
||||
| test | result on android-37.0 | `hasReadColorBufferDma` aborts in the window |
|
||||
|---|---|---|
|
||||
| `thePickedInputSurvivesARealRotation` | **fails**: `INSTRUMENTATION_ABORTED: System has crashed.`, `Expected 1 tests, received 0`. The framework dies **during** it, so the JUnit XML carries a failure with no text at all. | 3 |
|
||||
| `pickingAFileThroughTheSystemPickerFillsInTheFileCard` | **passes** | 4 |
|
||||
|
||||
So a rotation, which rebuilds every surface at once, is what the mapper does not survive. Merely
|
||||
starting DocumentsUI is not. Only the rotation test carries `@FailsOnEmulatorApi37`; the picker
|
||||
test runs on the gating leg like anything else.
|
||||
|
||||
#### The correction that produced that table
|
||||
|
||||
**The first version of this section said both tests failed, and put the marker on the class.** The
|
||||
picker test had indeed failed at API 37 — with `androidx.test.uiautomator.StaleObjectException`,
|
||||
which looked like a framework restart invalidating an accessibility node, because that is exactly
|
||||
what it looks like.
|
||||
|
||||
It was the test's own bug. `UiObject2` caches the `AccessibilityNodeInfo` it was found with, and
|
||||
DocumentsUI is still settling when a node first appears; the handle went stale before `click()`.
|
||||
CI then reproduced it **deterministically** at API 33, 34 and 35 — every cold runner emulator, not
|
||||
intermittently — which is what made it obviously not an API 37 property. It had passed locally
|
||||
only because the emulator was warm.
|
||||
|
||||
The lesson is worth more than the measurement: **an annotation is a claim about an image, and a
|
||||
broken test makes every image look broken.** Re-measure after fixing a test before deciding what
|
||||
the platform did. Both the abort and the stale node produce "the run fell over", and only one of
|
||||
them was the image.
|
||||
|
||||
#### Two consequences worth stating rather than discovering
|
||||
|
||||
- **`run-e2e.sh 37` applies no annotation filter**, unlike CI, so a local API 37 run includes the
|
||||
rotation test and therefore **does not finish**: its totals come back short and which later
|
||||
tests ran is arbitrary. The summary row says so.
|
||||
- **The advisory job is still named `E2E API 37 Media3 hardware transcode (advisory)`** and now
|
||||
carries a test that is neither Media3 nor a transcode. Renaming a check is a branch-protection
|
||||
change and was deliberately not made in the same PR; the name is stale, the behaviour is
|
||||
correct.
|
||||
|
||||
### The two remaining failures are the same bug, one layer down
|
||||
|
||||
```
|
||||
org.libremediaconverter.convert.Media3EngineTest > runsFromAThreadWithNoLooper FAILED
|
||||
org.libremediaconverter.convert.Media3EngineTest > transcodesH264ToH265AndReportsProgress FAILED
|
||||
|
||||
androidx.media3.transformer.ExportException: Codec exception:
|
||||
CodecInfo{type=VideoDecoder, ..., mime=video/avc, name=c2.goldfish.h264.decoder}
|
||||
at androidx.media3.transformer.DefaultCodec.maybeDequeueOutputBuffer(DefaultCodec.java:398)
|
||||
Caused by: android.media.MediaCodec$CodecException:
|
||||
at android.media.MediaCodec.native_dequeueOutputBuffer(Native Method)
|
||||
```
|
||||
|
||||
Three measurements say this is the emulator image and not this app, and not the software
|
||||
renderer. A fourth bullet offers a mechanism, and is inference rather than measurement:
|
||||
|
||||
- **Control at API 35 under the identical renderer.** `GPU_MODE=swangle_indirect
|
||||
tools/local-emulator/run-e2e.sh 35` → **49 / 0 / 0 / 2** at `22c7914`, green.
|
||||
`c2.goldfish.h264.decoder` is perfectly happy under ANGLE one API level down, so the renderer is
|
||||
not what breaks it.
|
||||
- **Real API 37 hardware passes**, see below. There is no `c2.goldfish.*` codec on a Pixel.
|
||||
- **API 36 against API 37 on CI, back to back, everything else held.** Same two tests, same
|
||||
`-gpu swiftshader_indirect`, same SystemUI-disable path — `pm disable-user`, `stop`, wait for
|
||||
`system_server` to actually be gone, `start`, then verify against `pm list packages -d`. Both
|
||||
runs were narrowed to the two failing tests:
|
||||
|
||||
```
|
||||
-Pandroid.testInstrumentationRunnerArguments.class=\
|
||||
org.libremediaconverter.convert.Media3EngineTest#transcodesH264ToH265AndReportsProgress,\
|
||||
org.libremediaconverter.convert.Media3EngineTest#runsFromAThreadWithNoLooper
|
||||
```
|
||||
|
||||
and the filter is confirmed three independent ways: `tests="2"` in the XML, `Expected 2 tests`
|
||||
in the abort message, and `run started: 2 tests` in the guest logcat.
|
||||
|
||||
| run | api | result XML |
|
||||
|---|---|---|
|
||||
| [32660148155](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660148155) | 37.0 | `tests="2" failures="2" errors="0" skipped="0"` |
|
||||
| [32660152961](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660152961) | 36 | `tests="2" failures="0" errors="0" skipped="0" time="4.603"` |
|
||||
|
||||
API 37 fails with the signature above — `name=c2.goldfish.h264.decoder`,
|
||||
`MediaCodec$CodecException` at `dequeueOutputBuffer(MediaCodec.java:4274)`. API 36 passes both in
|
||||
4.603 s, and `c2.goldfish.h264.decoder` is in *its* logcat too (44 mentions), so the two runs are
|
||||
not being served by different decoder names. **What this falsifies is "the stripped
|
||||
configuration is what breaks these tests"** — a reading none of the other measurements
|
||||
addresses, because they all compare against a device that still had SystemUI. Here SystemUI is
|
||||
absent and the framework has been restarted on both sides, and the healthy image is green anyway.
|
||||
|
||||
Two things it does **not** control, which is why it narrows the claim rather than closing it:
|
||||
|
||||
- **The restarts were not performed under equal conditions.** API 36 did its `stop`/`start` with
|
||||
`dma_aborts=0`; API 37's did the same restart with two aborts already logged. "A framework
|
||||
restart performed while the abort loop is running" therefore remains uncontrolled.
|
||||
- **The images differ on the encoder side.** These tests transcode H.264 → H.265. The API 37
|
||||
logcat carries `c2.goldfish.hevc.decoder` (16 mentions in the control run) where API 36 carries
|
||||
`c2.android.hevc.encoder` (32). The pipeline is not identical end to end, which is a second
|
||||
reason "the image ships a broken h264 decoder" is the wrong *shape* of claim: what is measured
|
||||
is that these two tests fail on the API 37 image, pass at API 36 under the same renderer *and*
|
||||
the same disable path, and pass at 33–36 without needing that path at all — because nothing
|
||||
below 37 has the bug it works around.
|
||||
- The failing call is `dequeueOutputBuffer` on the *goldfish* decoder — the emulator's own codec,
|
||||
which like `RegionSamplingThread` gets its frames out of a host-side colour buffer. Same
|
||||
readback machinery, one layer down. This is inference rather than a measurement, and is flagged
|
||||
as such; what is measured is the first three bullets.
|
||||
|
||||
**Do not try `-feature -HardwareDecoder`.** It is the obvious next idea and it is much worse:
|
||||
forcing the guest onto software decoders took the run from 2 failures to **46**, across
|
||||
`RemuxTest`, `ForcedFailureTest`, `HardwareFallbackTest` and `UnopenableUriTest` as well. The
|
||||
suite depends on those decoders existing.
|
||||
|
||||
### The intact-SystemUI counterfactual cannot be measured on CI
|
||||
|
||||
The control the block above still lacks is the obvious one: run those same two tests at API 37
|
||||
with SystemUI **left running**. Passing would put the failure on the disable rather than on the
|
||||
image; failing on the decoder would make the decoder attribution direct instead of inferred.
|
||||
|
||||
**Seven dispatches of `api37-debug.yml`, zero verdicts.** Not bad luck — a mechanism, which is why
|
||||
this is written down rather than left as a gap for the next person to spend seven runs on:
|
||||
|
||||
| arm | run | result XML | what actually happened |
|
||||
|---|---|---|---|
|
||||
| E1 | [32660528355](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660528355) | `tests="1" failures="1"`, `<failure>` body empty | `Expected 2 tests, received 0. INSTRUMENTATION_ABORTED: System has crashed.` |
|
||||
| E2 | [32660533845](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660533845) | `tests="0"` | never installed: `Failed to commit install session ... Failure calling service package: Broken pipe (32)` |
|
||||
| E3 | [32660539259](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32660539259) | `tests="0"` | `Test run failed to complete. No test results.` |
|
||||
| E4 | [32661117237](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32661117237) | `tests="2" failures="2"` | both failed in `@Before`, never reached MediaCodec |
|
||||
| E5 | [32661121972](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32661121972) | `tests="2" failures="2"` | same |
|
||||
| S1 | [32661127224](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32661127224) | `tests="1" failures="1"` | same, single-test arm |
|
||||
| S2 | [32661132024](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32661132024) | `tests="1" failures="1"` | same |
|
||||
|
||||
While the framework is crash-looping, the guest cannot reliably create per-user private
|
||||
directories. An app installed during the loop has no cache directory — and `Media3EngineTest`
|
||||
copies its H.264 fixture into `context.cacheDir` in `@Before`, so it dies there, **before any
|
||||
MediaCodec exists**:
|
||||
|
||||
```
|
||||
W/ContextImpl( 8216): Failed to ensure /data/user/0/org.libremediaconverter/cache
|
||||
I/TestRunner( 8216): run started: 1 tests
|
||||
E/TestRunner( 8216): failed: transcodesH264ToH265AndReportsProgress(...)
|
||||
E/TestRunner( 8216): java.io.FileNotFoundException:
|
||||
/data/user/0/org.libremediaconverter/cache/sample_h264.mp4: open failed: ENOENT
|
||||
at org.libremediaconverter.convert.Media3EngineTest.setUp(Media3EngineTest.kt:47)
|
||||
```
|
||||
|
||||
Not app-specific: `com.google.android.googlesdksetup` and `com.google.android.apps.nexuslauncher`
|
||||
hit the same `Failed to ensure /data/user/0/<pkg>/cache` in the same logcats.
|
||||
|
||||
**The result XML masks this, and reading only the report gets you the wrong bug.** What E4, E5, S1
|
||||
and S2 report is
|
||||
|
||||
```
|
||||
<failure>kotlin.UninitializedPropertyAccessException: lateinit property output has not been initialized
|
||||
at org.libremediaconverter.convert.Media3EngineTest.tearDown(Media3EngineTest.kt:56)
|
||||
```
|
||||
|
||||
— `tearDown` failing because `setUp` threw before it assigned `output`. That looks like a
|
||||
teardown defect in this repository and is not one; the cause is only in the guest logcat.
|
||||
|
||||
So the obstacle is structural: install, data-directory creation and instrumentation start-up do
|
||||
not fit between framework kills, and four of the seven runs show the directory creation itself is
|
||||
broken during the loop. More dispatches of this shape would repeat these outcomes. The
|
||||
counterfactual is still open on the **Pixel 10 Pro XL**, the one API 37 device here that is not an
|
||||
emulator — but a Pixel has no `c2.goldfish.*` codec at all, so it answers "does the app work at
|
||||
API 37", not "is that codec broken".
|
||||
|
||||
#### Abort cadence, corrected
|
||||
|
||||
`.github/workflows/api37-debug.yml` carried "roughly every 20 s" for the kill cycle in its own
|
||||
comments. That number was the watchdog's **sampling** interval, not the cadence, and the two got
|
||||
conflated. Measured across the **six runs whose crash buffer could be read** — r07 wedged adb
|
||||
before one could be taken, so it contributes no gaps — successive `hasReadColorBufferDma` aborts
|
||||
run **20 s to 90 s, median 60–70 s — three to five aborts in a four-minute window**.
|
||||
Slower than assumed, and still not slow enough: install, data-directory creation and
|
||||
instrumentation start-up do not fit inside one gap.
|
||||
|
||||
`sys.boot_completed` held at `1` throughout every one of those test windows. The device reports
|
||||
itself booted while zygote is being killed under it, which is why no boot-state check catches
|
||||
this and why `stop`/`start` waits must poll `pidof system_server` and `service check` instead
|
||||
(see `disable_region_sampling` in `tools/local-emulator/run-e2e.sh`).
|
||||
|
||||
### So should CI take API 37?
|
||||
|
||||
**Yes, as two jobs: a gating `E2E API 37` and an advisory leg carrying the two tests that do not
|
||||
pass.** That reverses the answer this section gave, and the reversal is measured rather than
|
||||
argued — two of its three reasons were claims *about CI*, and CI had never been measured. The
|
||||
instrument that measured it is [`.github/workflows/api37-debug.yml`](../.github/workflows/api37-debug.yml),
|
||||
dispatch-only, a copy of the E2E job with the matrix replaced by inputs.
|
||||
|
||||
Every run below is `ubuntu-latest`, KVM on, `pixel_6`, x86_64, disk 8G, RAM 2560M, emulator
|
||||
`37.1.11.0` build 15917651 — the same emulator build the local investigation used. Every **API
|
||||
37** row is `system-images;android-37.0;google_apis;x86_64`; c2 is the API 36 control and runs
|
||||
that level's own image, which is the whole point of it.
|
||||
|
||||
| # | run | api | `-gpu` | SystemUI | suite | verdict |
|
||||
|---|---|---|---|---|---|---|
|
||||
| c1 | [32644947334](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32644947334) | 37.0 | swiftshader_indirect | running | `Starting 0 tests` | FAIL |
|
||||
| c2 | [32644965828](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32644965828) | **36** | swiftshader_indirect | running | 57 tests, BUILD SUCCESSFUL | green control |
|
||||
| c3 | [32644970240](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32644970240) | 37.0 | swangle_indirect | running | `Starting 0 tests` | FAIL |
|
||||
| c5 | [32645543238](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32645543238) | 37.0 | swangle_indirect | disabled | 57 / 2 / 0 / 2 | suite ran |
|
||||
| c6 | [32646029143](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32646029143) | 37.0 | swiftshader_indirect | one-shot disable, **did not hold** | `Starting 0 tests` | FAIL |
|
||||
| c8 | [32646611485](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32646611485) | 37.0 | swiftshader_indirect | disabled, verified | 57 / 2 / 0 / 2 | suite ran |
|
||||
| c9 | [32646615706](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32646615706) | 37.0 | swiftshader_indirect | disabled, verified | 57 / 2 / 0 / 2 | suite ran |
|
||||
| c10 | [32646619472](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32646619472) | 37.0 | swiftshader_indirect | disabled, verified | 57 / 2 / 0 / 2 | suite ran |
|
||||
| c11 | [32647138060](https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32647138060) | 37.0 | swiftshader_indirect | disabled, verified | 57 / 2 / 0 / 2 | suite ran |
|
||||
|
||||
57 is that checkout's own `@Test` count at `acc71bc`, so those are whole-suite runs and not
|
||||
truncated ones — see [Reading these totals](#reading-these-totals). Taking the three old reasons
|
||||
in turn:
|
||||
|
||||
1. **"Nothing says a runner would be stable" — measured, and it is.** `-gpu swiftshader_indirect`
|
||||
on a GPU-less runner resolves to `gles_mode_selected:swiftshader`, a third renderer that
|
||||
locally never survives to say anything (Fedora's SELinux denies `execheap` to SwiftShader's
|
||||
Reactor JIT — see [`local-emulator.md`](local-emulator.md)). It boots `android-37.0` in about
|
||||
60 s. The local discriminator — fatal iff `gles_mode_selected:host` — holds, and a runner with
|
||||
no GPU can never select host, so CI was never in the fatal class. Switching CI's `-gpu` changes
|
||||
nothing either way: c1 and c3 both fail with SystemUI up, under swiftshader and swangle
|
||||
respectively, and c5 and c8–c11 show the suite running under either once SystemUI is gone.
|
||||
2. **"Bespoke device surgery" — still true, and now a written caveat rather than a reason to skip
|
||||
the level.** It is one env flag, `E2E_DISABLE_SYSTEM_UI`, read by `.github/scripts/e2e-run.sh`
|
||||
and unset on every other leg. What it costs is stated where it can be read from the failing
|
||||
check: the API 37 row runs a device configuration no other leg and no Pixel run uses. What
|
||||
makes it dependable is verification, not repetition — c6 is the counter-case, a one-shot
|
||||
`pm disable-user` that reported `new state: disabled-user` and then started SystemUI eight more
|
||||
times. The verified form is 4/4; the unverified form was 3/4.
|
||||
3. **"Permanently red or permanently allow-listed" — this was the real objection, and it is the
|
||||
one the split answers.** The two failures are marked `@FailsOnEmulatorApi37` in
|
||||
`app/src/androidTest`. The gating job runs `notAnnotation` on that marker and must be green;
|
||||
the advisory job runs `annotation` on the *same* marker, reports, and never blocks. One marker
|
||||
rather than two lists, so a test cannot silently end up in neither job — which would read as
|
||||
green.
|
||||
|
||||
The cost is about three minutes on the API 37 leg — the `stop`/`start` plus a 45 s quiet window,
|
||||
and another round when the first does not verify. Measured wall clock for the whole job, boot
|
||||
included: 6–7 minutes at API 37 against ~6 at API 36.
|
||||
|
||||
Two things this does **not** buy. The advisory job is expected red, so a *third* failure there is
|
||||
the signal and the run's logcat is the only thing that distinguishes it — which is why that job
|
||||
uploads diagnostics unconditionally. And a green `E2E API 37` still does not replace the release
|
||||
check on the Pixel: the emulator leg runs without SystemUI, and the Pixel does not.
|
||||
|
||||
The *local* story changed at the same time and independently: API 37 is no longer a level nobody
|
||||
can look at. A regression that shows up at 37 and not at 36 can be reproduced on this workstation
|
||||
in about four minutes.
|
||||
|
||||
## Verified on real API 37 hardware
|
||||
|
||||
The bug is confined to the emulator image. On 2026-08-21 the whole instrumented suite
|
||||
was run against a physical device and passed:
|
||||
Unchanged and still true. On 2026-08-21 the whole instrumented suite ran green on a physical
|
||||
device:
|
||||
|
||||
```
|
||||
Device: Pixel 10 Pro XL (mustang), arm64-v8a
|
||||
@@ -132,65 +602,94 @@ API: 37 (Android 17, codename REL -- a release build, not a preview)
|
||||
40 tests, 0 failures, 0 errors, 2 skipped BUILD SUCCESSFUL
|
||||
```
|
||||
|
||||
**That "40" is a measurement of the tree it ran on, not a baseline for today**, and it is not a
|
||||
contradiction of the totals in [`docs/local-emulator.md`](local-emulator.md) either.
|
||||
|
||||
### Reading these totals
|
||||
|
||||
Every total in this file and in [`docs/local-emulator.md`](local-emulator.md) is the size of
|
||||
`app/src/androidTest` on the checkout that produced it, and nothing else. The reported total has
|
||||
equalled that checkout's `@Test` count everywhere it has been checked:
|
||||
|
||||
| checkout | `@Test` methods | total the run reported |
|
||||
|---|---|---|
|
||||
| `edd6385` | 40 | 40 — the Pixel run above |
|
||||
| `22c7914` | 49 | 49 — the four local levels, and API 37 |
|
||||
| `18c53a3` | 57 | not run |
|
||||
|
||||
So the number to expect is not written down here. It is derived from the checkout in front of
|
||||
you, which is the only thing that cannot go stale:
|
||||
|
||||
```bash
|
||||
grep -rho '@Test' app/src/androidTest | wc -l
|
||||
```
|
||||
|
||||
**Before each release, run the suite on the Pixel 10 Pro XL and expect that many tests, 0
|
||||
failures, 0 errors, 2 skipped.** The failure, error and skip counts are the invariant; the total
|
||||
is not. A total that disagrees with your own checkout's count is the signal — an old checkout, a
|
||||
stale build, or tests that never ran — and it is worth stopping on either way.
|
||||
|
||||
The two skips are `RealMediaBenchmark.hardwareVersusSoftwareOnRealVideo` and
|
||||
`av1InputRoutesAccordingToDeviceDecodeSupport`, which `assumeTrue` their sample files
|
||||
are present and skip when they are not. That is by design and unrelated to API level.
|
||||
`av1InputRoutesAccordingToDeviceDecodeSupport`, which `assumeTrue` their sample files are present
|
||||
and skip when they are not. That is by design and unrelated to API level.
|
||||
|
||||
One harmless warning appears during the run and can be ignored:
|
||||
`No UID for androidx.test.services in user 0`, from an `appops` call the test services
|
||||
package makes before it is fully registered.
|
||||
|
||||
So the app is correct on Android 17. What is missing is only *automated* coverage in
|
||||
CI. Until the image is fixed, run the suite on a physical API 37 device before release;
|
||||
that is the substitute for the missing matrix row.
|
||||
`No UID for androidx.test.services in user 0`, from an `appops` call the test services package
|
||||
makes before it is fully registered.
|
||||
|
||||
## Reproducing it
|
||||
|
||||
Locally, with `-gpu host` so the emulator itself does not segfault on Intel graphics:
|
||||
Both halves, so the renderer claim can be checked rather than taken on trust:
|
||||
|
||||
```bash
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||
|
||||
sdkmanager --install "system-images;android-37.0;google_apis;x86_64"
|
||||
echo no | avdmanager create avd -n api37_repro \
|
||||
-k "system-images;android-37.0;google_apis;x86_64" -d pixel_6 --force
|
||||
|
||||
$ANDROID_HOME/emulator/emulator -avd api37_repro \
|
||||
-no-window -gpu host -noaudio -no-boot-anim -camera-back none -no-snapshot &
|
||||
# never boots -- surfaceflinger aborts every ~6 s, forever
|
||||
emulator -avd api37_repro -no-window -gpu host \
|
||||
-noaudio -no-boot-anim -camera-back none -no-snapshot &
|
||||
|
||||
# Boot never completes. Count the aborts:
|
||||
adb logcat -d -b crash | grep -c hasReadColorBufferDma
|
||||
# boots in ~85 s, having aborted once or twice on the way
|
||||
emulator -avd api37_repro -no-window -gpu swangle_indirect \
|
||||
-noaudio -no-boot-anim -camera-back none -no-snapshot &
|
||||
```
|
||||
|
||||
`sys.boot_completed` never reaches `1`, `pgrep -f system_server` stays empty, and
|
||||
`keystore2`'s watchdog logs `await_boot_completed ... Overdue` indefinitely.
|
||||
Count the aborts either way:
|
||||
|
||||
To see the CI-side form instead, restore the API 37 row in the E2E matrix of
|
||||
`status_check.yml` (`api-level: "37.0"` — a bare `37` fails earlier still, during SDK
|
||||
setup, because there is no `platforms;android-37`).
|
||||
```bash
|
||||
adb -s emulator-5554 logcat -d -b crash | grep -c hasReadColorBufferDma
|
||||
```
|
||||
|
||||
Under `-gpu host`, `sys.boot_completed` never reaches `1`, `pgrep -f system_server` stays empty,
|
||||
and `keystore2`'s watchdog logs `await_boot_completed ... Overdue` indefinitely.
|
||||
|
||||
`tools/local-emulator/run-e2e.sh 37` does all of this, with the working renderer picked
|
||||
automatically — see `gpu_for_api` in that file.
|
||||
|
||||
## Filing this upstream
|
||||
|
||||
Not yet filed. To file it:
|
||||
Not yet filed. The report is stronger than it was, because the renderer dependency narrows it:
|
||||
|
||||
1. Go to <https://issuetracker.google.com/> and sign in with a Google account.
|
||||
2. Choose **Report an issue**, then pick the component for the Android emulator — search
|
||||
the component picker for "Emulator"; it sits under the Android Studio component tree.
|
||||
If the picker is unclear, Android Studio's **Help → Submit Feedback** opens the same
|
||||
tracker with the component preselected, and the emulator's own **Extended controls →
|
||||
Help → File a bug** does likewise.
|
||||
3. Title it for the mechanism, not the symptom, so it is searchable — for example:
|
||||
`surfaceflinger aborts in GoldfishMapper::readFromHost (hasReadColorBufferDma) on
|
||||
android-37.0 google_apis x86_64`.
|
||||
4. Paste the assertion and backtrace from the top of this document, the environment
|
||||
table, and the reproduction steps above. State explicitly that it reproduces on two
|
||||
unrelated hosts under both GPU modes — that is the detail that stops it being closed
|
||||
as a local graphics problem.
|
||||
5. List what was ruled out. Bugs that arrive with `-feature -GLDMA` already eliminated
|
||||
tend not to bounce back asking for it.
|
||||
6. Attach:
|
||||
- the guest tombstone, via `adb pull /data/tombstones` (or the `pbtombstone` output
|
||||
the crash log names)
|
||||
1. Go to <https://issuetracker.google.com/>, **Report an issue**, and pick the Android emulator
|
||||
component (search the component picker for "Emulator"; Android Studio's **Help → Submit
|
||||
Feedback** opens the same tracker with it preselected).
|
||||
2. Title it for the mechanism: `surfaceflinger aborts in GoldfishMapper::readFromHost
|
||||
(hasReadColorBufferDma) on android-37.0 and android-37.1 x86_64 -- fatal under -gpu host,
|
||||
intermittent under ANGLE`.
|
||||
3. Paste the assertion and backtrace, the environment block, and the seven-row matrix. The
|
||||
matrix is the valuable part: it shows the abort is not renderer-specific but its *frequency*
|
||||
is, which points at the readback path rather than at any one GL implementation.
|
||||
4. State that it reproduces on two independent system images (`37.0` rev 6 and `37.1` rev 8) and
|
||||
on two unrelated hosts, and that `-feature -GLDMA,-GLDMA2,-GLDirectMem` does not suppress it.
|
||||
5. Attach:
|
||||
- the guest tombstone, via `adb pull /data/tombstones` (or the `pbtombstone` output the crash
|
||||
log names)
|
||||
- `adb logcat -d -b crash > crash.txt`
|
||||
- the emulator's own stdout log, captured by redirecting the launch command
|
||||
- the emulator's own stdout log (`-verbose -debug all`, redirected)
|
||||
- the AVD's `config.ini`
|
||||
- a link to a failing CI job, which shows it on hardware you do not control:
|
||||
<https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32545625459/job/96963461184>
|
||||
@@ -199,16 +698,52 @@ Record the issue number here once filed.
|
||||
|
||||
## When to revisit
|
||||
|
||||
Re-add the API 37 row when any of these happens:
|
||||
The old trigger list named "a new `android-37.0` revision", which is why nothing ever fired even
|
||||
though a new API level shipped. Watch for these instead:
|
||||
|
||||
- a new `android-37.0` system image revision ships (this was revision 6)
|
||||
- an ATD image appears for API 37
|
||||
- the upstream issue is marked fixed
|
||||
- **any new API 37.x system image**, not just a new revision of `37.0` — `37.1` rev 8 and
|
||||
`37.2-beta*` already exist, and more will. Test with `-gpu host`: if it boots, the guest mapper
|
||||
is fixed.
|
||||
- **an ATD image for API 37.** Still none as of 2026-08-22. ATD images ship without SystemUI,
|
||||
which is what drives `RegionSamplingThread`, so one would very likely sidestep the bug
|
||||
entirely. Try it before anything else here.
|
||||
- **the upstream issue being marked fixed.**
|
||||
- **`E2E API 37 Media3 hardware transcode (advisory)` going green.** Nothing announces this: the
|
||||
job is `continue-on-error`, so it fixing itself looks exactly like a check nobody reads
|
||||
quietly ceasing to be red. It is listed here because that makes it the *least* likely of these
|
||||
triggers to be noticed, not the most. When it happens, delete `@FailsOnEmulatorApi37` from
|
||||
everything carrying it rather than deleting the job — the gating leg picks them back up on its
|
||||
own, and the advisory job then runs nothing and can go.
|
||||
|
||||
Until then the gap is narrower than the missing row suggests. `targetSdk` is 37, so the
|
||||
app is compiled and unit-tested against it; the API-dependent behaviour this matrix
|
||||
exists to exercise — the foreground service type, absent below 34, `dataSync` at 34,
|
||||
`mediaProcessing` from 35 — is covered at 35 and 36; and the full instrumented suite has
|
||||
been run green on real API 37 hardware. What is missing is *automated* API 37 coverage,
|
||||
so a regression there would not be caught by a pull request. Run the suite on a physical
|
||||
API 37 device before each release for as long as this row is absent.
|
||||
**It is not two tests any more.** As of 2026-08-24 the marker is on `Media3EngineTest`'s two
|
||||
methods *and* on `SafPickerRoundTripTest` as a class, and the two groups fail for unrelated
|
||||
reasons — a codec and the gralloc mapper. They can go green independently, so check both before
|
||||
concluding the marker is done; and the job's name still says "Media3 hardware transcode", which
|
||||
half of what it runs is not.
|
||||
|
||||
## Correction owed to `CLAUDE.md`
|
||||
|
||||
`CLAUDE.md` currently says:
|
||||
|
||||
> - **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its own
|
||||
> gralloc mapper, so every test fails there regardless of this app.
|
||||
> `docs/api-37-emulator-crash.md` records the evidence and the ruled-out fixes; CI's matrix
|
||||
> therefore stops at API 36 even though targetSdk is 37.
|
||||
|
||||
The first sentence is right, and now under-specified in one direction and over-specified in the
|
||||
other: it is not only `android-37.0` (it is `37.1` too), and it does not crash-loop under every
|
||||
renderer. **The last clause is now simply false: CI's matrix does not stop at API 36 any more.**
|
||||
Proposed replacement, offered for review rather than applied here — `CLAUDE.md` is left alone
|
||||
deliberately, because several branches touch it:
|
||||
|
||||
> - **The API 37 images crash-loop surfaceflinger under the host GL renderer.** Both
|
||||
> `android-37.0` and `android-37.1` abort inside their own gralloc mapper
|
||||
> (`RegionSamplingThread` → `GoldfishMapper::readFromHost`), and when surfaceflinger dies init
|
||||
> SIGKILLs zygote, so the framework restarts under the test run. Under `-gpu host` it never
|
||||
> boots at all; under `-gpu swangle_indirect` it boots and the aborts merely become
|
||||
> intermittent. `docs/api-37-emulator-crash.md` has the seven-run matrix and the ruled-out
|
||||
> list, and `tools/local-emulator/run-e2e.sh` picks the working renderer per API level.
|
||||
> CI takes API 37 as two jobs: a gating `E2E API 37` that disables SystemUI first, and an
|
||||
> advisory leg carrying the two `@FailsOnEmulatorApi37` tests. The gating leg therefore runs a
|
||||
> device configuration nothing else does. **API 37 still needs a manual check on the Pixel 10
|
||||
> Pro XL before each release** — it is the only API 37 run with SystemUI intact.
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
# Coverage-read findings
|
||||
|
||||
**Status:** five findings, none fixed, none urgent. F5 was added on 2026-08-27, found while decomposing #132 into children — it had been listed there as a test gap, and is not one. Every entry here is a *code* observation —
|
||||
something a test would document rather than repair. The test gaps found in the same read are
|
||||
tickets #132 and #133, not entries here; see [Not covered here](#not-covered-here).
|
||||
**Scope:** what a JaCoCo read on 2026-08-26 turned up that writing a test would not fix. This is
|
||||
a survey, not a work order. Acting on any entry is a separate decision and would be its own commit.
|
||||
**Last verified:** `main` at `dc8b7c3`, 2026-08-26. Coverage re-measured that day with
|
||||
`./gradlew :app:jacocoTestReport`: **84.9% line (1971/2321), 63.8% branch (900/1410)**, against
|
||||
**456 JVM tests in 68 classes**. `CLAUDE.md` quotes 454 in 67 from four hours earlier; the
|
||||
percentages are unchanged, so no figure there is stale.
|
||||
|
||||
## Why this document is separate from `defect-audit.md`
|
||||
|
||||
`defect-audit.md` is the record of the 2026-08-22 defect sweep: sixteen entries, each a thing that
|
||||
is *wrong at runtime*. Nothing here is wrong at runtime today. These are arms that cannot be
|
||||
reached, accessors nobody calls, and one KDoc that contradicts the code beside it — the category
|
||||
`defect-audit.md` calls **latent**, plus one that is not a defect at all and is recorded so the
|
||||
next coverage read does not re-file it.
|
||||
|
||||
They are here rather than in that document because folding them in would inflate a sixteen-entry
|
||||
audit whose status metadata has already gone stale once, and because they share a provenance:
|
||||
every one fell out of reading a coverage report, and every one is the kind of thing a coverage
|
||||
report is *good* at surfacing and a test is bad at fixing. F5 is the clearest case — it was filed
|
||||
as a test gap first, and only stopped being one when someone went looking for its callers.
|
||||
|
||||
Entry ids are `F1`–`F5` so they cannot be confused with `defect-audit.md`'s `D1`–`D16`.
|
||||
|
||||
## How to read the confidence labels
|
||||
|
||||
Same vocabulary as `defect-audit.md`, deliberately, so the two read alike:
|
||||
|
||||
- **Confirmed by inspection** — the control flow is fully readable and the finding follows from it.
|
||||
- **Latent** — not reachable through today's UI, but wrong, and one change away from being live.
|
||||
- **No action** — recorded because it looks like a finding and is not.
|
||||
|
||||
Nothing below was observed on a device, and nothing below needs to be: every entry is a claim about
|
||||
what the code says, checkable by reading it.
|
||||
|
||||
---
|
||||
|
||||
## F1 — `FFmpegCommandBuilder` emits a Vorbis encoder that `ContainerCapabilities` says does not exist
|
||||
|
||||
**Severity: low · Latent · the more interesting reading is a missing feature, not dead code**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt:188
|
||||
app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt:84-91
|
||||
```
|
||||
|
||||
`FFmpegCommandBuilder.audioArgs` carries a live Vorbis arm:
|
||||
|
||||
```kotlin
|
||||
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
|
||||
```
|
||||
|
||||
`ContainerCapabilities` states, immediately above the set that governs it, that no such thing
|
||||
exists:
|
||||
|
||||
> `/** Vorbis is absent for the same reason: nothing here emits a Vorbis encoder. */`
|
||||
> `private val ENCODABLE_AUDIO = setOf(AAC, OPUS, MP3, FLAC, PCM)`
|
||||
|
||||
One of those two is wrong. The comment is the one that is wrong as written — something here does
|
||||
emit a Vorbis encoder, twelve lines of `FFmpegCommandBuilder`.
|
||||
|
||||
### Why the arm is unreachable today
|
||||
|
||||
Traced, not assumed:
|
||||
|
||||
| step | where | effect |
|
||||
|---|---|---|
|
||||
| `validate` runs before routing | `ConversionWorker.kt:123` | a spec is checked on every job, however it was enqueued |
|
||||
| `validateAudio` refuses non-encodable | `ContainerCapabilities.kt:246-251` | `VORBIS !in ENCODABLE_AUDIO` → `Invalid("This app cannot encode Vorbis audio.")` |
|
||||
| the only spec→plan encode path | `CopyPlanner.kt:104` | `AudioPlan.Encode(requested)` — but `requested` cannot be Vorbis by the row above |
|
||||
| the fallback encode path | `CopyPlanner.kt:112-115` | draws from `encodableAudio(container)`, itself filtered by `ENCODABLE_AUDIO` |
|
||||
|
||||
So `AudioPlan.Encode(VORBIS)` is not constructible through the app, and line 188 is dead.
|
||||
|
||||
### The reading that matters more
|
||||
|
||||
`CARRIES_AUDIO` lists Vorbis for WebM (`ContainerCapabilities.kt:62`) and OGG (`:67`). Because
|
||||
`encodableAudio` filters through `ENCODABLE_AUDIO`, the picker offers **Opus and nothing else** for
|
||||
WebM, and Opus/FLAC for OGG. FFmpeg on this device can encode Vorbis — the command is written and
|
||||
correct — and the app declines to offer it.
|
||||
|
||||
So the honest framing is not "delete a dead arm". It is: **is `ENCODABLE_AUDIO`'s omission of
|
||||
Vorbis a deliberate product call, or an accident that has been costing WebM/OGG users a format the
|
||||
app already supports?** Nothing in the repo records that decision.
|
||||
|
||||
### The precedent for whichever way it goes
|
||||
|
||||
`Media3Engine.audioMimeTypeFor` has the *same* Vorbis arm, and handles it exactly right
|
||||
(`Media3Engine.kt:221-233`): the KDoc names it dead, says why the arm stays anyway ("deleting a
|
||||
right answer out of unreachable code buys nothing"), and points at `Media3EngineMimeTypesTest`,
|
||||
which asserts which three of six codecs actually arrive — so the set moving fails a test rather
|
||||
than surprising someone.
|
||||
|
||||
`FFmpegCommandBuilder`'s arm has none of that. Whatever is decided, the fix is to make the two
|
||||
files agree and to say so in one place.
|
||||
|
||||
### What a fix has to decide
|
||||
|
||||
1. Whether Vorbis belongs in `ENCODABLE_AUDIO`. If yes, this is a feature and needs an e2e test
|
||||
that produces a playable Vorbis file; if no, go to 2.
|
||||
2. Correct the `ContainerCapabilities.kt:84` comment, which is false as written, and give the
|
||||
`FFmpegCommandBuilder` arm the treatment `Media3Engine.kt:221-233` already models.
|
||||
|
||||
---
|
||||
|
||||
## F2 — `ConversionRequest.hardwareEncodeAvailable` is written, read by nothing, and its KDoc describes behaviour that was removed
|
||||
|
||||
**Severity: low · Confirmed by inspection**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/model/OutputFormat.kt:211-219
|
||||
app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt:117
|
||||
```
|
||||
|
||||
The property is set on every request:
|
||||
|
||||
```kotlin
|
||||
hardwareEncodeAvailable = devices.canEncode(spec.videoCodec),
|
||||
```
|
||||
|
||||
`grep -rn 'hardwareEncodeAvailable' app/src/main` returns **that line and nothing else**. No
|
||||
production code reads it. Its getter is one of three uncovered methods in `OutputFormat.kt`, which
|
||||
is what surfaced it.
|
||||
|
||||
Its KDoc (`OutputFormat.kt:211-218`) explains at length what it is for:
|
||||
|
||||
> Knowing this lets the Fast tier choose a genuinely fast software preset instead of a mislabelled
|
||||
> slow one.
|
||||
|
||||
`FFmpegCommandBuilder` no longer does that, and its own test says so —
|
||||
`FFmpegCommandBuilderTest.kt:132`, `the encoder choice no longer depends on hardware availability`:
|
||||
|
||||
> Once FFmpeg stopped selecting MediaCodec encoders, this flag only affects whether the router sends
|
||||
> the job to Media3 at all — not what FFmpeg does.
|
||||
|
||||
That second clause is also not true. `ConversionRouter` decides hardware encodability by calling
|
||||
`device.canEncode(videoEncode)` itself (`ConversionRouter.kt:153`); it never reads
|
||||
`request.hardwareEncodeAvailable`. The flag is computed from the same source the router
|
||||
independently consults, carried through the request, and dropped.
|
||||
|
||||
This is the shape of open issue **#68** — a KDoc promising a switch that does not exist.
|
||||
|
||||
**Not harmful.** It costs one `canEncode` call per job and a field on a data class. It is recorded
|
||||
because the KDoc actively misleads: a reader changing the Fast-tier preset logic would look here
|
||||
first, and this is not where that decision lives.
|
||||
|
||||
### What a fix has to decide
|
||||
|
||||
Whether to delete the property (and the constructor parameter, and the four
|
||||
`FFmpegCommandBuilderTest` call sites that pass it) or to keep it and rewrite the KDoc to say it is
|
||||
vestigial. Deleting is cleaner; the test at `:132` is worth keeping either way, since it pins the
|
||||
"FFmpeg does not select MediaCodec encoders" rule that the deletion would otherwise erase.
|
||||
|
||||
---
|
||||
|
||||
## F3 — `ConversionRequest.videoCodec` and `.audioCodec` have no callers anywhere
|
||||
|
||||
**Severity: low · Confirmed by inspection**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/model/OutputFormat.kt:222-223
|
||||
```
|
||||
|
||||
```kotlin
|
||||
val container: Container get() = spec.container // used: FFmpegConcatCommand.kt:42, :80
|
||||
val videoCodec: VideoCodec get() = spec.videoCodec // no callers
|
||||
val audioCodec: AudioCodec get() = spec.audioCodec // no callers
|
||||
```
|
||||
|
||||
Three delegating accessors on `ConversionRequest`; the first is used twice, the other two are used
|
||||
nowhere in `main`, `test` or `androidTest`. Everything that wants those values reads
|
||||
`request.spec.videoCodec` or takes the `OutputSpec` directly.
|
||||
|
||||
**This is not a test gap and must not be filed as one.** A test asserting
|
||||
`request.videoCodec == request.spec.videoCodec` is vacuous by construction — it restates the
|
||||
implementation and would pass against any delegation, right or wrong. That is precisely the failure
|
||||
mode `CLAUDE.md` records from the mutation review (9 of 46 mutations vacuous, five over completely
|
||||
unguarded paths).
|
||||
|
||||
The two accessors are either convenience worth keeping for symmetry with `container`, or two lines
|
||||
to delete. Deleting them costs nothing and removes two uncovered methods that will otherwise be
|
||||
re-found by every future coverage read.
|
||||
|
||||
---
|
||||
|
||||
## F4 — Two guards are reachable only by direct call, and that is correct
|
||||
|
||||
**Severity: n/a · No action**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt:167-168
|
||||
app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt:175-176
|
||||
```
|
||||
|
||||
```kotlin
|
||||
VideoCodec.COPY, VideoCodec.NONE -> error("encodeVideo called for $codec, which is not an encode")
|
||||
```
|
||||
|
||||
```kotlin
|
||||
if (plan.video == VideoPlan.Copy && video == null) return false
|
||||
if (plan.audio == AudioPlan.Copy && audio == null) return false
|
||||
```
|
||||
|
||||
Both sit in private functions (`encodeVideo`, `media3CanMux`), and both are unreachable because a
|
||||
caller upstream already excluded the case — which each says in its own comment. `ConversionRouter`'s
|
||||
is labelled "the second line of defence"; `CopyPlanner` is the first.
|
||||
|
||||
**Recorded so the next coverage read does not treat them as gaps.** A second line of defence that
|
||||
can be provoked is not a second line of defence. Making these reachable from a test would mean
|
||||
widening the functions to `internal`, which buys a test that asserts an `error()` fires when called
|
||||
in a way production cannot call it. This is the same judgement issue **#88** reached about
|
||||
`getForegroundInfo` and closed on: naming the exemption rather than covering it.
|
||||
|
||||
Neither should change unless the upstream guard does. If `CopyPlanner` ever stops resolving `COPY`
|
||||
before the builder sees it, `FFmpegCommandBuilder.kt:167` becomes live and wants a test that day.
|
||||
|
||||
---
|
||||
|
||||
## F5 — `ConversionNotifications.areEnabled()` is never called
|
||||
|
||||
**Severity: low · Confirmed by inspection · found while decomposing the test-gap ticket**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt:60-62
|
||||
```
|
||||
|
||||
```kotlin
|
||||
fun areEnabled(): Boolean = context.getSystemService(NotificationManager::class.java)
|
||||
.areNotificationsEnabled()
|
||||
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
|
||||
```
|
||||
|
||||
`grep -rn 'areEnabled' app/src` returns **that declaration and nothing else**. `ConversionNotifications`
|
||||
is constructed in both workers (`ConversionWorker.kt:55`, `ConcatWorker.kt:35`) and only `build()` is
|
||||
ever called on it.
|
||||
|
||||
**This entry exists because it was very nearly filed as a test gap.** Its three lines are cold on the
|
||||
JVM, it has a KDoc explaining real user-visible stakes — a foreground service without
|
||||
`POST_NOTIFICATIONS` shows only in the Task Manager, so progress silently vanishes — and Robolectric
|
||||
can flip that permission in one line. Everything about it reads like a cheap, worthwhile test.
|
||||
|
||||
It is not, because **the behaviour the KDoc describes does not happen**. Nothing consults
|
||||
`areEnabled()`, so nothing warns, degrades, or logs when notifications are off. A test would assert
|
||||
that a function nobody calls returns what the platform told it — green, vacuous, and actively
|
||||
misleading, since it would imply the app handles the disabled-notification case. That is the failure
|
||||
mode `CLAUDE.md` records from the mutation review, reached from the opposite direction: not a test
|
||||
that fails to bite, but a test with nothing to bite.
|
||||
|
||||
### What a fix has to decide
|
||||
|
||||
Whether the app should act on this at all. The KDoc argues it should — a conversion whose progress is
|
||||
invisible is a real complaint, and `ConversionViewModel` or the worker's foreground start is where a
|
||||
check would go. If yes, that is a **feature** with a test; if no, delete the method and the KDoc's
|
||||
claim with it. What must not happen is a test that makes the current state look handled.
|
||||
|
||||
Related: **#16** is open on an adjacent gap — a user who *can* unblock a foreground-denied retry has
|
||||
no way to make it happen now.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| ID | Finding | Severity | Evidence | Action |
|
||||
|---|---|---|---|---|
|
||||
| F1 | `FFmpegCommandBuilder` emits a Vorbis encoder `ContainerCapabilities` says does not exist | low | confirmed by inspection; unreachability traced through four call sites | **decide**: feature or dead arm — the comment is false either way |
|
||||
| F2 | `hardwareEncodeAvailable` written, never read; KDoc describes removed behaviour | low | confirmed by inspection; `FFmpegCommandBuilderTest:132` corroborates | **decide**: delete or mark vestigial |
|
||||
| F3 | `ConversionRequest.videoCodec` / `.audioCodec` have no callers | low | confirmed by inspection | delete, or keep for symmetry — **not** a test gap |
|
||||
| F4 | Two private guards reachable only by direct call | n/a | confirmed by inspection | **no action** — named exemption, per #88 |
|
||||
| F5 | `ConversionNotifications.areEnabled()` is never called | low | confirmed by inspection; grep returns the declaration only | **decide**: act on it or delete it — **not** a test gap |
|
||||
|
||||
Order, if these are acted on: **F1 and F5 first, separately.** They are the two with a possible
|
||||
user-visible answer — a format the app can produce and does not offer, and a warning the app
|
||||
documents and does not give — and either answer changes what the tidying should look like. F2 and F3
|
||||
are tidying and belong in one commit with each other, not with F1 or F5. F4 is finished by being
|
||||
written down.
|
||||
|
||||
**F1 and F5 share a shape worth naming:** both are places where a comment describes behaviour the
|
||||
code does not have, and in both the tempting fix (delete the dead arm, test the dead method) would
|
||||
freeze the wrong answer in place. The decision comes first.
|
||||
|
||||
## Not covered here
|
||||
|
||||
**The test gaps from the same read.** Seven JVM-side gaps (**#132**) and three seam questions
|
||||
(**#133**) came out of this coverage read and are tracked there, because they are work rather than
|
||||
observations. This document holds only what a test would not fix. #133 also records why
|
||||
`AndroidDeviceCodecs.probe()` was considered and left out, so that spike is not run a third time.
|
||||
|
||||
**`ConversionForegroundType.current()`**, which looked like the sharpest gap in the read and is not.
|
||||
Its API 33 and 34 arms are cold on the JVM, but issue **#88** already established that the class is
|
||||
covered by `ConversionWorkerTest.foregroundTypeMatchesTheRunningApiLevel` across the CI matrix, and
|
||||
that its 0% is the `testDebugUnitTest`-only measurement boundary.
|
||||
|
||||
The premise worth re-checking was whether the 33/34 legs still complete, given #122's wedge.
|
||||
**They mostly do, and #122 is not resolved** — this entry said "they do" on first writing, from a
|
||||
single green run, and the PR carrying this very document proved that wrong:
|
||||
|
||||
| run | API 33 leg | shape |
|
||||
|---|---|---|
|
||||
| `32933262839` (#127) | success, 7m16s | `expected 60, received 60, failed 0, completed cleanly: yes` |
|
||||
| `33033036857` (PR #131, docs-only) | **failure, 23m08s** | `expected 60, received 60, failed unknown, wedged: yes — gradle killed after 1200s` |
|
||||
|
||||
Five of the last six completed API 33 legs passed in about seven minutes, so the wedge is
|
||||
intermittent rather than systematic. **What it costs is the verdict, not the execution**: `received:
|
||||
60` on the wedged run means all sixty tests still reported, so the API 33 regime *was* exercised —
|
||||
but `failed:` reads `unknown`, so that leg could not have told anyone if it had broken.
|
||||
|
||||
That is why this stays a note and not a ticket, and also why it is not simply deleted: #88's
|
||||
reasoning holds, but the leg it rests on cannot be relied on to report a failure. A
|
||||
`@Config(sdk = 33)` / `@Config(sdk = 34)` JVM test would pin all three arms deterministically in one
|
||||
run for about three lines. Small, and worth doing the next time this file is opened — but it is
|
||||
insurance against a flaky leg, not the uncovered behaviour it first looked like.
|
||||
|
||||
**The Compose screens' branch coverage.** `ConverterScreenKt` reports 110 of 200 branches missed and
|
||||
`JoinScreenKt` 60 of 82, which looks alarming and is not a signal: the Compose compiler synthesises
|
||||
`$changed`/`$dirty` recomposition-skip tests that JaCoCo counts as branches. The line figures are
|
||||
the real ones — **34 of 383** and **20 of 143** missed — and the screens are among the
|
||||
better-covered files in the repo, which is what #52, #57 and #61 were for. **Do not chase the
|
||||
branch number here.** If a future read wants a screen metric, use lines.
|
||||
|
||||
**Anything requiring a device.** `MediaProbe`'s FFprobe half (`MediaProbe.kt:151, 156-158, 173-188`)
|
||||
and `FFmpegEngine` in full report 0% on the JVM and are covered by `androidTest`. JaCoCo measures
|
||||
`testDebugUnitTest` only; their zeroes are a boundary, as #84, #85, #86 and #88 each recorded
|
||||
before this.
|
||||
+52
-17
@@ -1,8 +1,10 @@
|
||||
# Emulators do run on this host: the segfault is SwiftShader's JIT against SELinux
|
||||
|
||||
**Status:** solved. Local instrumented runs work with `-gpu host`, and the suite is green
|
||||
on API 33–36 — 49 tests, 0 failures, 0 errors, 2 skipped on every level. See
|
||||
[The sweep, run](#the-sweep-run).
|
||||
**Status:** solved. Local instrumented runs work with `-gpu host`, and the whole suite is green
|
||||
on API 33–36 — 0 failures, 0 errors and the two by-design skips on every level, measured as
|
||||
49 / 0 / 0 / 2 at `22c7914`, where the suite was 49 tests. See [The sweep, run](#the-sweep-run),
|
||||
and [Reading these totals](api-37-emulator-crash.md#reading-these-totals) before comparing any
|
||||
total with another checkout's.
|
||||
**Last verified:** 2026-08-22, emulator `37.1.11.0` (build 15917651), Fedora 44,
|
||||
kernel `7.1.8-200.fc44`, `selinux-policy-44.6-1.fc44`
|
||||
|
||||
@@ -190,11 +192,28 @@ emulator -avd <name> -no-window -gpu host \
|
||||
`.github/scripts/e2e-run.sh` for the run itself. Use it rather than the raw command:
|
||||
|
||||
```bash
|
||||
tools/local-emulator/run-e2e.sh # API 33 34 35 36
|
||||
tools/local-emulator/run-e2e.sh # API 33 34 35 36 37
|
||||
tools/local-emulator/run-e2e.sh 35 # one level
|
||||
tools/local-emulator/run-e2e.sh 37 37.1 # both API 37 images
|
||||
GPU_MODE=swangle_indirect tools/local-emulator/run-e2e.sh 35
|
||||
```
|
||||
|
||||
Levels are the labels above, not SDK ints: API 37's SDK directories are dotted
|
||||
(`android-37.0`, `android-37.1`) and there is no `android-37`, so `37` is accepted as a
|
||||
spelling of `37.0`. Setting `GPU_MODE` forces one renderer on every level, which is what
|
||||
you want when measuring a mode; leaving it unset lets `gpu_for_api` pick, which is what
|
||||
you want when running the suite — 33–36 need `host` and 37 must not have it.
|
||||
|
||||
**A bare `run-e2e.sh` exits 1, and that is the design.** API 37 is in the default list
|
||||
deliberately — leaving it out is what left the level unlooked-at for as long as it was — and
|
||||
it is permanently two failures short of green: `Media3EngineTest` cannot drive the emulator's
|
||||
`c2.goldfish.h264.decoder` on those images, which
|
||||
[`api-37-emulator-crash.md`](api-37-emulator-crash.md) pins on the image and not on this app
|
||||
(API 35 under the same renderer is green). The summary row names the two expected failures so
|
||||
that a third is visibly new, and the script repeats the point on the way out. Anything that
|
||||
treats a non-zero exit as breakage — a wrapper, a hook, a habit — should name the levels it
|
||||
wants: `run-e2e.sh 33 34 35 36` is the sweep that can be green.
|
||||
|
||||
`swangle_indirect` is the fallback worth knowing about. It is entirely software, so it
|
||||
does not depend on reaching the session's GPU — useful over plain SSH, where `-gpu host`
|
||||
has not been tested and may not find a device. It is also the closest local analogue to
|
||||
@@ -236,8 +255,9 @@ after an AGP upgrade.
|
||||
## The sweep, run
|
||||
|
||||
`tools/local-emulator/run-e2e.sh`, one invocation per level so each got a freshly created
|
||||
AVD, `-gpu host` throughout, 2026-08-22 19:42–19:56. Every level matches the physical
|
||||
Pixel 10 Pro XL (API 37) baseline of 49 / 0 / 0 / 2 exactly:
|
||||
AVD, `-gpu host` throughout, 2026-08-22 19:42–19:56, on `22c7914`. All four levels agree exactly,
|
||||
and 49 is that checkout's whole suite — every `@Test` in `app/src/androidTest`, two of which skip
|
||||
by design everywhere:
|
||||
|
||||
| API | Android | AVD | Boot | `connectedDebugAndroidTest` | Tests | Failures | Errors | Skipped |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -246,6 +266,12 @@ Pixel 10 Pro XL (API 37) baseline of 49 / 0 / 0 / 2 exactly:
|
||||
| 35 | 15 | `lmc_e2e_api35` | 40 s | 3 m 46 s | 49 | 0 | 0 | 2 |
|
||||
| 36 | 16 | `lmc_e2e_api36` | 90 s | 2 m 18 s | 49 | 0 | 0 | 2 |
|
||||
|
||||
The physical Pixel has never reported 49, and an earlier version of this paragraph said the
|
||||
sweep matched it exactly. Its green API 37 run was 40 / 0 / 0 / 2, at `edd6385` — the same suite
|
||||
nine tests earlier. What matches is 0 failures, 0 errors and the same two skips; totals only ever
|
||||
match between runs of one checkout, which
|
||||
[`api-37-emulator-crash.md`](api-37-emulator-crash.md#reading-these-totals) sets out.
|
||||
|
||||
Thirteen and a half minutes for the four levels, AVD creation and cold boots included;
|
||||
fifteen with the pre-warm build in front of them. Nothing needed a retry, and no level
|
||||
produced a `diagnostics-api*.txt` — `e2e-run.sh` writes that only on the failure path, so
|
||||
@@ -352,9 +378,11 @@ and the same binaries against the same kernel boot fine under `-gpu host`.
|
||||
before `sys.boot_completed` is ever set. Nothing in the guest — system image variant,
|
||||
RAM, disk size, ATD versus `google_apis` — can influence a host-side `mprotect` denial,
|
||||
so none of those axes was varied. (The API 37 failure in
|
||||
[`api-37-emulator-crash.md`](api-37-emulator-crash.md) is genuinely guest-side and
|
||||
genuinely unrelated: there the host emulator survives and the guest's `surfaceflinger`
|
||||
aborts.)
|
||||
[`api-37-emulator-crash.md`](api-37-emulator-crash.md) is genuinely guest-side — there the host
|
||||
emulator survives and the guest's `surfaceflinger` aborts — but it is **not** unrelated, as this
|
||||
paragraph originally claimed. Both are decided by the renderer, in opposite directions: below 37
|
||||
you must avoid SwiftShader GLES and `-gpu host` is the answer; at 37 you must avoid the *host* GL
|
||||
translator and `-gpu host` is the thing that never boots.)
|
||||
|
||||
**Turning the SELinux boolean on** — deliberately *not* done, though it would almost
|
||||
certainly work:
|
||||
@@ -401,8 +429,13 @@ are easy to forget to look at.
|
||||
"SwiftShader 4.0.0.1" as reported by the GLES translator.
|
||||
- **If `-gpu host` regresses** after a Mesa or kernel update, fall back to
|
||||
`GPU_MODE=swangle_indirect`, which needs no GPU at all.
|
||||
- **This changes nothing about API 37.** That image is broken for a different reason and
|
||||
still must be checked on the physical Pixel 10 Pro XL before each release.
|
||||
- **API 37 needs the opposite renderer, and this file used to say it needed nothing.** The
|
||||
original bullet here read "This changes nothing about API 37"; that turned out to be wrong.
|
||||
The API 37 images abort `surfaceflinger` under the *host* GL translator and boot under ANGLE —
|
||||
the exact mirror of the rule above — and `run-e2e.sh` therefore picks the renderer per API
|
||||
level. See [`api-37-emulator-crash.md`](api-37-emulator-crash.md), which was rewritten on
|
||||
2026-08-22 with the seven-run matrix. API 37 still must be checked on the physical Pixel 10 Pro
|
||||
XL before each release.
|
||||
|
||||
## Correction owed to `CLAUDE.md`
|
||||
|
||||
@@ -432,12 +465,14 @@ Proposed replacement for the section, offered for review rather than applied her
|
||||
> `auto` (the default), `off` and `guest` do when headless. `-gpu host` works, and the
|
||||
> harness both picks it and refuses the others. `docs/local-emulator.md` has the
|
||||
> backtrace and the mode matrix.
|
||||
> - **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its
|
||||
> own gralloc mapper, so every test fails there regardless of this app —
|
||||
> `docs/api-37-emulator-crash.md` records the evidence and the ruled-out fixes. This is
|
||||
> unrelated to the renderer above: it is a guest-side bug that CI hits too, which is why
|
||||
> the matrix stops at API 36 even though targetSdk is 37. **API 37 needs a manual check
|
||||
> on the Pixel 10 Pro XL before each release.**
|
||||
> - **API 37 needs the opposite renderer, and SystemUI turned off.** Both `android-37.0` and
|
||||
> `android-37.1` abort surfaceflinger inside their own gralloc mapper, and init SIGKILLs
|
||||
> zygote each time. Under `-gpu host` they never boot; under `-gpu swangle_indirect` they
|
||||
> boot, and disabling SystemUI removes the trigger. `run-e2e.sh` does all of that per level,
|
||||
> and the local API 37 result is two failures and the two usual skips, not a clean run. CI
|
||||
> takes API 37 as a gating leg plus an advisory one carrying those two tests.
|
||||
> `docs/api-37-emulator-crash.md` has the matrix and the reasoning. **API 37 needs a manual
|
||||
> check on the Pixel 10 Pro XL before each release.**
|
||||
|
||||
The wording is worth getting right rather than merely correcting, because the original was
|
||||
not a careless sentence — it was a reasonable inference from three crashes, written down
|
||||
|
||||
@@ -42,6 +42,18 @@ annotation = "1.+"
|
||||
junit = "4.+"
|
||||
androidxJunit = "1.+"
|
||||
espressoCore = "3.+"
|
||||
# UiAutomator. FLOATING, and the argument for it is the one the guard already makes:
|
||||
# androidx.test.uiautomator is inside `floatedGroupPrefixes` ("androidx."), so `2.+` reads
|
||||
# as "the newest RELEASED 2.x" exactly the way `work = "2.+"` does -- and this library does
|
||||
# publish alphas above its stable, so without the guard it would be a pin.
|
||||
#
|
||||
# Not pinned like ktlint/detekt/robolectric, because it is not that kind of dependency. Those
|
||||
# are pinned because a new *rule* or a new *runtime* makes untouched files fail -- the tool
|
||||
# changes its verdict on code nobody edited. UiAutomator has no verdict: it clicks what a
|
||||
# selector names, and a selector that stops matching is this repo's test to fix, in a diff
|
||||
# that says so. `2.` and not bare `+` because 3.x does not exist yet and a major is where the
|
||||
# selector API would be free to change under exactly that assumption.
|
||||
uiautomator = "2.+"
|
||||
# PINNED, unlike its neighbours. Under semver a 0.x minor is allowed to break, and
|
||||
# this library is load-bearing exactly where breakage is hardest to see: the wrapper
|
||||
# reaches for smartexception.java.Exceptions only when an FFmpeg call FAILS, so a
|
||||
@@ -80,6 +92,17 @@ jacoco = "0.8.15"
|
||||
# 4.16.1 is the newest RELEASED version; the 4.17 line is beta-only at the time of writing.
|
||||
robolectric = "4.16.1"
|
||||
|
||||
# kotlinx-coroutines-test. Already on the unit-test classpath transitively, through
|
||||
# compose-ui-test-junit4 -- declared here because a source file now imports it, and a direct
|
||||
# import of a transitive is a dependency nobody chose.
|
||||
#
|
||||
# PINNED, for the same reason as robolectric above: org.jetbrains.kotlinx is not one of the
|
||||
# groups in the prerelease guard's `floatedGroupPrefixes`, so a "1.+" here would be free to
|
||||
# resolve to a milestone build. This value is what the Compose BOM already resolves it to, so
|
||||
# stating it changes nothing in the graph today; if the BOM moves ahead, Gradle takes the
|
||||
# higher version and this stays a floor rather than a conflict.
|
||||
coroutinesTest = "1.9.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
@@ -128,12 +151,22 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
|
||||
# The only way to touch UI this app does not own. Compose's own matchers stop at this
|
||||
# process's composition, and the system file picker is a DocumentsUI activity in another
|
||||
# process -- so a SAF round trip is unreachable without it.
|
||||
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
|
||||
|
||||
# Robolectric — an Android runtime for the JVM test source set, so file-lifecycle behaviour
|
||||
# that needs a real Context can be verified without a device. The instrumented suite cannot
|
||||
# run on the development host at all (see CLAUDE.md), so an androidTest-only red test is not
|
||||
# a TDD loop anyone here can execute.
|
||||
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
|
||||
|
||||
# Only for its `runTest`, and only so the one test that deliberately lets a coroutine error
|
||||
# escape owns the collector callback while it does -- otherwise the error is kept process-wide
|
||||
# and rethrown at whichever `runTest` starts next. See ConversionViewModelProbeFailureTest.
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
|
||||
|
||||
[plugins]
|
||||
# com.android.application and org.jetbrains.kotlin.plugin.compose are deliberately absent.
|
||||
# They come from the root buildscript classpath (see build.gradle.kts) so that a newer KGP
|
||||
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Files a GitHub issue AND puts it on the project board, as one operation.
|
||||
#
|
||||
# Usage: tools/github/file-issue.sh --title TITLE (--body TEXT | --body-file PATH) [options]
|
||||
#
|
||||
# --status NAME board column, matched case-insensitively against the board's own
|
||||
# options; a miss lists what is available. Default: Backlog
|
||||
# --label NAME repeatable. Passed through to `gh issue create` unchanged.
|
||||
# --project N project number. Default: $ISSUE_PROJECT_NUMBER, else 6
|
||||
# --repo OWNER/NAME default: whatever `gh repo view` resolves in the working directory
|
||||
# --dry-run resolve and validate everything, create nothing
|
||||
#
|
||||
# EXIT CODE: 0 only when the issue exists, is on the board, AND reads back carrying the
|
||||
# Status that was asked for. 2 for a usage or validation error, before anything is created.
|
||||
# **3 means the issue was created but did not reach the board** -- the number is printed on
|
||||
# a line of its own, because that combination is the entire failure this script exists to
|
||||
# prevent and it must never be quiet.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# `gh issue create` does not touch the project board. The issue is created, carries its
|
||||
# labels, and is invisible in the Kanban -- which looks exactly like a ticket nobody filed.
|
||||
# Measured 2026-08-24: eight issues filed as a scripted batch all reached the board; one
|
||||
# filed as a one-off a few minutes later did not, and was caught only because someone went
|
||||
# looking. A batch carries the board step inside its loop. One-offs are where it slips, so
|
||||
# one-offs are what this is for.
|
||||
#
|
||||
# Adding an item and setting a field value are GraphQL-only. REST can list project items
|
||||
# and field definitions, but the `fields` array it returns on an item carries Title and
|
||||
# nothing else -- a REST-only check reports every item's Status as unset, which is why the
|
||||
# read-back at the end is a GraphQL query rather than the cheaper REST one.
|
||||
#
|
||||
# WHAT IT DELIBERATELY DOES NOT DO
|
||||
#
|
||||
# It does not cache the project, field or option ids. Resolving them by name costs one
|
||||
# GraphQL query per run, and it means a renamed or reordered column cannot make this write
|
||||
# a stale id. The ids are the fragile part; the names are what people actually use.
|
||||
#
|
||||
# It does not apply triage labels for you. `above-cut` and `backlog` are labels from one
|
||||
# specific 2026-08-22 triage pass -- they mean "worked autonomously overnight" and "held for
|
||||
# manual review", not "this is in the Backlog column". Status carries board state. Pass
|
||||
# --label only for things that are true about the issue itself.
|
||||
#
|
||||
# It does not create the project, the Status field, or a missing option. Anything absent is
|
||||
# an error to report, not to invent.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly EXIT_USAGE=2
|
||||
readonly EXIT_ORPHANED=3
|
||||
|
||||
die() {
|
||||
printf 'file-issue: %s\n' "$1" >&2
|
||||
exit "${2:-$EXIT_USAGE}"
|
||||
}
|
||||
|
||||
title=""
|
||||
body=""
|
||||
body_file=""
|
||||
status="Backlog"
|
||||
project="${ISSUE_PROJECT_NUMBER:-6}"
|
||||
repo=""
|
||||
dry_run=0
|
||||
labels=()
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) [ $# -ge 2 ] || die "--title needs a value"; title="$2"; shift 2 ;;
|
||||
--body) [ $# -ge 2 ] || die "--body needs a value"; body="$2"; shift 2 ;;
|
||||
--body-file) [ $# -ge 2 ] || die "--body-file needs a path"; body_file="$2"; shift 2 ;;
|
||||
--status) [ $# -ge 2 ] || die "--status needs a value"; status="$2"; shift 2 ;;
|
||||
--label) [ $# -ge 2 ] || die "--label needs a value"; labels+=("$2"); shift 2 ;;
|
||||
--project) [ $# -ge 2 ] || die "--project needs a number"; project="$2"; shift 2 ;;
|
||||
--repo) [ $# -ge 2 ] || die "--repo needs OWNER/NAME"; repo="$2"; shift 2 ;;
|
||||
--dry-run) dry_run=1; shift ;;
|
||||
-h|--help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"
|
||||
exit 0 ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$title" ] || die "--title is required"
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
die "pass --body or --body-file, not both"
|
||||
fi
|
||||
[ -n "$body" ] || [ -n "$body_file" ] || die "one of --body or --body-file is required"
|
||||
if [ -n "$body_file" ] && [ ! -r "$body_file" ]; then
|
||||
die "--body-file is not readable: $body_file"
|
||||
fi
|
||||
case "$project" in
|
||||
''|*[!0-9]*) die "--project must be a number, got: $project" ;;
|
||||
esac
|
||||
|
||||
command -v gh >/dev/null 2>&1 || die "gh is not on PATH"
|
||||
|
||||
if [ -z "$repo" ]; then
|
||||
repo=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') \
|
||||
|| die "could not resolve the repository; pass --repo OWNER/NAME"
|
||||
fi
|
||||
owner="${repo%%/*}"
|
||||
[ -n "$owner" ] || die "could not read an owner out of: $repo"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolve the board by NAME. Every id below is read fresh; none is hardcoded.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The $names in the query are GraphQL variables, declared by the query and bound by the
|
||||
# -f flags. Expanding them in the shell would send this shell's idea of $owner to the
|
||||
# API instead of declaring a parameter -- which is why every query here is single-quoted.
|
||||
# shellcheck disable=SC2016
|
||||
board=$(gh api graphql \
|
||||
-f query='
|
||||
query($owner: String!, $number: Int!) {
|
||||
user(login: $owner) {
|
||||
projectV2(number: $number) {
|
||||
id
|
||||
title
|
||||
field(name: "Status") {
|
||||
... on ProjectV2SingleSelectField { id options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' \
|
||||
-f owner="$owner" -F number="$project" 2>&1) \
|
||||
|| die "could not read project $project for $owner. A 403 naming scopes means gh is
|
||||
missing 'project'; a 403 naming a rate limit is the GraphQL budget, not permissions. The
|
||||
API said: $board"
|
||||
|
||||
project_id=$(printf '%s' "$board" | jq -r '.data.user.projectV2.id // empty')
|
||||
field_id=$(printf '%s' "$board" | jq -r '.data.user.projectV2.field.id // empty')
|
||||
project_title=$(printf '%s' "$board" | jq -r '.data.user.projectV2.title // empty')
|
||||
|
||||
[ -n "$project_id" ] || die "no project number $project under user $owner"
|
||||
[ -n "$field_id" ] || die "project $project has no single-select field named 'Status'"
|
||||
|
||||
# Case-insensitive match, so "backlog" and "Backlog" both work. The canonical name is
|
||||
# what gets reported back, so a sloppy argument still produces an exact log line.
|
||||
option=$(printf '%s' "$board" | jq -r --arg want "$status" '
|
||||
.data.user.projectV2.field.options[]
|
||||
| select((.name | ascii_downcase) == ($want | ascii_downcase))
|
||||
| "\(.id)\t\(.name)"' | head -n 1)
|
||||
|
||||
if [ -z "$option" ]; then
|
||||
printf 'file-issue: no Status option named %s. Available:\n' "$status" >&2
|
||||
printf '%s' "$board" | jq -r '.data.user.projectV2.field.options[] | " " + .name' >&2
|
||||
exit "$EXIT_USAGE"
|
||||
fi
|
||||
option_id="${option%%$'\t'*}"
|
||||
status_canonical="${option#*$'\t'}"
|
||||
|
||||
printf 'repo %s\n' "$repo"
|
||||
printf 'board %s (project %s)\n' "$project_title" "$project"
|
||||
printf 'status %s\n' "$status_canonical"
|
||||
printf 'labels %s\n' "${labels[*]:-(none)}"
|
||||
printf 'title %s\n' "$title"
|
||||
|
||||
if [ "$dry_run" -eq 1 ]; then
|
||||
printf '\ndry run: everything above resolved; nothing was created.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create. Past this line a failure can leave an issue off the board, so every
|
||||
# error path prints the number.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
create_args=(--repo "$repo" --title "$title")
|
||||
if [ -n "$body_file" ]; then
|
||||
create_args+=(--body-file "$body_file")
|
||||
else
|
||||
create_args+=(--body "$body")
|
||||
fi
|
||||
for label in ${labels[@]+"${labels[@]}"}; do
|
||||
create_args+=(--label "$label")
|
||||
done
|
||||
|
||||
issue_url=$(gh issue create "${create_args[@]}") || die "gh issue create failed; nothing was filed"
|
||||
issue_number="${issue_url##*/}"
|
||||
case "$issue_number" in
|
||||
''|*[!0-9]*) die "could not read an issue number out of: $issue_url" ;;
|
||||
esac
|
||||
|
||||
orphaned() {
|
||||
printf 'file-issue: %s\n' "$1" >&2
|
||||
printf 'file-issue: THE ISSUE EXISTS BUT IS NOT ON THE BOARD. Fix it by hand:\n' >&2
|
||||
printf '%s\n' "$issue_url" >&2
|
||||
exit "$EXIT_ORPHANED"
|
||||
}
|
||||
|
||||
content_id=$(gh api "/repos/$repo/issues/$issue_number" --jq '.node_id') \
|
||||
|| orphaned "could not read the node id for #$issue_number"
|
||||
|
||||
# shellcheck disable=SC2016 # GraphQL variables, as above
|
||||
item_id=$(gh api graphql \
|
||||
-f query='
|
||||
mutation($project: ID!, $content: ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $project, contentId: $content}) {
|
||||
item { id }
|
||||
}
|
||||
}' \
|
||||
-f project="$project_id" -f content="$content_id" \
|
||||
--jq '.data.addProjectV2ItemById.item.id') \
|
||||
|| orphaned "could not add #$issue_number to the board"
|
||||
[ -n "$item_id" ] || orphaned "the board add returned no item id for #$issue_number"
|
||||
|
||||
# shellcheck disable=SC2016 # GraphQL variables, as above
|
||||
gh api graphql \
|
||||
-f query='
|
||||
mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $project, itemId: $item, fieldId: $field,
|
||||
value: {singleSelectOptionId: $option}
|
||||
}) { projectV2Item { id } }
|
||||
}' \
|
||||
-f project="$project_id" -f item="$item_id" -f field="$field_id" -f option="$option_id" \
|
||||
>/dev/null \
|
||||
|| orphaned "#$issue_number is on the board but its Status could not be set"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read back. A mutation returning 200 is not evidence the board shows what was
|
||||
# asked for -- this is the only check that is.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# shellcheck disable=SC2016 # GraphQL variables, as above
|
||||
readback=$(gh api graphql \
|
||||
-f query='
|
||||
query($item: ID!) {
|
||||
node(id: $item) {
|
||||
... on ProjectV2Item {
|
||||
content { ... on Issue { number } }
|
||||
fieldValueByName(name: "Status") {
|
||||
... on ProjectV2ItemFieldSingleSelectValue { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' \
|
||||
-f item="$item_id") \
|
||||
|| orphaned "#$issue_number was written but could not be read back"
|
||||
|
||||
seen_number=$(printf '%s' "$readback" | jq -r '.data.node.content.number // empty')
|
||||
seen_status=$(printf '%s' "$readback" | jq -r '.data.node.fieldValueByName.name // empty')
|
||||
|
||||
if [ "$seen_number" != "$issue_number" ]; then
|
||||
orphaned "read-back names issue #${seen_number:-<none>}, expected #$issue_number"
|
||||
fi
|
||||
if [ "$seen_status" != "$status_canonical" ]; then
|
||||
orphaned "read-back Status is ${seen_status:-<unset>}, expected $status_canonical"
|
||||
fi
|
||||
|
||||
printf '\n#%s on %s as %s -- verified by read-back\n' \
|
||||
"$issue_number" "$project_title" "$seen_status"
|
||||
printf '%s\n' "$issue_url"
|
||||
+390
-50
@@ -3,13 +3,26 @@
|
||||
# Runs the instrumented suite on a local emulator, on this workstation, for one or more
|
||||
# API levels.
|
||||
#
|
||||
# Usage: tools/local-emulator/run-e2e.sh [API ...] # default: 33 34 35 36
|
||||
# Usage: tools/local-emulator/run-e2e.sh [API ...] # default: 33 34 35 36 37
|
||||
#
|
||||
# GPU_MODE=host renderer to use; see the refusal list below
|
||||
# API levels are the labels below, not SDK ints: 33-36, plus `37` (= `37.0`) and `37.1`.
|
||||
#
|
||||
# GPU_MODE= force one renderer on every level; unset means per-API (gpu_for_api)
|
||||
# EMULATOR_PORT=5560 console port, so the serial is deterministic
|
||||
# BOOT_TIMEOUT=300 seconds to wait for sys.boot_completed
|
||||
# KEEP_AVD=1 do not delete an AVD this script created
|
||||
#
|
||||
# EXIT CODE: 0 only if every level was green; 1 if any level failed, wedged or could not be
|
||||
# set up; 2 if it refused to start at all. **A bare `run-e2e.sh` therefore exits 1 by design.**
|
||||
# API 37 is in the default list on purpose -- leaving it out is what left the level unlooked-at
|
||||
# for as long as it was -- and it is permanently short of green, on the emulator image rather
|
||||
# than on anything this app does. Since 2026-08-24 it does not even FINISH: one of its expected
|
||||
# failures kills the framework, so the totals come back short with an arbitrary tail. The summary
|
||||
# names every failure it expects, so an unnamed one is visibly new, and the last line printed
|
||||
# says the same thing. Anything that reads a non-zero exit as breakage should name the levels it
|
||||
# wants: `run-e2e.sh 33 34 35 36` is the sweep that can be green.
|
||||
# docs/api-37-emulator-crash.md has the measurements.
|
||||
#
|
||||
# WHY THIS EXISTS, AND WHAT IT DELIBERATELY DOES NOT DO
|
||||
#
|
||||
# It is a *launcher*, not a second test harness. The diagnostics -- the FAILED-vs-WEDGED
|
||||
@@ -34,6 +47,14 @@
|
||||
# those modes was measured crashing. docs/local-emulator.md has the backtrace, the faulting
|
||||
# page's RW-without-E segment flags, and the full mode matrix.
|
||||
#
|
||||
# AND THE ONE THING API 37 NEEDS THAT 33-36 DO NOT: the opposite renderer. On the API 37
|
||||
# images the guest's Gralloc5 mapper aborts surfaceflinger from RegionSamplingThread
|
||||
# (`Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma`). Under `-gpu host` that
|
||||
# repeats every few seconds and the device never boots; under ANGLE it fires a handful of
|
||||
# times and the boot survives. So `host` is required below 37 and forbidden at 37, which is
|
||||
# why the renderer is chosen per level in gpu_for_api rather than set once.
|
||||
# docs/api-37-emulator-crash.md has that matrix.
|
||||
#
|
||||
# THE OTHER LOCAL-ONLY HAZARD: a physical Pixel is usually plugged into this machine, so
|
||||
# `adb` is ambiguous in a way it never is on a runner, and an unpinned run would install
|
||||
# and execute this suite on the phone. Every path below pins the emulator serial.
|
||||
@@ -65,12 +86,14 @@ export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||
|
||||
GPU_MODE="${GPU_MODE:-host}"
|
||||
# Empty means "let each level pick" -- see gpu_for_api. Setting GPU_MODE forces one renderer
|
||||
# on every level, which is what you want when measuring a mode, not when running the suite.
|
||||
GPU_MODE="${GPU_MODE:-}"
|
||||
EMULATOR_PORT="${EMULATOR_PORT:-5560}"
|
||||
BOOT_TIMEOUT="${BOOT_TIMEOUT:-300}"
|
||||
SERIAL="emulator-${EMULATOR_PORT}"
|
||||
APIS=("$@")
|
||||
[ "${#APIS[@]}" -eq 0 ] && APIS=(33 34 35 36)
|
||||
[ "${#APIS[@]}" -eq 0 ] && APIS=(33 34 35 36 37)
|
||||
|
||||
# Matches CI. `disk-size: 8G` because the FFmpeg libraries do not fit the default userdata
|
||||
# partition; `ram-size: 2560M` because the emulator's own floor varies by API level and
|
||||
@@ -83,21 +106,28 @@ RESULTS_DIR="app/build/outputs/androidTest-results"
|
||||
LOG_DIR="${TMPDIR:-/tmp}/lmc-local-e2e"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# The two things that outlive a level, declared here rather than where they are first
|
||||
# assigned, because the cleanup trap below can fire before either has been reached.
|
||||
EMU_PID=""
|
||||
CREATED_AVDS=()
|
||||
|
||||
# ---------------------------------------------------------------- renderer preflight ---
|
||||
case "$GPU_MODE" in
|
||||
swiftshader_indirect | auto | off | guest)
|
||||
echo "REFUSING to launch with -gpu $GPU_MODE."
|
||||
echo "On this host that resolves to SwiftShader's GLES, whose JIT is denied execheap by"
|
||||
echo "SELinux; the emulator segfaults (exit 139) before boot. See docs/local-emulator.md."
|
||||
echo "Working modes: host (default), angle_indirect, swangle_indirect."
|
||||
exit 2
|
||||
;;
|
||||
host | angle_indirect | swangle_indirect) ;;
|
||||
*)
|
||||
echo "Unrecognised GPU_MODE '$GPU_MODE'. Known-good: host, angle_indirect, swangle_indirect."
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
if [ -n "$GPU_MODE" ]; then
|
||||
case "$GPU_MODE" in
|
||||
swiftshader_indirect | auto | off | guest)
|
||||
echo "REFUSING to launch with -gpu $GPU_MODE."
|
||||
echo "On this host that resolves to SwiftShader's GLES, whose JIT is denied execheap by"
|
||||
echo "SELinux; the emulator segfaults (exit 139) before boot. See docs/local-emulator.md."
|
||||
echo "Working modes: host, angle_indirect, swangle_indirect."
|
||||
exit 2
|
||||
;;
|
||||
host | angle_indirect | swangle_indirect) ;;
|
||||
*)
|
||||
echo "Unrecognised GPU_MODE '$GPU_MODE'. Known-good: host, angle_indirect, swangle_indirect."
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# A courtesy, not a gate: the boolean being on means SwiftShader would work too, and the
|
||||
# refusal list above could be relaxed. It is off on a stock Fedora.
|
||||
@@ -121,19 +151,109 @@ host_forensics() {
|
||||
journalctl --since "$since" --no-pager 2> /dev/null | grep -E 'avc: .*denied' | tail -10 || echo " (none)"
|
||||
}
|
||||
|
||||
# The API 37 counterpart of host_forensics. `-gpu host` there aborts surfaceflinger in a loop
|
||||
# and the device never boots; the working renderers abort it a few times and survive. Either way
|
||||
# the count is the number to look at, and the crash buffer is where it lives -- so print it on
|
||||
# every 37 level, not only on the failure path, because a level that passed with 40 aborts is
|
||||
# telling you something a level that passed with 1 is not.
|
||||
guest_forensics() {
|
||||
local api="$1" n
|
||||
case "$api" in 37 | 37.*) ;; *) return 0 ;; esac
|
||||
n="$(emu_adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma')"
|
||||
echo " surfaceflinger hasReadColorBufferDma aborts: ${n:-?} (docs/api-37-emulator-crash.md)"
|
||||
}
|
||||
|
||||
# API label -> system image. API 33-36 are plain integers with a `google_apis` image. API 37
|
||||
# is not: its SDK directories are dotted minor versions (`android-37.0`, `android-37.1`), there
|
||||
# is no `android-37`, and from 37.1 onwards Google ships only 16 KB-page (`ps16k`) images for
|
||||
# x86_64. `37` is accepted as a spelling of `37.0` because that is what people type.
|
||||
image_pkg_for_api() {
|
||||
case "$1" in
|
||||
37 | 37.0) echo "system-images;android-37.0;google_apis;x86_64" ;;
|
||||
37.1) echo "system-images;android-37.1;google_apis_ps16k;x86_64" ;;
|
||||
*) echo "system-images;android-$1;google_apis;x86_64" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The renderer requirement is per-API and the two levels want OPPOSITE things, which is why this
|
||||
# is a function and not a constant.
|
||||
#
|
||||
# 33-36: must NOT be SwiftShader GLES (host-side SELinux/execheap segfault) -- `host` is right.
|
||||
# 37.x: must NOT be the host GL translator. With `-gpu host` the guest's Gralloc5 mapper
|
||||
# aborts surfaceflinger in a loop and the device never boots; under ANGLE the same
|
||||
# assertion fires a handful of times and the boot survives it. Measured, not guessed --
|
||||
# docs/api-37-emulator-crash.md has the matrix.
|
||||
#
|
||||
# `swangle_indirect` rather than `angle_indirect` for 37: both boot, and swangle names its
|
||||
# renderer outright instead of resolving through `auto`'s path.
|
||||
gpu_for_api() {
|
||||
if [ -n "$GPU_MODE" ]; then
|
||||
echo "$GPU_MODE"
|
||||
return
|
||||
fi
|
||||
case "$1" in
|
||||
37 | 37.*) echo "swangle_indirect" ;;
|
||||
*) echo "host" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# `lmc_e2e_api37.0` would be a legal AVD name but an awkward one to type and to grep for.
|
||||
# `37` and `37.0` therefore give two AVD names (`lmc_e2e_api37`, `lmc_e2e_api37_0`) for the one
|
||||
# image. Harmless -- two AVDs off the same system image cost only disk -- and deliberately not
|
||||
# normalised, so that `run-e2e.sh 37 37.0` does not have both levels fight over one AVD.
|
||||
avd_for_api() { echo "lmc_e2e_api${1//./_}"; }
|
||||
|
||||
# Where avdmanager actually put the AVD. `$HOME/.android/avd` is only the default:
|
||||
# ANDROID_AVD_HOME, ANDROID_USER_HOME and ANDROID_SDK_HOME each move it, and hardcoding the
|
||||
# default meant a machine that sets any of them silently ran every level at stock RAM and
|
||||
# userdata size. Rather than encode a precedence that cannot be verified from here, look in
|
||||
# every location avdmanager honours and let the existence check pick.
|
||||
avd_config_path() {
|
||||
local avd="$1" base cfg
|
||||
for base in "${ANDROID_AVD_HOME:-}" \
|
||||
"${ANDROID_USER_HOME:+$ANDROID_USER_HOME/avd}" \
|
||||
"${ANDROID_SDK_HOME:+$ANDROID_SDK_HOME/.android/avd}" \
|
||||
"$HOME/.android/avd"; do
|
||||
[ -n "$base" ] || continue
|
||||
cfg="$base/${avd}.avd/config.ini"
|
||||
if [ -f "$cfg" ]; then
|
||||
echo "$cfg"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_avd() {
|
||||
local api="$1" avd="$2"
|
||||
local pkg="system-images;android-${api};google_apis;x86_64"
|
||||
local pkg
|
||||
pkg="$(image_pkg_for_api "$api")"
|
||||
local img_dir="$ANDROID_HOME/system-images/${pkg#system-images;}"
|
||||
img_dir="${img_dir//;//}"
|
||||
|
||||
if avdmanager list avd -c 2> /dev/null | grep -qx "$avd"; then
|
||||
echo " reusing existing AVD $avd"
|
||||
else
|
||||
if [ ! -d "$ANDROID_HOME/system-images/android-${api}/google_apis/x86_64" ]; then
|
||||
if [ ! -d "$img_dir" ]; then
|
||||
echo " installing $pkg"
|
||||
yes | sdkmanager --install "$pkg" > /dev/null 2>&1 || {
|
||||
# Read sdkmanager's own status, not the pipeline's. `yes` never ends, so the moment
|
||||
# sdkmanager exits and closes the pipe, `yes` dies of SIGPIPE with 141 -- and this
|
||||
# script runs under `pipefail`, which takes the rightmost NON-ZERO status. A package
|
||||
# that installed perfectly therefore reported "FAILED to install".
|
||||
#
|
||||
# Measured rather than reasoned: under `set -o pipefail`, `yes | true` exits 141 on
|
||||
# every run, and `yes | sh -c 'exit 3'` exits 3 -- so the pipeline status cannot tell
|
||||
# a clean install from a broken one, while ${PIPESTATUS[1]} reports 0 and 3.
|
||||
#
|
||||
# The `echo no | avdmanager` below is deliberately NOT changed. One line fits the pipe
|
||||
# buffer, so echo has already exited before the close and there is no signal to
|
||||
# receive; `echo no | true` measured 0 on every run. Only an unbounded producer is
|
||||
# exposed to this.
|
||||
yes | sdkmanager --install "$pkg" > /dev/null 2>&1
|
||||
if [ "${PIPESTATUS[1]}" -ne 0 ]; then
|
||||
echo " FAILED to install $pkg"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
echo " creating AVD $avd from $pkg"
|
||||
echo no | avdmanager create avd -n "$avd" -k "$pkg" -d pixel_6 --force > /dev/null 2>&1 || {
|
||||
@@ -144,18 +264,37 @@ ensure_avd() {
|
||||
fi
|
||||
|
||||
# Written into config.ini rather than passed on the command line, which is how
|
||||
# reactivecircus/android-emulator-runner applies the same two settings in CI.
|
||||
local cfg="$HOME/.android/avd/${avd}.avd/config.ini"
|
||||
sed -i -e '/^disk\.dataPartition\.size=/d' -e '/^hw\.ramSize=/d' "$cfg"
|
||||
printf 'disk.dataPartition.size=%s\nhw.ramSize=%s\n' "$DISK_SIZE_BYTES" "$RAM_SIZE_MB" >> "$cfg"
|
||||
# reactivecircus/android-emulator-runner applies the same two settings in CI. On the reuse
|
||||
# path too, so an AVD left over from an older run gets today's pins.
|
||||
#
|
||||
# A level that cannot be pinned FAILS rather than running at the defaults. Unpinned, it
|
||||
# dies much later with "not enough space", which reads as a device problem -- CI's own
|
||||
# history is where that lesson comes from -- and nothing points back to a `sed` that
|
||||
# edited a path this script guessed wrong.
|
||||
local cfg
|
||||
if ! cfg="$(avd_config_path "$avd")"; then
|
||||
echo " FAILED: no config.ini for $avd in any directory avdmanager uses"
|
||||
echo " (ANDROID_AVD_HOME=${ANDROID_AVD_HOME:-unset}, ANDROID_USER_HOME=${ANDROID_USER_HOME:-unset},"
|
||||
echo " ANDROID_SDK_HOME=${ANDROID_SDK_HOME:-unset}, HOME=$HOME)"
|
||||
return 1
|
||||
fi
|
||||
if ! sed -i -e '/^disk\.dataPartition\.size=/d' -e '/^hw\.ramSize=/d' "$cfg"; then
|
||||
echo " FAILED to rewrite $cfg"
|
||||
return 1
|
||||
fi
|
||||
if ! printf 'disk.dataPartition.size=%s\nhw.ramSize=%s\n' \
|
||||
"$DISK_SIZE_BYTES" "$RAM_SIZE_MB" >> "$cfg"; then
|
||||
echo " FAILED to write the RAM/disk pins into $cfg"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
boot_emulator() {
|
||||
local avd="$1" api="$2"
|
||||
local avd="$1" api="$2" gpu="$3"
|
||||
local boot_log="$LOG_DIR/emulator-api${api}.log"
|
||||
|
||||
emulator -avd "$avd" -port "$EMULATOR_PORT" \
|
||||
-no-window -gpu "$GPU_MODE" -noaudio -no-boot-anim -camera-back none -no-snapshot \
|
||||
-no-window -gpu "$gpu" -noaudio -no-boot-anim -camera-back none -no-snapshot \
|
||||
> "$boot_log" 2>&1 &
|
||||
EMU_PID=$!
|
||||
|
||||
@@ -181,6 +320,103 @@ boot_emulator() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# API 37 only, and the reason API 37 can be run at all.
|
||||
#
|
||||
# The abort that breaks these images is reached from SurfaceFlinger's RegionSamplingThread,
|
||||
# which exists only because SystemUI registers a nav-bar luma-sampling listener. Each abort
|
||||
# kills surfaceflinger, and init responds by SIGKILLing zygote -- so the whole framework
|
||||
# restarts underneath the test run, which arrives as `Can't find service: package` and
|
||||
# `INSTRUMENTATION_ABORTED: System has crashed`. Under the host GL renderer that repeats
|
||||
# forever; under ANGLE it is roughly one every fifteen seconds, which a five-minute suite does
|
||||
# not survive either.
|
||||
#
|
||||
# Removing the listener removes the whole chain. Measured on android-37.0 under
|
||||
# swangle_indirect: 10-11 aborts per 150 s idle with SystemUI running, and 0 in 180 s with it
|
||||
# disabled, framework services up throughout.
|
||||
#
|
||||
# THIS IS A DEVIATION, and it is deliberately loud rather than silent. The API 37 leg does not
|
||||
# run the same device configuration as API 33-36 or as the Pixel. It is defensible only
|
||||
# because nothing in this suite touched SystemUI -- Media3, FFmpeg and WorkManager tests --
|
||||
# and because the alternative is no API 37 coverage at all. Anything that ever does depend on
|
||||
# system UI must not trust this leg. docs/api-37-emulator-crash.md explains why.
|
||||
#
|
||||
# "Touched", past tense, since 2026-08-24. SafPickerRoundTripTest drives DocumentsUI and rotates
|
||||
# the display, and both reach the gralloc mapper these images abort in -- disabling SystemUI
|
||||
# removes the IDLE trigger, not those. Measured per method on android-37.0: the ROTATION test
|
||||
# takes the framework down (INSTRUMENTATION_ABORTED) and carries @FailsOnEmulatorApi37; the
|
||||
# picker test passes.
|
||||
#
|
||||
# THIS SCRIPT APPLIES NO ANNOTATION FILTER, unlike CI, so a local `run-e2e.sh 37` runs the
|
||||
# rotation test anyway -- and because that test kills the framework rather than merely failing,
|
||||
# THE LEVEL DOES NOT FINISH. Its totals come back short and which later tests ran is arbitrary.
|
||||
# CI's gating leg never sees it.
|
||||
#
|
||||
# The retry loop is not defensive padding: at the moment boot_completed flips, the framework
|
||||
# may be in one of its restarts and `pm` is simply not published yet. The first attempt at this
|
||||
# failed exactly that way, with `cmd: Can't find service: package`.
|
||||
#
|
||||
# The framework restart at the end is not optional, and finding that out cost a run. By the
|
||||
# time `sys.boot_completed` flips, SystemUI has already registered its region-sampling listener,
|
||||
# and `pm disable-user` does not retract a registration that already happened -- it only stops
|
||||
# the package being started again. So the first attempt disabled SystemUI, reported success, and
|
||||
# then died exactly as before with `Starting 0 tests` and four more aborts. `stop; start` cycles
|
||||
# zygote deliberately, and the framework that comes back up does not start SystemUI at all.
|
||||
disable_region_sampling() {
|
||||
local api="$1" out i before after ready
|
||||
case "$api" in 37 | 37.*) ;; *) return 0 ;; esac
|
||||
|
||||
out=""
|
||||
for i in $(seq 1 20); do
|
||||
out="$(emu_adb shell pm disable-user --user 0 com.android.systemui 2>&1 | tr -d '\r')"
|
||||
case "$out" in
|
||||
*"new state: disabled"*)
|
||||
echo " SystemUI disabled on attempt $i"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
out=""
|
||||
sleep 5
|
||||
done
|
||||
if [ -z "$out" ]; then
|
||||
echo " WARNING: could not disable SystemUI after 20 attempts."
|
||||
echo " Expect INSTRUMENTATION_ABORTED -- docs/api-37-emulator-crash.md"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " restarting the framework so the region-sampling listener goes with it"
|
||||
emu_adb shell stop > /dev/null 2>&1
|
||||
emu_adb shell start > /dev/null 2>&1
|
||||
# There is no property worth waiting on here, and an earlier version of this only looked
|
||||
# like it was waiting on one: `stop` does not clear sys.boot_completed, so it still reads
|
||||
# `1` throughout the restart and any loop over it returns at once. The loop below is the
|
||||
# wait -- and it polls the better thing anyway, since `Can't find service: package` is the
|
||||
# failure it exists to prevent.
|
||||
ready=0
|
||||
for i in $(seq 1 30); do
|
||||
if emu_adb shell service check package 2> /dev/null | grep -q ': found' \
|
||||
&& emu_adb shell service check activity 2> /dev/null | grep -q ': found'; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo " WARNING: package and activity services still absent 150 s after the restart."
|
||||
echo " Expect INSTRUMENTATION_ABORTED -- docs/api-37-emulator-crash.md"
|
||||
fi
|
||||
|
||||
# Prove it worked rather than assume it. Zero new aborts over this window is what makes the
|
||||
# difference between a run that completes and one that reports `Starting 0 tests`.
|
||||
before="$(emu_adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma')"
|
||||
emu_adb shell 'sleep 45' > /dev/null 2>&1
|
||||
after="$(emu_adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma')"
|
||||
echo " quiet check: $((after - before)) new surfaceflinger aborts in 45 s (want 0)"
|
||||
if [ "$((after - before))" -ne 0 ]; then
|
||||
echo " WARNING: region sampling is still live; the run may not survive."
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# CI gets this from the action's `disable-animations: true`.
|
||||
disable_animations() {
|
||||
local s
|
||||
@@ -189,17 +425,81 @@ disable_animations() {
|
||||
done
|
||||
}
|
||||
|
||||
# `${EMU_PID:-0}` used to guard these three calls, and it guarded the wrong thing: EMU_PID
|
||||
# is *empty*, not unset, if the background launch never produced a job, and `kill` reads pid
|
||||
# 0 as "the sender's whole process group" -- this script and, on a terminal, everything else
|
||||
# in the foreground group with it. The `kill -0` wait loop had the same shape and would have
|
||||
# spent its full grace period testing the group. Nothing to stop is now a return, never a
|
||||
# guess. (boot_emulator's own `kill -0 "$EMU_PID"` is unguarded and cannot reach that form:
|
||||
# it runs only after the assignment.)
|
||||
#
|
||||
# max_wait is a parameter so the interrupt path need not sit through the full grace period.
|
||||
stop_emulator() {
|
||||
local max_wait="${1:-30}" waited=0
|
||||
[ -n "${EMU_PID:-}" ] || return 0
|
||||
emu_adb emu kill > /dev/null 2>&1
|
||||
local waited=0
|
||||
while kill -0 "${EMU_PID:-0}" 2> /dev/null && [ "$waited" -lt 30 ]; do
|
||||
while kill -0 "$EMU_PID" 2> /dev/null && [ "$waited" -lt "$max_wait" ]; do
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
kill -9 "${EMU_PID:-0}" 2> /dev/null
|
||||
wait "${EMU_PID:-0}" 2> /dev/null
|
||||
kill -9 "$EMU_PID" 2> /dev/null
|
||||
wait "$EMU_PID" 2> /dev/null
|
||||
EMU_PID=""
|
||||
}
|
||||
|
||||
delete_created_avds() {
|
||||
local avd
|
||||
[ "${KEEP_AVD:-0}" = "1" ] && return 0
|
||||
for avd in ${CREATED_AVDS[@]+"${CREATED_AVDS[@]}"}; do
|
||||
# A SIGKILLed emulator does not get to remove its own lock files, and avdmanager can
|
||||
# refuse over them. Staying silent there would leak the very thing this exists to clean.
|
||||
avdmanager delete avd -n "$avd" > /dev/null 2>&1 \
|
||||
|| echo " WARNING: could not delete AVD $avd -- 'avdmanager delete avd -n $avd' by hand"
|
||||
done
|
||||
CREATED_AVDS=()
|
||||
}
|
||||
|
||||
# What an interrupted sweep used to leave behind: a headless emulator holding console port
|
||||
# $EMULATOR_PORT, and an lmc_e2e_apiNN AVD. The next run's `emulator -port` then collides
|
||||
# with the orphan, and `emu_adb` can resolve to it -- on a workstation that also has the
|
||||
# Pixel plugged in, exactly the ambiguity the ANDROID_SERIAL pinning exists to prevent. A
|
||||
# sweep is up to five boots long, so the window for one Ctrl-C is not small.
|
||||
#
|
||||
# Idempotent, and called explicitly on the normal path so its output cannot land after the
|
||||
# summary; the EXIT trap then finds nothing left to do. The emulator logs are deliberately
|
||||
# NOT removed -- they live in $LOG_DIR and are the only evidence a failed boot leaves.
|
||||
CLEANED=0
|
||||
cleanup() {
|
||||
[ "$CLEANED" = "1" ] && return 0
|
||||
CLEANED=1
|
||||
stop_emulator "${1:-30}"
|
||||
delete_created_avds
|
||||
}
|
||||
|
||||
# 6 s, not 30: Ctrl-C has already reached the emulator through the foreground process group,
|
||||
# so this is only waiting for it to finish writing, and `kill -9` follows regardless. The
|
||||
# EXIT trap is disarmed before exiting so the status below is the one that survives.
|
||||
#
|
||||
# bash runs a trap only between commands, so this starts when whatever was in the foreground
|
||||
# returns -- which for Ctrl-C is immediately, because the same interrupt reached that command
|
||||
# too. `kill -INT` aimed at this script alone waits for the foreground command to finish.
|
||||
# Invoked indirectly -- installed as the INT and TERM trap a few lines below. Both codes,
|
||||
# because shellcheck 0.9.0 reports this as unreachable commands (SC2317) and 0.11.0 as an
|
||||
# uninvoked function (SC2329); CI pins 0.11.0 but a local install may be either.
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
on_signal() {
|
||||
echo
|
||||
echo "interrupted (SIG$1) -- stopping the emulator and removing the AVDs this run created"
|
||||
echo " emulator logs kept in $LOG_DIR"
|
||||
cleanup 6
|
||||
trap - EXIT
|
||||
exit "$2"
|
||||
}
|
||||
|
||||
trap 'on_signal INT 130' INT
|
||||
trap 'on_signal TERM 143' TERM
|
||||
trap cleanup EXIT
|
||||
|
||||
# The XML is authoritative. The console counter double-counts skips, so a run that reports
|
||||
# "42 tests" on stdout can be 40 in the report.
|
||||
#
|
||||
@@ -236,37 +536,44 @@ PY
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------ main ---
|
||||
CREATED_AVDS=()
|
||||
SUMMARY=()
|
||||
overall=0
|
||||
|
||||
for api in "${APIS[@]}"; do
|
||||
if [ "$api" = "37" ] || [ "$api" = "37.0" ]; then
|
||||
echo "SKIPPING API $api: the android-37.0 image crash-loops surfaceflinger."
|
||||
echo " See docs/api-37-emulator-crash.md. Test API 37 on the physical Pixel."
|
||||
continue
|
||||
fi
|
||||
# Whether the red exit is the expected one depends on which level produced it, and only the
|
||||
# loop knows that -- so it is recorded where `overall` is set rather than guessed from the
|
||||
# summary afterwards. A note at the end claiming a genuine API 34 failure was "by design"
|
||||
# would be the same defect it is there to prevent, one layer up.
|
||||
NON37_RED=0
|
||||
mark_red() {
|
||||
overall=1
|
||||
case "$1" in 37 | 37.*) ;; *) NON37_RED=1 ;; esac
|
||||
}
|
||||
|
||||
avd="lmc_e2e_api${api}"
|
||||
for api in "${APIS[@]}"; do
|
||||
avd="$(avd_for_api "$api")"
|
||||
gpu="$(gpu_for_api "$api")"
|
||||
started="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "=============================================================="
|
||||
echo "API $api (avd=$avd gpu=$GPU_MODE serial=$SERIAL)"
|
||||
echo "API $api (avd=$avd gpu=$gpu serial=$SERIAL)"
|
||||
echo " image: $(image_pkg_for_api "$api")"
|
||||
echo "=============================================================="
|
||||
|
||||
if ! ensure_avd "$api" "$avd"; then
|
||||
SUMMARY+=("API $api: AVD SETUP FAILED")
|
||||
overall=1
|
||||
mark_red "$api"
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! boot_emulator "$avd" "$api"; then
|
||||
if ! boot_emulator "$avd" "$api" "$gpu"; then
|
||||
host_forensics "$started"
|
||||
guest_forensics "$api"
|
||||
SUMMARY+=("API $api: BOOT FAILED")
|
||||
overall=1
|
||||
mark_red "$api"
|
||||
stop_emulator
|
||||
continue
|
||||
fi
|
||||
|
||||
disable_region_sampling "$api"
|
||||
disable_animations
|
||||
rm -rf "$RESULTS_DIR"
|
||||
|
||||
@@ -281,23 +588,56 @@ for api in "${APIS[@]}"; do
|
||||
unset ANDROID_SERIAL E2E_EXTRA_GRADLE_ARGS
|
||||
|
||||
line="$(summarise_results "$api")"
|
||||
guest_forensics "$api"
|
||||
# API 37 is in the default list on purpose, and it is expected to be red. Leaving it out would
|
||||
# put the level back where this whole exercise found it -- untested and unlooked-at -- but a
|
||||
# summary that just says "N failures" with no explanation trains people to ignore the exit
|
||||
# code. So the row NAMES the expected ones, and anything else is then obviously new.
|
||||
#
|
||||
# The list grew on 2026-08-24 and the shape of the row changed with it. The two
|
||||
# Media3EngineTest failures are a codec; the third is the gralloc bug reached through system
|
||||
# UI, and it takes the framework DOWN rather than merely failing -- so the level does not
|
||||
# finish, and the totals come back SHORT (50 of 59 when this was written) with the later
|
||||
# tests never run. A run whose totals do not add up is expected here now, which it never
|
||||
# was before.
|
||||
case "$api" in
|
||||
37 | 37.*)
|
||||
line="$line
|
||||
expected here: 2 Media3EngineTest failures on c2.goldfish.h264.decoder, plus
|
||||
SafPickerRoundTripTest.thePickedInputSurvivesARealRotation -- which kills the framework
|
||||
rather than merely failing, so the run ABORTS partway and the total comes back SHORT with
|
||||
an arbitrary tail. That is expected here too, and never was before. Anything else is new.
|
||||
CI's gating leg sees only the first two: the rotation test carries @FailsOnEmulatorApi37
|
||||
and this script, unlike CI, applies no annotation filter.
|
||||
docs/api-37-emulator-crash.md"
|
||||
;;
|
||||
esac
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
line="$line [gradle exit $rc]"
|
||||
overall=1
|
||||
mark_red "$api"
|
||||
host_forensics "$started"
|
||||
fi
|
||||
SUMMARY+=("$line")
|
||||
stop_emulator
|
||||
done
|
||||
|
||||
if [ "${KEEP_AVD:-0}" != "1" ]; then
|
||||
for avd in ${CREATED_AVDS[@]+"${CREATED_AVDS[@]}"}; do
|
||||
avdmanager delete avd -n "$avd" > /dev/null 2>&1
|
||||
done
|
||||
fi
|
||||
cleanup
|
||||
|
||||
echo
|
||||
echo "===================== LOCAL E2E SUMMARY ======================"
|
||||
printf '%s\n' ${SUMMARY[@]+"${SUMMARY[@]}"}
|
||||
echo "=============================================================="
|
||||
|
||||
# An unexplained red exit trains people to stop reading exit codes, and this one is expected
|
||||
# whenever API 37 is in the sweep -- which the default list makes the common case. Said here
|
||||
# rather than only in the docs, because this is where it is actually read. Only when 37.x is
|
||||
# the ONLY thing that went red: a note calling a real failure elsewhere "by design" would be
|
||||
# worse than no note at all.
|
||||
if [ "$overall" -ne 0 ] && [ "$NON37_RED" -eq 0 ]; then
|
||||
echo "note: the only level that went red is API 37, which exits non-zero by design -- it is"
|
||||
echo " permanently short of green, and since 2026-08-24 it does not even finish. Confirm"
|
||||
echo " its row above names every failure it shows; docs/api-37-emulator-crash.md says why"
|
||||
echo " each of them is the image rather than this app."
|
||||
fi
|
||||
|
||||
exit "$overall"
|
||||
|
||||
Reference in New Issue
Block a user