Compare commits
218
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4de169c99b | ||
|
|
e27d7601b1 | ||
|
|
e81403c5f3 | ||
|
|
54167c052f | ||
|
|
1f21557b8d | ||
|
|
3b0c262030 | ||
|
|
18aff51c98 | ||
|
|
b23ff0f082 | ||
|
|
fa10d94192 | ||
|
|
69d5392227 | ||
|
|
e7c3e5688f | ||
|
|
b3d4318273 | ||
|
|
07f7ed4259 | ||
|
|
dc6ee3dc9b | ||
|
|
7fd95ddede | ||
|
|
350b179c9e | ||
|
|
0cc4c4f3a3 | ||
|
|
c5b2dc0f55 | ||
|
|
8db9a6f52f | ||
|
|
31f249ae04 | ||
|
|
d6e1e3bf86 | ||
|
|
6992f0e783 | ||
|
|
bb920b5bd0 | ||
|
|
802997439d | ||
|
|
495eaa4ab8 | ||
|
|
bc8e67888e | ||
|
|
706eea8709 | ||
|
|
163ce54b77 | ||
|
|
d293646f69 | ||
|
|
5416788274 | ||
|
|
ad2a75d9a0 | ||
|
|
2b921fafe4 | ||
|
|
98c0e4dba2 | ||
|
|
557b3edab4 | ||
|
|
cf540f1ecc | ||
|
|
e0412329ff | ||
|
|
97558c259f | ||
|
|
948d53b67e | ||
|
|
54932e97c6 | ||
|
|
745c4f62ce | ||
|
|
ba16f5a89b | ||
|
|
e2f8ef2918 | ||
|
|
393b931fff | ||
|
|
bffcff92c7 | ||
|
|
9f06eb9988 | ||
|
|
d759ef32f1 | ||
|
|
06ca167034 | ||
|
|
c757565d64 | ||
|
|
4d090d9a81 | ||
|
|
39327beea7 | ||
|
|
4b02294cfb | ||
|
|
79097a0256 | ||
|
|
6004398a83 | ||
|
|
a354620bf5 | ||
|
|
17c91081cd | ||
|
|
20f718842d | ||
|
|
dec7089b59 | ||
|
|
b677a9ad02 | ||
|
|
34e4ab52a4 | ||
|
|
1437157a8f | ||
|
|
1041faf920 | ||
|
|
fe68f839c1 | ||
|
|
f65578b1f7 | ||
|
|
b38ad6a683 | ||
|
|
9a0f494e26 | ||
|
|
f3478706b3 | ||
|
|
61c400d2c6 | ||
|
|
d83775d5c6 | ||
|
|
e90f5a801c | ||
|
|
68015b3374 | ||
|
|
32ab54da3c | ||
|
|
e7caeeac43 | ||
|
|
ec2cae256f | ||
|
|
4e88de3045 | ||
|
|
2db0dc65d3 | ||
|
|
6334dcba34 | ||
|
|
7e09f010c7 | ||
|
|
49249be280 | ||
|
|
2125763ebf | ||
|
|
6d700f0014 | ||
|
|
da8d53851b | ||
|
|
cc215195ee | ||
|
|
92bcff8656 | ||
|
|
a847d3a81d | ||
|
|
e4867ff956 | ||
|
|
4d5fba515a | ||
|
|
223fe6deea | ||
|
|
54ca2dda5a | ||
|
|
5a5a680b4c | ||
|
|
7f23ea8fd7 | ||
|
|
05422a6896 | ||
|
|
fa3a32e5c0 | ||
|
|
e2c9981bbe | ||
|
|
7a5622c896 | ||
|
|
d4ca6b7b0f | ||
|
|
bbe40cf8f7 | ||
|
|
f81d76e730 | ||
|
|
8849ae96eb | ||
|
|
25e14b26d9 | ||
|
|
aa7e1d8b01 | ||
|
|
794cef7b34 | ||
|
|
eded47d666 | ||
|
|
b41341a1cb | ||
|
|
0f842243b5 | ||
|
|
2fbc957119 | ||
|
|
a645442acc | ||
|
|
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 | ||
|
|
d37c391c60 |
Executable
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Exercises e2e-report-shape.sh's baseline counter against fixture source, with no emulator and
|
||||
# no CI run. Run it directly:
|
||||
#
|
||||
# .github/scripts/e2e-report-shape-test.sh
|
||||
#
|
||||
# WHY THIS CAN EXIST AT ALL: the counter is a pure function of the working tree. It greps
|
||||
# `app/src/androidTest` for `@FailsOnEmulatorApi37` and compares the total against the number
|
||||
# committed in FailsOnEmulatorApi37.kt. Nothing about that needs a device, which is the whole
|
||||
# reason #120 could be measured rather than argued about.
|
||||
#
|
||||
# WHY A THROWAWAY REPO ROOT rather than a knob on the script. The report finds its own root from
|
||||
# `BASH_SOURCE`, so a copy of it placed at `<root>/.github/scripts/` reads `<root>/app/src/...`.
|
||||
# Building that root is three mkdirs and costs the shipped script nothing:
|
||||
#
|
||||
# - the REAL script is what runs, byte for byte, so reverting the matcher reddens this test
|
||||
# rather than a testing-only code path beside it;
|
||||
# - no environment variable exists that could point the LIVE count somewhere else, which is
|
||||
# the failure mode #83 built the baseline check to prevent in the first place;
|
||||
# - XML_DIR resolves inside the throwaway root, so a stale app/build/outputs left by a real
|
||||
# run on a developer machine cannot leak into the numbers here.
|
||||
#
|
||||
# WHAT IT GUARDS (#120). The old matcher looked for the string anywhere on any line, so a KDoc
|
||||
# saying `Deliberately not @FailsOnEmulatorApi37` counted as a marked test and every PR got a
|
||||
# deviation notice that was wrong. The obvious repair -- count only lines that are nothing but
|
||||
# the annotation -- silently stops counting `@FailsOnEmulatorApi37 @Test`, which is legal Kotlin,
|
||||
# and undercounting is the direction that hides a genuine new marker. The fixture carries every
|
||||
# shape at once -- three that count and three that must not, enumerated in its own header -- so
|
||||
# both mistakes fail here instead of on a PR: against testdata/marker-shapes the old matcher says
|
||||
# 5, own-line-only says 2, and the shipped one 3.
|
||||
#
|
||||
# NOT WIRED INTO CI, deliberately and as a known gap. Adding a step to the Static analysis job
|
||||
# would add a new way for a gating job to go red, and #120 was explicit that nothing about it may
|
||||
# change any job's status or the pass/fail rules. shellcheck still covers this file, since that
|
||||
# step reads `git ls-files '*.sh'` rather than a fixed list.
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPORT="$SCRIPT_DIR/e2e-report-shape.sh"
|
||||
FIXTURE_DIR="$SCRIPT_DIR/testdata/marker-shapes"
|
||||
FIXTURE="$FIXTURE_DIR/MarkerShapes.kt"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
# Single quotes: the path is expanded when the trap fires, not when it is set.
|
||||
trap 'rm -rf -- "$TMP"' EXIT
|
||||
|
||||
failures=0
|
||||
|
||||
pass() { printf 'ok %s\n' "$1"; }
|
||||
|
||||
fail() {
|
||||
failures=$((failures + 1))
|
||||
printf 'FAIL %s\n' "$1"
|
||||
shift
|
||||
printf ' %s\n' "$@"
|
||||
}
|
||||
|
||||
# assert_contains <name> <haystack> <needle>
|
||||
# `case` rather than grep: the strings being matched carry backticks and an em dash, and this
|
||||
# way neither the shell nor a regex engine gets an opinion about them.
|
||||
assert_contains() {
|
||||
case "$2" in
|
||||
*"$3"*) pass "$1" ;;
|
||||
*) fail "$1" "wanted to find: $3" "in:" "$2" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
assert_absent() {
|
||||
case "$2" in
|
||||
*"$3"*) fail "$1" "did NOT want to find: $3" "in:" "$2" ;;
|
||||
*) pass "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# make_root <marked-tree-dir> <baseline-number>
|
||||
# Assembles a throwaway repo root around the given tree and prints its path.
|
||||
make_root() {
|
||||
local tree="$1" baseline="$2" root testdir
|
||||
root="$(mktemp -d "$TMP/root.XXXXXX")"
|
||||
testdir="$root/app/src/androidTest/java/org/libremediaconverter"
|
||||
mkdir -p "$root/.github/scripts" "$testdir"
|
||||
cp -- "$REPORT" "$root/.github/scripts/"
|
||||
cp -- "$tree"/*.kt "$testdir/"
|
||||
|
||||
# The synthetic stand-in for the committed baseline. Its KDoc names the marker the way the real
|
||||
# file does -- in brackets, never with an `@` -- because the real file lives inside the tree
|
||||
# being counted, so an `@` spelling here would add a phantom to every number below.
|
||||
cat > "$testdir/FailsOnEmulatorApi37.kt" <<EOF
|
||||
package org.libremediaconverter
|
||||
|
||||
/** Stand-in for the real marker file. Only [FAILS_ON_EMULATOR_API37_BASELINE] is read. */
|
||||
const val FAILS_ON_EMULATOR_API37_BASELINE = $baseline
|
||||
EOF
|
||||
|
||||
# A clean, untruncated run of exactly <baseline> tests, all failing -- which is what the
|
||||
# advisory leg looks like when nothing has drifted. It leaves the marked count as the only
|
||||
# field that can deviate, so every assertion below is about the thing under test.
|
||||
cat > "$root/gradle.log" <<EOF
|
||||
> Task :app:connectedDebugAndroidTest
|
||||
Starting $baseline tests on test(AVD) - 16
|
||||
There was $baseline failure(s).
|
||||
EOF
|
||||
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
# run_report <root> -- stdout of the real script; its summary lands in <root>/summary.md.
|
||||
run_report() {
|
||||
E2E_WEDGED_AFTER='' GITHUB_STEP_SUMMARY="$1/summary.md" \
|
||||
bash "$1/.github/scripts/e2e-report-shape.sh" 37 "$1/gradle.log" \
|
||||
"$1/app/src/androidTest/java/org/libremediaconverter/FailsOnEmulatorApi37.kt"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The fixture still carries every shape.
|
||||
#
|
||||
# Three of the checks below are covered twice over -- deleting a real annotation moves the count
|
||||
# and fails a case further down. The two decoys are not: drop the KDoc mention and the count
|
||||
# stays 3, so the precision this whole ticket is about would stop being tested and nothing would
|
||||
# say so. That asymmetry is why the shapes are asserted by name rather than only by their effect
|
||||
# on the total.
|
||||
# ---------------------------------------------------------------------------
|
||||
fixture_text="$(cat -- "$FIXTURE")"
|
||||
assert_contains "fixture: the import" "$fixture_text" 'import org.libremediaconverter.FailsOnEmulatorApi37'
|
||||
assert_contains "fixture: annotation own line" "$fixture_text" '
|
||||
@FailsOnEmulatorApi37
|
||||
@Test'
|
||||
assert_contains "fixture: annotation with @Test on one line" "$fixture_text" '@FailsOnEmulatorApi37 @Test'
|
||||
assert_contains "fixture: annotation nested and indented" "$fixture_text" '
|
||||
@FailsOnEmulatorApi37'
|
||||
assert_contains "fixture: KDoc mention (this is #120)" "$fixture_text" "* Deliberately not \`@FailsOnEmulatorApi37\`"
|
||||
assert_contains "fixture: commented-out annotation" "$fixture_text" '// @FailsOnEmulatorApi37'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The fixture's three real annotations against a baseline of 3: no deviation.
|
||||
#
|
||||
# This one case fails under both wrong matchers -- the old one counts 5, own-line-only counts 2 --
|
||||
# which is why it is first.
|
||||
# ---------------------------------------------------------------------------
|
||||
root="$(make_root "$FIXTURE_DIR" 3)"
|
||||
out="$(run_report "$root")"
|
||||
assert_contains "3 real markers, baseline 3: reports a match" "$out" ' baseline: matches (3 expected, 3 failed)'
|
||||
assert_absent "3 real markers, baseline 3: says nothing about the tree" "$out" 'the tree carries'
|
||||
assert_contains "3 real markers, baseline 3: summary agrees" \
|
||||
"$(cat -- "$root/summary.md")" '**Matches the committed baseline of 3**'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. A fourth REAL annotation. The count has to move and the deviation has to fire.
|
||||
#
|
||||
# The important half of #120: precision was the bug, but a matcher that stopped noticing a new
|
||||
# marker would have been a worse one, silently.
|
||||
# ---------------------------------------------------------------------------
|
||||
plus_one="$(mktemp -d "$TMP/plusone.XXXXXX")"
|
||||
cp -- "$FIXTURE" "$plus_one/"
|
||||
cat > "$plus_one/FourthMarker.kt" <<'EOF'
|
||||
package org.libremediaconverter.fixture
|
||||
|
||||
class FourthMarker {
|
||||
@FailsOnEmulatorApi37
|
||||
@Test
|
||||
fun addedToday() = Unit
|
||||
}
|
||||
EOF
|
||||
root="$(make_root "$plus_one" 3)"
|
||||
out="$(run_report "$root")"
|
||||
assert_contains "a 4th real marker: the deviation fires, and counts 4" "$out" \
|
||||
" baseline DEVIATION: the tree carries 4 tests marked \`@FailsOnEmulatorApi37\` but the baseline says 3 — update FAILS_ON_EMULATOR_API37_BASELINE"
|
||||
assert_contains "a 4th real marker: the summary carries it too" "$(cat -- "$root/summary.md")" \
|
||||
"- the tree carries 4 tests marked \`@FailsOnEmulatorApi37\` but the baseline says 3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Delete the same-line annotation and the count must drop to 2.
|
||||
#
|
||||
# This is the trap, pinned down. `@FailsOnEmulatorApi37 @Test` on one line is what separates the
|
||||
# shipped matcher from `^[[:space:]]*@NAME[[:space:]]*$`, and without this case the fixture entry
|
||||
# guarding it could be deleted as decoration -- case 1 would then pass under the wrong matcher.
|
||||
# Here the same-line entry is worth exactly one, and it is asserted to be.
|
||||
# ---------------------------------------------------------------------------
|
||||
minus_same_line="$(mktemp -d "$TMP/minus.XXXXXX")"
|
||||
sed -e '/@FailsOnEmulatorApi37 @Test/d' -- "$FIXTURE" > "$minus_same_line/MarkerShapes.kt"
|
||||
root="$(make_root "$minus_same_line" 3)"
|
||||
out="$(run_report "$root")"
|
||||
assert_contains "same-line annotation removed: counts 2, so it was worth 1" "$out" \
|
||||
" baseline DEVIATION: the tree carries 2 tests marked \`@FailsOnEmulatorApi37\` but the baseline says 3 — update FAILS_ON_EMULATOR_API37_BASELINE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. A run the abort truncated, with fewer failures than the baseline: NOT a deviation.
|
||||
#
|
||||
# `expected` comes from `Starting N tests`, printed before anything can abort, so it still
|
||||
# answers "is the marked set the size the baseline says". `failed` is a tally of what actually
|
||||
# ran, and on a truncated run the tests after the abort never start. Measured on 2026-09-05, two
|
||||
# api37-debug dispatches of the same four marked tests: 4/4/4 and then 4/3/3. Announcing the
|
||||
# second as "one now passes" is the wrong reading, and #120 is the standing lesson about a notice
|
||||
# that is wrong often enough to be skimmed past.
|
||||
# ---------------------------------------------------------------------------
|
||||
root="$(make_root "$FIXTURE_DIR" 3)"
|
||||
cat > "$root/gradle.log" <<'TRUNCATED'
|
||||
> Task :app:connectedDebugAndroidTest
|
||||
Starting 3 tests on test(AVD) - 16
|
||||
There was 2 failure(s).
|
||||
Test run failed to complete. Expected 3 tests, received 2. onError: commandError=false message=INSTRUMENTATION_ABORTED: System has crashed.
|
||||
TRUNCATED
|
||||
out="$(run_report "$root")"
|
||||
assert_contains "truncated run: the truncation is reported" "$out" ' completed cleanly: no'
|
||||
assert_absent "truncated run: the short failure count is not a deviation" "$out" 'tests failed, the baseline is'
|
||||
# And the match line has to say what actually happened rather than repeat the baseline: PR #245's
|
||||
# advisory leg printed `failed: 4` three lines above `matches (5 expected, 5 failed)`.
|
||||
assert_contains "truncated run: the match line does not claim the baseline's failure count" "$out" \
|
||||
' baseline: matches (3 expected; 2 of 3 failed, on a run the abort truncated — not compared)'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. The same short failure count on a run that finished IS a deviation.
|
||||
#
|
||||
# The pair is the point: case 4 must not have bought its quiet by disabling the check outright.
|
||||
# ---------------------------------------------------------------------------
|
||||
root="$(make_root "$FIXTURE_DIR" 3)"
|
||||
cat > "$root/gradle.log" <<'CLEAN'
|
||||
> Task :app:connectedDebugAndroidTest
|
||||
Starting 3 tests on test(AVD) - 16
|
||||
There was 2 failure(s).
|
||||
CLEAN
|
||||
out="$(run_report "$root")"
|
||||
assert_contains "clean run, short by one: the deviation fires" "$out" \
|
||||
'2 tests failed, the baseline is 3'
|
||||
|
||||
echo
|
||||
if [ "$failures" -eq 0 ]; then
|
||||
echo "e2e-report-shape-test.sh: all checks passed"
|
||||
exit 0
|
||||
fi
|
||||
echo "e2e-report-shape-test.sh: $failures check(s) failed"
|
||||
exit 1
|
||||
Executable
+376
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Reports the SHAPE of an instrumented run -- how many tests were expected, how many
|
||||
# reported, how many failed, and whether the run completed at all -- to the step log and to
|
||||
# the job summary. In advisory mode it also compares that shape against a committed baseline
|
||||
# and says plainly whether it matches.
|
||||
#
|
||||
# WHY THIS EXISTS (#83): the advisory API 37 leg is red on every PR by design, so a NEW failure
|
||||
# joining the known ones is invisible -- nothing in a red X distinguishes "the known ones" from
|
||||
# "the known ones plus yours". CLAUDE.md tells everyone not to read that job's red as their
|
||||
# change breaking something, which is correct, and which also means nobody looks.
|
||||
#
|
||||
# WHY NOT A BARE FAILURE COUNT, measured rather than assumed. On this image the run is usually
|
||||
# truncated: `Test run failed to complete. Expected 3 tests, received 2.` with
|
||||
# `INSTRUMENTATION_ABORTED: System has crashed.` A count taken from a truncated run misleads in
|
||||
# both directions -- a fourth marked test can still yield the same number if the abort lands
|
||||
# earlier, and the known set getting worse can LOWER it. So all four fields are recorded, and
|
||||
# the one saying the run was truncated is recorded with them.
|
||||
#
|
||||
# WHY IT IS A SEPARATE SCRIPT rather than a function inside e2e-run.sh: it is a pure seam. It
|
||||
# reads a captured log plus the test XML and writes a report, so it can be run against a REAL
|
||||
# log saved from a REAL CI run -- which is how the baseline comparison was shown to fire
|
||||
# without waiting on an emulator. `git ls-files '*.sh'` also picks it up for shellcheck for
|
||||
# free.
|
||||
#
|
||||
# THIS SCRIPT NEVER FAILS A RUN. It is a diagnostic, and e2e-run.sh's header explains why that
|
||||
# rule is absolute here. Every field defaults to `unknown` and every comparison is guarded,
|
||||
# because an unset variable under `set -u`, or a `[ "" -eq 3 ]`, is exactly how a diagnostic
|
||||
# becomes the thing that turns a leg red. It exits 0 unconditionally.
|
||||
#
|
||||
# Usage:
|
||||
# e2e-report-shape.sh <label> <gradle-log> [<baseline-file>]
|
||||
# E2E_WEDGED_AFTER=<seconds> the wrapper timeout killed gradle after that many seconds
|
||||
#
|
||||
# With a third argument the run is compared against the baseline in that file (advisory mode)
|
||||
# and a `::notice::` is emitted per deviation. NEVER `::error::`: the advisory job is
|
||||
# `continue-on-error: true` and stays that way, and an error annotation would be a new way for
|
||||
# a diagnostic to change a conclusion.
|
||||
#
|
||||
# WHY THE WEDGE ARRIVES AS AN ENV VAR (#118) rather than being read out of the log like every
|
||||
# other field: there is nothing in the log to read. A wedge is gradle never returning, so gradle
|
||||
# never printed a verdict, never printed a truncation line, and never aborted instrumentation --
|
||||
# the log of a wedged leg is the log of a run that simply stops. Measured on job 98035980326:
|
||||
# `expected: 59`, `received: 59`, `completed cleanly: yes`, six seconds before the wedge warning,
|
||||
# for a leg that the timeout had killed 22 minutes in. Only e2e-run.sh knows, because only it
|
||||
# saw `timeout` exit 124, so it says so. Guessing it from a log that ends abruptly would call
|
||||
# every cancelled run a wedge.
|
||||
#
|
||||
# It is read as a STRING and only ever interpolated into one. `[ -n ... ]`, never `-gt`: it
|
||||
# crosses a process boundary from a shell that deliberately sets it EMPTY on every non-wedge
|
||||
# path, and an arithmetic test on an empty string is the header's rule four paragraphs up.
|
||||
set -uo pipefail
|
||||
|
||||
LABEL="${1:-unknown}"
|
||||
LOG="${2:-}"
|
||||
BASELINE_FILE="${3:-}"
|
||||
WEDGED_AFTER="${E2E_WEDGED_AFTER:-}"
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
XML_DIR="$REPO_ROOT/app/build/outputs/androidTest-results/connected/debug"
|
||||
|
||||
# Gradle colours its output even when it is piped, so `FAILED` arrives wrapped in escape codes.
|
||||
# The numeric lines parsed below are not coloured, but stripping is cheap insurance against a
|
||||
# pattern that would otherwise silently match nothing.
|
||||
ESC="$(printf '\033')"
|
||||
scan() { [ -s "$LOG" ] && sed -e "s/${ESC}\[[0-9;]*[a-zA-Z]//g" -- "$LOG"; }
|
||||
first_number() { grep -oE '[0-9]+' | head -1; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source 1: the runner's own output. This is the ONLY place a truncation is visible. The test
|
||||
# XML read below is written even for an aborted run and says nothing whatever about the abort
|
||||
# -- measured on run 32865281555, where the XML reports a tidy tests="3" failures="3" for a run
|
||||
# the runner had just described as truncated. That is the reason this parses stdout at all.
|
||||
# ---------------------------------------------------------------------------
|
||||
starting_line="$(scan | grep -aoE 'Starting [0-9]+ tests on .*' | tail -1)"
|
||||
abort_line="$(scan | grep -aoE 'Test run failed to complete\. Expected [0-9]+ tests, received [0-9]+\.' | tail -1)"
|
||||
aborted_hits="$(scan | grep -ac 'INSTRUMENTATION_ABORTED' || true)"
|
||||
failure_line="$(scan | grep -aoE 'There was [0-9]+ failure\(s\)\.' | tail -1)"
|
||||
failed_names="$(scan | grep -aoE 'Execute [A-Za-z0-9_.$]+: FAILED' | sed -e 's/^Execute //' -e 's/: FAILED$//' | sort -u)"
|
||||
|
||||
expected="$(printf '%s' "$starting_line" | first_number)"
|
||||
expected_src="\`$starting_line\`"
|
||||
abort_expected="$(printf '%s' "$abort_line" | grep -oE 'Expected [0-9]+' | first_number)"
|
||||
abort_received="$(printf '%s' "$abort_line" | grep -oE 'received [0-9]+' | first_number)"
|
||||
log_failed="$(printf '%s' "$failure_line" | first_number)"
|
||||
|
||||
# `Starting N tests` is missing when the framework restarted under the run and Gradle never got
|
||||
# a test list. The truncation line still carries the number it was told to expect.
|
||||
if [ -z "$expected" ] && [ -n "$abort_expected" ]; then
|
||||
expected="$abort_expected"
|
||||
expected_src="\`$abort_line\`"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source 2: the JUnit XML. Measured on both a truncated advisory run and a green gating leg:
|
||||
# `<testsuites tests="N" failures="M">` is present in both, and aggregates every suite. It is
|
||||
# the authority on how many results landed and how many were failures. It is NOT an authority
|
||||
# on whether the run finished, which is what source 1 is for.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Read ONLY when the runner said a test run happened. `app/build` survives between runs on a
|
||||
# developer machine -- tools/local-emulator/run-e2e.sh drives several API levels against one
|
||||
# checkout -- so a leg that never got as far as starting tests would otherwise be reported from
|
||||
# the previous leg's XML, which is a wrong answer rather than a missing one.
|
||||
xml_head=""
|
||||
xml_count=0
|
||||
if [ -n "$starting_line$abort_line" ] && [ -d "$XML_DIR" ]; then
|
||||
while IFS= read -r f; do
|
||||
xml_count=$((xml_count + 1))
|
||||
[ -z "$xml_head" ] && xml_head="$(grep -ao '<testsuites[^>]*>' "$f" | head -1)"
|
||||
done < <(find "$XML_DIR" -maxdepth 1 -name 'TEST-*.xml' -print 2> /dev/null | sort)
|
||||
fi
|
||||
xml_tests="$(printf '%s' "$xml_head" | grep -oE ' tests="[0-9]+"' | first_number)"
|
||||
xml_failed="$(printf '%s' "$xml_head" | grep -oE ' failures="[0-9]+"' | first_number)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derive the four fields, each with where its number came from. Everything stays a string, so a
|
||||
# missing source reads `unknown` rather than becoming 0 -- a report claiming 0 tests when it
|
||||
# merely could not see them would announce a deviation on every cancelled run.
|
||||
# ---------------------------------------------------------------------------
|
||||
received="unknown"
|
||||
received_src="no source"
|
||||
if [ -n "$xml_tests" ]; then
|
||||
received="$xml_tests"
|
||||
received_src="test XML \`<testsuites tests=\"$xml_tests\">\`"
|
||||
elif [ -n "$abort_received" ]; then
|
||||
received="$abort_received"
|
||||
received_src="\`$abort_line\`"
|
||||
elif [ -n "$expected" ] && [ -z "$abort_line" ]; then
|
||||
received="$expected"
|
||||
received_src="the run was not truncated, so every expected test reported"
|
||||
# ... unless it was killed, in which case "not truncated" is only "gradle never got as far as
|
||||
# saying so". This is the branch the wedged leg in #118 took -- with no XML written yet, the
|
||||
# number is what the runner was TOLD to run, and the source line said the opposite in the same
|
||||
# table that called the leg clean. The number is deliberately left alone: it is still the best
|
||||
# available answer, and only the claim about where it came from was wrong.
|
||||
[ -n "$WEDGED_AFTER" ] \
|
||||
&& received_src="no test XML was written and gradle never printed a truncation line — but the leg was killed mid-run, so this is what it was told to run, not what reported"
|
||||
fi
|
||||
|
||||
failed="unknown"
|
||||
failed_src="no source"
|
||||
if [ -n "$xml_failed" ]; then
|
||||
failed="$xml_failed"
|
||||
failed_src="test XML \`<testsuites failures=\"$xml_failed\">\`"
|
||||
elif [ -n "$log_failed" ]; then
|
||||
failed="$log_failed"
|
||||
failed_src="\`$failure_line\`"
|
||||
fi
|
||||
|
||||
if [ -z "$expected" ]; then
|
||||
expected="unknown"
|
||||
expected_src="no \`Starting N tests\` line"
|
||||
fi
|
||||
|
||||
# A run whose start nobody can see is not a run of zero tests. Cancellation (this workflow sets
|
||||
# cancel-in-progress) and the `Starting 0 tests` shape a framework restart produces both land
|
||||
# here, and both have to say so rather than compare a number that does not exist.
|
||||
no_run="none"
|
||||
if [ "$expected" = "unknown" ] && [ "$received" = "unknown" ]; then
|
||||
no_run="nothing"
|
||||
elif [ "$expected" = "0" ]; then
|
||||
no_run="zero"
|
||||
fi
|
||||
|
||||
if [ -n "$abort_line" ]; then
|
||||
completed="**no**"
|
||||
completed_src="\`$abort_line\` with \`INSTRUMENTATION_ABORTED\`"
|
||||
elif [ "${aborted_hits:-0}" -gt 0 ]; then
|
||||
completed="**no**"
|
||||
completed_src="\`INSTRUMENTATION_ABORTED\` in the runner output"
|
||||
elif [ "$no_run" = "nothing" ]; then
|
||||
# "cleanly" would be a lie about a run that left no evidence it happened.
|
||||
completed="unknown"
|
||||
completed_src="no runner output to read"
|
||||
elif [ -n "$WEDGED_AFTER" ]; then
|
||||
# The wedge is checked LAST of the four, so it only ever overrides the `yes`. The two "no"s
|
||||
# above are already right and name the abort, which the wedge row does not; `unknown` is
|
||||
# already right too. A wedge on top of an abort is both facts, and both get printed.
|
||||
completed="**no**"
|
||||
completed_src="the wrapper timeout killed gradle after ${WEDGED_AFTER}s — instrumentation itself was never aborted, which is why nothing in the log says so"
|
||||
else
|
||||
completed="yes"
|
||||
completed_src="no truncation line and no \`INSTRUMENTATION_ABORTED\`"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Advisory mode: compare against the committed baseline.
|
||||
#
|
||||
# ONE number covers both compared fields, and that is deliberate rather than a shortcut. The
|
||||
# marker means "cannot pass on this image", so the number of tests carrying it is both how many
|
||||
# the advisory leg should run and how many should fail. A smaller `failed` means one now passes
|
||||
# -- which is the trigger to delete the annotation, written down in FailsOnEmulatorApi37.kt.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `advisory` and `baseline` are two variables on purpose. "A comparison was asked for" and "a
|
||||
# number was found to compare against" are different facts, and collapsing them is how this
|
||||
# report would go quietly back to being the thing #83 filed: the `sed` below is anchored, so
|
||||
# indenting the const into an object -- or renaming it, or moving it to another file -- empties
|
||||
# `baseline`, and a single flag would take the whole comparison down with it while the table
|
||||
# kept printing. An unreadable baseline is itself a deviation, and is announced as one.
|
||||
advisory="no"
|
||||
baseline=""
|
||||
marked=""
|
||||
deviations=()
|
||||
if [ -n "$BASELINE_FILE" ]; then
|
||||
advisory="yes"
|
||||
[ -f "$BASELINE_FILE" ] \
|
||||
&& baseline="$(sed -nE 's/^const val FAILS_ON_EMULATOR_API37_BASELINE = ([0-9]+).*/\1/p' "$BASELINE_FILE" | head -1)"
|
||||
# What the tree actually carries. Reported next to the baseline so a stale baseline shows up
|
||||
# here rather than only once the emulator disagrees with it.
|
||||
#
|
||||
# ANCHORED AT LINE START, AND WHITESPACE-OR-END-OF-LINE AFTER THE NAME (#120). The #81 check
|
||||
# this replaces matched the string anywhere on any line, so #113's KDoc reading `Deliberately
|
||||
# not @FailsOnEmulatorApi37` counted as a fourth marked test and the report announced a
|
||||
# deviation on every PR. That is worse than a wrong number: #83 built this so a new failure
|
||||
# could not be invisible, and a notice that is wrong every time teaches everyone to skim past
|
||||
# deviation notices.
|
||||
#
|
||||
# THE OBVIOUS REPAIR IS A TRAP, and the reason for the second half of the pattern.
|
||||
# `^[[:space:]]*@NAME[[:space:]]*$` -- "the annotation on a line of its own" -- also stops
|
||||
# counting `@FailsOnEmulatorApi37 @Test`, which is legal Kotlin, and UNDERcounting is the
|
||||
# dangerous direction: it hides a genuine new marker, which is the one thing this exists to
|
||||
# catch. Measured against `testdata/marker-shapes`, a fixture carrying every shape at once:
|
||||
# the old matcher says 5, own-line-only says 2, this one says 3. On the real tree, 4 / 3 / 3.
|
||||
# e2e-report-shape-test.sh runs that fixture through this whole script.
|
||||
#
|
||||
# `^[[:space:]]*@` cannot match an `import` line, so the old `grep -v import` goes with it
|
||||
# rather than staying to imply a filter is still doing work.
|
||||
#
|
||||
# This is a regex over source text and not a parser. An annotation inside a multi-line string,
|
||||
# or inside a `/* */` block that opened mid-line, would still be counted. Neither exists here;
|
||||
# if one ever does, this check wants a different tool rather than a longer regex.
|
||||
if [ -d "$REPO_ROOT/app/src/androidTest" ]; then
|
||||
marked="$(grep -rhcE '^[[:space:]]*@FailsOnEmulatorApi37([[:space:]]|$)' \
|
||||
"$REPO_ROOT/app/src/androidTest" --include='*.kt' \
|
||||
| awk '{ total += $1 } END { print total + 0 }' || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$advisory" = "yes" ] && [ -z "$baseline" ]; then
|
||||
deviations+=("the committed baseline could not be read from \`$(basename -- "$BASELINE_FILE")\` — has \`FAILS_ON_EMULATOR_API37_BASELINE\` been renamed, indented into a class, or moved? Nothing was compared")
|
||||
fi
|
||||
|
||||
if [ -n "$baseline" ]; then
|
||||
if [ "$no_run" = "nothing" ]; then
|
||||
deviations+=("no test run observed — the runner never reported starting one, where the baseline expects $baseline tests carrying \`@FailsOnEmulatorApi37\`")
|
||||
elif [ "$no_run" = "zero" ]; then
|
||||
deviations+=("the runner started 0 tests, where the baseline expects $baseline — on this image that is the framework having restarted under the run, not an empty test list")
|
||||
else
|
||||
if [ "$expected" != "unknown" ] && [ "$expected" != "$baseline" ]; then
|
||||
deviations+=("the runner started $expected tests, the baseline is $baseline")
|
||||
fi
|
||||
# `expected` is compared on every run and `failed` only on a run that finished, and the
|
||||
# difference is the truncation this file already records rather than compares. `expected`
|
||||
# comes from `Starting N tests`, which is printed before anything can abort, so it answers
|
||||
# "is the marked set the size the baseline says" whatever happens afterwards. `failed` is a
|
||||
# tally of what actually ran: on a truncated run the tests after the abort never start, so
|
||||
# comparing it to the baseline announces a deviation about the framework dying rather than
|
||||
# about the test list. Measured on 2026-09-05, two api37-debug dispatches of the same four
|
||||
# marked tests: 4/4/4 and then 4/3/3, the second having lost the last test to the abort.
|
||||
# Announcing that as "one now passes" is exactly the wrong reading, and #120 is the standing
|
||||
# lesson about a notice that is wrong often enough to be skimmed past.
|
||||
if [ "$failed" != "unknown" ] && [ "$failed" != "$baseline" ]; then
|
||||
if [ "$completed" = "**no**" ]; then
|
||||
echo "::debug::$failed of $baseline marked tests failed, on a run the abort truncated — not compared"
|
||||
else
|
||||
deviations+=("$failed tests failed, the baseline is $baseline — every test carrying the marker is expected to fail on this image, so fewer means one now passes and more means a new one joined")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$marked" ] && [ "$marked" != "$baseline" ]; then
|
||||
deviations+=("the tree carries $marked tests marked \`@FailsOnEmulatorApi37\` but the baseline says $baseline — update FAILS_ON_EMULATOR_API37_BASELINE")
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emit. Step log first, so the common case needs neither the summary page nor an artifact.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "----- RUN SHAPE (api${LABEL}) -----"
|
||||
echo " expected: $expected"
|
||||
echo " received: $received"
|
||||
echo " failed: $failed"
|
||||
# Above `completed cleanly`, because it is the line that says what happened to the leg and the
|
||||
# other one only qualifies it. A reader who stops after three rows still sees it.
|
||||
if [ -n "$WEDGED_AFTER" ]; then
|
||||
echo " wedged: yes -- gradle was killed after ${WEDGED_AFTER}s and never returned"
|
||||
fi
|
||||
echo " completed cleanly: ${completed//\*/}"
|
||||
if [ -n "$abort_received" ]; then
|
||||
echo " received before the abort: $abort_received"
|
||||
fi
|
||||
if [ -n "$failed_names" ]; then
|
||||
echo " failed tests:"
|
||||
printf '%s\n' "$failed_names" | sed -e 's/^/ /'
|
||||
fi
|
||||
if [ "$advisory" = "yes" ]; then
|
||||
if [ "${#deviations[@]}" -eq 0 ]; then
|
||||
# Two spellings, because one of them would be a lie half the time. `$baseline expected,
|
||||
# $baseline failed` is only true of a run that finished; on a truncated one `failed` is a
|
||||
# tally of the tests that got to run before the framework died, and printing the baseline in
|
||||
# its place claims a number nobody measured. Seen on PR #245's advisory leg, which reported
|
||||
# `failed: 4` three lines above `matches (5 expected, 5 failed)`.
|
||||
if [ "$failed" != "unknown" ] && [ "$failed" != "$baseline" ]; then
|
||||
echo " baseline: matches ($baseline expected; $failed of $baseline failed, on a run the abort truncated — not compared)"
|
||||
else
|
||||
echo " baseline: matches ($baseline expected, $baseline failed)"
|
||||
fi
|
||||
else
|
||||
printf ' baseline DEVIATION: %s\n' "${deviations[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# A notice, never an error. See the header.
|
||||
if [ "${#deviations[@]}" -gt 0 ]; then
|
||||
for d in "${deviations[@]}"; do
|
||||
echo "::notice::E2E api${LABEL}: $d"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{
|
||||
echo "### E2E api${LABEL} — run shape"
|
||||
echo
|
||||
echo "| field | value | where it came from |"
|
||||
echo "| --- | --- | --- |"
|
||||
echo "| expected | $expected | $expected_src |"
|
||||
echo "| received | $received | $received_src |"
|
||||
echo "| failed | $failed | $failed_src |"
|
||||
if [ -n "$WEDGED_AFTER" ]; then
|
||||
echo "| wedged | **yes** | \`timeout\` fired after ${WEDGED_AFTER}s and killed gradle (exit 124), which is what e2e-run.sh then captured the wedge diagnostics for |"
|
||||
fi
|
||||
echo "| completed cleanly | $completed | $completed_src |"
|
||||
if [ -n "$abort_received" ]; then
|
||||
echo "| received before the abort | $abort_received | the same line — the XML above counts the truncated test as a failure, this number does not |"
|
||||
fi
|
||||
echo
|
||||
if [ -n "$failed_names" ]; then
|
||||
echo "Failed:"
|
||||
echo
|
||||
printf '%s\n' "$failed_names" | sed -e 's/^/- `/' -e 's/$/`/'
|
||||
echo
|
||||
fi
|
||||
if [ "$xml_count" -gt 1 ]; then
|
||||
echo "> $xml_count test XML files were present; the counts above come from the first."
|
||||
echo
|
||||
fi
|
||||
if [ "$advisory" = "yes" ]; then
|
||||
if [ "${#deviations[@]}" -eq 0 ]; then
|
||||
echo "**Matches the committed baseline of $baseline** — $baseline tests carry \`@FailsOnEmulatorApi37\` and all $baseline failed, which is what this job is for."
|
||||
elif [ -z "$baseline" ]; then
|
||||
echo "**The committed baseline could not be read, so nothing was compared.** Announced as a notice, not an error: this job is advisory and its conclusion is unchanged by anything here."
|
||||
echo
|
||||
printf -- '- %s\n' "${deviations[@]}"
|
||||
else
|
||||
echo "**DEVIATION from the committed baseline of $baseline.** Announced as a notice, not an error: this job is advisory and its conclusion is unchanged by anything here."
|
||||
echo
|
||||
printf -- '- %s\n' "${deviations[@]}"
|
||||
fi
|
||||
echo
|
||||
echo "<sub>The baseline lives beside the marker, in \`FailsOnEmulatorApi37.kt\`. \`completed cleanly\` is recorded rather than compared: the truncation is intermittent — of eight advisory runs read on 2026-08-25, seven aborted and one did not — so comparing it would announce a deviation on a run that is fine.</sub>"
|
||||
else
|
||||
echo "<sub>No baseline comparison: that is the advisory API 37 leg only. The shape is recorded here anyway because a truncated run reports fewer results than it ran, which is what issue #108 looks like on a gating leg.</sub>"
|
||||
fi
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# The summary page is the deliverable -- "readable without opening a log" is what #83 asked
|
||||
# for -- and GitHub exposes no API for reading a job summary back, so a write that silently
|
||||
# did not happen would be invisible. This line is in the step log, which can be read.
|
||||
echo " (the table above is also on the job summary page)"
|
||||
else
|
||||
echo " (GITHUB_STEP_SUMMARY is unset -- step log only)"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+117
-52
@@ -34,6 +34,13 @@ TMP="${RUNNER_TEMP:-/tmp}"
|
||||
LOGCAT_LOG="$TMP/logcat-api${LABEL}.txt"
|
||||
DIAG_LOG="$TMP/diagnostics-api${LABEL}.txt"
|
||||
WEDGE_LOG="$TMP/wedge-diagnostics-api${LABEL}.txt"
|
||||
# Gradle's own output, captured to a file as well as the step log, because the run-shape report
|
||||
# below has to parse it. Uploaded with the diagnostics, so a report that reads wrong can be
|
||||
# checked against what it read.
|
||||
GRADLE_LOG="$TMP/gradle-api${LABEL}.txt"
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# ~5 min is a healthy leg (measured across API 33-36), and this wraps only the gradle client,
|
||||
# a subset of that. 20 min is generous enough never to trip on a slow-but-working run, and far
|
||||
@@ -45,40 +52,72 @@ WEDGE_TIMEOUT=1200
|
||||
# the same shape as E2E_EXTRA_GRADLE_ARGS below. The other four E2E legs run byte-identical
|
||||
# commands with it unset.
|
||||
#
|
||||
# WHY IT RUNS HERE, BEFORE THE LOGCAT STREAM: `adb shell stop` ends the `adb logcat` started
|
||||
# below, and nothing restarts it, so a disable performed after that point would cost this leg
|
||||
# its whole diagnostic story for the part of the run that matters. Everything this function
|
||||
# counts comes from `adb logcat -d -b crash`, which is a fresh read each time and independent
|
||||
# of the stream.
|
||||
# WHY IT RUNS HERE, BEFORE THE LOGCAT STREAM: it is a 45-second wait, and the stream below is
|
||||
# meant to cover the suite rather than the wait. Everything this function counts comes from
|
||||
# `adb logcat -d -b crash`, a fresh read each time and independent of the stream. (The original
|
||||
# reason was stronger and no longer applies: `adb shell stop` would have ended the streamed
|
||||
# `adb logcat` and nothing restarts it. There is no `stop` here any more -- see below.)
|
||||
#
|
||||
# WHAT IT IS FOR: the android-37.x images abort surfaceflinger from RegionSamplingThread inside
|
||||
# their own gralloc mapper (docs/api-37-emulator-crash.md). surfaceflinger is a critical service,
|
||||
# so init SIGKILLs zygote with it and the framework restarts under the run -- Gradle then reports
|
||||
# WHAT IT IS FOR -- AND THE NAME IS NOW WRONG, WHICH IS WHY THIS PARAGRAPH IS LONG.
|
||||
# The android-37.x images abort surfaceflinger from RegionSamplingThread inside their own gralloc
|
||||
# mapper (docs/api-37-emulator-crash.md). surfaceflinger is a critical service, so init SIGKILLs
|
||||
# zygote with it and the framework restarts under the run -- Gradle then reports
|
||||
# `cmd: Can't find service: package` and `Starting 0 tests`. RegionSamplingThread exists only
|
||||
# because SystemUI registers a nav-bar luma-sampling listener, so removing the package removes
|
||||
# the whole chain. Measured cadence of those kills: 20-90 s apart, median 60-70 s, three to five
|
||||
# in a four-minute window -- fast enough that install and instrumentation start-up do not fit
|
||||
# inside one gap.
|
||||
# because SystemUI registers a nav-bar luma-sampling listener, so this was written to remove the
|
||||
# package and with it the whole chain. Measured cadence of those kills on `-gpu host`: 20-90 s
|
||||
# apart, median 60-70 s, three to five in a four-minute window.
|
||||
#
|
||||
# **THE DISABLE HALF OF THAT HAS NEVER WORKED, AND THE QUIET WINDOW IS WHAT THE LEG ACTUALLY
|
||||
# GETS.** Measured 2026-09-05, two ways that agree:
|
||||
#
|
||||
# - On CI, in the gating leg of run 34006456986: `pm disable-user` is accepted at 02:28:37.9 and
|
||||
# `com.android.systemui` really is in `pm list packages -d` at 02:29:33 -- and SystemUI is
|
||||
# started anyway at 02:28:39.5 and again at 02:28:52.3, the second of which (pid 4275) is
|
||||
# alive for the whole instrumentation run, logging `WindowManagerShell ...
|
||||
# app=com.android.systemui` minutes after this function prints its final line.
|
||||
# - Locally on android-37.0, with the package verified disabled before AND after a deliberate
|
||||
# `stop; start`: `com.android.systemui` comes up 3 s after `system_server` regardless.
|
||||
#
|
||||
# So `pm disable-user --user 0 com.android.systemui` does not stop SystemUI starting on this
|
||||
# image, whatever else happens. The name `E2E_DISABLE_SYSTEM_UI` and the name of this function are
|
||||
# kept because the matrix row, both workflows and two documents refer to them, and a rename would
|
||||
# touch all of that to no benefit -- read this comment, not the name.
|
||||
#
|
||||
# WHAT IS LEFT IS LOAD-BEARING, so do not delete the function as dead weight. It is the 45-second
|
||||
# window with zero new `hasReadColorBufferDma` aborts. The boot-time aborts land close together --
|
||||
# 02:28:18 and 02:28:43 in that same run -- and the wait is what puts instrumentation (02:32:42)
|
||||
# after them rather than inside one. That is what stops a leg reporting `Starting 0 tests`, and it
|
||||
# is why the three-round retry stays.
|
||||
#
|
||||
# THE `pm disable-user` CALL STAYS TOO, for a narrower reason than it was written for: every green
|
||||
# leg and every measurement quoted anywhere about this row was taken with it applied and SystemUI
|
||||
# running. Removing it would change the configuration the numbers came from, which is not a change
|
||||
# to make while fixing a flake.
|
||||
#
|
||||
# AND THE FRAMEWORK RESTART IS GONE, having been measured to be worse than nothing. It was written
|
||||
# as `adb shell stop; adb shell start`, which are root-only; adbd is not root, so every leg printed
|
||||
# `Must be root` twice and restarted nothing. Adding `adb root` made it real, and api37-debug run
|
||||
# 34010167885 is what that looks like: `pm disable-user` reports success, the stop lands ~2 s later
|
||||
# and kills system_server before PackageManager has flushed its delayed write of package
|
||||
# restrictions, so the state is gone on the way back up -- `NOT DISABLED after the restart`, three
|
||||
# rounds, `final state: SystemUI STILL ENABLED`, and the leg then reported `expected: 0,
|
||||
# received: 0`. A 15 s pause before the stop does make the state survive (bisected locally), and it
|
||||
# still does not help, because of the two measurements above. So the restart is removed rather than
|
||||
# repaired: it cost the leg every test it had, and there is nothing for it to buy.
|
||||
#
|
||||
# NOTHING HERE TRUSTS A COMMAND'S OWN REPORT, and that is not paranoia: of four runs of an
|
||||
# earlier one-shot version, one (32646029143) reported `new state: disabled-user` and then
|
||||
# started SystemUI eight more times, with ten more aborts. `pm disable-user` can be accepted by
|
||||
# a system_server that is SIGKILLed before the state is written, and `pm disable-user` does not
|
||||
# retract SystemUI's existing region-sampling registration either -- by the time boot completes
|
||||
# it has already registered, so only a framework restart brings back a SystemUI-less
|
||||
# surfaceflinger. Hence: disable, take the framework DOWN and confirm system_server is really
|
||||
# gone (an earlier probe asked `service check` 0.3 s after `stop` and got `found` from the
|
||||
# system_server that was still exiting, so its wait was not a wait), bring it back, verify the
|
||||
# package against `pm list packages -d`, and require a 45 s window with zero new aborts.
|
||||
# Three rounds, because one is not reliable and the failure is silent.
|
||||
# started SystemUI eight more times. So this reports what `pm list packages -d` says AND what
|
||||
# `pidof` says, side by side, rather than one line implying both.
|
||||
# ---------------------------------------------------------------------------
|
||||
count_aborts() { adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma'; }
|
||||
systemui_disabled() { adb shell pm list packages -d 2> /dev/null | grep -q 'com.android.systemui'; }
|
||||
systemui_pid() { adb shell pidof com.android.systemui 2> /dev/null | tr -d '\r\n'; }
|
||||
|
||||
disable_region_sampling() {
|
||||
local round=1 i out before after
|
||||
local round=1 i out pid before after
|
||||
while [ "$round" -le 3 ]; do
|
||||
echo "--- SystemUI disable, round $round ---"
|
||||
echo "--- round $round ---"
|
||||
for i in $(seq 1 10); do
|
||||
out="$(adb shell pm disable-user --user 0 com.android.systemui 2>&1 | tr -d '\r')"
|
||||
echo " pm attempt $i: $out"
|
||||
@@ -86,36 +125,20 @@ disable_region_sampling() {
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo " restarting the framework"
|
||||
adb shell stop
|
||||
for i in $(seq 1 20); do
|
||||
[ -z "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ] && break
|
||||
sleep 2
|
||||
done
|
||||
echo " system_server down after ~$((i * 2)) s"
|
||||
adb shell start
|
||||
for i in $(seq 1 30); do
|
||||
if adb shell service check package 2> /dev/null | grep -q ': found' \
|
||||
&& adb shell service check activity 2> /dev/null | grep -q ': found' \
|
||||
&& [ -n "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ]; then
|
||||
echo " services back after ~$((i * 5)) s"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if systemui_disabled; then
|
||||
echo " verified: com.android.systemui is in pm list packages -d"
|
||||
echo " pm list packages -d: com.android.systemui is in it"
|
||||
else
|
||||
echo " NOT DISABLED after the restart -- the package state did not survive"
|
||||
round=$((round + 1))
|
||||
continue
|
||||
echo " pm list packages -d: com.android.systemui is NOT in it"
|
||||
fi
|
||||
# Printed next to the line above precisely because the two disagree on this image, and a
|
||||
# reader who sees only the first will believe something that is not true.
|
||||
pid="$(systemui_pid)"
|
||||
echo " com.android.systemui pid: ${pid:-none} (expected: a pid -- see the header)"
|
||||
|
||||
before="$(count_aborts)"
|
||||
sleep 45
|
||||
after="$(count_aborts)"
|
||||
echo " abort rate, SystemUI disabled: $((after - before)) new in 45 s (total ${after:-0})"
|
||||
echo " aborts: $((after - before)) new in 45 s (total ${after:-0})"
|
||||
[ "$((after - before))" -eq 0 ] && break
|
||||
echo " still aborting after round $round"
|
||||
round=$((round + 1))
|
||||
@@ -124,16 +147,16 @@ disable_region_sampling() {
|
||||
# A warning rather than an exit. If the disable did not take, the run is about to report
|
||||
# `Starting 0 tests` and fail on its own -- and it will do so with the logcat, the crash
|
||||
# buffer and the diagnostics attached, which is more useful than dying here with none of it.
|
||||
if systemui_disabled; then
|
||||
echo " final state: SystemUI disabled"
|
||||
if [ "$((after - before))" -eq 0 ]; then
|
||||
echo " final state: 45 s with no new aborts -- the suite starts here"
|
||||
else
|
||||
echo "::warning::E2E api${LABEL}: SystemUI is still enabled -- expect INSTRUMENTATION_ABORTED"
|
||||
echo "::warning::E2E api${LABEL}: still aborting after three rounds -- expect INSTRUMENTATION_ABORTED"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ "${E2E_DISABLE_SYSTEM_UI:-}" = "1" ]; then
|
||||
echo "::group::E2E api${LABEL} -- removing the region-sampling listener"
|
||||
echo "::group::E2E api${LABEL} -- waiting out the boot-time gralloc aborts"
|
||||
disable_region_sampling
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
@@ -214,18 +237,60 @@ status=0
|
||||
# script rather than forking it: that runs several API levels back to back against one checkout
|
||||
# and passes `--rerun`, so a level cannot be skipped as up-to-date and report the previous
|
||||
# level's results as its own. CI gets a fresh runner per level and does not need it.
|
||||
#
|
||||
# `2>&1 | tee`, and the `2>&1` is the load-bearing half. The step log merges both streams, so
|
||||
# reading one cannot tell you which stream a line came from -- and the single line the report
|
||||
# below needs most, `Test run failed to complete. ... INSTRUMENTATION_ABORTED`, is not on
|
||||
# stdout. Capturing stdout alone would leave the report saying "completed cleanly: yes" forever,
|
||||
# which is precisely the comparison that cannot fire. pipefail is already set and tee exits 0,
|
||||
# so the pipeline's status is still gradle's -- including the 124 that means the wrapper fired.
|
||||
#
|
||||
# `tee` and not `tee -a`, unlike the logcat above: CI gets a fresh runner per leg, but
|
||||
# tools/local-emulator/run-e2e.sh reuses one machine, and an appended log would have the report
|
||||
# reading the PREVIOUS run of the same API level. The console goes plain rather than showing
|
||||
# gradle's live progress bar, which is what it already did in CI.
|
||||
# shellcheck disable=SC2086
|
||||
timeout -k 30s "$WEDGE_TIMEOUT" \
|
||||
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64 --stacktrace \
|
||||
${E2E_EXTRA_GRADLE_ARGS:-} || status=$?
|
||||
${E2E_EXTRA_GRADLE_ARGS:-} 2>&1 | tee "$GRADLE_LOG" || status=$?
|
||||
echo "::endgroup::"
|
||||
|
||||
# Whether the wrapper timeout fired, decided ONCE. 124 is `timeout` saying it killed the
|
||||
# command, and two places downstream need that fact: capture_wedge below, and the report, which
|
||||
# otherwise calls a killed leg `completed cleanly: yes` (#118). Deriving it twice is how those
|
||||
# two would drift apart -- the report would keep printing after someone changed what a wedge
|
||||
# means here. It stays a string: empty on every other path, so those legs pass an empty
|
||||
# E2E_WEDGED_AFTER and the report behaves exactly as before.
|
||||
wedged=""
|
||||
[ "$status" -eq 124 ] && wedged="$WEDGE_TIMEOUT"
|
||||
|
||||
# The run-shape report: expected/received/failed and whether the run finished, every time,
|
||||
# green or red. It never changes `status` -- it is a diagnostic, and the header's rule about
|
||||
# diagnostics applies to it as much as to every probe below.
|
||||
#
|
||||
# E2E_WEDGED_AFTER is the wedge, told to the report rather than left for it to infer. It cannot
|
||||
# be inferred: a wedge is gradle never returning, so gradle printed no verdict at all, and the
|
||||
# log the report reads looks like a run that simply stopped. Only this script knows the
|
||||
# difference, because only this script saw the exit status.
|
||||
#
|
||||
# The baseline argument, and only it, turns on the comparison, and only the advisory API 37 job
|
||||
# passes E2E_ADVISORY=1. Comparing on the gating legs would announce a deviation on all five of
|
||||
# them every run, since they run the whole suite rather than the marked three. They still get
|
||||
# the report: a truncated run reporting fewer results than it ran is what #108 looks like, and
|
||||
# `completed cleanly` is the field that shows it.
|
||||
if [ "${E2E_ADVISORY:-}" = "1" ]; then
|
||||
E2E_WEDGED_AFTER="$wedged" bash "$SCRIPT_DIR/e2e-report-shape.sh" "$LABEL" "$GRADLE_LOG" \
|
||||
"$REPO_ROOT/app/src/androidTest/java/org/libremediaconverter/FailsOnEmulatorApi37.kt" || true
|
||||
else
|
||||
E2E_WEDGED_AFTER="$wedged" bash "$SCRIPT_DIR/e2e-report-shape.sh" "$LABEL" "$GRADLE_LOG" || true
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
kill "$LOGCAT_PID" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 124 ]; then
|
||||
if [ -n "$wedged" ]; then
|
||||
capture_wedge "api${LABEL}"
|
||||
else
|
||||
echo "::error::E2E api${LABEL} failed (exit $status)"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// NOT A TEST, AND NEVER COMPILED. This is fixture data for e2e-report-shape-test.sh, which
|
||||
// copies it into a throwaway repo root and runs the real report script against that. It lives
|
||||
// under .github/ deliberately: Gradle only compiles app/src/**, ktlint and detekt are applied to
|
||||
// :app only, and the report's own count reads app/src/androidTest -- so nothing here can reach
|
||||
// the build, the linters, or the number the advisory job compares against. Verified by running
|
||||
// the report against the real repo root with this file committed: still 3.
|
||||
//
|
||||
// It carries every shape the counter has to tell apart, in one file, because the bug in #120 was
|
||||
// exactly that two of them look alike to a substring match. Three count and three must not:
|
||||
//
|
||||
// COUNTS the annotation on its own line
|
||||
// COUNTS the annotation sharing a line with @Test -- legal Kotlin, and the case the
|
||||
// obvious "own line only" repair silently drops
|
||||
// COUNTS the annotation indented inside a nested class
|
||||
// must NOT a KDoc mentioning it -- this is #120 itself, copied from Media3EngineTest
|
||||
// must NOT a commented-out annotation
|
||||
// must NOT the import
|
||||
//
|
||||
// Three count. That is what the synthetic baseline in the test is set to, so the fixture and the
|
||||
// baseline agree exactly the way the real tree and FAILS_ON_EMULATOR_API37_BASELINE do.
|
||||
//
|
||||
// The `@Test` here is spelled the way a real test spells it so the fixture reads like source
|
||||
// rather than like a regex exercise. Nothing runs it.
|
||||
|
||||
package org.libremediaconverter.fixture
|
||||
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.FailsOnEmulatorApi37
|
||||
|
||||
class MarkerShapes {
|
||||
@FailsOnEmulatorApi37
|
||||
@Test
|
||||
fun ownLine() = Unit
|
||||
|
||||
@FailsOnEmulatorApi37 @Test
|
||||
fun sameLineAsTest() = Unit
|
||||
|
||||
/**
|
||||
* Deliberately not `@FailsOnEmulatorApi37`: nothing here decodes or encodes, so no emulator
|
||||
* codec is involved and the API 37 image has no quarrel with it.
|
||||
*/
|
||||
@Test
|
||||
fun mentionedInKdoc() = Unit
|
||||
|
||||
// @FailsOnEmulatorApi37 -- taken off on 2026-01-01, kept as a note rather than deleted
|
||||
@Test
|
||||
fun commentedOut() = Unit
|
||||
|
||||
class Nested {
|
||||
@FailsOnEmulatorApi37
|
||||
@Test
|
||||
fun indentedDeeper() = Unit
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,15 @@ name: API 37 debug
|
||||
# - It does not fork .github/scripts/e2e-run.sh. That script owns the FAILED-vs-WEDGED
|
||||
# split, the SIGQUIT thread dump and the streamed logcat, and it is the copy CI
|
||||
# exercises every day. This calls it, exactly as status_check.yml does.
|
||||
#
|
||||
# The SystemUI disable below is the exception, and it is a real one: this workflow
|
||||
# drives it from its own probe step so `disable_system_ui` can be turned off for a
|
||||
# dispatch, where the real leg gets it through `E2E_DISABLE_SYSTEM_UI`. Two copies of
|
||||
# that logic therefore exist and must be changed together. **This instrument is also
|
||||
# what established that the disable half of it does nothing** -- run 34010167885, in
|
||||
# which making its framework restart real cost the leg every test it had. Read
|
||||
# .github/scripts/e2e-run.sh's header for the measurements; the restart is gone from
|
||||
# both copies and what remains is the 45-second quiet window.
|
||||
# - It does not change status_check.yml. If a configuration here turns out to work,
|
||||
# the change to the real matrix is proposed separately.
|
||||
#
|
||||
@@ -291,45 +300,30 @@ jobs:
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# pm disable-user does not retract SystemUI's existing region-sampling
|
||||
# registration -- by the time boot completes it has already registered. Only a
|
||||
# framework restart brings back a SystemUI-less SurfaceFlinger. See
|
||||
# disable_region_sampling in tools/local-emulator/run-e2e.sh.
|
||||
echo " restarting the framework"
|
||||
adb shell stop
|
||||
for i in $(seq 1 20); do
|
||||
[ -z "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ] && break
|
||||
sleep 2
|
||||
done
|
||||
echo " system_server down after $((i * 2)) s"
|
||||
adb shell start
|
||||
for i in $(seq 1 30); do
|
||||
if adb shell service check package 2> /dev/null | grep -q ': found' \
|
||||
&& adb shell service check activity 2> /dev/null | grep -q ': found' \
|
||||
&& [ -n "$(adb shell pidof system_server 2> /dev/null | tr -d '\r\n')" ]; then
|
||||
echo " services back after $((i * 5)) s"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# NO FRAMEWORK RESTART. There was one here, and making it work (it needed
|
||||
# `adb root`) is what proved the whole disable is ineffective on this image:
|
||||
# SystemUI starts anyway, measured on CI and locally, and the restart itself
|
||||
# loses the package state to PackageManager's delayed write and leaves the leg
|
||||
# reporting `Starting 0 tests`. e2e-run.sh's header carries the measurements.
|
||||
# What is left, and what is load-bearing, is the quiet window below.
|
||||
if systemui_disabled; then
|
||||
echo " verified: com.android.systemui is in pm list packages -d"
|
||||
echo " pm list packages -d: com.android.systemui is in it"
|
||||
else
|
||||
echo " NOT DISABLED after the restart -- the package state did not survive"
|
||||
round=$((round + 1))
|
||||
continue
|
||||
echo " pm list packages -d: com.android.systemui is NOT in it"
|
||||
fi
|
||||
# Beside it, because the two disagree on this image and the first line alone
|
||||
# reads as a claim about the process that is not true.
|
||||
echo " com.android.systemui pid: $(adb shell pidof com.android.systemui 2> /dev/null | tr -d '\r\n')"
|
||||
|
||||
before="$(count_aborts)"
|
||||
sleep 45
|
||||
after="$(count_aborts)"
|
||||
echo "--- abort rate, SystemUI disabled: $((after - before)) new in 45 s (total ${after:-0}) ---"
|
||||
echo "--- aborts: $((after - before)) new in 45 s (total ${after:-0}) ---"
|
||||
[ "$((after - before))" -eq 0 ] && break
|
||||
echo " still aborting after round $round"
|
||||
round=$((round + 1))
|
||||
done
|
||||
systemui_disabled && echo "final state: SystemUI disabled" || echo "final state: SystemUI STILL ENABLED -- expect Starting 0 tests"
|
||||
systemui_disabled && echo "final state: com.android.systemui is disabled in pm (it still runs)" || echo "final state: com.android.systemui is not even disabled in pm"
|
||||
fi
|
||||
|
||||
echo "--- crash buffer (tail 60) ---"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -254,38 +254,48 @@ jobs:
|
||||
api-level: "36"
|
||||
# API 37, and it is NOT the same device as the four rows above it.
|
||||
#
|
||||
# CAVEAT, read this before trusting a green here: this leg runs with
|
||||
# SystemUI disabled and the framework restarted under it. No other leg
|
||||
# and no Pixel run uses that configuration. It is defensible only because
|
||||
# nothing THIS LEG RUNS touches system UI -- Media3, FFmpeg and
|
||||
# WorkManager tests -- and because the alternative is no CI coverage of
|
||||
# the level this app targets. **Anything that ever does depend on system
|
||||
# UI must not trust this row.** E2E_DISABLE_SYSTEM_UI is what does it;
|
||||
# .github/scripts/e2e-run.sh explains the mechanism and why every step of
|
||||
# it is verified rather than assumed.
|
||||
# THE CAVEAT THAT USED TO BE HERE IS WITHDRAWN, 2026-09-05, and the
|
||||
# withdrawal is good news. It said this leg "runs with SystemUI disabled
|
||||
# and the framework restarted under it", that no other leg or Pixel run
|
||||
# uses that configuration, and that anything depending on system UI must
|
||||
# not trust this row. **None of that was ever true.** Measured: the
|
||||
# framework restart is two root-only adb commands that answered `Must be
|
||||
# root` on every leg ever run, and `pm disable-user` does not stop SystemUI
|
||||
# starting on this image anyway -- in run 34006456986 the package is
|
||||
# verified disabled at 02:29:33 and SystemUI (pid 4275) is up from 02:28:52
|
||||
# for the whole run. So this row's device configuration is the same as the
|
||||
# other four's, and a green here means what a green on 33-36 means.
|
||||
#
|
||||
# "this leg" and not "this suite", since 2026-08-24, and the difference is
|
||||
# now load-bearing: SafPickerRoundTripTest DOES touch system UI. It drives
|
||||
# DocumentsUI and rotates the display, and both reach the gralloc mapper
|
||||
# this image aborts in -- disabling SystemUI removes the IDLE trigger, not
|
||||
# those. Measured per method on android-37.0: the ROTATION test takes the
|
||||
# framework down (INSTRUMENTATION_ABORTED) and carries
|
||||
# @FailsOnEmulatorApi37, so notAnnotation below keeps it off this row; the
|
||||
# PICKER test passes and runs here like anything else. A rotation rebuilds
|
||||
# every surface at once, and starting another app's activity does not.
|
||||
# E2E_DISABLE_SYSTEM_UI still exists and still runs, because what it
|
||||
# actually buys is a 45-second window with no new gralloc aborts before the
|
||||
# suite starts -- the boot-time ones land close together and instrumentation
|
||||
# has to begin after them, not between them. The name is stale and kept:
|
||||
# read .github/scripts/e2e-run.sh's header, which carries the measurements.
|
||||
#
|
||||
# So this row does now run one test that depends on system UI, and the
|
||||
# caveat above still applies to it: a green here is not evidence the picker
|
||||
# works on a device with SystemUI running -- the Pixel release check is.
|
||||
# docs/api-37-emulator-crash.md has the per-method measurements, and the
|
||||
# correction that produced them.
|
||||
# notAnnotation below keeps six tests off this row. SafPickerRoundTripTest's
|
||||
# PICKER test was measured on 2026-08-24 as passing here and was left on the
|
||||
# leg; four gating logcats read on 2026-09-05 show it aborting system_server
|
||||
# from the task-snapshot path on every single run, pass or fail, which is what
|
||||
# had been failing unrelated PRs (#108). All THREE of that class's tests now
|
||||
# carry the marker -- the save through the picker (#226) joined on 2026-09-06
|
||||
# by inheritance rather than measurement, since it opens the same picker.
|
||||
# docs/api-37-emulator-crash.md has the timings and the correction, and
|
||||
# FailsOnEmulatorApi37.kt has why the third one cannot be measured here.
|
||||
#
|
||||
# api-level must be "37.0". A bare 37 is not an SDK package and fails
|
||||
# during setup, which cost a run to discover.
|
||||
# api-level must be a POINT release. A bare 37 is not an SDK package and
|
||||
# fails during setup, which cost a run to discover. `37.0` is the choice
|
||||
# here rather than the only option: `37.1` and `37.2-beta*` exist and
|
||||
# abort the same way, and api37-debug.yml's inputs document both, with
|
||||
# the wrinkle that above 37.0 they ship only as google_apis_ps16k.
|
||||
# docs/api-37-emulator-crash.md measures 37.0 rev 6 and 37.1 rev 8 side
|
||||
# by side, so pinning 37.0 is a decision, not a constraint.
|
||||
#
|
||||
# notAnnotation removes the three tests that do not pass on this image; they
|
||||
# notAnnotation removes the six tests that cannot be RUN on this image; they
|
||||
# run in the advisory job below, off the same marker so they cannot end up
|
||||
# in both or neither. docs/api-37-emulator-crash.md has the measurements.
|
||||
# in both or neither. "Cannot be run" rather than "do not pass" is deliberate:
|
||||
# four fail outright, one of those aborts the framework on its way down, and on
|
||||
# the advisory leg the two picker tests behind it never report at all.
|
||||
# docs/api-37-emulator-crash.md has the measurements.
|
||||
- label: "37"
|
||||
api-level: "37.0"
|
||||
disable-system-ui: "1"
|
||||
@@ -372,6 +382,7 @@ jobs:
|
||||
path: |
|
||||
${{ runner.temp }}/logcat-api${{ matrix.label }}.txt
|
||||
${{ runner.temp }}/diagnostics-api${{ matrix.label }}.txt
|
||||
${{ runner.temp }}/gradle-api${{ matrix.label }}.txt
|
||||
if-no-files-found: warn
|
||||
|
||||
# Only exists when the wrapper timeout tripped, so `ignore` keeps healthy runs quiet
|
||||
@@ -462,6 +473,12 @@ jobs:
|
||||
# The complement of the gating row's notAnnotation, off the same marker,
|
||||
# so a test can never be excluded from both jobs or run in both.
|
||||
E2E_EXTRA_GRADLE_ARGS: "-Pandroid.testInstrumentationRunnerArguments.annotation=org.libremediaconverter.FailsOnEmulatorApi37"
|
||||
# Turns on the baseline comparison in the run-shape report, and only here. Every leg
|
||||
# prints the shape; this is the one that also says whether it matches
|
||||
# FAILS_ON_EMULATOR_API37_BASELINE, because this is the one whose test list is the
|
||||
# marker. A deviation is a `::notice::` -- this job stays continue-on-error and stays
|
||||
# out of the required contexts, so nothing the report finds can change a conclusion.
|
||||
E2E_ADVISORY: "1"
|
||||
with:
|
||||
# Every device pin below matches the gating row exactly, so a difference
|
||||
# between the two jobs is the test selection and nothing else.
|
||||
@@ -494,6 +511,7 @@ jobs:
|
||||
path: |
|
||||
${{ runner.temp }}/logcat-api${{ env.E2E_LABEL }}.txt
|
||||
${{ runner.temp }}/diagnostics-api${{ env.E2E_LABEL }}.txt
|
||||
${{ runner.temp }}/gradle-api${{ env.E2E_LABEL }}.txt
|
||||
if-no-files-found: warn
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -76,21 +76,73 @@ days. Read it as the current answer, and see the git history if you need the old
|
||||
`angle_indirect` and `swangle_indirect` all boot, while `auto`, `off`, `guest` and
|
||||
`swiftshader_indirect` do not. `docs/local-emulator.md` has the evidence and the per-API renderer
|
||||
table.
|
||||
- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Three** of the 59 instrumented
|
||||
tests cannot pass on that image, for two unrelated reasons: two Media3 hardware transcodes fail
|
||||
inside the emulator's own `c2.goldfish.h264.decoder`, and one SAF test takes the framework down
|
||||
when it rotates the display. All three carry `@FailsOnEmulatorApi37` and run in a separate
|
||||
`continue-on-error` job; the gating leg runs the other 56.
|
||||
- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Six** of the 70 instrumented
|
||||
tests cannot be *run* on that image, for three measured reasons and one inherited: three Media3
|
||||
tests fail inside the emulator's own `c2.goldfish.h264.decoder`, one SAF test takes the framework
|
||||
down when it rotates the display, and its sibling — the SAF picker round trip — aborts
|
||||
`system_server` from the task-snapshot path whether it passes or not. The sixth, that class's
|
||||
save through the picker (#226), carries the marker because it opens the same picker and a second
|
||||
DocumentsUI dialog on top of it — **not** because it has ever been observed here. It cannot be:
|
||||
the rotation test runs first and takes the framework down, so **all four** advisory runs at this
|
||||
baseline report `expected: 6, received: 4, failed: 4`, and the four are the three Media3 tests
|
||||
plus the rotation — runs 34041156680, 34041593697, 34042397320 and 34043502322. **Neither picker
|
||||
test has ever reported on the advisory leg**, which is a correction to what the marker's own KDoc
|
||||
says. All six carry
|
||||
`@FailsOnEmulatorApi37` and run in a separate `continue-on-error` job; the gating leg runs the
|
||||
other 64 — **the same 64 as before**, which is exactly how this paragraph went stale unnoticed.
|
||||
|
||||
**These two numbers move with the suite and are derived, not remembered.** `grep -cE
|
||||
'^\s*@Test' ` over `app/src/androidTest` is the first; the second is that minus the marker
|
||||
count `.github/scripts/e2e-report-shape.sh` greps. Cross-check against any run's shape rather
|
||||
than trusting the sentence: a leg below 37 reports the first as `expected`, and the API 37
|
||||
gating leg reports the second.
|
||||
|
||||
**That third reason is why "cannot pass" became "cannot be run" on 2026-09-05.** Four gating
|
||||
runs were read logcat-first — 34006456986, 34001744574, 34001377499 and the green 34002313300 —
|
||||
and each carries exactly two `hasReadColorBufferDma` aborts before the suite (surfaceflinger,
|
||||
during boot and the SystemUI disable) and exactly **one** during it: `system_server`, thread
|
||||
`TaskSnapshotPer`, always inside the picker test's window, and nothing else in the gating set
|
||||
reached the mapper at all. Whether the leg went red was luck — one run passed the test and lost
|
||||
the leg anyway with `failed: 0`, another passed it 0.6 s after the abort and went green. That is
|
||||
#108, it cost roughly a third of the gating legs over the wave-4 landings (#190), and a marker
|
||||
is what it needed. `docs/api-37-emulator-crash.md` has the timings.
|
||||
|
||||
**A second thing came out of those logcats, and it withdraws a caveat rather than adding one.**
|
||||
The API 37 row was documented as the one leg running "with SystemUI disabled and the framework
|
||||
restarted under it", which nothing else does. Neither half was ever happening: `adb shell stop`
|
||||
and `start` are root-only and answered `Must be root` on every leg ever run, and `pm
|
||||
disable-user` does not stop SystemUI starting on this image anyway — measured on CI and locally,
|
||||
with and without a real restart. **So this row's device configuration is the same as the other
|
||||
four's, and a green here means what a green at 33–36 means.** `E2E_DISABLE_SYSTEM_UI` is kept
|
||||
under its now-stale name because what it really buys is a 45-second window with no new gralloc
|
||||
aborts before the suite starts, which is load-bearing; `.github/scripts/e2e-run.sh`'s header is
|
||||
where that is written down.
|
||||
|
||||
That job is still called `E2E API 37 Media3 hardware transcode (advisory)`, which no longer
|
||||
describes everything in it. The name is kept deliberately — it is not a required context and
|
||||
people have learned to look for it — so **read the marker, not the name**, for what it holds.
|
||||
**It is red on every PR, by design**: do not read it as your change breaking something, and do
|
||||
not read a green run as evidence those three tests pass.
|
||||
not read a green run as evidence those six tests pass.
|
||||
`docs/api-37-emulator-crash.md` has the measurements.
|
||||
|
||||
**That instruction is also why nobody looks, so the job now reports its own shape** — expected,
|
||||
received, failed, and whether the run completed — to the job summary, and compares it against
|
||||
`FAILS_ON_EMULATOR_API37_BASELINE`, committed beside the marker. A deviation is a `::notice::`;
|
||||
the job stays advisory and its conclusion is untouched. **Add or remove a `@FailsOnEmulatorApi37`
|
||||
and that number changes in the same diff**, or the next run says so. A bare failure count would
|
||||
not have worked: the run is usually truncated by an `INSTRUMENTATION_ABORTED`, and the test XML
|
||||
is written anyway and says nothing about it — `.github/scripts/e2e-report-shape.sh` is where that
|
||||
is measured and explained.
|
||||
|
||||
Every leg prints that table, advisory or not, and **on the wedge path it also carries a `wedged:`
|
||||
row** (#118). `completed cleanly` only ever meant "instrumentation was not aborted", which stays
|
||||
true of a leg the `WEDGE_TIMEOUT` killed 22 minutes in — so without that row the table read
|
||||
`received: 59, completed cleanly: yes` for a leg that had just died. The wedge is passed to the
|
||||
report as `E2E_WEDGED_AFTER` by `e2e-run.sh`, which is the only thing that can know it: a wedge
|
||||
is gradle never returning, so the log it left says nothing about it.
|
||||
|
||||
Still true, and the reason the advisory job is not simply deleted: **API 37 needs a manual check on
|
||||
the Pixel 10 Pro XL before each release.** Those three tests are the one thing CI cannot answer
|
||||
the Pixel 10 Pro XL before each release.** Those six tests are the one thing CI cannot answer
|
||||
for.
|
||||
|
||||
On a device or emulator, build only the ABI it can execute:
|
||||
@@ -114,10 +166,11 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
- The `model` package is excluded from `ReturnCount` and `CyclomaticComplexMethod` only. It is the
|
||||
decision layer, where one branch is one documented user-visible outcome and the metric counts
|
||||
answers rather than complexity. Every other rule still applies there.
|
||||
- **Coverage is reported, not gated** — **69.2% of lines (1519/2194), 53.2% of branches**,
|
||||
measured 2026-08-24 with `./gradlew :app:jacocoTestReport`.
|
||||
- **Coverage is reported, not gated** — **94.2% of lines (2234/2372), 87.5% of branches
|
||||
(1171/1338)**, measured 2026-09-05 with `./gradlew :app:jacocoTestReport`, against 628 JVM tests
|
||||
in 96 classes.
|
||||
|
||||
**Every figure this file carried before that date was an artifact, roughly half the real one.**
|
||||
**Every figure this file carried before 2026-08-24 was an artifact, roughly half the real one.**
|
||||
Robolectric loads classes through its own sandbox classloader with no source location, JaCoCo
|
||||
skips no-location classes by default, and nothing told it otherwise — so **not one Robolectric
|
||||
test counted**, and Robolectric is what exercises the framework edge here. The
|
||||
@@ -131,9 +184,220 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
disproportionately Robolectric, so each one added denominator and no numerator — the measurement
|
||||
was punishing exactly the tests that were hardest to write.
|
||||
|
||||
Two things still hold. A floor needs a baseline that has settled, and this one has now moved by
|
||||
39 points in a single build change, so it has not. And **re-measure before quoting** — that
|
||||
instruction is the only reason this was caught.
|
||||
Two things still hold. A floor needs a baseline that has settled, and this one has not. It moved
|
||||
39 points in a single build change on 2026-08-24; then another 16 as the #52 test push and the
|
||||
fixes it turned up landed — 69.2% -> 84.9% line, 53.2% -> 63.8% branch — while the denominator
|
||||
grew 2194 -> 2321, because that work added production code of its own; then again on 2026-08-27
|
||||
as #132 and #133's ten children landed — 84.9% -> 87.1% line, 63.8% -> **69.1%** branch, 454 ->
|
||||
502 tests. **Branch moved four times as far as line that last time**, and that is the shape to
|
||||
expect from this kind of work rather than a surprise: those children targeted decision code —
|
||||
enum fallbacks, refusal arms, cursor shapes, a `when` over container rules — where one test
|
||||
chooses a branch the suite had never taken. Line coverage barely notices; branch coverage is the
|
||||
whole point.
|
||||
|
||||
Then #153's five children on 2026-08-29 — 87.1% -> 88.9% line, 69.1% -> **75.4%** branch, 502 ->
|
||||
546 tests.
|
||||
|
||||
**That last branch figure moved for two reasons, and only one of them is new tests.** The
|
||||
numerator rose 974 -> 1011; the denominator *fell* 1410 -> 1340. Both are the seam work. Pulling
|
||||
a `when` out of a lambda inside a `collect` deletes the coroutine state machine's synthesized
|
||||
branches around it, and what is left is a plain function whose branches a test can choose:
|
||||
`ConversionViewModel$observe$1$1` went from carrying the whole mapping to 6 branches, while the
|
||||
extracted `ConversionViewModelKt` covers 41 of 42 and `JoinViewModelKt` 38 of 39. So a seam is
|
||||
worth more than the tests it enables — it also stops the measurement counting scaffolding.
|
||||
|
||||
Be careful quoting a branch move on its own for that reason. A percentage that rises because the
|
||||
denominator shrank is not the same claim as one that rises because more branches are tested, and
|
||||
this entry has a history of explaining its own numbers wrongly.
|
||||
|
||||
Then wave 3 (#167-#178) on 2026-09-02 — 88.9% -> 92.8% line, 75.4% -> **81.3%** branch, 546 ->
|
||||
584 tests, in ten PRs from #179 to #188.
|
||||
|
||||
**Its shape is different from the two before it, and the difference is the thing to carry
|
||||
forward.** Waves 1 and 2 were finding uncovered code. By wave 3 there was not much of that left,
|
||||
so the gaps were sorted into two kinds before any test was written:
|
||||
|
||||
- **coverage gaps** — the line never executes. Filtered to sites where JaCoCo reports `mi > 0`, a
|
||||
concrete instruction no test runs, which is what separates a real gap from a partial branch on
|
||||
a compound condition. That filter cut the candidate list roughly in half and was right to.
|
||||
**Wave 4 found it wrong in both directions, though — use the two filters below instead.**
|
||||
- **assertion gaps** — JaCoCo is green and nothing checks the answer. `MainActivity`'s rail and
|
||||
bottom bar were both *executed* by `AppRootRestorationTest` and **transposing them passed the
|
||||
entire suite**; so did swapping the two progress-notification strings, and swapping `Content`'s
|
||||
two destinations. No coverage number would ever have found any of the three.
|
||||
|
||||
**Wave 4 (2026-09-02) corrected that first filter, and the correction is the reusable part.**
|
||||
`mi > 0` fails in both directions. It *over-reports* on Compose: `JoinScreen.kt:222` reads
|
||||
`mi=10` and also `ci=38`, and `JoinStateAffordancesTest` already clicks that Save button and
|
||||
asserts `save:joined.mp4` — the missed instructions are the synthesized `$changed`/`$dirty`
|
||||
recomposition-skip path, the same codegen this file already warns about for *branch* counts,
|
||||
showing up in the instruction count too. And it *under-reports* on warm methods with cold arms:
|
||||
`ConversionViewModel.cancel()` misses no line, yet `activeWorkId?.let(...)` had only ever been
|
||||
entered on the null side in 584 tests. Use two filters together instead:
|
||||
|
||||
- **`ci == 0`** — the line never executed. This is JaCoCo's own missed-line definition, so it
|
||||
totals exactly the reported missed-line count and needs no judgement.
|
||||
- **`ci > 0 && mb > 0` at method level** — a covered method with an arm nothing takes. This is
|
||||
the only one that finds the `cancel()` shape.
|
||||
|
||||
Of wave 4's 251 missed branches, just **18** sat on lines that do execute, so the branch gap and
|
||||
the line gap are largely the same gap; the second filter is about which of them are reachable.
|
||||
|
||||
So **every ticket named the mutation that had to go red, and that was its acceptance criterion
|
||||
rather than a coverage delta**. It caught **two vacuous tests written in the same session**,
|
||||
before either shipped:
|
||||
|
||||
- a `firstContainerHolding` test asserting a refusal still offered *something*. True, and
|
||||
useless: the source container is a candidate in its own right, so the list stays non-empty
|
||||
whatever the fallback does. What it actually buys is the codec the user asked for.
|
||||
- a staged-delete test scanning for a `"join-"` prefix `StagingNames.forJob` does not produce —
|
||||
it names files `<jobId>.<ext>`, so the assertion was true of everything.
|
||||
|
||||
It also corrected a *third* test that was not vacuous: `probeForConcat`'s KDoc claimed to drive
|
||||
the `catch` arm, and rethrowing from that catch left it green. That is how the arm turned out to
|
||||
be unreachable — see the next paragraph. A passing test with a wrong explanation is its own
|
||||
failure mode.
|
||||
|
||||
**A green mutation is only evidence when the mutation is a real change**, which is the mirror
|
||||
trap: one `classify` mutation stayed green because reordering two arms was semantically
|
||||
equivalent for every reachable input. A bad mutation and a weak test look identical in the output.
|
||||
|
||||
Three things came back **not as the ticket described them**, which is a result rather than a
|
||||
shortfall:
|
||||
|
||||
- `ContainerCapabilities:282`'s `exclude` filter **cannot drop anything**. `repair` always
|
||||
changes a codec on the shared container — a codec it left alone is one `validate` would not
|
||||
have refused — and the one non-default `exclude` carries `COPY` while every candidate carries
|
||||
`NONE`. F4-shaped.
|
||||
- `probeForConcat`'s catch arm is **unreachable on this runtime**. Robolectric's `MediaExtractor`
|
||||
never throws from `setDataSource`, measured across an unregistered `content://` authority, a
|
||||
missing `file://`, a file of garbage bytes and an `http://` URL — all four returned with
|
||||
`trackCount = 0`. It stays device-only.
|
||||
- `JobSnapshots:31`'s missed arm was **not** the `!isFile` one the ticket named — that is already
|
||||
covered by the `reclaimed` fixture. It was `path == null`: a job carrying no output path at
|
||||
all. Read the report, not the ticket, when the two disagree.
|
||||
|
||||
One item was **included against** the F4 rule rather than exempted by it, and the distinction is
|
||||
worth having written down since both live in the same function: `ContainerCapabilities:94`
|
||||
(`accepts(container, VideoCodec.NONE, mode)`) is dead in production today — every caller guards
|
||||
`NONE` first — and was tested anyway, because its audio twin at `:101` has had a test since #136
|
||||
and the asymmetry was the argument. The `COPY -> error(...)` arms beside it stay exempt, because
|
||||
a second line of defence that can be provoked is not one.
|
||||
|
||||
Denominators moved here too, in both directions and for two different reasons: 1340 -> 1342
|
||||
branches from `MediaProbe.merge`, 2348 -> 2352 lines from the `ConcatJoiner` interface. Neither
|
||||
is new untested code.
|
||||
|
||||
And **re-measure before quoting**: this entry was once written quoting 81.4%, measured four hours
|
||||
earlier, and was already three points stale by the time it was ready to merge.
|
||||
|
||||
**Wave 4's read (2026-09-02) moved no number at all, and that is its result.** It was a triage
|
||||
rather than a test push: twelve tickets (**#192-#203**), four deferred candidates (**#204**), and
|
||||
five findings (**F6-F10** in `docs/coverage-read-findings.md`). What it establishes is the shape
|
||||
of what is left, which is different again from wave 3's:
|
||||
|
||||
- Of 169 never-executed lines, **81 are native or device edges and stay that way** —
|
||||
`FFmpegEngine` 33, `Media3Engine` 24, `ConcatEngine` 14, `MainActivity.onCreate` 10 — their
|
||||
zeroes being the `testDebugUnitTest`-only measurement boundary that #84, #85, #86 and #88 each
|
||||
recorded before. A further **34 are device-bound only until a seam moves them**:
|
||||
`AndroidDeviceCodecs` 20 (#194) and the 14 of `MediaProbe`'s 26 that are `readMediaInformation`
|
||||
(#195). Do not read that second group as exempt — the two tickets exist because it is not.
|
||||
- Most of the rest is **already closed with a reason on record**, or compiler-generated: default-arg
|
||||
bridges, DI factory lambdas, synthetic `NoWhenBranchMatchedException` arms, coroutine completion.
|
||||
- Six of the ten findings in that document are now "no action" or "not a test gap". By this point
|
||||
the report's remaining red is mostly arms nothing can reach, members nothing calls, and arms a
|
||||
test *can* reach but cannot pin — and a coverage number tells none of them apart.
|
||||
|
||||
**The biggest single gap it found was not a missed line.** `ConversionViewModel.cancel()` and
|
||||
`JoinViewModel.cancel()` report every line covered; only the null arm of
|
||||
`activeWorkId?.let(workManager::cancelWorkById)` had ever been entered, so nothing in 584 tests
|
||||
connected the Cancel button to WorkManager (#192). That is what the second filter above is for.
|
||||
|
||||
It also re-opened a mechanism, not a close: #86 and #133 ruled `AndroidDeviceCodecs.probe()` out
|
||||
**through `ShadowMediaCodecList`**, on the grounds that the builder cannot set `isAlias` or
|
||||
`canonicalName`. A pure seam does not have that constraint, and #133 did not evaluate one. Read
|
||||
#194 before re-arguing either way — and note the reason it is worth cutting is not coverage but
|
||||
that the `runCatching` fallback logs "assuming permissive" while returning empty sets, which makes
|
||||
`canEncode` and `canDecode` answer *no* for everything.
|
||||
|
||||
**Wave 4's tests then landed on 2026-09-05**, as #206-#217 for the twelve tickets plus #218
|
||||
(#159) and #219 (#122): 92.8% -> **94.2%** line, 81.3% -> **87.5%** branch, 584 -> 628 tests in 87
|
||||
-> 96 classes. Missed lines 169 -> 138, missed branches 251 -> 167.
|
||||
|
||||
**Its branch move is a different animal from the 2026-08-29 seam work's, and the difference is the
|
||||
point.** That one gained 6.3 branch points with the numerator up 37 (974 -> 1011) while the
|
||||
denominator *fell* 70 (1410 -> 1340) — much of the rise was scaffolding leaving the measurement
|
||||
rather than arms being covered. Here the numerator is up **80** (1091 -> 1171) and
|
||||
the denominator moved **-4** (1342 -> 1338). So this one is almost entirely tests choosing arms
|
||||
nothing had chosen, which is what the entry above warns to check before quoting a branch figure.
|
||||
The line denominator rose the other way, 2352 -> 2372, and that is new production code rather than
|
||||
untested code: the seams the wave cut — `capabilitiesFrom`, `ffprobeInfoFrom`, `sessionOutcome`,
|
||||
and `sweepScope`/`startupSweep`.
|
||||
|
||||
**The two-filter method above is what found the work**, and its second filter earned its place:
|
||||
the largest single gap of the wave (#192, the Cancel button never shown to reach WorkManager) sits
|
||||
on lines that were already green and no line-level filter could see it.
|
||||
|
||||
One result worth carrying forward about *evidence* rather than coverage. #218 fixed a flake whose
|
||||
reproduction is statistical, and running the whole suite six times per arm caught nothing either
|
||||
way — at the observed rate a clean six-run arm is roughly a coin flip, so the comparison was
|
||||
underpowered and proved nothing. What settled it was a deterministic mutation, and then the merge
|
||||
train confirmed it by accident: the race reproduced on #217's Unit tests leg, which sits below
|
||||
#218 and carries the unfixed scope. **Prefer a mutation that must go red to a repetition count**
|
||||
when a fix is for something intermittent.
|
||||
|
||||
**Every number above is `testDebugUnitTest` only, and on 2026-09-05 the instrumented suite got its
|
||||
first read for that reason** — `docs/e2e-read-findings.md`, entries **E1-E7**, tickets
|
||||
**#223-#230**. Four waves had been steered by a figure that **cannot see `app/src/androidTest` at
|
||||
all**, so nothing had ever asked what those 60 device tests pin, only that they were green.
|
||||
|
||||
**It found one test that passes while testing nothing, and it is the one that matters most.**
|
||||
`HardwareFallbackTest` is the only automated check of the hardware→software fallback against a
|
||||
*real* codec failure, and on run `34004304566` the API 33, 34, 35 and 37 legs each log
|
||||
`Routing sample_h264_444.mp4 -> ... via FFMPEG (NO_HARDWARE_ENCODER)` (API 36's logcat artifact on
|
||||
that run is truncated, so it is unread rather than different): emulators expose no
|
||||
hardware encoder, so the job never reaches Media3 and the `catch` it exists to prove is never
|
||||
entered. Its two assertions — succeeded, output non-empty — are true anyway, and it finishes in
|
||||
448 ms. **Deleting that `catch` reddens nothing on any leg** (#223).
|
||||
|
||||
Two things generalise from it. **A test can assert and still not reach**, which no coverage
|
||||
number and no "does it assert something" review would catch — the filter that works is *does this
|
||||
test's premise hold on the machine that runs it?*. And the codebase **already knew**: the sibling
|
||||
`ForcedFailureTest` pins `DeviceCodecs.PERMISSIVE` against exactly this hazard and writes out why,
|
||||
as does `ConversionWorkerTest`. The difference is that their assertions are about the *path*, so
|
||||
without the pin they would fail loudly; `HardwareFallbackTest`'s are about the *output*, so it
|
||||
passes quietly. **Prefer asserting the path over asserting the artefact** where the two differ.
|
||||
|
||||
The read was a triage, not a test push, and six of its seven findings are prose rather than code —
|
||||
the suite itself is in good shape. What had drifted is its self-description.
|
||||
|
||||
**Working the tickets then found the thing the read could not: one production defect.** #238 —
|
||||
joining files picked through the system picker failed outright on the stream-copy path. The
|
||||
concat demuxer whitelists protocols separately from `-safe 0`, and `ffkitsaf` was not on the
|
||||
list; only `STREAM_COPY` feeds it a list file, and every existing join test passed
|
||||
`Uri.fromFile`, so **the one broken combination was the only one a user could reach**. Not a
|
||||
missed line and not an unasserted value — two covered things no test put together, which is the
|
||||
gap shape a coverage number is worst at.
|
||||
|
||||
**E7 is the other reusable result**, because it re-scoped its own ticket. A real
|
||||
`DocumentsProvider` cannot be reached without the picker: an unprotected one is refused at
|
||||
install, instrumentation runs in the app's uid so the test APK's identity is no help, and shell
|
||||
identity is denied too — each denial naming `ACTION_OPEN_DOCUMENT`. So #226 has no cheap headless
|
||||
half. But the *input* bridge needs no documents provider at all, which is what kept #225 headless
|
||||
and is how #238 surfaced.
|
||||
|
||||
**The 2026-09-06 re-check found that the read's own last PR had re-introduced the drift the read
|
||||
was about**, and that is the entry worth carrying forward. #226 moved the suite 69 -> 70 and the
|
||||
markers 5 -> 6 and changed neither the count in this file, the marker's KDoc, nor the two
|
||||
comments in `status_check.yml`. **The gating figure is what hid it**: 69 - 5 and 70 - 6 are both
|
||||
64, so the one number a reader checks against a run had not moved — which is precisely why the
|
||||
paragraph above says to derive these rather than remember them. Worse, two KDoc claims in the new
|
||||
test described a draft rather than the code: it says MP3 was chosen so the setup could not depend
|
||||
on the device's codecs, while the code converts at the default `MP4_H265`/`FAST` and therefore
|
||||
routes on `canEncode(H265)` — the *negation* of the stated reason. **That is E1 and E3's failure
|
||||
mode, committed by the wave that found it.** All of it is fixed; the standing item is **#250**,
|
||||
because #226 proved D4's premise and never drove its delete arm.
|
||||
|
||||
- **Testable code is not done until it is tested.** If a piece is unit testable, it gets unit
|
||||
tests before it counts as done. If it is e2e testable, it gets e2e tests. Both clauses apply —
|
||||
a change that is both needs both.
|
||||
@@ -155,6 +419,32 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
|
||||
- `kotlin.code.style=official`. Gradle stays Kotlin DSL.
|
||||
|
||||
- **A stacked PR does not merge with `gh pr merge`, and `MERGED` is not proof it reached `main`.**
|
||||
Two separate traps, both measured on 2026-08-27 while landing #144-#151.
|
||||
|
||||
`gh pr merge` uses the GraphQL mutation, which refuses a stacked PR outright: *"This pull request
|
||||
is part of a stack and must be merged using the asynchronous merge REST API."* So does
|
||||
`PUT .../pulls/{n}/merge`. The one that works is
|
||||
`gh api -X PUT repos/OWNER/REPO/pulls/N/merge-async -f merge_method=merge`, which returns a uuid
|
||||
to poll at `.../merge-async/{uuid}` until `status` is `merged` or `failed`.
|
||||
|
||||
**The second trap is worse, because nothing looks wrong.** GitHub retargets a stacked PR's base
|
||||
to `main` when the PR below it merges, but *asynchronously*. Merge a stack faster than that
|
||||
settles — five PRs about thirty seconds apart, in the case that found this — and each one merges
|
||||
into its own base branch, which has itself already been merged and left behind. Every call
|
||||
returns `status: merged` and every one is true. `gh pr list --state open` comes back empty, every
|
||||
PR shows `MERGED`, and **none of the content is on `main`**.
|
||||
|
||||
What caught it was a coverage re-measure reading two points lower than the same tree had measured
|
||||
an hour earlier; a fresh `git pull` changed nothing, which is what turned it into a question.
|
||||
`git merge-base --is-ancestor <merge-sha> origin/main` answers it in one line. Do that after
|
||||
merging a stack, or merge one at a time and re-read `baseRefName` between. #160 is what the
|
||||
recovery cost.
|
||||
|
||||
The auto-retarget belongs to the stacking feature specifically. A PR opened with a plain
|
||||
`gh pr create --base some-branch` does **not** retarget when that branch merges — it is left
|
||||
pointing at a dead base and has to be moved by hand.
|
||||
|
||||
- **File one-off issues with `tools/github/file-issue.sh`, not `gh issue create`.** `gh issue
|
||||
create` does not touch the project board, so the issue exists, carries its labels, and is
|
||||
invisible in the Kanban — indistinguishable from never having been filed. Measured 2026-08-24:
|
||||
@@ -227,3 +517,37 @@ Because versions float, a build can change without a commit. `./gradlew :app:dep
|
||||
`@OptIn`. Android lint's `UnsafeOptInUsageError` catches a missed one.
|
||||
- **Release builds ship both ABIs.** `-PabiFilters` is a test-run override only; `build.yml`
|
||||
verifies the released APK carries every ABI and that all native libraries are 16 KB aligned.
|
||||
- **A JUnit `Timeout` — rule or `@Test(timeout=)` — cannot be used in the JVM suite.** Both run the
|
||||
test body on a separate thread, and every Compose test here goes through Robolectric's paused
|
||||
main looper: `UnsupportedOperationException: main looper can only be controlled from main
|
||||
thread`, from `ShadowPausedLooper` under `RobolectricIdlingStrategy.runUntilIdle`. The identical
|
||||
tests pass with the timeout removed, so it is the mechanism, not the test. What bounds a hung
|
||||
run instead is `timeout` on the `Test` tasks plus the jstack watchdog beside it in
|
||||
`app/build.gradle.kts`, neither of which moves a thread. `HangBoundTest` guards both numbers,
|
||||
and **a timed-out run writes no XML for the class that hung** — the dump is its only
|
||||
attribution, so do not delete the watchdog as stray config. It has since been exercised in anger:
|
||||
on 2026-09-05 it caught #125's Room/WorkManager deadlock on CI, failing in 10m57s with the hung
|
||||
test named, where that ticket had predicted a 60-minute cap and no cause. #125 is closed as
|
||||
bounded on the strength of it — the inversion itself is internal to the two libraries and still
|
||||
live at `work-runtime` 2.11.2 / `room` 2.7.0.
|
||||
- **The JVM suite does not run `LibreMediaConverterApp`.** `app/src/test/resources/robolectric.properties`
|
||||
names `TestLibreMediaConverterApp` for every test, and it differs from the real class in exactly
|
||||
one thing: `sweepScope` is `Dispatchers.Unconfined`, so the startup staging sweep finishes before
|
||||
`onCreate()` returns instead of running on `Dispatchers.IO`.
|
||||
|
||||
**That line is load-bearing — do not delete it as stray config.** Robolectric builds an
|
||||
`Application` per test class that asks for one, and each `onCreate` launched a sweep over the
|
||||
shared `<cacheDir>/conversions/` that nothing joined. So a test asserting about a staged file was
|
||||
racing every sweep the classes before it had left in flight (#159). It was CI-only until wave 4
|
||||
added ten Robolectric classes, at which point `OutputPublisherStagingTest` failed on roughly one
|
||||
local run in six. Per-test opt-in was measured and rejected: **27 of the 58 Robolectric classes
|
||||
touch that directory**. The `SupervisorJob` is kept in the test scope so a throwing sweep is
|
||||
swallowed there exactly as in production — the dispatcher is the only intended difference.
|
||||
|
||||
**It cost one assertion, knowingly.** `AppStartSweepTest` used to open by asserting that the
|
||||
manifest's `android:name` is what Robolectric instantiated, so the sweep is code that actually
|
||||
runs. An `application=` override *replaces* the manifest rather than being checked against it, and
|
||||
`applicationInfo.className` reports the override too — measured — so that claim is not merely
|
||||
unasserted on the JVM now, it is unobservable, and a rewritten version would assert the override
|
||||
against itself. **The manifest link is device-only.** What remains is the `as LibreMediaConverterApp`
|
||||
cast in that class's `setUp`, which catches only the test app ceasing to extend the real one.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.testing.jacoco.tasks.JacocoReport
|
||||
import java.io.File
|
||||
import java.time.Duration
|
||||
|
||||
plugins {
|
||||
// Applied by id: these two come from the root buildscript classpath, which is what
|
||||
@@ -206,6 +209,125 @@ detekt {
|
||||
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
|
||||
// no location for either, and the test JVM dies rather than reporting a number.
|
||||
tasks.withType<Test>().configureEach {
|
||||
// ReleasePermissionTest reads .github/workflows/build.yml, and Gradle cannot infer that a
|
||||
// test depends on a file outside the source set. Without this the task stays UP-TO-DATE
|
||||
// when the workflow changes, so the guard goes stale exactly when it matters. Measured:
|
||||
// deleting the release job's `contents: write` and re-running gave "BUILD SUCCESSFUL in
|
||||
// 614ms" with the test never executing; the same mutation under --rerun-tasks failed it.
|
||||
// A guard that does not re-run when its subject changes is not a guard.
|
||||
inputs.file(rootProject.file(".github/workflows/build.yml"))
|
||||
.withPropertyName("releaseWorkflow")
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
|
||||
// Same reasoning, same trap: HangBoundTest reads the two numbers below out of this file, and
|
||||
// they are the one part of the change that does not compile. Without this the task stays
|
||||
// UP-TO-DATE when the build script changes, so the guard would go stale on exactly the edit
|
||||
// it exists to catch.
|
||||
inputs.file(project.file("build.gradle.kts"))
|
||||
.withPropertyName("moduleBuildScript")
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
|
||||
// --- Bounding a hung run (#125) -----------------------------------------------------------
|
||||
//
|
||||
// This suite had no timeout of any kind, so a hang ran until something outside it gave up.
|
||||
// #125 is a real Java-level deadlock -- a lock-order inversion between Room's
|
||||
// TransactionExecutor and WorkManager's SerialExecutorImpl, reached through the WorkInfo flow
|
||||
// -- and one local run sat in it for 47 minutes. On CI it would burn the Unit tests job's
|
||||
// 30-minute cap and report as a job timeout with no cause at all.
|
||||
//
|
||||
// WHY NOT A JUnit `Timeout` RULE, which is the obvious answer: it runs the test body on a
|
||||
// separate thread, and this suite is thread-affine. Measured here, `@Rule Timeout` and
|
||||
// `@Test(timeout = ...)` against a `createComposeRule()` Robolectric test both give:
|
||||
//
|
||||
// java.lang.UnsupportedOperationException: main looper can only be controlled from main
|
||||
// at org.robolectric.shadows.ShadowPausedLooper.executeOnLooper
|
||||
// at androidx.compose.ui.test.RobolectricIdlingStrategy.runUntilIdle
|
||||
//
|
||||
// The same two tests with the timeout removed pass, so that is the mechanism and not the
|
||||
// probe. Nothing that moves a test off its own thread can be used here.
|
||||
//
|
||||
// `Task.timeout` moves nothing -- it stops the forked test JVM from outside. Its weakness is
|
||||
// that it kills without a thread dump, and the jstack is the only reason #125 could be named
|
||||
// at all; the watchdog below is what answers that, and it only dumps.
|
||||
//
|
||||
// THE NUMBER, against the slowest observed *pass* rather than the typical one. Eight CI runs
|
||||
// sampled 2026-08-26, whole `./gradlew :app:testDebugUnitTest` invocation with compilation in
|
||||
// it and this task a subset: 62, 76, 77, 79, 81, 84, 86 and 90 seconds. Locally the task
|
||||
// itself is ~11 s over 454 tests. Ten minutes is ~6.7x the slowest of those and a third of
|
||||
// the job's 30-minute cap, so a fired timeout still has room to be reported and uploaded. It
|
||||
// is deliberately nowhere near the observed duration: a timeout that fires on a healthy slow
|
||||
// runner turns a real signal into noise and teaches people to re-run reflexively.
|
||||
timeout.set(Duration.ofMinutes(10))
|
||||
|
||||
// The dump, two minutes before the kill. jstack is what turned #125 from "CI timed out" into
|
||||
// a named lock-order inversion, and `Task.timeout` on its own would have thrown it away.
|
||||
//
|
||||
// It is deliberately incapable of failing a build: it reads a live process and writes a file.
|
||||
// Nothing here kills, interrupts or signals anything, so the worst a misfire can do is leave a
|
||||
// stack trace nobody needed. It has one, and it is the ordinary CI shape rather than an exotic
|
||||
// case: the worker is found by scanning this daemon's descendants for GradleWorkerMain, which
|
||||
// cannot tell one invocation's worker from the next, and the Unit tests job runs
|
||||
// testDebugUnitTest and jacocoTestReport back to back against the same daemon. If this task's
|
||||
// own worker lived and died inside a single poll, the watchdog can adopt the following one.
|
||||
//
|
||||
// Everything it needs is read here, at configuration time, and captured by value. Reaching
|
||||
// back through the task or the project from inside the action would not survive the
|
||||
// configuration cache, which `gradle.properties` turns on for every build.
|
||||
val threadDump = layout.buildDirectory.file("reports/hang/$name-threads.txt").get().asFile
|
||||
val taskPath = path
|
||||
val dumpAfterNanos = Duration.ofMinutes(8).toNanos()
|
||||
val captureWindowNanos = Duration.ofMinutes(1).toNanos()
|
||||
val pollMillis = 1_000L
|
||||
doFirst {
|
||||
val watchdog = Thread {
|
||||
val startedAt = System.nanoTime()
|
||||
var worker: ProcessHandle? = null
|
||||
while (true) {
|
||||
Thread.sleep(pollMillis)
|
||||
val elapsed = System.nanoTime() - startedAt
|
||||
val watched = worker
|
||||
if (watched == null) {
|
||||
// Gradle forks the worker moments after this task starts. If none has shown
|
||||
// up by the end of the capture window there is nothing to watch, and going on
|
||||
// polling would only risk adopting some other build's.
|
||||
if (elapsed > captureWindowNanos) return@Thread
|
||||
worker = ProcessHandle.current().descendants()
|
||||
.filter { it.info().commandLine().orElse("").contains("GradleWorkerMain") }
|
||||
.findFirst().orElse(null)
|
||||
} else if (!watched.isAlive) {
|
||||
return@Thread // the run finished; this is the healthy exit
|
||||
} else if (elapsed >= dumpAfterNanos) {
|
||||
val jstack = File(File(System.getProperty("java.home"), "bin"), "jstack")
|
||||
threadDump.parentFile.mkdirs()
|
||||
if (jstack.canExecute()) {
|
||||
ProcessBuilder(jstack.absolutePath, "-l", watched.pid().toString())
|
||||
.redirectErrorStream(true)
|
||||
.redirectOutput(threadDump)
|
||||
.start()
|
||||
.waitFor()
|
||||
} else {
|
||||
threadDump.writeText("no jstack at ${jstack.absolutePath}\n")
|
||||
}
|
||||
// To stdout as well as to the file, and that is the half that matters on CI:
|
||||
// the Unit tests job uploads app/build/reports/tests/ and nothing else, so a
|
||||
// dump that only ever existed under reports/hang/ would be unreachable from a
|
||||
// red run -- which is the "timed out with no cause" this exists to end. The
|
||||
// step log always survives, and needs no workflow edit to say so.
|
||||
println(
|
||||
"$taskPath is still running after ${Duration.ofNanos(elapsed).toMinutes()} " +
|
||||
"minutes and is about to be timed out. Thread dump of pid " +
|
||||
"${watched.pid()}, also written to $threadDump -- look for 'Found one " +
|
||||
"Java-level deadlock' (that is #125).\n" + threadDump.readText(),
|
||||
)
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}
|
||||
watchdog.isDaemon = true
|
||||
watchdog.name = "hang-watchdog"
|
||||
watchdog.start()
|
||||
}
|
||||
|
||||
extensions.configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
|
||||
@@ -42,6 +42,28 @@
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
|
||||
<!--
|
||||
A PLAIN provider, for the ffkitsaf bridge on the success path.
|
||||
|
||||
FFmpegKitConfig.getSafParameterForRead is on every real user conversion and was on no
|
||||
passing test: they all pass Uri.fromFile, which takes the other arm. Only its failure
|
||||
side was covered, by UnopenableUriTest naming an authority that does not exist.
|
||||
|
||||
The documents provider above cannot serve this. Any DOCUMENTS_PROVIDER must hold
|
||||
MANAGE_DOCUMENTS or the platform refuses to install it, instrumentation runs in the
|
||||
target app's process and so carries the app's uid, and the resulting denial says what
|
||||
is actually required: access obtained through ACTION_OPEN_DOCUMENT. That means a picker,
|
||||
and the flake it brings. See issue #226.
|
||||
|
||||
The bridge does not need a documents provider. It opens a descriptor through the
|
||||
resolver and hands FFmpeg a saf: path, so any readable content:// URI exercises it, and
|
||||
an ordinary provider is allowed to be exported without a permission.
|
||||
-->
|
||||
<provider
|
||||
android:name="org.libremediaconverter.saf.FixtureContentProvider"
|
||||
android:authorities="org.libremediaconverter.test.content"
|
||||
android:exported="true" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.libremediaconverter
|
||||
|
||||
/**
|
||||
* Marks an instrumented test that does not pass on the `android-37.x` **emulator** system images.
|
||||
* Marks an instrumented test that cannot be run on the `android-37.x` **emulator** system images.
|
||||
*
|
||||
* This is a marker, not a skip. Nothing reads it except CI, and CI reads it twice — once with
|
||||
* `notAnnotation` to build the gating API 37 leg, and once with `annotation` to build the advisory
|
||||
@@ -9,6 +9,23 @@ package org.libremediaconverter
|
||||
* That is the whole reason there is one annotation rather than a pair of test lists: two lists
|
||||
* drift, and the drift is silent in both directions (a test that runs nowhere reads as green).
|
||||
*
|
||||
* **"Cannot be run" covers three things now, and it covered only the first until 2026-09-05.**
|
||||
* Four of the six carriers simply fail: three Media3 tests die in the image's own
|
||||
* `c2.goldfish.h264.decoder`, and the SAF rotation test takes the framework down with it. The
|
||||
* fifth — `SafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard` —
|
||||
* **passes about half the time and aborts `system_server` every time**, which is worse for a
|
||||
* gating leg than an honest failure: it fails the leg from the teardown, with no failing test to
|
||||
* point at (#108). The wording was widened rather than the test excused; that test's own KDoc has
|
||||
* the four-run measurement.
|
||||
*
|
||||
* **The sixth is the new third thing: it is marked by inheritance, not by measurement.**
|
||||
* `SafPickerRoundTripTest.aSaveWritesToTheDocumentTheSystemPickerCreated` (#226) opens the same
|
||||
* picker and then a second DocumentsUI dialog on top of it, so it sits on the same task-snapshot
|
||||
* path its sibling was marked for. It has never been observed at API 37 either way — see the
|
||||
* measurement under [FAILS_ON_EMULATOR_API37_BASELINE], which is why it cannot be. Marking it was
|
||||
* the conservative choice, and **the trigger for revisiting it is the rotation test, not itself**:
|
||||
* while that one truncates the advisory run, nothing downstream of it can report.
|
||||
*
|
||||
* It says only what has been measured: **on the emulator, at API 37.** The same tests pass on a
|
||||
* physical Pixel 10 Pro XL at API 37 and at API 33–36 on the same runner under the same renderer,
|
||||
* so this must never be read as "this test is allowed to fail at API 37" — only as "the API 37
|
||||
@@ -17,8 +34,66 @@ package org.libremediaconverter
|
||||
*
|
||||
* Removing it is the goal, and the trigger is written down: a new API 37.x system image, or an
|
||||
* ATD image for 37. Delete the annotation from the tests, and the advisory job goes empty and
|
||||
* the gating one grows by two.
|
||||
* the gating one grows by [FAILS_ON_EMULATOR_API37_BASELINE].
|
||||
*
|
||||
* **How many tests carry it is committed below**, as [FAILS_ON_EMULATOR_API37_BASELINE], and the
|
||||
* advisory job checks the run against it. Adding or removing a marker means changing that number
|
||||
* in the same diff.
|
||||
*/
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
|
||||
annotation class FailsOnEmulatorApi37
|
||||
|
||||
/**
|
||||
* How many tests carry [FailsOnEmulatorApi37] — the advisory API 37 job's committed baseline.
|
||||
*
|
||||
* **No Kotlin reads this, and it is not stray config.** `.github/scripts/e2e-report-shape.sh`
|
||||
* parses it out of this file by name, with a line-anchored pattern, and the advisory job compares
|
||||
* the run it just did against it: this many tests should start, and all of them should fail.
|
||||
* Deleting it, renaming it, or indenting it into a class stops the comparison — the report would
|
||||
* keep printing with nothing to compare to, so it announces that it could not read the baseline
|
||||
* rather than falling quiet. If you see that notice, this line is what it means.
|
||||
*
|
||||
* **One number, both checks, and that is what the marker was meant to mean.** A test carrying it
|
||||
* cannot be run on this image, so the count is meant to be simultaneously how many the advisory
|
||||
* leg runs and how many fail. A *smaller* failure count is the interesting direction: it means one
|
||||
* of them now passes, which is the trigger the KDoc above names for deleting the annotation.
|
||||
* **Since 2026-09-06 the second half no longer holds in practice** — the run truncates before two
|
||||
* of the six start, which the last paragraph below measures. `expected` still holds, and it is the
|
||||
* field that catches a marker added without changing this number.
|
||||
*
|
||||
* **The picker tests are the ones to read that sentence carefully for, and the reason changed
|
||||
* on 2026-09-06.** `pickingAFileThroughTheSystemPickerFillsInTheFileCard` was marked on
|
||||
* 2026-09-05 for aborting `system_server` rather than for failing (#108), and on the gating leg
|
||||
* it passed two runs of four. It was recorded here as *failing* on the advisory leg, behind the
|
||||
* rotation test — measured, `api37-debug.yml` run 34008889182, `expected: 4, received: 4,
|
||||
* failed: 4`, in the order Media3, Media3, rotation, picker. (Those dispatches predate the third
|
||||
* Media3 marker, so their totals are four rather than six.)
|
||||
*
|
||||
* **But a second dispatch of the identical configuration reported 4/3/3**, having lost the last
|
||||
* test to the abort rather than to anything about the test list, and that is why
|
||||
* `e2e-report-shape.sh` compares `failed` only on a run that finished. `expected` is compared
|
||||
* always — it comes from `Starting N tests`, which is printed before anything can abort, so it is
|
||||
* the field that answers "is the marked set the size this number says". Read a *clean* run
|
||||
* reporting fewer failures than this as one of them now passing; read a truncated one as the
|
||||
* framework having died, which is this job's normal.
|
||||
*
|
||||
* **That is no longer what happens, and the difference is that neither picker test reports at
|
||||
* all.** With six carriers the rotation test truncates the run before them: **all four** advisory
|
||||
* runs at this baseline — 34041156680, 34041593697, 34042397320 and 34043502322 — report
|
||||
* `expected: 6, received: 4, failed: 4`, and the four are the three Media3 tests plus the
|
||||
* rotation. So the advisory leg currently answers for
|
||||
* four of its six, and the comparison below is unaffected only because `failed` is not compared
|
||||
* on a truncated run. Read it as **unmeasured**, not as passing or failing.
|
||||
*
|
||||
* So: adding or removing a [FailsOnEmulatorApi37] means changing this number, in this file, in
|
||||
* the same diff. The report says so on the run itself if you forget — it prints the tree's own
|
||||
* `grep` count beside this one.
|
||||
*
|
||||
* Why a baseline at all (#83): that job is `continue-on-error` and red on every PR by design, so
|
||||
* a red X cannot distinguish the known failures from the known failures plus a new one. Counting
|
||||
* failures alone does not fix it either — the run is usually truncated by an
|
||||
* `INSTRUMENTATION_ABORTED`, so the count is a number taken from a partial run. The report
|
||||
* records the truncation next to the counts for that reason.
|
||||
*/
|
||||
const val FAILS_ON_EMULATOR_API37_BASELINE = 6
|
||||
|
||||
@@ -36,8 +36,20 @@ import java.io.File
|
||||
* 1. that the hardware path is worth having a second engine for at all, and
|
||||
* 2. that x264's CRF is worth the GPL licence the app carries for it.
|
||||
*
|
||||
* Skips itself when the sample files are absent, so it is harmless in CI. Populate with:
|
||||
* adb push <file>.mp4 /sdcard/Android/data/org.libremediaconverter/files/
|
||||
* Skips itself when the sample files are absent, so it is harmless in CI — every green E2E
|
||||
* leg reports two skips, and these are they.
|
||||
*
|
||||
* The two files it looks for, by exact name:
|
||||
*
|
||||
* - [H264_SAMPLE] for [hardwareVersusSoftwareOnRealVideo]
|
||||
* - [AV1_SAMPLE] for [av1InputRoutesAccordingToDeviceDecodeSupport]
|
||||
*
|
||||
* **Where they go, and how, is on [samples] — read it before staging anything.** This used to
|
||||
* carry an `adb push` line naming the external files dir, which [samples] then explains cannot
|
||||
* work: a pushed file stays owned by the shell user and the app reads EACCES, surfacing as an
|
||||
* unparseable input rather than a permission error. The instruction and its own refutation sat
|
||||
* twelve lines apart. It is named in one place now rather than restated here, because restating
|
||||
* it is what let the two drift.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
|
||||
@@ -7,19 +7,35 @@ import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.FailsOnEmulatorApi37
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.AudioPlan
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.CopyPlanner
|
||||
import org.libremediaconverter.model.InputKind
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.VideoPlan
|
||||
import java.io.File
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -170,6 +186,63 @@ class Media3EngineTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The builders that used to throw where nothing could catch them.
|
||||
*
|
||||
* `EditedMediaItem.Builder` rejects a composition with both tracks removed —
|
||||
* checkState("Audio and video cannot both be removed") — and the engine builds it on its own
|
||||
* HandlerThread. That build sat *between* two narrow `runCatching` blocks, one around
|
||||
* `buildTransformer` and one around `start`, so the exception reached the thread's uncaught
|
||||
* handler and took the process with it while the continuation was never resumed.
|
||||
*
|
||||
* `ContainerCapabilities.validate` now refuses the spec that gets here from the picker; this
|
||||
* is the other half — the engine surviving a request that arrives without being validated.
|
||||
* Deliberately not `@FailsOnEmulatorApi37`: nothing here decodes or encodes, so no emulator
|
||||
* codec is involved. The builder refuses the input before any media is touched.
|
||||
*/
|
||||
@Test
|
||||
fun aPlanThatRemovesBothTracksFailsInsteadOfKillingTheProcess() {
|
||||
val request = ConversionRequest(
|
||||
spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
probe = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
),
|
||||
)
|
||||
// Asserted rather than assumed: ConversionRequest's default probe says hasVideo = true,
|
||||
// and with it this same spec plans to (Encode, Drop) and nothing throws at all — which
|
||||
// would make the whole test vacuous without a word of warning.
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
|
||||
val failure = runCatching {
|
||||
runBlocking {
|
||||
withTimeout(BUILDER_TIMEOUT_MS) {
|
||||
engine.transcode(Uri.fromFile(input), output, request) {}
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
|
||||
// Two assertions, and the second is not pedantry. withTimeout raises
|
||||
// TimeoutCancellationException, and `java.util.concurrent.CancellationException` *extends*
|
||||
// IllegalStateException — so testing only the type below would call an unresumed
|
||||
// continuation a pass. A hang is the other half of this defect and every bit as bad as the
|
||||
// crash: the worker would sit holding a foreground service forever.
|
||||
assertFalse(
|
||||
"the continuation was never resumed — the failure escaped instead of being reported: " +
|
||||
"$failure",
|
||||
failure is CancellationException,
|
||||
)
|
||||
assertTrue(
|
||||
"the builder's refusal must surface as a failed job, not a dead process; got $failure",
|
||||
failure is IllegalStateException,
|
||||
)
|
||||
}
|
||||
|
||||
private fun durationMsOf(file: File): Long {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
@@ -194,6 +267,92 @@ class Media3EngineTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelling a *running* export stops it, completing #224's third engine.
|
||||
*
|
||||
* The two FFmpeg engines were done first (`ad2a75d`, `d293646`); this is
|
||||
* `Media3Engine.transcode`'s `invokeOnCancellation`, which posts `transformer.cancel()` onto the
|
||||
* engine's own `HandlerThread` because `cancel()` has the same single-thread requirement as
|
||||
* `start()`.
|
||||
*
|
||||
* ## Why the assertion is the output file here, and was not for FFmpeg
|
||||
*
|
||||
* The FFmpeg side could not use the file: `invokeOnCancellation` unlinks it, and on POSIX ffmpeg
|
||||
* keeps writing to the unlinked inode, so the path stays gone whether or not the cancel landed.
|
||||
* It asserted the session's return code instead.
|
||||
*
|
||||
* `Media3Engine` deletes nothing — the partial is `ConversionWorker`'s to clean up — so the file
|
||||
* *is* the evidence. An export that was cancelled leaves no moov atom, so `MediaExtractor`
|
||||
* either finds no video track or refuses the file outright with
|
||||
* `IOException: Failed to instantiate extractor` — measured, and both mean interrupted. One
|
||||
* that ran to completion leaves a playable HEVC file, which is the only outcome treated as a
|
||||
* miss. The wait before
|
||||
* reading it is deliberately several times the length of the export, so a *non*-cancelled export
|
||||
* has certainly finished by then: the failure direction is "the file became valid", never "we
|
||||
* did not wait long enough".
|
||||
*
|
||||
* ## Why it retries
|
||||
*
|
||||
* Same reason as the other two, measured there: the committed fixture is 3 s at 320x240 and the
|
||||
* export outruns a naive cancel on a loaded runner. An attempt whose export finished before the
|
||||
* cancel landed has tested nothing, so it is a miss and is retried; only exhausting
|
||||
* [CANCEL_ATTEMPTS] fails. With `transformer.cancel()` removed every attempt produces a playable
|
||||
* file, so the mutation still bites — it just takes five tries to say so.
|
||||
*
|
||||
* Progress having been reported is what proves the export really started, so a miss is
|
||||
* distinguishable from an export that never ran at all — which matters on the API 37 image,
|
||||
* where the decoder is what fails.
|
||||
*/
|
||||
@Test
|
||||
@FailsOnEmulatorApi37
|
||||
fun cancellingARunningExportStopsIt(): Unit = runBlocking {
|
||||
val outcomes = mutableListOf<String>()
|
||||
|
||||
repeat(CANCEL_ATTEMPTS) { attempt ->
|
||||
val partial = File(context.cacheDir, "cancelled_export_$attempt.mp4").apply { delete() }
|
||||
|
||||
val job = launch(Dispatchers.IO) {
|
||||
engine.transcode(
|
||||
input = Uri.fromFile(input),
|
||||
output = partial,
|
||||
request = ConversionRequest(OutputFormat.MP4_H265.spec),
|
||||
)
|
||||
}
|
||||
|
||||
// The muxer creating the file is proof the export really started, and it is the
|
||||
// earliest such proof available -- earlier than the first progress tick.
|
||||
withTimeout(TIMEOUT_MS) {
|
||||
while (!partial.exists() && job.isActive) delay(POLL_MS)
|
||||
}
|
||||
val started = partial.exists()
|
||||
job.cancelAndJoin()
|
||||
|
||||
if (!started) {
|
||||
// The export failed before writing anything. That is not a cancellation result
|
||||
// either way, so it is not allowed to pass as one.
|
||||
outcomes += "attempt $attempt never produced an output file to cancel"
|
||||
return@repeat
|
||||
}
|
||||
|
||||
// Several times the export's own length, so a cancel that did not land has certainly
|
||||
// finished. The failure direction is "the file became playable", never "too soon".
|
||||
delay(SETTLE_MS)
|
||||
|
||||
// A cancelled export reports itself two ways and both mean the same thing: no video
|
||||
// track, or MediaExtractor refusing the file outright with "Failed to instantiate
|
||||
// extractor" because there is no moov atom to read. Only a *playable* file is a miss.
|
||||
val video = runCatching { videoMimeTypeOf(partial) }.getOrNull()
|
||||
partial.delete()
|
||||
if (video == null) return@runBlocking
|
||||
outcomes += "attempt $attempt produced a playable $video"
|
||||
}
|
||||
|
||||
fail(
|
||||
"never interrupted a running export in $CANCEL_ATTEMPTS attempts, so either every " +
|
||||
"export finished first or cancellation does not reach the transformer: $outcomes",
|
||||
)
|
||||
}
|
||||
|
||||
private fun videoMimeTypeOf(file: File): String? {
|
||||
val extractor = MediaExtractor()
|
||||
try {
|
||||
@@ -211,5 +370,25 @@ class Media3EngineTest {
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 120L
|
||||
|
||||
/** Bounds the wait for the muxer to create the file; a hang here is a defect. */
|
||||
const val TIMEOUT_MS = 30_000L
|
||||
const val POLL_MS = 25L
|
||||
|
||||
/**
|
||||
* How long to let a *failed* cancel finish. Several times the export's own length, so
|
||||
* "the file is not playable" cannot mean "not yet".
|
||||
*/
|
||||
const val SETTLE_MS = 10_000L
|
||||
|
||||
/** See the KDoc: a miss is the loaded-runner case, not a defect. */
|
||||
const val CANCEL_ATTEMPTS = 5
|
||||
|
||||
/**
|
||||
* Short on purpose. Nothing is decoded or encoded on this path — the builder refuses the
|
||||
* input outright — so anything approaching this is a hang, which is what the test is
|
||||
* looking for.
|
||||
*/
|
||||
const val BUILDER_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -27,7 +28,10 @@ import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinState
|
||||
import org.libremediaconverter.join.JoinViewModel
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.libremediaconverter.work.JobTags
|
||||
@@ -64,6 +68,26 @@ class EchoWorker(context: Context, params: WorkerParameters) : Worker(context, p
|
||||
* path, foreground service included — into a synchronous test double, depending on class order.
|
||||
*/
|
||||
@UnstableApi
|
||||
/**
|
||||
* A [SoftwareTranscoder] that holds the worker in [WorkInfo.State.RUNNING] until released.
|
||||
*
|
||||
* Declared here rather than in `FakeFailures` because it is the only test that needs a job to stay
|
||||
* live on demand, and the shape is specific to that: the others fake a *failure*, this fakes
|
||||
* *duration*.
|
||||
*/
|
||||
private class BlockingTranscoder(private val released: CompletableDeferred<Unit>) : SoftwareTranscoder {
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
released.await()
|
||||
output.writeBytes(ByteArray(1_024))
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReattachOnLaunchTest {
|
||||
|
||||
@@ -75,7 +99,13 @@ class ReattachOnLaunchTest {
|
||||
fun clearTheQueue() = emptyQueueAndStaging()
|
||||
|
||||
@After
|
||||
fun leaveNothingBehind() = emptyQueueAndStaging()
|
||||
fun leaveNothingBehind() {
|
||||
// The suite runs without Android Test Orchestrator, so every class shares one process and
|
||||
// a swapped seam outlives the class that set it. Only one test here swaps one, but a
|
||||
// BlockingTranscoder left in place would hang the next class that converts anything.
|
||||
ConversionDependencies.reset()
|
||||
emptyQueueAndStaging()
|
||||
}
|
||||
|
||||
/**
|
||||
* The claim the whole fix rests on, checked against the production request builder rather
|
||||
@@ -261,6 +291,69 @@ class ReattachOnLaunchTest {
|
||||
return request.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Reattaching to a conversion that is **running right now**, which nothing had ever driven.
|
||||
*
|
||||
* This class covers a job that finished, one whose staged file is gone, an ambiguous pair, one
|
||||
* still queued, and one the user cancelled. [Reattachment.rank] gives
|
||||
* [WorkInfo.State.RUNNING] the **highest** rank of all — "live work outranks a finished result
|
||||
* because a running job is holding a foreground service" — and no test on either source set
|
||||
* ever produced one. `ReattachmentTest` exercises the ranking as a pure function over
|
||||
* fabricated snapshots; what was missing is a ViewModel meeting a real running job.
|
||||
*
|
||||
* It is also the likeliest reattachment there is: the user starts a conversion, leaves, and
|
||||
* comes back while it is still going.
|
||||
*
|
||||
* ## Why the engine is a fake here, and why that is not a weakening
|
||||
*
|
||||
* The job has to still be running when the ViewModel is built, and every real conversion in
|
||||
* this suite finishes in about a second — racing that is what made the cancellation tests flaky
|
||||
* enough to need retries (#224). A [SoftwareTranscoder] that blocks until released removes the
|
||||
* race outright: the job is `RUNNING` for exactly as long as the test wants.
|
||||
*
|
||||
* Nothing about reattachment depends on which engine is transcoding. What is under test is the
|
||||
* tag query, [Reattachment.choose] over live WorkManager state, and `observe` mapping it to
|
||||
* [ConversionState.Converting] — all of which run identically whatever is doing the work.
|
||||
*
|
||||
* ## What this does not do, and cannot (#230)
|
||||
*
|
||||
* It does not kill the process. `docs/defect-audit.md` D3/D13 record that `am kill` refuses a
|
||||
* process holding a foreground service, and there is a more basic obstacle: **instrumentation
|
||||
* runs in the app's own process**, so any route that really killed it would take the test
|
||||
* runner with it and there would be nothing left to assert with. A relaunch-and-observe test
|
||||
* needs two instrumentation runs, which the runner does not provide.
|
||||
*
|
||||
* So process death stays device-manual, and this is the closest observable analogue: a fresh
|
||||
* ViewModel, with no memory of the work, meeting a job that is genuinely mid-flight.
|
||||
*/
|
||||
@Test
|
||||
fun reattachesToAConversionThatIsStillRunning(): Unit = runBlocking {
|
||||
val released = CompletableDeferred<Unit>()
|
||||
ConversionDependencies.software = { BlockingTranscoder(released) }
|
||||
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(stage("running_input.mp3")),
|
||||
displayName = RUNNING_NAME,
|
||||
sizeBytes = RUNNING_SIZE,
|
||||
spec = OutputFormat.MP3.spec,
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
// Deterministic: the worker cannot finish until this test lets it.
|
||||
withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it?.state == WorkInfo.State.RUNNING }
|
||||
}
|
||||
|
||||
val reattached = awaitConversion<ConversionState.Converting>()
|
||||
|
||||
assertEquals(RUNNING_NAME, reattached.input.displayName)
|
||||
assertEquals(RUNNING_SIZE, reattached.input.sizeBytes)
|
||||
|
||||
released.complete(Unit)
|
||||
workManager.cancelWorkById(request.id).result.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues a job that stays [WorkInfo.State.ENQUEUED]. The delay is what holds it there: it
|
||||
* is long enough that nothing can run it during a test, and it is cancelled either way.
|
||||
@@ -326,5 +419,9 @@ class ReattachOnLaunchTest {
|
||||
* against WorkManager's database, so this is generous rather than tuned.
|
||||
*/
|
||||
const val SETTLE_MS = 5_000L
|
||||
|
||||
/** Read back off the job's tags by the reattaching ViewModel, so both have to survive. */
|
||||
const val RUNNING_NAME = "still_running.mp3"
|
||||
const val RUNNING_SIZE = 4_242L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,17 @@ import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.ConversionRouter
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import java.io.File
|
||||
|
||||
@@ -34,6 +40,47 @@ import java.io.File
|
||||
* hand — a regression test that silently skips is worse than no test, because the count
|
||||
* still reads as coverage.
|
||||
*
|
||||
* ## Why this skips on emulators, and why that is the honest answer (#223)
|
||||
*
|
||||
* **This test used to pass everywhere while proving nothing.** Two independent facts stop the
|
||||
* fallback happening on an emulator, and both were measured rather than reasoned:
|
||||
*
|
||||
* 1. **The router never sends the job to Media3.** A Fast MP4/H.265 job goes to the hardware path
|
||||
* only when `device.canEncode(H265)`, and emulators expose no hardware encoder — every leg of
|
||||
* run `34004304566` logged
|
||||
* `Routing sample_h264_444.mp4 -> ... via FFMPEG (NO_HARDWARE_ENCODER)`. The whole test
|
||||
* finished in 448 ms, which is not long enough to fail an export and then re-encode.
|
||||
* 2. **Forcing it to Media3 does not help either, which is the part that settles it.** Pinning
|
||||
* `ConversionDependencies.deviceCodecs` to [DeviceCodecs.PERMISSIVE] — the trick
|
||||
* [ForcedFailureTest] uses — makes the router choose Media3, and the export then *succeeds*.
|
||||
* Measured on a local API 34 emulator: `MediaCodecInfo` logs
|
||||
* `NoSupport [codec.profileLevel, avc1.F4000C, video/avc]` for **both**
|
||||
* `c2.goldfish.h264.decoder` and `c2.android.avc.decoder`, and ExoPlayer allocates the
|
||||
* goldfish decoder anyway, which decodes the file regardless of the profile it declares.
|
||||
* `c2.android.hevc.encoder` then encodes the result and the job reports `MEDIA3`.
|
||||
*
|
||||
* So the class KDoc above — "Media3 fails partway through the export on every device" — **is not
|
||||
* true of the emulator images**, and no amount of routing pressure makes this fixture force a
|
||||
* fallback there. The emulator cannot answer this question, so the test says so out loud instead
|
||||
* of passing.
|
||||
*
|
||||
* That is why the gate is [assumeTrue] on the *production* premise (`canEncode(H265)`) rather than
|
||||
* a pinned profile: pinning would also swap in software codecs, which is not the path a real
|
||||
* device takes and is what made the forced run succeed. **This is now the third permanent skip**;
|
||||
* the other two are [org.libremediaconverter.bench.RealMediaBenchmark]'s.
|
||||
*
|
||||
* `ForcedFailureTest.hardwareFailureFallsBackToSoftware` still covers the fallback *wiring* on
|
||||
* every leg, with an `ExplodingHardware` double. What only a device with a real hardware encoder
|
||||
* can show is two real engines disagreeing about a real file, and that is what this is for.
|
||||
*
|
||||
* ## Why the assertion is a pair
|
||||
*
|
||||
* `KEY_ENGINE_USED` is `FFMPEG` whether the fallback fired **or** the router went straight there,
|
||||
* so asserting it alone would not have caught any of the above. The premise is asserted
|
||||
* separately: [ConversionRouter.route] chooses `MEDIA3` for this request on this device. Static
|
||||
* routing wanted hardware, the runtime result was software — together, and only together, that is
|
||||
* the fallback.
|
||||
*
|
||||
* The fixture was produced with x264, which the host toolchain cannot do (Fedora's
|
||||
* ffmpeg ships openh264, which is Constrained Baseline only):
|
||||
*
|
||||
@@ -66,6 +113,15 @@ class HardwareFallbackTest {
|
||||
|
||||
@Test
|
||||
fun aFileMedia3CannotDecodeStillConvertsViaFfmpeg(): Unit = runBlocking {
|
||||
// See "Why this skips on emulators" on the class. Without a real hardware encoder the
|
||||
// router never chooses Media3, and forcing it makes the export succeed instead of fail --
|
||||
// so there is no fallback to observe and a green run would mean nothing.
|
||||
assumeTrue(
|
||||
"no hardware HEVC encoder, so the router cannot choose Media3 and there is no " +
|
||||
"fallback to exercise",
|
||||
AndroidDeviceCodecs.get().canEncode(VideoCodec.H265),
|
||||
)
|
||||
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = SAMPLE,
|
||||
@@ -75,6 +131,19 @@ class HardwareFallbackTest {
|
||||
// the tier where the fallback has to rescue the conversion.
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
// The premise, asserted rather than assumed: this request is one the router wants to send
|
||||
// to hardware on this device. Without it the test is green whether the fallback fired or
|
||||
// the job never went near Media3, which is exactly how #223 stayed invisible.
|
||||
val decision = ConversionRouter.route(
|
||||
ConversionRequest(OutputFormat.MP4_H265.spec, quality = QualityTier.FAST),
|
||||
AndroidDeviceCodecs.get(),
|
||||
)
|
||||
assertEquals(
|
||||
"this test only means something if the router sends this job to Media3",
|
||||
Engine.MEDIA3,
|
||||
decision.engine,
|
||||
)
|
||||
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
@@ -88,6 +157,14 @@ class HardwareFallbackTest {
|
||||
terminal?.state,
|
||||
)
|
||||
|
||||
// The outcome. Paired with the routing assertion above this is the fallback and nothing
|
||||
// else: hardware was chosen, software is what ran.
|
||||
assertEquals(
|
||||
"the router chose Media3, so a successful job must have fallen back to FFmpeg",
|
||||
Engine.FFMPEG.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
|
||||
val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
|
||||
assertTrue("no output produced", out.exists() && out.length() > 0)
|
||||
out.delete()
|
||||
|
||||
@@ -4,10 +4,20 @@ import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegSession
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import com.arthenica.ffmpegkit.SessionState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -113,6 +123,11 @@ class FFmpegEngineTest {
|
||||
fun encodesFlacLosslessAudio() {
|
||||
val out = convert(OutputFormat.FLAC)
|
||||
assertTrue("no FLAC produced", out.exists() && out.length() > 0)
|
||||
// "fLaC", the native FLAC stream marker. Without this the test passed on any non-empty
|
||||
// file, so a builder arm emitting the wrong encoder into a .flac name shipped green
|
||||
// (#228) -- the same shape the five assertions above already guard against.
|
||||
val magic = out.inputStream().use { String(it.readNBytes(4), Charsets.US_ASCII) }
|
||||
assertEquals("fLaC", magic)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -127,6 +142,165 @@ class FFmpegEngineTest {
|
||||
fun encodesOpus() {
|
||||
val out = convert(OutputFormat.OPUS)
|
||||
assertTrue("no Opus produced", out.exists() && out.length() > 0)
|
||||
// OutputFormat.OPUS is Container.OGG, so the file is an Ogg stream: "OggS" (#228).
|
||||
// Deliberately the container marker rather than the codec -- it is what the other
|
||||
// container-level assertions in this class check, and it is four bytes at offset 0.
|
||||
val magic = out.inputStream().use { String(it.readNBytes(4), Charsets.US_ASCII) }
|
||||
assertEquals("OggS", magic)
|
||||
}
|
||||
|
||||
/**
|
||||
* The percentage itself, which every other test in this class computes and none of them reads.
|
||||
*
|
||||
* `FFmpegEngine` derives progress as `stats.time / durationMs * 100`, and the statistics
|
||||
* callback runs on every conversion here — but every call site omits `onProgress`, so until
|
||||
* this test nothing on any source set had ever looked at the number (#229). #196 covered the
|
||||
* *worker's* progress lambda, and did it with a fake engine that reports whatever the test
|
||||
* tells it to; `ProgressNotificationTest` covers throttling the same way. The arithmetic was
|
||||
* the one part with no reader.
|
||||
*
|
||||
* ## Why the duration is deliberately wrong
|
||||
*
|
||||
* `sample_h264.mp4` is exactly 3.000 s, and this passes **30 s** as the duration. So the
|
||||
* conversion still encodes the whole clip, `stats.time` still climbs to about 3000 ms, and the
|
||||
* reported percentage tops out around **10** rather than 100.
|
||||
*
|
||||
* That is what makes the assertion bite. A range check alone is worthless here: replacing
|
||||
* `percent` with a constant `0` satisfies "every value is in 0..100" and "the values never go
|
||||
* backwards", and so does a list of `[0, 100]`. Pinning the *band* rejects every constant, and
|
||||
* — because the band is a tenth of the way up — it also rejects an implementation that ignores
|
||||
* `durationMs`, which would report ~100 for the same run.
|
||||
*
|
||||
* The bound is deliberately loose (5..25 for an expected 10). The last statistics callback can
|
||||
* land slightly before the final frame, so the peak is "about 3000 ms of a claimed 30 000",
|
||||
* not exactly it.
|
||||
*/
|
||||
@Test
|
||||
fun progressIsReportedAsAFractionOfTheDurationItWasGiven() {
|
||||
val seen = mutableListOf<Int>()
|
||||
val out = outputFor("out_progress.mp4")
|
||||
runBlocking {
|
||||
engine.run(
|
||||
request = ConversionRequest(spec = OutputFormat.MP4_H264.spec, quality = QualityTier.BEST),
|
||||
inputPath = input.absolutePath,
|
||||
output = out,
|
||||
// Ten times the fixture's real 3 s. See the KDoc.
|
||||
durationMs = 30_000,
|
||||
onProgress = { percent -> seen += percent },
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("the statistics callback never reported progress", seen.isNotEmpty())
|
||||
assertTrue("progress out of range: $seen", seen.all { it in 0..100 })
|
||||
assertEquals("progress went backwards: $seen", seen.sorted(), seen)
|
||||
// The band. Rejects any constant, and rejects ignoring durationMs (which would read ~100).
|
||||
val peak = seen.max()
|
||||
assertTrue(
|
||||
"3 s of media against a claimed 30 s should peak near 10%, got $peak from $seen",
|
||||
peak in 5..25,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelling a *running* conversion actually stops the native session.
|
||||
*
|
||||
* Nothing on any source set did this before (#224). Every `cancel` in `app/src/androidTest` is
|
||||
* `WorkManager.cancelWorkById` against work that is **queued or already finished** — the two in
|
||||
* `ReattachOnLaunchTest` cancel a job carrying a one-hour initial delay, and one immediately
|
||||
* after enqueue. On the JVM, `WorkerCancellationTest` and `HardwareFallbackTest`'s cancellation
|
||||
* case drive a `SoftwareTranscoder` double that records the call. No test had ever asked a real
|
||||
* native session to stop. This is `docs/defect-audit.md` **D10**'s forcing condition.
|
||||
*
|
||||
* It is the one path where cancelling wrong is silently expensive rather than loudly broken: a
|
||||
* missed `FFmpegKit.cancel` leaves the native process encoding to completion while the UI says
|
||||
* the job is cancelled, and nothing reports the battery and thermal cost.
|
||||
*
|
||||
* ## Why the assertion is the session's return code, not the output file
|
||||
*
|
||||
* The obvious assertion — the partial output is gone — **cannot fail**, so it would have been a
|
||||
* vacuous test. `invokeOnCancellation` deletes the path, and on POSIX unlinking a file ffmpeg
|
||||
* still holds open leaves ffmpeg writing to the unlinked inode; the path stays gone whether or
|
||||
* not the cancel ever reached the session. Deleting `FFmpegKit.cancel` and keeping
|
||||
* `output.delete()` passes that check every time.
|
||||
*
|
||||
* What distinguishes them is the session's own verdict: a cancelled session ends with the
|
||||
* cancel return code, a completed one ends successfully. That is a fact about the session
|
||||
* rather than about timing, so it is read *after* waiting for the session to leave
|
||||
* [SessionState.RUNNING] rather than at a fixed delay.
|
||||
*
|
||||
* ## Why it cancels on RUNNING rather than on the first progress callback
|
||||
*
|
||||
* Measured. Cancelling from the first `onProgress` was tried first and **failed on a local API
|
||||
* 34 emulator with `state=COMPLETED rc=0`** — every committed fixture is 2-3 s at 320x240, and
|
||||
* the encode finishes before the first statistics callback has been delivered and acted on. The
|
||||
* progress callback proves the session is running, but arrives too late to interrupt anything.
|
||||
* `FFmpegKit.listSessions` shows the session [SessionState.RUNNING] far earlier.
|
||||
*
|
||||
* ## Why it retries, which is the part that took two attempts to get right
|
||||
*
|
||||
* Waiting for `RUNNING` is not on its own enough. With `MP4_H265` at [QualityTier.BEST] this
|
||||
* passed four consecutive local runs and all five CI legs, then failed on the API 34 and 35 legs
|
||||
* of the next PR with `state=COMPLETED rc=0`. Nothing had changed: on a loaded runner the thread
|
||||
* that observed `RUNNING` can be descheduled long enough for a short encode to finish before it
|
||||
* calls `cancel`. A longer timeout does not help — the wait already succeeded.
|
||||
*
|
||||
* Two changes together, because neither is sufficient:
|
||||
*
|
||||
* - **A slower encode.** `WEBM_VP9` at `BEST` is the slowest thing this builder emits:
|
||||
* `libvpx-vp9 -crf 31 -b:v 0`, with `-deadline realtime` added **only** on
|
||||
* [QualityTier.FAST]. Probed on an API 34 emulator, that session is still `RUNNING` at 1 s
|
||||
* and finished by 2 s, against well under a second for x265 `-preset medium`.
|
||||
* - **Retrying the attempt.** An attempt whose session finished before the cancel landed has
|
||||
* not tested anything, so it is not a failure — it is a miss, and it is retried. Only
|
||||
* exhausting [CANCEL_ATTEMPTS] is a failure, and its message says which case it hit.
|
||||
*
|
||||
* That keeps the mutation honest: with `FFmpegKit.cancel` removed **every** attempt ends
|
||||
* `COMPLETED`, so the test still fails — it just takes [CANCEL_ATTEMPTS] tries to say so.
|
||||
*
|
||||
* The session is identified by diffing against the ids present before each attempt, because
|
||||
* this class has already produced eight of them by the time this executes.
|
||||
*/
|
||||
@Test
|
||||
fun cancellingARunningConversionCancelsTheNativeSession(): Unit = runBlocking {
|
||||
val outcomes = mutableListOf<String>()
|
||||
|
||||
repeat(CANCEL_ATTEMPTS) { attempt ->
|
||||
val before = FFmpegKit.listSessions().map { it.getSessionId() }.toSet()
|
||||
val out = outputFor("out_cancelled_$attempt.webm")
|
||||
|
||||
val job = launch(Dispatchers.IO) {
|
||||
engine.run(
|
||||
// The slowest target this builder emits -- see the KDoc. Not decoration:
|
||||
// with a faster one this loses the race on a loaded CI runner.
|
||||
request = ConversionRequest(spec = OutputFormat.WEBM_VP9.spec, quality = QualityTier.BEST),
|
||||
inputPath = input.absolutePath,
|
||||
output = out,
|
||||
durationMs = 3_000,
|
||||
)
|
||||
}
|
||||
|
||||
val ours = withTimeout(TIMEOUT_MS) {
|
||||
var found: FFmpegSession? = null
|
||||
while (found == null) {
|
||||
found = FFmpegKit.listSessions().firstOrNull { it.getSessionId() !in before }
|
||||
if (found == null) delay(POLL_MS)
|
||||
}
|
||||
found
|
||||
}
|
||||
job.cancelAndJoin()
|
||||
withTimeout(TIMEOUT_MS) {
|
||||
while (ours.getState() == SessionState.RUNNING) delay(POLL_MS)
|
||||
}
|
||||
|
||||
if (ReturnCode.isCancel(ours.getReturnCode())) return@runBlocking
|
||||
// The encode beat us to it. That attempt proved nothing either way, so try again.
|
||||
outcomes += "state=${ours.getState()} rc=${ours.getReturnCode()}"
|
||||
}
|
||||
|
||||
fail(
|
||||
"never interrupted a running session in $CANCEL_ATTEMPTS attempts, so either every " +
|
||||
"encode finished first or cancellation does not reach it: $outcomes",
|
||||
)
|
||||
}
|
||||
|
||||
// --- the quality tier the GPL licence was taken for --------------------
|
||||
@@ -166,4 +340,19 @@ class FFmpegEngineTest {
|
||||
}.exceptionOrNull()
|
||||
assertTrue("expected an FFmpegException, got $failure", failure is FFmpegEngine.FFmpegException)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Generous: it bounds a hang, and every wait here normally settles in well under a second. */
|
||||
const val TIMEOUT_MS = 30_000L
|
||||
const val POLL_MS = 50L
|
||||
|
||||
/**
|
||||
* How many times to try to catch the session mid-encode.
|
||||
*
|
||||
* Each miss costs about the length of one VP9 encode -- a second or two -- and a miss is
|
||||
* the loaded-runner case rather than a defect. Five is enough that exhausting them means
|
||||
* cancellation is not reaching the session, which is what the failure message says.
|
||||
*/
|
||||
const val CANCEL_ATTEMPTS = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,29 @@ import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegSession
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import com.arthenica.ffmpegkit.SessionState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
import org.libremediaconverter.convert.StagingNames
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.ffmpeg.FFmpegEngine
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -50,6 +62,82 @@ class ConcatEngineTest {
|
||||
(staged + listOf(clipA, clipB, clipMismatched)).forEach { it.delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelling a *running* join actually stops the native session.
|
||||
*
|
||||
* The `FFmpegEngine` half of #224 landed first (PR #236); this is the same gap in
|
||||
* [ConcatEngine]. Before these two, no test on any source set had ever asked a real native
|
||||
* session to stop — every `cancel` in `app/src/androidTest` targets WorkManager entries that
|
||||
* are queued or already finished.
|
||||
*
|
||||
* ## Two things carried over from the conversion side, both measured there
|
||||
*
|
||||
* **The assertion is the session's return code.** A cancelled session ends with the cancel
|
||||
* code, a completed one does not. The alternative — checking the output file — is even less
|
||||
* available here than it was for conversions: [ConcatEngine] does not delete its output on
|
||||
* cancellation at all. Its `invokeOnCancellation` is `FFmpegKit.cancel(...)` and nothing else,
|
||||
* where [org.libremediaconverter.ffmpeg.FFmpegEngine]'s also deletes the partial. Whether that
|
||||
* asymmetry is deliberate is a separate question from this test, which is why this asserts the
|
||||
* thing that is true of both.
|
||||
*
|
||||
* **The cancel is triggered on [SessionState.RUNNING], not on progress.** `ConcatWorker`
|
||||
* publishes no progress at all, so there is no callback to hang it on even in principle — but
|
||||
* the conversion side established the deeper reason: the committed clips are 2 s at 320x240 and
|
||||
* the encode outruns a callback-triggered cancel.
|
||||
*
|
||||
* **And the attempt is retried**, for the reason the conversion side measured the hard way: on
|
||||
* a loaded runner the thread that observed `RUNNING` can be descheduled long enough for a short
|
||||
* encode to finish before it calls `cancel`, which failed two CI legs there. An attempt whose
|
||||
* session finished first has tested nothing, so it is a miss rather than a failure; only
|
||||
* exhausting [CANCEL_ATTEMPTS] fails, and with `FFmpegKit.cancel` removed every attempt misses,
|
||||
* so the mutation still bites.
|
||||
*
|
||||
* The inputs are deliberately the **mismatched** pair, so [ConcatStrategy.REENCODE] is chosen.
|
||||
* A stream copy of two short clips is close to instantaneous and would leave nothing to
|
||||
* interrupt; re-encoding is the case where a user would actually reach for Cancel.
|
||||
*
|
||||
* *Mutation:* drop `FFmpegKit.cancel(session.getSessionId())` from `ConcatEngine`'s
|
||||
* `invokeOnCancellation` — the session runs to completion and this fails.
|
||||
*/
|
||||
@Test
|
||||
fun cancellingARunningJoinCancelsTheNativeSession(): Unit = runBlocking {
|
||||
val outcomes = mutableListOf<String>()
|
||||
|
||||
repeat(CANCEL_ATTEMPTS) { attempt ->
|
||||
val before = FFmpegKit.listSessions().map { it.getSessionId() }.toSet()
|
||||
val out = output("cancelled_join_$attempt.mp4")
|
||||
|
||||
val job = launch(Dispatchers.IO) {
|
||||
engine.join(
|
||||
listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)),
|
||||
out,
|
||||
ConcatWorker.DEFAULT_FORMAT,
|
||||
)
|
||||
}
|
||||
|
||||
val ours = withTimeout(TIMEOUT_MS) {
|
||||
var found: FFmpegSession? = null
|
||||
while (found == null) {
|
||||
found = FFmpegKit.listSessions().firstOrNull { it.getSessionId() !in before }
|
||||
if (found == null) delay(POLL_MS)
|
||||
}
|
||||
found
|
||||
}
|
||||
job.cancelAndJoin()
|
||||
withTimeout(TIMEOUT_MS) {
|
||||
while (ours.getState() == SessionState.RUNNING) delay(POLL_MS)
|
||||
}
|
||||
|
||||
if (ReturnCode.isCancel(ours.getReturnCode())) return@runBlocking
|
||||
outcomes += "state=${ours.getState()} rc=${ours.getReturnCode()}"
|
||||
}
|
||||
|
||||
fail(
|
||||
"never interrupted a running join in $CANCEL_ATTEMPTS attempts, so either every " +
|
||||
"encode finished first or cancellation does not reach it: $outcomes",
|
||||
)
|
||||
}
|
||||
|
||||
private fun copyAsset(name: String): File {
|
||||
val out = File(context.cacheDir, name)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
@@ -149,6 +237,55 @@ class ConcatEngineTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed join tells the user the return code and what FFmpeg said.
|
||||
*
|
||||
* **This is the device half of #203/#217**, whose PR closed by noting the join legs had not
|
||||
* been run. Running them would not have answered it: nothing on either source set drove a real
|
||||
* join *failure*, so the unified message was asserted only against values a JVM test hands to
|
||||
* `sessionOutcome` directly.
|
||||
*
|
||||
* What is device-only here is that the three reads behind that message work against a real
|
||||
* native session at all — `getReturnCode`, `getFailStackTrace` and `getAllLogsAsString`. If
|
||||
* the log tail came back null or empty on a device, the user would get `Joining failed (1): `
|
||||
* with nothing after the colon and every JVM test would still pass.
|
||||
*
|
||||
* **What this deliberately does not pin is the preference between the two detail sources.** On
|
||||
* an ordinary non-zero return code FFmpegKit reports no fail stack trace, so the stack-trace-
|
||||
* first rule and the log-tail-first rule produce the same text and no assertion here can tell
|
||||
* them apart. That ordering is [SessionOutcomeTest][org.libremediaconverter.ffmpeg.SessionOutcomeTest]'s
|
||||
* job, where both sources can be non-blank at once. Asserting it here would be a test whose
|
||||
* KDoc claims more than it checks — the `probeForConcat` mistake wave 3 caught.
|
||||
*
|
||||
* The failure is forced with an input that does not exist, which the concat demuxer rejects
|
||||
* the same way on every FFmpeg build, rather than with malformed media whose handling varies.
|
||||
*/
|
||||
@Test
|
||||
fun aFailedJoinReportsTheReturnCodeAndWhatFFmpegSaid(): Unit = runBlocking {
|
||||
val missing = File(context.cacheDir, "no_such_clip.mp4").also { it.delete() }
|
||||
val out = output("joined_failure.mp4")
|
||||
|
||||
val failure = runCatching {
|
||||
engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(missing)), out)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertTrue(
|
||||
"a join over a missing input must fail, got $failure",
|
||||
failure is FFmpegEngine.FFmpegException,
|
||||
)
|
||||
val message = failure?.message.orEmpty()
|
||||
assertTrue(
|
||||
"the message must name the operation and carry the return code, was: '$message'",
|
||||
message.startsWith("Joining failed ("),
|
||||
)
|
||||
// The half a JVM test cannot reach: a real session actually produced detail to show.
|
||||
val detail = message.substringAfter("): ", "")
|
||||
assertTrue(
|
||||
"the message stopped at the return code and told the user nothing, was: '$message'",
|
||||
detail.isNotBlank(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theListFileIsCleanedUpAfterJoining(): Unit = runBlocking {
|
||||
val out = output("joined_cleanup.mp4")
|
||||
@@ -180,4 +317,13 @@ class ConcatEngineTest {
|
||||
a.width != mismatched.width || a.height != mismatched.height,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Generous: it bounds a hang, and both waits here normally settle in well under a second. */
|
||||
const val TIMEOUT_MS = 30_000L
|
||||
const val POLL_MS = 50L
|
||||
|
||||
/** See the conversion side: a miss is the loaded-runner case, not a defect. */
|
||||
const val CANCEL_ATTEMPTS = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.libremediaconverter.saf
|
||||
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A `content://` input reaching FFmpeg successfully, which nothing had ever driven (#225).
|
||||
*
|
||||
* `FFmpegKitConfig.getSafParameterForRead` stands between a SAF grant and the native process, and
|
||||
* it is on **every real user conversion**. Every passing convert and join test in this suite hands
|
||||
* the worker a `Uri.fromFile(...)`, which takes the `uri.path` arm instead — so the bridge was
|
||||
* exercised only on its failure side, by `UnopenableUriTest` naming an authority that does not
|
||||
* exist. That proves the error message, not the bridge.
|
||||
*
|
||||
* ## Why a plain provider rather than the documents one
|
||||
*
|
||||
* [FixtureDocumentsProvider] cannot be reached from the app, measured three ways on an API 34
|
||||
* emulator (#226): a `DOCUMENTS_PROVIDER` declared without `MANAGE_DOCUMENTS` is refused at install
|
||||
* — *"Provider must be protected by MANAGE_DOCUMENTS"*; instrumentation runs in the **target app's
|
||||
* process**, so `Instrumentation.getContext()` still carries the app's uid and is denied; and
|
||||
* `adoptShellPermissionIdentity(MANAGE_DOCUMENTS)` is denied identically. The denial names the only
|
||||
* way in: *"you obtain access using ACTION_OPEN_DOCUMENT or related APIs"*.
|
||||
*
|
||||
* The bridge does not need one. It opens a descriptor through the resolver and hands FFmpeg a
|
||||
* `saf:` path, so any readable `content://` URI exercises it — and [FixtureContentProvider] is an
|
||||
* ordinary provider, which may be exported without a permission. The whole class is headless: no
|
||||
* DocumentsUI, and none of the flake #190 records.
|
||||
*
|
||||
* ## Why MP3
|
||||
*
|
||||
* The bridge lives on the FFmpeg arm, and MP3 is the format the router sends there unconditionally
|
||||
* — no platform encoder exists at any API level, so `ConversionWorkerTest.routesAnMp3JobToFfmpeg…`
|
||||
* relies on the same fact. Choosing a video target would make the engine depend on the device's
|
||||
* codecs, and #223 is what that costs.
|
||||
*
|
||||
* *Mutation:* make `getSafParameterForRead` return `uri.toString()`. FFmpeg cannot open it and both
|
||||
* tests fail; nothing else in either suite notices.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ContentUriInputTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aContentUriInputConvertsThroughTheSafBridge(): Unit = runBlocking {
|
||||
val input = FixtureContentProvider.uriFor(SAMPLE)
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = input,
|
||||
displayName = SAMPLE,
|
||||
sizeBytes = 0L,
|
||||
spec = OutputFormat.MP3.spec,
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
val error = terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)
|
||||
assertEquals(
|
||||
"a content:// input must convert, but failed with: $error",
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
terminal?.state,
|
||||
)
|
||||
// The bridge is on the FFmpeg arm only, so this is part of the claim rather than colour.
|
||||
assertEquals(Engine.FFMPEG.name, terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED))
|
||||
|
||||
val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
|
||||
assertTrue("no output produced from a content:// input", out.exists() && out.length() > 0)
|
||||
out.delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* The same bridge on the join path, which has its own copy of the call (`ConcatEngine:36`).
|
||||
*
|
||||
* Driven through the engine rather than `ConcatWorker` because the engine is where the branch
|
||||
* is; the worker adds a foreground service and nothing else this is about.
|
||||
*/
|
||||
@Test
|
||||
fun contentUriInputsJoinThroughTheSafBridge(): Unit = runBlocking {
|
||||
val out = File(context.cacheDir, "joined_from_content.mp4").apply { delete() }
|
||||
val result = ConcatEngine(context).join(
|
||||
listOf(FixtureContentProvider.uriFor(CLIP_A), FixtureContentProvider.uriFor(CLIP_B)),
|
||||
out,
|
||||
OutputFormat.MP4_H264,
|
||||
)
|
||||
|
||||
assertTrue("no output produced from content:// inputs", result.output.length() > 0)
|
||||
out.delete()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SAMPLE = "sample_h264.mp4"
|
||||
const val CLIP_A = "clip_a.mp4"
|
||||
const val CLIP_B = "clip_b.mp4"
|
||||
const val TIMEOUT_MS = 300_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package org.libremediaconverter.saf;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.OpenableColumns;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* A plain {@link ContentProvider} serving the committed media fixtures over {@code content://}.
|
||||
*
|
||||
* <p><b>Why this exists alongside {@link FixtureDocumentsProvider}.</b> Every passing convert and
|
||||
* join test hands the worker a {@code Uri.fromFile(...)}, which takes the {@code uri.path} arm and
|
||||
* never touches {@code FFmpegKitConfig.getSafParameterForRead}. That bridge is on 100% of real user
|
||||
* conversions and was on 0% of tested ones; only its failure side was covered, by
|
||||
* {@code UnopenableUriTest} pointing at an authority that does not exist.
|
||||
*
|
||||
* <p><b>Why not the documents provider.</b> It cannot be reached. Measured three ways on an API 34
|
||||
* emulator: a {@code DOCUMENTS_PROVIDER} declared without {@code MANAGE_DOCUMENTS} is refused at
|
||||
* install ("Provider must be protected by MANAGE_DOCUMENTS"); instrumentation runs in the target
|
||||
* app's process, so {@code Instrumentation.getContext()} still carries the app's uid and is denied;
|
||||
* and {@code adoptShellPermissionIdentity(MANAGE_DOCUMENTS)} is denied identically. The denial says
|
||||
* what is required — <i>"you obtain access using ACTION_OPEN_DOCUMENT or related APIs"</i> — so a
|
||||
* documents provider is reachable only through a picker-issued grant. See issue #226.
|
||||
*
|
||||
* <p>The bridge does not need one. {@code getSafParameterForRead} opens a file descriptor through
|
||||
* the resolver and hands FFmpeg a {@code saf:} path; any readable {@code content://} URI exercises
|
||||
* it. An ordinary provider may be exported without a permission, so this one is, and the whole test
|
||||
* stays headless — no DocumentsUI, and none of the flake #190 records.
|
||||
*
|
||||
* <p>Unlike {@link FixtureDocumentsProvider} this may use {@code androidx} and Kotlin freely — it is
|
||||
* loaded into the app process like any other provider, not into the bare test process. It is kept
|
||||
* in Java anyway, next to its sibling, so the two read alike.
|
||||
*/
|
||||
public final class FixtureContentProvider extends ContentProvider {
|
||||
|
||||
/** Authority. Distinct from the documents provider's, and from anything the app declares. */
|
||||
public static final String AUTHORITY = "org.libremediaconverter.test.content";
|
||||
|
||||
/** Builds a URI for one of this source set's committed assets, e.g. {@code sample_h264.mp4}. */
|
||||
public static Uri uriFor(String assetName) {
|
||||
return new Uri.Builder().scheme("content").authority(AUTHORITY).appendPath(assetName).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
if (!"r".equals(mode)) {
|
||||
throw new FileNotFoundException("this provider is read-only: " + mode);
|
||||
}
|
||||
return ParcelFileDescriptor.open(unpack(assetOf(uri)), ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enough of {@link OpenableColumns} for {@code InputQuery.describe} to name and size the input.
|
||||
*
|
||||
* <p>Without these the app reaches the "Size unknown" screen, which is a different test.
|
||||
*/
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection, String[] args, String sort) {
|
||||
String asset = assetOf(uri);
|
||||
File file;
|
||||
try {
|
||||
file = unpack(asset);
|
||||
} catch (FileNotFoundException e) {
|
||||
return null;
|
||||
}
|
||||
MatrixCursor cursor = new MatrixCursor(
|
||||
new String[] {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE});
|
||||
cursor.newRow().add(OpenableColumns.DISPLAY_NAME, asset).add(OpenableColumns.SIZE, file.length());
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
return assetOf(uri).endsWith(".m4a") ? "audio/mp4" : "video/mp4";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("read-only fixture provider");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] args) {
|
||||
throw new UnsupportedOperationException("read-only fixture provider");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] args) {
|
||||
throw new UnsupportedOperationException("read-only fixture provider");
|
||||
}
|
||||
|
||||
private static String assetOf(Uri uri) {
|
||||
String asset = uri.getLastPathSegment();
|
||||
return asset == null ? "" : asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* The asset on disk, unpacked the first time anything asks.
|
||||
*
|
||||
* <p>Reported as {@link FileNotFoundException} rather than swallowed: a provider answering with
|
||||
* a zero-byte file would fail the conversion for a reason nothing states.
|
||||
*/
|
||||
private File unpack(String asset) throws FileNotFoundException {
|
||||
File file = new File(getContext().getCacheDir(), "provided_" + asset);
|
||||
if (file.length() > 0L) {
|
||||
return file;
|
||||
}
|
||||
try (InputStream source = getContext().getAssets().open(asset);
|
||||
OutputStream sink = new FileOutputStream(file)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = source.read(buffer)) != -1) {
|
||||
sink.write(buffer, 0, read);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new FileNotFoundException("could not unpack " + asset + ": " + e);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
+118
-4
@@ -14,6 +14,8 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One file, offered to the system file picker, so that picking one can be tested at all.
|
||||
@@ -104,6 +106,18 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
|
||||
private static final String ROOT_DOCUMENT_ID = "root";
|
||||
private static final String FIXTURE_DOCUMENT_ID = "root/" + FIXTURE_DISPLAY_NAME;
|
||||
|
||||
/**
|
||||
* Prefix for documents this provider CREATES, as opposed to the one it serves for reading.
|
||||
*
|
||||
* <p>Two namespaces rather than one so a destination can never be confused with the fixture.
|
||||
* The fixture is read-only and must stay that way for the picker tests; a destination is
|
||||
* writable and deletable, which is what {@code PublishToRealSafDestinationTest} needs.
|
||||
*/
|
||||
public static final String DESTINATION_PREFIX = "dest/";
|
||||
|
||||
/** Document ids {@link #deleteDocument} was called with, newest last. Cleared by {@link #reset}. */
|
||||
private static final List<String> DELETED = new ArrayList<>();
|
||||
|
||||
/** Already in this source set, and already a real H.264 MP4 the engines can open. */
|
||||
private static final String FIXTURE_ASSET = "sample_h264.mp4";
|
||||
|
||||
@@ -147,7 +161,7 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
|
||||
.add(Root.COLUMN_TITLE, ROOT_TITLE)
|
||||
.add(Root.COLUMN_SUMMARY, "Instrumentation fixture")
|
||||
.add(Root.COLUMN_MIME_TYPES, FIXTURE_MIME_TYPE)
|
||||
.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY)
|
||||
.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_CREATE)
|
||||
.add(Root.COLUMN_ICON, android.R.drawable.ic_menu_gallery);
|
||||
return cursor;
|
||||
}
|
||||
@@ -159,6 +173,8 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
|
||||
addDirectoryRow(cursor);
|
||||
} else if (FIXTURE_DOCUMENT_ID.equals(documentId)) {
|
||||
addFixtureRow(cursor);
|
||||
} else if (documentId != null && documentId.startsWith(DESTINATION_PREFIX)) {
|
||||
addDestinationRow(cursor, documentId);
|
||||
} else {
|
||||
throw new FileNotFoundException("no such document: " + documentId);
|
||||
}
|
||||
@@ -178,10 +194,86 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
|
||||
@Override
|
||||
public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal)
|
||||
throws FileNotFoundException {
|
||||
if (!FIXTURE_DOCUMENT_ID.equals(documentId)) {
|
||||
if (FIXTURE_DOCUMENT_ID.equals(documentId)) {
|
||||
return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
if (documentId == null || !documentId.startsWith(DESTINATION_PREFIX)) {
|
||||
throw new FileNotFoundException("no such document: " + documentId);
|
||||
}
|
||||
return ParcelFileDescriptor.open(fixtureFile(), ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
int flags = "r".equals(mode)
|
||||
? ParcelFileDescriptor.MODE_READ_ONLY
|
||||
: ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_TRUNCATE;
|
||||
return ParcelFileDescriptor.open(destinationFile(documentId), flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a real, empty file and reports the document id for it.
|
||||
*
|
||||
* <p><b>Empty is the whole point, and this provider does not get to decide it.</b> The premise
|
||||
* under test in {@code PublishToRealSafDestinationTest} is what <i>DocumentsUI</i> hands back
|
||||
* from {@code ACTION_CREATE_DOCUMENT}, and {@code OutputPublisher.destinationIsKnownEmpty}
|
||||
* authorises its cleanup delete only on a positive zero. This creates the file and writes
|
||||
* nothing to it, which is what the SAF contract documents; the test asserts what actually came
|
||||
* back rather than trusting either side.
|
||||
*/
|
||||
@Override
|
||||
public String createDocument(String parentDocumentId, String mimeType, String displayName)
|
||||
throws FileNotFoundException {
|
||||
if (!ROOT_DOCUMENT_ID.equals(parentDocumentId)) {
|
||||
throw new FileNotFoundException("cannot create in: " + parentDocumentId);
|
||||
}
|
||||
String documentId = DESTINATION_PREFIX + displayName;
|
||||
File file = destinationFile(documentId);
|
||||
try {
|
||||
if (!file.createNewFile() && !file.exists()) {
|
||||
throw new FileNotFoundException("could not create: " + documentId);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new FileNotFoundException("could not create " + documentId + ": " + e);
|
||||
}
|
||||
return documentId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteDocument(String documentId) throws FileNotFoundException {
|
||||
if (documentId == null || !documentId.startsWith(DESTINATION_PREFIX)) {
|
||||
throw new FileNotFoundException("refusing to delete: " + documentId);
|
||||
}
|
||||
synchronized (DELETED) {
|
||||
DELETED.add(documentId);
|
||||
}
|
||||
destinationFile(documentId).delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Document ids {@link #deleteDocument} was called with, newest last.
|
||||
*
|
||||
* <p><b>Nothing reads this yet, and that is recorded rather than hidden (#250).</b> It was
|
||||
* added with #226 to assert {@code OutputPublisher.deletePartialOutput} — D4's cleanup — against
|
||||
* a real {@code DocumentsProvider}. #226 only reached the <i>success</i> path, so the
|
||||
* {@code catch} that calls it is still asserted only against {@code FakeSafProvider} under
|
||||
* Robolectric. It is kept because the forcing condition is one {@code openDestination} override
|
||||
* away and #250 says exactly what to add; if that ticket is closed any other way, delete this
|
||||
* and {@link #DELETED} with it rather than leaving an accessor implying coverage.
|
||||
*/
|
||||
public static List<String> deletedDocumentIds() {
|
||||
synchronized (DELETED) {
|
||||
return new ArrayList<>(DELETED);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets recorded deletes and removes created destinations. The process outlives one class. */
|
||||
public static void reset(File filesDir) {
|
||||
synchronized (DELETED) {
|
||||
DELETED.clear();
|
||||
}
|
||||
File dir = new File(filesDir, "destinations");
|
||||
File[] children = dir.listFiles();
|
||||
if (children != null) {
|
||||
for (File child : children) {
|
||||
child.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addDirectoryRow(MatrixCursor cursor) {
|
||||
@@ -189,10 +281,32 @@ public final class FixtureDocumentsProvider extends DocumentsProvider {
|
||||
.add(Document.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
|
||||
.add(Document.COLUMN_DISPLAY_NAME, ROOT_TITLE)
|
||||
.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
|
||||
.add(Document.COLUMN_FLAGS, 0)
|
||||
.add(Document.COLUMN_FLAGS, Document.FLAG_DIR_SUPPORTS_CREATE)
|
||||
.add(Document.COLUMN_SIZE, null);
|
||||
}
|
||||
|
||||
private void addDestinationRow(MatrixCursor cursor, String documentId) throws FileNotFoundException {
|
||||
File file = destinationFile(documentId);
|
||||
if (!file.exists()) {
|
||||
throw new FileNotFoundException("no such document: " + documentId);
|
||||
}
|
||||
cursor.newRow()
|
||||
.add(Document.COLUMN_DOCUMENT_ID, documentId)
|
||||
.add(Document.COLUMN_DISPLAY_NAME, documentId.substring(DESTINATION_PREFIX.length()))
|
||||
.add(Document.COLUMN_MIME_TYPE, FIXTURE_MIME_TYPE)
|
||||
.add(Document.COLUMN_FLAGS, Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE)
|
||||
.add(Document.COLUMN_SIZE, file.length())
|
||||
.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
|
||||
}
|
||||
|
||||
private File destinationFile(String documentId) throws FileNotFoundException {
|
||||
File dir = new File(getContext().getFilesDir(), "destinations");
|
||||
if (!dir.isDirectory() && !dir.mkdirs()) {
|
||||
throw new FileNotFoundException("could not make the destinations directory");
|
||||
}
|
||||
return new File(dir, documentId.substring(DESTINATION_PREFIX.length()));
|
||||
}
|
||||
|
||||
private void addFixtureRow(MatrixCursor cursor) throws FileNotFoundException {
|
||||
File file = fixtureFile();
|
||||
cursor.newRow()
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
package org.libremediaconverter.saf
|
||||
|
||||
import android.app.UiAutomation
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.compose.ui.test.ComposeTimeoutException
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.runner.lifecycle.ActivityLifecycleCallback
|
||||
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
|
||||
import androidx.test.runner.lifecycle.Stage
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.BySelector
|
||||
import androidx.test.uiautomator.Configurator
|
||||
@@ -17,13 +26,22 @@ import androidx.test.uiautomator.StaleObjectException
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.FailsOnEmulatorApi37
|
||||
import org.libremediaconverter.MainActivity
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* Choosing a file, through the real system picker, and still having it after a rotation.
|
||||
@@ -203,6 +221,8 @@ import org.libremediaconverter.ui.TestTags
|
||||
* driven there at all. That is why this gap survived as long as it did.
|
||||
* `tools/local-emulator/run-e2e.sh` runs API 33-36 on the development host, and both tests pass
|
||||
* there: **59 / 0 / 0 / 2 at API 33 and again at API 36**, whole suite, 2026-08-24.
|
||||
* (Since #223 the skip column reads 3 on an emulator — `HardwareFallbackTest` now announces
|
||||
* that it cannot run without a hardware HEVC encoder rather than passing vacuously.)
|
||||
*
|
||||
* ### Why only the rotation test carries [FailsOnEmulatorApi37]
|
||||
*
|
||||
@@ -235,12 +255,72 @@ import org.libremediaconverter.ui.TestTags
|
||||
* file".** That is what API 33 through 36 are for, and they answer it.
|
||||
*/
|
||||
@UnstableApi
|
||||
/**
|
||||
* Reads what SAF handed back, then publishes for real.
|
||||
*
|
||||
* The premise `OutputPublisher.destinationIsKnownEmpty` depends on has only ever been asserted
|
||||
* against a fake built to match it — `OutputPublisherPublishTest` writes `ByteArray(0)` into
|
||||
* `FakeSafProvider` before each case, under a comment stating this is how `CreateDocument` behaves.
|
||||
* This records what stock DocumentsUI actually produced, at the moment `publish` sees it and before
|
||||
* a byte is written, and then lets the real copy proceed. See #226.
|
||||
*/
|
||||
private class RecordingPublisher(private val app: Context) : OutputPublisher(app) {
|
||||
|
||||
override fun publish(staged: File, destination: Uri) {
|
||||
seenDestination = destination
|
||||
seenIsDocumentUri = DocumentsContract.isDocumentUri(app, destination)
|
||||
seenSizeBefore = app.contentResolver
|
||||
.query(destination, arrayOf(OpenableColumns.SIZE), null, null, null)
|
||||
?.use { row ->
|
||||
val column = row.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (column >= 0 && row.moveToFirst() && !row.isNull(column)) row.getLong(column) else null
|
||||
}
|
||||
// Read before the copy: the ViewModel deletes the staged file once publish returns.
|
||||
savedBytes = staged.readBytes()
|
||||
super.publish(staged, destination)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var savedBytes: ByteArray = ByteArray(0)
|
||||
var seenDestination: Uri? = null
|
||||
var seenIsDocumentUri: Boolean? = null
|
||||
var seenSizeBefore: Long? = null
|
||||
|
||||
fun reset() {
|
||||
savedBytes = ByteArray(0)
|
||||
seenDestination = null
|
||||
seenIsDocumentUri = null
|
||||
seenSizeBefore = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SafPickerRoundTripTest {
|
||||
|
||||
/**
|
||||
* Installs [RecordingPublisher] before the Activity exists.
|
||||
*
|
||||
* `ConversionViewModel` resolves its publisher through `ConversionDependencies` **at
|
||||
* construction**, and the Compose rule launches `MainActivity` as part of the rule chain —
|
||||
* which wraps `@Before`, so `@Before` is already too late. JUnit constructs the test instance
|
||||
* before it evaluates the rules, so an initialiser is early enough, and it needs no
|
||||
* `@BeforeClass` (this class's companion is private, and JUnit wants a public static there).
|
||||
*
|
||||
* Harmless for the other two tests: neither saves, so `publish` is never called and the
|
||||
* subclass behaves exactly like `OutputPublisher`. `restoreOrientation` puts the seam back.
|
||||
*/
|
||||
init {
|
||||
RecordingPublisher.reset()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(it) }
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private val device: UiDevice =
|
||||
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
|
||||
@@ -251,6 +331,21 @@ class SafPickerRoundTripTest {
|
||||
/** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */
|
||||
private var rotated = false
|
||||
|
||||
/** Counts [MainActivity] creations from the moment [watchForRecreation] is called. */
|
||||
private val recreations = AtomicInteger()
|
||||
|
||||
/**
|
||||
* Counts a rotation's recreation without asking the Activity anything.
|
||||
*
|
||||
* Deliberately not `composeRule.activity`, which resolves through `scenario.onActivity` and so
|
||||
* blocks on the main thread. Polling *that* across a recreation is a plausible reading of the
|
||||
* 20-minute wedges in #122, which would make the obvious barrier the bug it is meant to fix.
|
||||
* The runner's lifecycle monitor is a callback: reading the counter touches no looper.
|
||||
*/
|
||||
private val recreationWatcher = ActivityLifecycleCallback { activity, stage ->
|
||||
if (activity is MainActivity && stage == Stage.CREATED) recreations.incrementAndGet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the device the way it was found — and only if this test moved it.
|
||||
*
|
||||
@@ -270,13 +365,44 @@ class SafPickerRoundTripTest {
|
||||
*/
|
||||
@After
|
||||
fun restoreOrientation() {
|
||||
// The suite runs without Android Test Orchestrator, so a swapped seam outlives the class.
|
||||
ConversionDependencies.reset()
|
||||
ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(recreationWatcher)
|
||||
if (!rotated) return
|
||||
device.setOrientationNatural()
|
||||
device.unfreezeRotation()
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
/**
|
||||
* **Marked for API 37 because of what it does to the image, not because it fails there.**
|
||||
*
|
||||
* This is the one place the marker's KDoc phrase "cannot pass on this image" does not fit, and
|
||||
* the distinction is worth keeping rather than smoothing over. Across the four gating API 37
|
||||
* runs whose logcats were read on 2026-09-05 — 34006456986, 34001744574, 34001377499 and the
|
||||
* green 34002313300 — the leg carries exactly two `hasReadColorBufferDma` aborts before the
|
||||
* suite starts (both `surfaceflinger`, during boot and the SystemUI disable) and then exactly
|
||||
* **one** during it. Every time, that one is `system_server` on the `TaskSnapshotPer` thread,
|
||||
* and every time it lands inside this test's window. No other test in the gating set reaches
|
||||
* the mapper at all.
|
||||
*
|
||||
* So this test kills the framework on that image whether it passes or not, and whether the leg
|
||||
* goes red is luck: 34001377499 passed it and lost the leg anyway (`failed: 0`, teardown
|
||||
* broken), 34002313300 passed it 0.6 s after the abort and went green. That is #108, and it is
|
||||
* why the leg was failing on unrelated PRs.
|
||||
*
|
||||
* `docs/api-37-emulator-crash.md` measured this test on 2026-08-24, recorded "passes, 4 aborts
|
||||
* in the window", and concluded that a rotation reaches the mapper where starting DocumentsUI
|
||||
* does not. The aborts were seen; what was not drawn out is that they are this test's own and
|
||||
* are not intermittent.
|
||||
*
|
||||
* The marker is what routes it off the gating leg and into the advisory job beside its
|
||||
* rotation sibling. **It is not a statement about the picker**: the same test passes on API
|
||||
* 33–36 on the same runner and on the Pixel 10 Pro XL, which is where API 37's answer comes
|
||||
* from.
|
||||
*/
|
||||
@Test
|
||||
@FailsOnEmulatorApi37
|
||||
fun pickingAFileThroughTheSystemPickerFillsInTheFileCard() {
|
||||
pickTheFixture()
|
||||
|
||||
@@ -303,9 +429,11 @@ class SafPickerRoundTripTest {
|
||||
// The identity hash rather than the Activity itself, so nothing here keeps a destroyed
|
||||
// Activity reachable across the recreation it is being used to detect.
|
||||
val before = System.identityHashCode(composeRule.activity)
|
||||
watchForRecreation()
|
||||
|
||||
device.setOrientationLandscape()
|
||||
rotated = true
|
||||
awaitRecreation()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
// Two guards before the assertion that matters, because both of the ways this test could
|
||||
@@ -354,6 +482,192 @@ class SafPickerRoundTripTest {
|
||||
* are all warm and the only thing being waited on is one screen. That is what keeps the cost
|
||||
* of a genuinely absent root bounded — see the class KDoc.
|
||||
*/
|
||||
/**
|
||||
* The save side of SAF, end to end, against a document stock DocumentsUI created (#226).
|
||||
*
|
||||
* ## What this settles
|
||||
*
|
||||
* `publish` deletes a destination it could not write to — `docs/defect-audit.md` **D4**'s fix,
|
||||
* so a failed save does not leave a truncated file at the name the user chose — but only when
|
||||
* that destination was **positively zero bytes** first. `destinationIsKnownEmpty` is careful
|
||||
* that "I could not tell" never authorises a delete, which is right, and which makes the
|
||||
* precondition load-bearing.
|
||||
*
|
||||
* Until now that precondition was asserted only against a fake built to match it:
|
||||
* `OutputPublisherPublishTest` writes `ByteArray(0)` into `FakeSafProvider` before each case,
|
||||
* under a comment stating this is how `CreateDocument` behaves. **If it is false in production,
|
||||
* D4's fix is inert and every existing test still passes.** [RecordingPublisher] reads what SAF
|
||||
* actually handed over, at the moment `publish` sees it and before a byte is written.
|
||||
*
|
||||
* ## Why it has to go through the app, and through the picker
|
||||
*
|
||||
* Through the **picker** because a `DocumentsProvider` cannot be reached any other way —
|
||||
* measured three ways and recorded as **E7** in `docs/e2e-read-findings.md`: an unprotected one
|
||||
* is refused at install, instrumentation carries the app's uid so the test APK's own identity
|
||||
* is no help, and shell identity is denied too, each denial naming `ACTION_OPEN_DOCUMENT`.
|
||||
*
|
||||
* Through the **app** because the same constraint sinks the obvious alternative. A host
|
||||
* Activity in this source set that owns a `CreateDocument` launcher cannot be started:
|
||||
* `ActivityScenario` refuses with *"Intent in process org.libremediaconverter resolved to
|
||||
* different process org.libremediaconverter.test"*. Instrumentation runs in the target app's
|
||||
* process, so the only Activity available to drive is the app's own — which is also the more
|
||||
* faithful thing to drive.
|
||||
*
|
||||
* ## The conversion is setup, not subject
|
||||
*
|
||||
* Save is only offered on `Converted`, so the test converts first, at the screen's default
|
||||
* `MP4_H265` / `FAST`. That is **not** codec-independent, and this KDoc claimed the opposite
|
||||
* until 2026-09-06: an earlier draft used MP3 for exactly that reason, and the format had to
|
||||
* move for a different constraint the picker imposes — [convertToTheDefaultFormat] has it.
|
||||
* `MP4_H265` at `FAST` reaches `ConversionRouter`'s `canEncode(H265)` gate, so it runs on
|
||||
* FFmpeg on the emulators (no hardware H265) and on Media3 on the Pixel.
|
||||
*
|
||||
* **That is tolerable here, and #223 is the reason it needs saying.** There, the routing
|
||||
* decided whether the *subject* was reached, so a route to FFmpeg made the test pass while
|
||||
* proving nothing. Here the conversion is setup: if it goes the other way and fails, this test
|
||||
* fails loudly on the setup rather than quietly on the assertion. The subject is what `publish`
|
||||
* was handed, which the engine that produced the file does not touch.
|
||||
*
|
||||
* ## Why it carries [FailsOnEmulatorApi37]
|
||||
*
|
||||
* By inheritance, not measurement. It opens the same picker as
|
||||
* [pickingAFileThroughTheSystemPickerFillsInTheFileCard], which was marked for aborting
|
||||
* `system_server` from the task-snapshot path (#108), and then a second DocumentsUI dialog on
|
||||
* top of it. It has never been observed at API 37 either way: the rotation test truncates the
|
||||
* advisory run first, so all four advisory runs at this baseline report
|
||||
* `expected: 6, received: 4` without reaching either picker test. Marking it was the conservative choice and it is
|
||||
* recorded as unmeasured in `FailsOnEmulatorApi37.kt` rather than dressed up as a measurement.
|
||||
*/
|
||||
@Test
|
||||
@FailsOnEmulatorApi37
|
||||
fun aSaveWritesToTheDocumentTheSystemPickerCreated() {
|
||||
pickTheFixture()
|
||||
convertToTheDefaultFormat()
|
||||
|
||||
saveThroughTheSystemPicker()
|
||||
|
||||
val destination = RecordingPublisher.seenDestination
|
||||
assertNotNull("publish was never reached, so nothing was saved", destination)
|
||||
assertTrue(
|
||||
"SAF handed back something that is not a document URI, so publish's cleanup can " +
|
||||
"never run and D4's fix is inert: $destination",
|
||||
RecordingPublisher.seenIsDocumentUri == true,
|
||||
)
|
||||
assertEquals(
|
||||
"SAF handed back a document that is not positively empty, so " +
|
||||
"destinationIsKnownEmpty answers false and a failed save keeps its partial file",
|
||||
0L,
|
||||
RecordingPublisher.seenSizeBefore,
|
||||
)
|
||||
|
||||
// And the bytes really arrived, which only the failure side was covered for on a device.
|
||||
val staged = File(context.cacheDir, "conversions")
|
||||
assertArrayEquals(
|
||||
"the destination did not receive what was staged",
|
||||
RecordingPublisher.savedBytes,
|
||||
context.contentResolver.openInputStream(destination!!)!!.use { it.readBytes() },
|
||||
)
|
||||
assertTrue("staging should be empty after a successful save", staged.listFiles().isNullOrEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the conversion, leaving the screen on `Converted`.
|
||||
*
|
||||
* **The format is left at its default, and that is a constraint rather than laziness.**
|
||||
* `ConverterScreen` registers `CreateDocument` with the *output's* MIME type, and
|
||||
* [FixtureDocumentsProvider] advertises `Root.COLUMN_MIME_TYPES` of `video/mp4` — deliberately,
|
||||
* so the picker's MIME filter has a mutation with a shape. DocumentsUI honours that on the save
|
||||
* side too: choosing MP3 makes the destination type `audio/mpeg`, and the fixture root is then
|
||||
* filtered out of the save dialog entirely. Measured, as *"the create-document dialog never
|
||||
* showed LMC R38 fixtures"*. The default `MP4_H265` produces `video/mp4` and the root is
|
||||
* offered.
|
||||
*
|
||||
* **The notification dialog is dismissed rather than pre-granted, and that is the honest
|
||||
* version.** Convert never calls `convert()` directly — it launches `RequestPermission` for
|
||||
* `POST_NOTIFICATIONS` and converts from the callback **whichever way the answer goes**. So the
|
||||
* dialog only has to be got out of the way; denying it is a real user's path and the conversion
|
||||
* still runs. Granting it programmatically was tried first and did not take —
|
||||
* `GrantPermissionsActivity` appeared anyway, the click that followed went to it rather than to
|
||||
* the app, and the screen sat in `Ready` with nothing enqueued.
|
||||
*
|
||||
* **Both taps scroll first.** On `Ready` the screen carries a file card, five pickers and then
|
||||
* the button, so Convert is below the fold on a phone. `performClick` on an off-screen node
|
||||
* dispatches at a position that hits nothing and throws nothing, and `assertIsEnabled` passes
|
||||
* either way — the first version of this sat waiting for a `Converted` that could never come.
|
||||
*/
|
||||
private fun convertToTheDefaultFormat() {
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CONVERT)
|
||||
.performScrollTo()
|
||||
.assertIsEnabled()
|
||||
.performClick()
|
||||
|
||||
dismissThePermissionDialog()
|
||||
awaitNode(TestTags.SAVE_FILE, CONVERSION_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the `POST_NOTIFICATIONS` dialog out of the way, if this device shows one.
|
||||
*
|
||||
* Backing out of it is a denial, and a denial is fine here: the conversion starts either way,
|
||||
* and what that costs the user is a progress notification confined to the Task Manager. Waiting
|
||||
* only briefly, because on a device where the permission is already held no dialog appears at
|
||||
* all and the conversion is already under way.
|
||||
*/
|
||||
private fun dismissThePermissionDialog() {
|
||||
if (device.wait(Until.hasObject(By.pkg(PERMISSION_UI_PACKAGE)), PERMISSION_DIALOG_MS) != true) {
|
||||
return
|
||||
}
|
||||
device.pressBack()
|
||||
device.wait(Until.gone(By.pkg(PERMISSION_UI_PACKAGE)), PERMISSION_DIALOG_MS)
|
||||
// And wait for the app to be in front again before anything asks Compose about it.
|
||||
// Querying while another window still owns the screen raises "No compose hierarchies found
|
||||
// in the app", which is what this test did on an API 35 leg: the back press had landed but
|
||||
// the dialog had not finished going away.
|
||||
//
|
||||
// Asked of UiAutomator rather than through awaitAppFocus, which is the opposite of what the
|
||||
// class KDoc argues for elsewhere and is right here: awaitAppFocus goes through
|
||||
// composeRule.waitUntil, so it would raise the very error it is being used to avoid.
|
||||
device.wait(Until.hasObject(By.pkg(context.packageName)), FOCUS_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Taps Save and drives the create-document dialog into the fixture root.
|
||||
*
|
||||
* Retried whole, for the reason [pickTheFixture] documents: a dialog that came up unreadable
|
||||
* cannot be recovered from inside, and a fresh one is the only answer.
|
||||
*/
|
||||
private fun saveThroughTheSystemPicker() {
|
||||
var missing: BySelector? = null
|
||||
repeat(PICK_ATTEMPTS) { attempt ->
|
||||
requireAReadableScreen()
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performClick()
|
||||
missing = walkTheSaveDialog(
|
||||
if (attempt == 0) PICKER_TIMEOUT_MS else REOPENED_TIMEOUT_MS,
|
||||
)
|
||||
if (missing == null) {
|
||||
awaitNode(TestTags.Converter.CONVERT_ANOTHER, SAVE_TIMEOUT_MS)
|
||||
return
|
||||
}
|
||||
dismissThePicker()
|
||||
}
|
||||
throw AssertionError(
|
||||
"the create-document dialog never showed $missing, in $PICK_ATTEMPTS separate " +
|
||||
"dialogs (the last one left ${device.currentPackageName} in front)",
|
||||
)
|
||||
}
|
||||
|
||||
/** Into the fixture root, then Save. Returns the selector never found, or null. */
|
||||
private fun walkTheSaveDialog(timeoutMs: Long): BySelector? {
|
||||
val picker = By.pkg(DOCUMENTS_UI_PACKAGE)
|
||||
val root = By.text(FixtureDocumentsProvider.ROOT_TITLE)
|
||||
return when {
|
||||
device.wait(Until.hasObject(picker), timeoutMs) != true -> picker
|
||||
!tapPickerNode(root, timeoutMs, ifAbsent = ::openTheRootsDrawer) -> root
|
||||
!tapPickerNode(SAVE_BUTTON, timeoutMs) -> SAVE_BUTTON
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickTheFixture() {
|
||||
var missing: BySelector? = null
|
||||
repeat(PICK_ATTEMPTS) { attempt ->
|
||||
@@ -580,6 +894,9 @@ class SafPickerRoundTripTest {
|
||||
* It is also why this counts backs rather than pressing a fixed number of them. One back is
|
||||
* enough from Recent and two are needed from inside the root, but a third from Recent would
|
||||
* finish `MainActivity` and take the rest of the test with it.
|
||||
*
|
||||
* **[forceStopThePicker] is the escalation after the presses, and it exists because a back
|
||||
* press is not always deliverable.** See its own KDoc for the measurement.
|
||||
*/
|
||||
private fun dismissThePicker() {
|
||||
repeat(BACK_PRESSES) {
|
||||
@@ -594,15 +911,50 @@ class SafPickerRoundTripTest {
|
||||
// The check after the last press, and not a spare one: `repeat` presses on its final
|
||||
// iteration too, so without this a dismissal that worked on the last press would still be
|
||||
// reported as a failure to close.
|
||||
if (awaitAppFocus()) return
|
||||
forceStopThePicker()
|
||||
if (!awaitAppFocus()) {
|
||||
throw AssertionError(
|
||||
"the system picker would not close: after $BACK_PRESSES back presses the app " +
|
||||
"still does not have the window focus, and ${device.currentPackageName} is " +
|
||||
"in front. What could be seen: " + describeWindows(),
|
||||
"the system picker would not close: after $BACK_PRESSES back presses and a " +
|
||||
"force-stop of $DOCUMENTS_UI_PACKAGE the app still does not have the window " +
|
||||
"focus, and ${device.currentPackageName} is in front. What could be seen: " +
|
||||
describeWindows(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills the picker's process, for when no back press can reach it.
|
||||
*
|
||||
* **The failure this exists for cannot be answered with input, and that is the whole point.**
|
||||
* Measured on the gating API 37 legs of runs 34006456986 and 34001744574, which fail this way
|
||||
* and whose logcats say the same thing in the same order. `UiObject2.click()` on the fixture's
|
||||
* root is injected at the node's centre and the framework discards it —
|
||||
* `InputDispatcher: No new touched window at (539.0, 525.0) in display 0` — because
|
||||
* `PickActivity` has published accessibility nodes but has no touchable window there yet.
|
||||
* `click()` cannot see that and returns normally, so the walk goes on to wait out
|
||||
* [PICKER_TIMEOUT_MS] for a fixture that was never navigated to. By the time this function's
|
||||
* caller starts pressing back, WindowManager is still saying
|
||||
* `no window has focus but ...PickActivity may eventually add a window when it finishes
|
||||
* starting up` — and goes on saying it for another 63 s. Every one of the four presses is
|
||||
* dropped, and DocumentsUI ANRs on `Input dispatching timed out`.
|
||||
*
|
||||
* So the picker is in front, unreachable by key or by touch, and [pickTheFixture]'s whole
|
||||
* point — that a second `PickActivity` rebuilds every window and list in it — is unreachable
|
||||
* with it. `am force-stop` goes around input entirely: `UiAutomation` runs shell commands as
|
||||
* uid 2000, which holds `FORCE_STOP_PACKAGES`, so the picker's process is killed, its
|
||||
* activity leaves the task it was launched into, and `MainActivity` — the activity below it in
|
||||
* that same task — is resumed with the focus.
|
||||
*
|
||||
* **Only on the failure path**, after every back press has been spent, so a picker that closes
|
||||
* the ordinary way never reaches this and is not altered by it. If the framework itself is
|
||||
* gone, this cannot help either, and the caller still reports what it could see.
|
||||
*/
|
||||
private fun forceStopThePicker() {
|
||||
device.executeShellCommand("am force-stop $DOCUMENTS_UI_PACKAGE")
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
/** True once [MainActivity] has the window focus, false if it does not take it in time. */
|
||||
private fun awaitAppFocus(): Boolean = try {
|
||||
composeRule.waitUntil("the app has the window focus back", FOCUS_TIMEOUT_MS) {
|
||||
@@ -675,8 +1027,38 @@ class SafPickerRoundTripTest {
|
||||
* `Condition still not satisfied after 30000 ms` — which names neither the node nor the test.
|
||||
* With the description it says which affordance never arrived, which is the whole finding.
|
||||
*/
|
||||
private fun awaitNode(tag: String) {
|
||||
composeRule.waitUntil("a node tagged $tag exists", APP_TIMEOUT_MS) {
|
||||
/** Starts counting [MainActivity] creations, so [awaitRecreation] can wait for the next one. */
|
||||
private fun watchForRecreation() {
|
||||
recreations.set(0)
|
||||
ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(recreationWatcher)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the rotation to actually rebuild [MainActivity], which `waitForIdle` does not.
|
||||
*
|
||||
* **This is #122.** `waitForIdle()` waits for the compose hierarchy to settle. Immediately
|
||||
* after a rotation the window manager has accepted but not yet delivered as a configuration
|
||||
* change, the *old* Activity's composition is already idle — so it returns, `composeRule
|
||||
* .activity` still resolves to the old instance, and the guard below reads an unchanged
|
||||
* identity hash. That is the clean `AssertionError` seen on the API 33 gating leg of #217, and
|
||||
* the wedges on #122 are the same race taken the other way: land while the composition is
|
||||
* being torn down and there is nothing coherent for `waitForIdle` to settle on.
|
||||
*
|
||||
* A bounded wait is worth having even if that second half is wrong. It turns a 20-minute
|
||||
* `WEDGE_TIMEOUT` — which costs the leg and names no test — into a fast failure that says which
|
||||
* test and what it was waiting for.
|
||||
*/
|
||||
private fun awaitRecreation() {
|
||||
composeRule.waitUntil(
|
||||
"the rotation did not recreate MainActivity within $RECREATION_TIMEOUT_MS ms",
|
||||
RECREATION_TIMEOUT_MS,
|
||||
) {
|
||||
recreations.get() > 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitNode(tag: String, timeoutMs: Long = APP_TIMEOUT_MS) {
|
||||
composeRule.waitUntil("a node tagged $tag exists", timeoutMs) {
|
||||
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
@@ -691,6 +1073,30 @@ class SafPickerRoundTripTest {
|
||||
const val PICKER_TIMEOUT_MS = 30_000L
|
||||
const val APP_TIMEOUT_MS = 30_000L
|
||||
|
||||
/** The runtime-permission dialog's package, so it can be recognised and dismissed. */
|
||||
const val PERMISSION_UI_PACKAGE = "com.google.android.permissioncontroller"
|
||||
|
||||
/** Short: either the dialog is up almost immediately, or the permission was already held. */
|
||||
const val PERMISSION_DIALOG_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Only bounds a hang, and it is an order of magnitude clear of the real cost: the whole
|
||||
* test — pick, convert, save — takes **11.8 s** on the API 34 CI leg (run 34043502322).
|
||||
* Deliberately generous because the engine is not fixed: the default `MP4_H265` at `FAST`
|
||||
* lands on FFmpeg on an emulator and on Media3 on real hardware, which is faster rather
|
||||
* than slower — see [convertToTheDefaultFormat].
|
||||
*/
|
||||
const val CONVERSION_TIMEOUT_MS = 120_000L
|
||||
|
||||
/** The copy is a few kilobytes, but it crosses a provider. */
|
||||
const val SAVE_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* DocumentsUI's save button. Case-insensitive because the label is "SAVE" on some images
|
||||
* and "Save" on others, and the difference is not what this test is about.
|
||||
*/
|
||||
val SAVE_BUTTON: BySelector = By.text(Pattern.compile("save", Pattern.CASE_INSENSITIVE))
|
||||
|
||||
/**
|
||||
* The same wait once a picker has already come and gone, and shorter for a reason.
|
||||
*
|
||||
@@ -703,6 +1109,15 @@ class SafPickerRoundTripTest {
|
||||
*/
|
||||
const val REOPENED_TIMEOUT_MS = 10_000L
|
||||
|
||||
/**
|
||||
* How long a rotation is given to destroy and rebuild the Activity.
|
||||
*
|
||||
* Generous against the API 33 and 34 emulators #122 was measured on, where the rotation is
|
||||
* slow enough for the gap this bound exists to cover to be observable at all — and still
|
||||
* two orders of magnitude inside the 1200 s `WEDGE_TIMEOUT` it replaces.
|
||||
*/
|
||||
const val RECREATION_TIMEOUT_MS = 15_000L
|
||||
|
||||
/**
|
||||
* How long the app is given to take the window focus back after a back press.
|
||||
*
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The Cancel button in the notification shade actually cancels the job.
|
||||
*
|
||||
* `ConversionNotifications.build` attaches one action, wired to
|
||||
* `WorkManager.createCancelPendingIntent(id)`. Before this test `createCancelPendingIntent` had
|
||||
* **no references anywhere outside its own declaration** — no JVM test, no instrumented test
|
||||
* (#227).
|
||||
*
|
||||
* That matters more than an ordinary uncovered line. A conversion runs in a foreground service and
|
||||
* the user is invited to leave the app; once they do, this action is the only way to stop it. If
|
||||
* the `PendingIntent` carries the wrong id, the button does nothing, the notification stays, and
|
||||
* the job runs to completion — with no error, no log, and no screen to look at.
|
||||
*
|
||||
* ## Why this fires the intent rather than reading the shade
|
||||
*
|
||||
* The obvious version asks `NotificationManager.getActiveNotifications()` for id 1001 and taps what
|
||||
* it finds. That was rejected: the instrumented suite grants no runtime permissions, so
|
||||
* `POST_NOTIFICATIONS` is denied throughout, and whether a suppressed foreground-service
|
||||
* notification is returned there is a platform detail that varies — the test would be asserting
|
||||
* something about notification *visibility* rather than about cancellation.
|
||||
*
|
||||
* The `PendingIntent` is the subject; where it is read from is incidental. Building the
|
||||
* notification for a real, live work id and firing its action exercises exactly the thing that can
|
||||
* be wrong — a real `PendingIntent` dispatch reaching real `WorkManager` — and does it the same way
|
||||
* on every API level.
|
||||
*
|
||||
* ## Why the job is delayed rather than running
|
||||
*
|
||||
* A conversion of the committed 3 s fixture finishes in well under a second on an emulator
|
||||
* (`HardwareFallbackTest` completed one in 448 ms), so racing a cancel against a running job would
|
||||
* be flaky in the direction that fails. An initial delay keeps the job reliably `ENQUEUED`, which
|
||||
* is a state `cancelWorkById` acts on identically — what is under test is whether firing the action
|
||||
* reaches WorkManager with the right id, not which state it interrupts.
|
||||
*
|
||||
* *Mutation:* build the `PendingIntent` from `UUID.randomUUID()` instead of the request's id. The
|
||||
* notification looks identical and the job is never cancelled.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NotificationCancelActionTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private lateinit var input: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
input = File(context.cacheDir, "cancel_action_sample.mp4")
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("sample_h264.mp4")
|
||||
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
input.delete()
|
||||
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNotificationsCancelActionCancelsThatJob(): Unit = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = input.name,
|
||||
sizeBytes = input.length(),
|
||||
spec = OutputFormat.MP4_H264.spec,
|
||||
quality = QualityTier.FAST,
|
||||
).let { base ->
|
||||
// Rebuild with a delay so the job stays ENQUEUED for the whole test. See the KDoc.
|
||||
OneTimeWorkRequestBuilder<ConversionWorker>()
|
||||
.setInputData(base.workSpec.input)
|
||||
.setInitialDelay(1, TimeUnit.HOURS)
|
||||
.build()
|
||||
}
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
// The job is queued and waiting, which is the state the cancel has to interrupt.
|
||||
assertEquals(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null }
|
||||
}?.state,
|
||||
)
|
||||
|
||||
val notification = ConversionNotifications(context)
|
||||
.build(request.id, title = input.name, percent = 0, indeterminate = true)
|
||||
val action = notification.actions?.firstOrNull()
|
||||
assertNotNull("the progress notification carries no action to cancel with", action)
|
||||
|
||||
// The whole point: fire it the way the shade would, and see the job stop.
|
||||
action!!.actionIntent.send()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
assertEquals(
|
||||
"firing the notification's Cancel action must cancel the job it was built for",
|
||||
WorkInfo.State.CANCELLED,
|
||||
terminal?.state,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.libremediaconverter
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
@@ -17,14 +18,36 @@ import org.libremediaconverter.convert.OutputPublisher
|
||||
* ever becomes a `Converted` state, or a `reset()`'s delete is cancelled along with the
|
||||
* Activity. Process start is the one moment those leftovers are reliably observable.
|
||||
*/
|
||||
class LibreMediaConverterApp : Application() {
|
||||
open class LibreMediaConverterApp : Application() {
|
||||
|
||||
/**
|
||||
* Deliberately process-lifetime and never cancelled: the work it carries is a single
|
||||
* short task that should outlive nothing in particular and be interrupted by nothing.
|
||||
* A `SupervisorJob` so a failure here could never take a sibling down with it.
|
||||
*
|
||||
* **`protected open` for #159.** Robolectric builds an `Application` for every test that asks
|
||||
* for one, so on the JVM this is not one background sweep but one *per test* — all of them on
|
||||
* `Dispatchers.IO`, all touching the same `cacheDir`, none of them joined by anything. That is
|
||||
* a race against any test asserting about a file under `conversions/`, and it grew with the
|
||||
* suite: wave 4 added ten Robolectric classes and took it from CI-only to roughly one local run
|
||||
* in six. The JVM suite substitutes a scope that runs the sweep inline — see
|
||||
* `app/src/test/resources/robolectric.properties` and `TestLibreMediaConverterApp`.
|
||||
*
|
||||
* A constructor parameter would be the ordinary way to inject this and is not available: the
|
||||
* framework builds this class, so the seam has to be a member.
|
||||
*/
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
protected open val sweepScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* The sweep [onCreate] last started, so a caller that needs it finished can wait for it.
|
||||
*
|
||||
* Nothing in production reads this — process start does not wait for its own housekeeping. It
|
||||
* exists because the alternative for a test is a timed poll, and a poll cannot tell "the sweep
|
||||
* has not run yet" from "the sweep ran and did nothing".
|
||||
*/
|
||||
@Volatile
|
||||
var startupSweep: Job? = null
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -53,6 +76,6 @@ class LibreMediaConverterApp : Application() {
|
||||
//
|
||||
// sweepStaging() also re-reads each timestamp immediately before deleting, which
|
||||
// closes the window between listing the directory and acting on the listing.
|
||||
appScope.launch { OutputPublisher(this@LibreMediaConverterApp).sweepStaging() }
|
||||
startupSweep = sweepScope.launch { OutputPublisher(this@LibreMediaConverterApp).sweepStaging() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,11 @@ import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.libremediaconverter.convert.ConverterScreen
|
||||
import org.libremediaconverter.join.JoinScreen
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.libremediaconverter.ui.theme.LibreMediaConverterTheme
|
||||
|
||||
/**
|
||||
@@ -114,7 +116,7 @@ internal fun AppRoot(
|
||||
|
||||
if (useRail) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
NavigationRail {
|
||||
NavigationRail(modifier = Modifier.testTag(TestTags.Shell.NAVIGATION_RAIL)) {
|
||||
Destination.entries.forEach { item ->
|
||||
NavigationRailItem(
|
||||
selected = destination == item,
|
||||
@@ -132,7 +134,7 @@ internal fun AppRoot(
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBar(modifier = Modifier.testTag(TestTags.Shell.NAVIGATION_BAR)) {
|
||||
Destination.entries.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = destination == item,
|
||||
|
||||
@@ -21,6 +21,11 @@ import org.libremediaconverter.model.VideoCodec
|
||||
* words, "cannot be tested for correctness". It is a hint, not a guarantee, which is
|
||||
* why the router treats a failed hardware export as a signal to fall back rather
|
||||
* than trusting this up front.
|
||||
* - **An enumeration that fails answers no to everything**, which sends every job to
|
||||
* FFmpeg. Empty sets are not a permissive default: `canEncode` looks a MIME type up in
|
||||
* [hardwareEncodeMimes] and finds nothing there. That is the intended answer — FFmpeg
|
||||
* can do whatever Media3 can, only slower — but it is the opposite of what this class
|
||||
* said until #194, so it is written down rather than left to be re-derived.
|
||||
*/
|
||||
class AndroidDeviceCodecs private constructor(
|
||||
private val hardwareEncodeMimes: Set<String>,
|
||||
@@ -46,22 +51,62 @@ class AndroidDeviceCodecs private constructor(
|
||||
|
||||
fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
|
||||
|
||||
private fun probe(): AndroidDeviceCodecs {
|
||||
/**
|
||||
* One entry of the platform's codec list, reduced to what the rules below read.
|
||||
*
|
||||
* The five booleans and the type list are the whole of what [capabilitiesFrom] needs, and
|
||||
* none of them can be set on a `MediaCodecInfo` from a test: Robolectric ships
|
||||
* `MediaCodecInfoBuilder`, but it has no `setIsAlias` and no `setCanonicalName`, which is
|
||||
* exactly the objection #133 raised against reaching this code through
|
||||
* `ShadowMediaCodecList`. That objection is about the shadow. It does not apply to a
|
||||
* function that takes its own entry type, which is why this exists.
|
||||
*/
|
||||
internal data class CodecEntry(
|
||||
val canonicalName: String,
|
||||
val isAlias: Boolean,
|
||||
val isEncoder: Boolean,
|
||||
val isHardwareAccelerated: Boolean,
|
||||
val isSoftwareOnly: Boolean,
|
||||
val supportedTypes: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The enumeration rules, over entries a caller chooses.
|
||||
*
|
||||
* [probe] is the only production caller and supplies the real codec list; a test supplies
|
||||
* its own, which is the point — the two rules this class's KDoc calls out as easy to get
|
||||
* wrong, the alias skip and the canonical-name dedup, are unreachable any other way.
|
||||
*
|
||||
* **`enumerate` returns a `Sequence`, deliberately.** The `runCatching` has to wrap the
|
||||
* *iteration* rather than a list built before it, because a `MediaCodecInfo` whose
|
||||
* properties throw does so partway through — and when that happens the codecs already read
|
||||
* are kept. Taking a `List` here would move that throw outside the loop and silently turn a
|
||||
* partial answer into an empty one. That behaviour predates this seam; a `List` parameter
|
||||
* would have changed it as a side effect of a refactor.
|
||||
*
|
||||
* **An enumeration that fails answers restrictively, and that is deliberate.** The sets
|
||||
* come back empty, and `"video/avc" in emptySet()` is `false`, so [canEncode] and
|
||||
* [canDecode] both answer no and every job routes to FFmpeg. FFmpeg can do everything
|
||||
* Media3 can, only slower, so refusing the hardware path is the safe reading of "we could
|
||||
* not find out what this device supports". This used to log "assuming permissive", which
|
||||
* described the opposite of what the code does.
|
||||
*/
|
||||
internal fun capabilitiesFrom(enumerate: () -> Sequence<CodecEntry>): AndroidDeviceCodecs {
|
||||
val encoders = mutableSetOf<String>()
|
||||
val decoders = mutableSetOf<String>()
|
||||
val seen = mutableSetOf<String>()
|
||||
|
||||
runCatching {
|
||||
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.forEach { info ->
|
||||
enumerate().forEach { entry ->
|
||||
// Aliases point at the same underlying codec; counting both would
|
||||
// double-count capabilities.
|
||||
if (info.isAlias) return@forEach
|
||||
if (!seen.add(info.canonicalName)) return@forEach
|
||||
if (entry.isAlias) return@forEach
|
||||
if (!seen.add(entry.canonicalName)) return@forEach
|
||||
|
||||
info.supportedTypes.forEach { mime ->
|
||||
entry.supportedTypes.forEach { mime ->
|
||||
if (!mime.startsWith("video/")) return@forEach
|
||||
if (info.isEncoder) {
|
||||
if (info.isHardwareAccelerated && !info.isSoftwareOnly) {
|
||||
if (entry.isEncoder) {
|
||||
if (entry.isHardwareAccelerated && !entry.isSoftwareOnly) {
|
||||
encoders += mime
|
||||
}
|
||||
} else {
|
||||
@@ -69,12 +114,32 @@ class AndroidDeviceCodecs private constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Codec enumeration failed; assuming permissive.", it) }
|
||||
}.onFailure { Log.w(TAG, "Codec enumeration failed; routing everything to FFmpeg.", it) }
|
||||
|
||||
Log.i(TAG, "Hardware video encoders: $encoders")
|
||||
return AndroidDeviceCodecs(encoders, decoders)
|
||||
}
|
||||
|
||||
/**
|
||||
* The thin edge: the real codec list, mapped onto [CodecEntry] one at a time.
|
||||
*
|
||||
* Lazily, so a property that throws does it inside [capabilitiesFrom]'s `runCatching` and
|
||||
* on the entry that caused it — see that function's note on why the parameter is a
|
||||
* `Sequence`.
|
||||
*/
|
||||
private fun probe(): AndroidDeviceCodecs = capabilitiesFrom {
|
||||
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.asSequence().map { info ->
|
||||
CodecEntry(
|
||||
canonicalName = info.canonicalName,
|
||||
isAlias = info.isAlias,
|
||||
isEncoder = info.isEncoder,
|
||||
isHardwareAccelerated = info.isHardwareAccelerated,
|
||||
isSoftwareOnly = info.isSoftwareOnly,
|
||||
supportedTypes = info.supportedTypes.toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `internal` rather than `private` so the cross-check test can ask what a [VideoCodec]
|
||||
* means here and compare it with what [NAME_TO_MIME] says the same codec's names mean.
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -71,6 +72,119 @@ data class InputFile(
|
||||
val probe: InputProbe? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One update about a running conversion, as WorkManager last reported it.
|
||||
*
|
||||
* Only the fields [conversionStateFrom] reads — the same shape, and for the same reason, as
|
||||
* `JobSnapshot` beside `Reattachment.choose`: the rule stays testable on the JVM because nothing
|
||||
* in it needs a `WorkInfo`, which a test cannot readily build.
|
||||
*
|
||||
* [outputData] stays a `Data` rather than being unpacked into five nullable strings. It is what a
|
||||
* test already builds with `workDataOf` everywhere in this suite, so unpacking would move the same
|
||||
* reads without making anything easier to drive.
|
||||
*/
|
||||
internal data class ConversionUpdate(
|
||||
val state: WorkInfo.State,
|
||||
val progressPercent: Int,
|
||||
val runAttemptCount: Int,
|
||||
val outputData: Data,
|
||||
)
|
||||
|
||||
/**
|
||||
* What the screen should show, given what WorkManager last said about the job.
|
||||
*
|
||||
* ## Why this is a function rather than the body of a `collect`
|
||||
*
|
||||
* It was the body of one. `workManager` is built in the constructor from `WorkManager.getInstance`,
|
||||
* `observe` is private, and nothing could hand either a chosen `WorkInfo` — so every arm below ran
|
||||
* only when a real worker happened to produce it. A real worker produces a terminal state with
|
||||
* well-formed output, which meant six of these arms had never been chosen by any test: the progress
|
||||
* read, both sides of the retry check, a success with no file, a failure with nothing to say, and
|
||||
* the two that map to a state the user cannot otherwise reach.
|
||||
*
|
||||
* That is the argument #141 made for `MediaProbe`'s track walk, against `WorkManager` instead of a
|
||||
* media fixture, and it takes the same answer: the branch matrix is a pure function, and what is
|
||||
* left needing the framework — the flow, the null check, the ownership check — is the thin edge.
|
||||
*
|
||||
* ## What is deliberately *not* in here
|
||||
*
|
||||
* The ownership check stays at the call site. Its comment is explicit that it guards the file
|
||||
* ownership the `SUCCEEDED` arm takes, not merely the assignment, so moving it inside would change
|
||||
* what it protects. And this function takes no responsibility for the staged file: it returns the
|
||||
* state, and the caller reads the file off it. A pure function that deletes files is not a seam.
|
||||
*
|
||||
* @param cancelled where a cancellation lands, which differs for a reattached job — see [observe].
|
||||
* @param fallbackSpec the current settings, read only when finished work predates the worker
|
||||
* reporting its own name and MIME type.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal fun conversionStateFrom(
|
||||
update: ConversionUpdate,
|
||||
input: InputFile,
|
||||
cancelled: ConversionState,
|
||||
fallbackSpec: OutputSpec,
|
||||
): ConversionState = when (update.state) {
|
||||
WorkInfo.State.RUNNING -> ConversionState.Converting(input, update.progressPercent)
|
||||
|
||||
// ENQUEUED after a run means a retry is pending. Either the six-hour foreground budget ran out
|
||||
// mid-job, or the system refused to let the job start again while the app was in the background
|
||||
// — the second being the likelier of the two, since it needs only a process restart. Nothing
|
||||
// here can tell them apart, and nothing needs to: the answer is the same.
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (update.runAttemptCount > 0) {
|
||||
ConversionState.Waiting(input)
|
||||
} else {
|
||||
ConversionState.Converting(input, 0)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> convertedFrom(update.outputData, input, fallbackSpec)
|
||||
|
||||
// A worker that dies before it can report anything leaves no output data at all — a
|
||||
// foreground-service start refused after a process restart is one way — and an exception's
|
||||
// message can be an empty string. Both would read as a failure with nothing said, so blank
|
||||
// falls back like missing does.
|
||||
WorkInfo.State.FAILED -> ConversionState.Failed(
|
||||
update.outputData.getString(ConversionWorker.KEY_ERROR)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConversionWorker.GENERIC_FAILURE_MESSAGE,
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> cancelled
|
||||
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `SUCCEEDED` arm, which is the only one that reads more than one field.
|
||||
*
|
||||
* Split out so [conversionStateFrom] stays a table of one line per state. A success with no output
|
||||
* path is a failure: the job said it finished and named nothing, and there is no file to offer.
|
||||
*/
|
||||
@UnstableApi
|
||||
private fun convertedFrom(outputData: Data, input: InputFile, fallbackSpec: OutputSpec): ConversionState {
|
||||
val path = outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
|
||||
?: return ConversionState.Failed(SUCCEEDED_WITHOUT_A_FILE_MESSAGE)
|
||||
return ConversionState.Converted(
|
||||
input = input,
|
||||
staged = File(path),
|
||||
engineUsed = outputData.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
|
||||
routeReason = outputData.getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(),
|
||||
// Work enqueued before the worker reported this carries nothing, and WorkManager keeps
|
||||
// finished work for about a week -- so this branch is ordinary for a few days rather than a
|
||||
// corner. It is the old derivation, kept because it is the same guess the app already made
|
||||
// and there is genuinely nothing better available for such a job. New work never reaches it.
|
||||
suggestedName = outputData.getString(ConversionWorker.KEY_SUGGESTED_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConversionWorker.outputNameFor(input.displayName, fallbackSpec),
|
||||
mimeType = outputData.getString(ConversionWorker.KEY_MIME_TYPE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: fallbackSpec.mimeType,
|
||||
)
|
||||
}
|
||||
|
||||
/** A job that reported success and named no file. There is nothing to offer the user to save. */
|
||||
internal const val SUCCEEDED_WITHOUT_A_FILE_MESSAGE: String =
|
||||
"Conversion reported success but produced no file."
|
||||
|
||||
sealed interface ConversionState {
|
||||
data object Idle : ConversionState
|
||||
data class Ready(val input: InputFile) : ConversionState
|
||||
@@ -101,7 +215,42 @@ sealed interface ConversionState {
|
||||
val mimeType: String = "",
|
||||
) : ConversionState
|
||||
data class Saved(val displayName: String) : ConversionState
|
||||
data class Failed(val message: String) : ConversionState
|
||||
|
||||
/**
|
||||
* The job, or the save that followed it, could not be finished.
|
||||
*
|
||||
* [retry] is non-null for exactly one cause: a [ConversionViewModel.save] whose copy to the
|
||||
* user's destination threw. That save deliberately keeps the staged file -- it can be the only
|
||||
* copy of an hour of transcoding -- and this is what lets the screen offer it again. Every
|
||||
* other failure leaves it null, because there is nothing staged to offer: a transcode that
|
||||
* died produced no output, and a save that found the file gone has nothing left to save.
|
||||
*
|
||||
* Nullable rather than a `SaveFailed` state of its own. What the screen does with the message
|
||||
* is identical either way, so a second variant would make every exhaustive `when` grow an arm
|
||||
* that duplicates this one.
|
||||
*
|
||||
* A view of the file, not a second owner of it -- see [PendingSave].
|
||||
*/
|
||||
data class Failed(val message: String, val retry: PendingSave? = null) : ConversionState
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged output a save would target from this state, or null when there is nothing to save.
|
||||
*
|
||||
* One function for two callers that have to agree. [ConversionViewModel.save] picks the file to
|
||||
* copy with it, and `ConverterScreen` registers its `CreateDocument` contract with the MIME type
|
||||
* it returns; when those two read the state separately, a retry offered after a failed save opened
|
||||
* the dialog with the *picker's* current type instead of the finished job's -- wrong for any job
|
||||
* whose spec has been edited since, and for every reattached job, whose spec was never in these
|
||||
* settings at all.
|
||||
*
|
||||
* Top-level and `internal` rather than a member of the ViewModel, so the screen can call it
|
||||
* without one -- which is also what makes the derivation testable on the JVM.
|
||||
*/
|
||||
internal fun ConversionState.pendingSave(): PendingSave? = when (this) {
|
||||
is ConversionState.Converted -> PendingSave(staged, suggestedName, mimeType)
|
||||
is ConversionState.Failed -> retry
|
||||
else -> null
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@@ -158,13 +307,34 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
private var observer: Job? = null
|
||||
private var activeWorkId: UUID? = null
|
||||
|
||||
/**
|
||||
* Who is allowed to write to this screen — see [ScreenOwnership] for the rule and why
|
||||
* cancelling the superseded coroutine is not one.
|
||||
*
|
||||
* Every write below that lands after a suspension point is guarded by it: the two in
|
||||
* [onInputPicked] and the one in [observe].
|
||||
*
|
||||
* [save] is the one left out, deliberately — and not because it is safe in both directions.
|
||||
* Nothing can overwrite what it writes: it is reachable only from [ConversionState.Converted]
|
||||
* or a [ConversionState.Failed] carrying its file, so the only observation that could belongs
|
||||
* to a job already in a terminal state, which will not emit again. What it can still do is
|
||||
* land on top of a [reset] taken while its copy was in flight, putting `Saved` on a screen the
|
||||
* user has just cleared. Guarding it would drop that write instead, reporting nothing for a
|
||||
* file that may genuinely have reached the user's destination. Which of those two is right is
|
||||
* a question about what the screen should offer during a save, not about this race, so it is
|
||||
* filed as issue #123 rather than decided here in passing.
|
||||
*/
|
||||
private val ownership = ScreenOwnership()
|
||||
|
||||
/**
|
||||
* The staged output this ViewModel is responsible for deleting.
|
||||
*
|
||||
* A field rather than something read back out of [_state], because the state machine
|
||||
* cannot answer the question on the path that needs it most: a failed [save] lands on
|
||||
* [ConversionState.Failed], which carries a message and no file at all. By then the
|
||||
* only remaining reference would have been lost.
|
||||
* A field rather than something read back out of [_state], and still one now that
|
||||
* [ConversionState.Failed] carries a [PendingSave] after a failed [save]. That handle is a
|
||||
* view for the screen to offer a retry through; this one is the single reference [reset]
|
||||
* deletes through, and keeping the two apart is what stops a second owner appearing. Reading
|
||||
* the file back out of the state machine instead would mean trusting every state that has no
|
||||
* file -- `Idle`, `Saved`, a transcode failure -- to say so.
|
||||
*/
|
||||
private var pendingStaged: File? = null
|
||||
|
||||
@@ -207,6 +377,10 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
* `Data` — see [ConversionState.Converted].
|
||||
*/
|
||||
private fun reattach() {
|
||||
// Read before the launch, and before the query it is about to suspend in. This is the
|
||||
// claim the answer will belong to: anything the user does from here on supersedes it, and
|
||||
// reading it on the far side of the query would read whatever superseded it instead.
|
||||
val token = ownership.current
|
||||
viewModelScope.launch {
|
||||
val reattachment = Reattachment.choose(
|
||||
workManager.jobSnapshots(
|
||||
@@ -217,8 +391,17 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
|
||||
// The query suspends, so by now the user may have picked a file or started a
|
||||
// conversion of their own. Either owns the screen; reattaching over it would throw
|
||||
// away what they just did. Both this check and the assignment below run on the main
|
||||
// dispatcher with no suspension point between them, so nothing can interleave.
|
||||
// away what they just did.
|
||||
//
|
||||
// This catches a pick that has already *landed*, and only that. It used to claim that
|
||||
// "both this check and the assignment below run on the main dispatcher with no
|
||||
// suspension point between them, so nothing can interleave" — which was the exact
|
||||
// opposite of what happens. There is no assignment below. There is observe(), which
|
||||
// launches a *separate* coroutine that must suspend on `collect` before it can write
|
||||
// anything, so the check happens at one moment and the write lands at another with a
|
||||
// whole pick able to fit in between. That was issue #49, and believing this comment is
|
||||
// why it read as flaky CI for two days. What actually holds the line is the token
|
||||
// observe() carries: see [ScreenOwnership].
|
||||
if (_state.value !is ConversionState.Idle || activeWorkId != null) return@launch
|
||||
|
||||
// Only a job that is the sole explanation for its staged file gets to name the input.
|
||||
@@ -244,7 +427,7 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
activeWorkId = reattachment.job.id
|
||||
// No initial state of our own: the flow's first emission carries the job's real
|
||||
// state, so observe() maps it exactly as it would for a conversion started here.
|
||||
observe(reattachment.job.id, input, cancelled = ConversionState.Idle)
|
||||
observe(reattachment.job.id, input, cancelled = ConversionState.Idle, token = token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,25 +443,34 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
|
||||
fun setEnginePreference(preference: EnginePreference) = _settings.update { it.copy(enginePreference = preference) }
|
||||
|
||||
/**
|
||||
* The tap is the claim, which is why [ScreenOwnership.claim] is called here and not inside the
|
||||
* `launch`. A claim made in the coroutine would only be immediate for as long as
|
||||
* `Dispatchers.Main.immediate` happened to run it inline, and a deferred claim leaves the same
|
||||
* gap this closes: it is the difference between the user owning the screen from the moment
|
||||
* they tapped and owning it from whenever their coroutine got around to running.
|
||||
*/
|
||||
fun onInputPicked(uri: Uri) {
|
||||
val token = ownership.claim()
|
||||
viewModelScope.launch {
|
||||
// Both the metadata query and the probe touch disk, and the probe spawns FFprobe.
|
||||
// Neither belongs on the main thread.
|
||||
val file = withContext(pickDispatcher) { InputQuery.describe(getApplication(), uri) }
|
||||
// Every write below the hop above is guarded, this one included: two picks in quick
|
||||
// succession suspend here together, and without this the slower one would land last
|
||||
// and put the file the user did not choose on screen.
|
||||
if (!ownership.stillHeldBy(token)) return@launch
|
||||
// Show the file as soon as its name and size are known. Probing now runs FFprobe on
|
||||
// every pick, which is a native process spawn, and making the whole screen wait on it
|
||||
// would read as the app having ignored the tap.
|
||||
_state.value = ConversionState.Ready(file)
|
||||
|
||||
val probe = withContext(pickDispatcher) { probeOrUnreadable(uri) }
|
||||
// Only fill in the probe if the user has not moved on in the meantime.
|
||||
_state.update { current ->
|
||||
if (current is ConversionState.Ready && current.input.uri == uri) {
|
||||
ConversionState.Ready(file.copy(probe = probe))
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
// Only fill in the probe if the user has not moved on in the meantime. The claim is
|
||||
// what says whether they have -- it covers a second pick of the same URI, which a
|
||||
// comparison of URIs cannot, and every state a later claim could have written.
|
||||
if (!ownership.stillHeldBy(token)) return@launch
|
||||
_state.value = ConversionState.Ready(file.copy(probe = probe))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,10 +521,13 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
quality = settings.quality,
|
||||
enginePreference = settings.enginePreference,
|
||||
)
|
||||
// Tapping Convert claims the screen for this job, which is what supersedes the pick's
|
||||
// still-in-flight probe and any reattachment that has not finished asking.
|
||||
val token = ownership.claim()
|
||||
activeWorkId = request.id
|
||||
workManager.enqueue(request)
|
||||
_state.value = ConversionState.Converting(input, 0)
|
||||
observe(request.id, input)
|
||||
observe(request.id, input, token = token)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,81 +535,45 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
* picked file, ready to convert again. For one picked up by [reattach] there is no picked
|
||||
* file — the URI that job holds belongs to a process that no longer exists — so it lands
|
||||
* on Idle instead, rather than offering a Convert button over a file nothing can open.
|
||||
* @param token the claim this observation belongs to. Nothing here can write until `collect`
|
||||
* has resumed with a `WorkInfo`, which is some time after the caller decided to observe, so
|
||||
* the claim is checked again at the last possible moment rather than trusted from then. This
|
||||
* is issue #49's fix and the only thing standing between a superseded observation and the
|
||||
* user's screen — see [ScreenOwnership].
|
||||
*/
|
||||
private fun observe(id: UUID, input: InputFile, cancelled: ConversionState = ConversionState.Ready(input)) {
|
||||
private fun observe(
|
||||
id: UUID,
|
||||
input: InputFile,
|
||||
cancelled: ConversionState = ConversionState.Ready(input),
|
||||
token: Long,
|
||||
) {
|
||||
observer?.cancel()
|
||||
observer = viewModelScope.launch {
|
||||
workManager.getWorkInfoByIdFlow(id).collect { info ->
|
||||
if (info == null) return@collect
|
||||
_state.value = when (info.state) {
|
||||
WorkInfo.State.RUNNING -> ConversionState.Converting(
|
||||
input,
|
||||
info.progress.getInt(ConversionWorker.KEY_PROGRESS, 0),
|
||||
)
|
||||
|
||||
// ENQUEUED after a run means a retry is pending. Either the six-hour
|
||||
// foreground budget ran out mid-job, or the system refused to let the job
|
||||
// start again while the app was in the background — the second being the
|
||||
// likelier of the two, since it needs only a process restart. Nothing here
|
||||
// can tell them apart, and nothing needs to: the answer is the same.
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (info.runAttemptCount > 0) {
|
||||
ConversionState.Waiting(input)
|
||||
} else {
|
||||
ConversionState.Converting(input, 0)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
val path = info.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
|
||||
if (path == null) {
|
||||
ConversionState.Failed("Conversion reported success but produced no file.")
|
||||
} else {
|
||||
val staged = File(path)
|
||||
// Take responsibility for the file at the same moment the state
|
||||
// starts referring to it, so the two cannot disagree.
|
||||
pendingStaged = staged
|
||||
ConversionState.Converted(
|
||||
input = input,
|
||||
staged = staged,
|
||||
engineUsed = info.outputData
|
||||
.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
|
||||
routeReason = info.outputData
|
||||
.getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(),
|
||||
suggestedName = info.outputData
|
||||
.getString(ConversionWorker.KEY_SUGGESTED_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
// Work enqueued before the worker reported this carries
|
||||
// nothing, and WorkManager keeps finished work for about a
|
||||
// week -- so this branch is ordinary for a few days rather
|
||||
// than a corner. It is the old derivation, kept because it is
|
||||
// the same guess the app already made and there is genuinely
|
||||
// nothing better available for such a job. New work never
|
||||
// reaches it.
|
||||
?: ConversionWorker.outputNameFor(
|
||||
input.displayName,
|
||||
_settings.value.spec,
|
||||
),
|
||||
mimeType = info.outputData
|
||||
.getString(ConversionWorker.KEY_MIME_TYPE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: _settings.value.spec.mimeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A worker that dies before it can report anything leaves no output data at
|
||||
// all — a foreground-service start refused after a process restart is one
|
||||
// way — and an exception's message can be an empty string. Both would read
|
||||
// as a failure with nothing said, so blank falls back like missing does.
|
||||
WorkInfo.State.FAILED -> ConversionState.Failed(
|
||||
info.outputData.getString(ConversionWorker.KEY_ERROR)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Conversion failed.",
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> cancelled
|
||||
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
|
||||
}
|
||||
// Ahead of the `when`, not merely ahead of the assignment: the SUCCEEDED branch
|
||||
// takes ownership of the staged file, and a superseded observation must not do
|
||||
// that either. The state and `pendingStaged` are meant to refer to the same file
|
||||
// or to no file, and this is where that stays true.
|
||||
if (!ownership.stillHeldBy(token)) return@collect
|
||||
val next = conversionStateFrom(
|
||||
ConversionUpdate(
|
||||
state = info.state,
|
||||
progressPercent = info.progress.getInt(ConversionWorker.KEY_PROGRESS, 0),
|
||||
runAttemptCount = info.runAttemptCount,
|
||||
outputData = info.outputData,
|
||||
),
|
||||
input = input,
|
||||
cancelled = cancelled,
|
||||
fallbackSpec = _settings.value.spec,
|
||||
)
|
||||
// Take responsibility for the file at the same moment the state starts referring
|
||||
// to it, so the two cannot disagree. Read off the result rather than assigned
|
||||
// inside the mapping: `Converted` is the only state that carries a staged file, so
|
||||
// "the state and `pendingStaged` refer to the same file or to no file" is now the
|
||||
// shape of the code rather than a rule two branches have to keep.
|
||||
if (next is ConversionState.Converted) pendingStaged = next.staged
|
||||
_state.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,35 +585,50 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
/**
|
||||
* Copies the staged result out to the destination the user picked.
|
||||
*
|
||||
* Reached from [ConversionState.Converted] and again from a [ConversionState.Failed] that an
|
||||
* earlier save left carrying its file. [pendingSave] is what makes those one call rather than
|
||||
* two, so a retry cannot drift from the first attempt in what it copies or what it calls it.
|
||||
*
|
||||
* The existence check is not redundant with the one reattachment already made. That one ran
|
||||
* inside a tag query which, for a result offered on launch, can be hours older than the tap —
|
||||
* and `cacheDir` is exactly the directory the OS empties when it wants space, which is also
|
||||
* what the sweep does to anything a day old. Without it the file's absence arrived as
|
||||
* `staged.inputStream()` throwing, and `e.message` put a raw ENOENT path on screen.
|
||||
* `staged.inputStream()` throwing, and `e.message` put a raw ENOENT path on screen. A retry
|
||||
* meets that same check a second time, which is the point of reusing it here.
|
||||
*/
|
||||
fun save(destination: Uri) {
|
||||
val converted = _state.value as? ConversionState.Converted ?: return
|
||||
if (!converted.staged.isFile) {
|
||||
val pending = _state.value.pendingSave() ?: return
|
||||
if (!pending.staged.isFile) {
|
||||
// No retry handle: the file such a state would offer again is exactly the one that
|
||||
// has gone, so carrying it would put a button on screen that cannot do anything.
|
||||
_state.value = ConversionState.Failed(STAGED_FILE_GONE_MESSAGE)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
publisher.publish(converted.staged, destination)
|
||||
converted.staged.delete()
|
||||
publisher.publish(pending.staged, destination)
|
||||
pending.staged.delete()
|
||||
}
|
||||
}.onSuccess {
|
||||
// publish() already deleted it; nothing left to clean up.
|
||||
pendingStaged = null
|
||||
_state.value = ConversionState.Saved(converted.suggestedName)
|
||||
_state.value = ConversionState.Saved(pending.suggestedName)
|
||||
}.onFailure { e ->
|
||||
// Deliberately NOT cleared. A failed save may mean the staged file is the
|
||||
// only copy of an hour of transcoding, and the user's destination did not
|
||||
// receive it -- deleting here would destroy the work to tidy up a cache
|
||||
// directory. It stays collectable: by a later reset(), or by the sweep once
|
||||
// it is old enough to be certain nobody is coming back for it.
|
||||
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
|
||||
//
|
||||
// `pending` rides on the state so the screen can offer that file again. It used
|
||||
// to live only in `pendingStaged`, where nothing on screen could reach it -- so
|
||||
// the single button this branch rendered was "Start over", which deletes the very
|
||||
// file the paragraph above goes out of its way to keep. It is `pending` rather
|
||||
// than a fresh handle for the second failure's sake: a retry that fails again
|
||||
// lands back here still carrying the file, not on a bare Failed that would take
|
||||
// the offer away.
|
||||
_state.value = ConversionState.Failed(e.message ?: SAVE_FAILED_MESSAGE, pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,8 +642,18 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
* cancelled with [viewModelScope] if the Activity finishes first, so it is a best
|
||||
* effort rather than a guarantee. `OutputPublisher.sweepStaging` is the backstop for
|
||||
* the times it does not run.
|
||||
*
|
||||
* **It still deletes from a [ConversionState.Failed] carrying a [PendingSave], and that is a
|
||||
* decision rather than something inherited.** Deletion is acceptable there only because the
|
||||
* alternative was offered first: the screen puts "Try saving again" directly above this
|
||||
* button, so reaching it is the user saying the work is not worth keeping. Until that button
|
||||
* existed, this delete was the only thing a failed save could lead to — which was the defect.
|
||||
*/
|
||||
fun reset() {
|
||||
// Start over is a claim like any other. The cancel below is a request honoured at the next
|
||||
// suspension point, so a collector already on its way to a write has nothing left to
|
||||
// honour it at; the claim is what actually stops that write landing on top of Idle.
|
||||
ownership.claim()
|
||||
observer?.cancel()
|
||||
observer = null
|
||||
activeWorkId = null
|
||||
@@ -489,13 +673,23 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun currentInput(): InputFile? = when (val s = _state.value) {
|
||||
is ConversionState.Ready -> s.input
|
||||
is ConversionState.Converting -> s.input
|
||||
is ConversionState.Waiting -> s.input
|
||||
is ConversionState.Converted -> s.input
|
||||
else -> null
|
||||
}
|
||||
/**
|
||||
* The input `convert()` may act on, which is only ever the one on a `Ready` screen.
|
||||
*
|
||||
* This used to answer for `Converting`, `Waiting` and `Converted` as well. Those arms were not
|
||||
* reachable by tapping Convert -- the button renders only in the `Ready` branch -- but they
|
||||
* were reachable through the POST_NOTIFICATIONS **result**, which `ConverterScreen.kt:91` wires
|
||||
* to `convert()` rather than to the button. Reaching one of them enqueued a *second* job over a
|
||||
* live one: `activeWorkId` was overwritten, and the first job kept running with its foreground
|
||||
* notification orphaned and nothing left holding its id to cancel it.
|
||||
*
|
||||
* Narrowed under #202 rather than tested as it stood, because a test written against the old
|
||||
* shape would have frozen the double-enqueue as intended behaviour -- the F1/F5 failure mode.
|
||||
*
|
||||
* `JoinViewModel.join()` has been `(_state.value as? JoinState.Ready)?.inputs ?: return` all
|
||||
* along. The two screens are the same shape and only one of them was over-general.
|
||||
*/
|
||||
private fun currentInput(): InputFile? = (_state.value as? ConversionState.Ready)?.input
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
|
||||
@@ -73,8 +73,11 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
// stands: some providers rewrite a document's extension to match it, so an MP3 offered as
|
||||
// video/webm can arrive with the wrong one. Read straight off the collected state, so this
|
||||
// recomposes because it depends on that rather than because an unrelated line happens to.
|
||||
// Through pendingSave() rather than a cast to Converted, so a retry offered after a failed
|
||||
// save opens the dialog with the type its first attempt used -- the cast answered null for a
|
||||
// Failed, and the fallback below is the current picker, which a reattached job never set.
|
||||
// Remembered against the type so the launcher re-registers only when it actually changes.
|
||||
val destinationMime = (state as? ConversionState.Converted)?.mimeType ?: settings.spec.mimeType
|
||||
val destinationMime = state.pendingSave()?.mimeType ?: settings.spec.mimeType
|
||||
val chooseDestination = rememberLauncherForActivityResult(
|
||||
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
@@ -91,24 +94,61 @@ fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewMode
|
||||
state = state,
|
||||
settings = settings,
|
||||
validation = validation,
|
||||
actions = ConverterActions(
|
||||
actions = converterActions(
|
||||
viewModel = viewModel,
|
||||
onPickInput = { pickInput.launch(arrayOf("*/*")) },
|
||||
onPreset = viewModel::setPreset,
|
||||
onContainer = viewModel::setContainer,
|
||||
onVideoCodec = viewModel::setVideoCodec,
|
||||
onAudioCodec = viewModel::setAudioCodec,
|
||||
onSuggestion = viewModel::applySuggestion,
|
||||
onQuality = viewModel::setQuality,
|
||||
onEnginePreference = viewModel::setEnginePreference,
|
||||
onConvert = { requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) },
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
||||
onReset = viewModel::reset,
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the ViewModel's methods each affordance on the screen calls.
|
||||
*
|
||||
* ## Why this is a function rather than an argument list
|
||||
*
|
||||
* It was an argument list, inside [ConverterScreen], which no test reached: `ConverterScreenContent`
|
||||
* builds its own [ConverterActions], so every test in the suite drove the stateless inner and none
|
||||
* of them ever saw the wiring.
|
||||
*
|
||||
* Most of the list is safe without a test, and saying so is more useful than pretending otherwise:
|
||||
* `onContainer`, `onVideoCodec`, `onAudioCodec`, `onPreset`, `onSuggestion`, `onQuality` and
|
||||
* `onEnginePreference` each take a distinct type, so binding one to another's setter does not
|
||||
* compile. Verified rather than assumed — swapping `onVideoCodec` and `onAudioCodec` fails with
|
||||
* *"Inapplicable candidate(s): fun setAudioCodec(codec: AudioCodec)"*.
|
||||
*
|
||||
* **[ConverterActions.onCancel] and [ConverterActions.onReset] are the exception.** Both are
|
||||
* `() -> Unit`, so swapping them compiles silently — also verified — and ships a Cancel button that
|
||||
* throws the conversion away and a Start-over button that leaves it on screen. That pair is what
|
||||
* `ConverterWiringTest` exists for.
|
||||
*
|
||||
* The three launcher-backed actions stay parameters: they need an `ActivityResultLauncher`, which
|
||||
* is the part that genuinely needs the composition, and keeping them out means the rest can be
|
||||
* checked without one.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal fun converterActions(
|
||||
viewModel: ConversionViewModel,
|
||||
onPickInput: () -> Unit,
|
||||
onConvert: () -> Unit,
|
||||
onSave: (suggestedName: String) -> Unit,
|
||||
): ConverterActions = ConverterActions(
|
||||
onPickInput = onPickInput,
|
||||
onPreset = viewModel::setPreset,
|
||||
onContainer = viewModel::setContainer,
|
||||
onVideoCodec = viewModel::setVideoCodec,
|
||||
onAudioCodec = viewModel::setAudioCodec,
|
||||
onSuggestion = viewModel::applySuggestion,
|
||||
onQuality = viewModel::setQuality,
|
||||
onEnginePreference = viewModel::setEnginePreference,
|
||||
onConvert = onConvert,
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = onSave,
|
||||
onReset = viewModel::reset,
|
||||
)
|
||||
|
||||
/**
|
||||
* Everything [ConverterScreenContent] can ask for, in one value.
|
||||
*
|
||||
@@ -325,13 +365,36 @@ internal fun ConverterScreenContent(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
val retry = s.retry
|
||||
if (retry == null) {
|
||||
// Nothing was staged, so "Start over" is the whole of what is on
|
||||
// offer and stays the primary button.
|
||||
Button(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
} else {
|
||||
Button(
|
||||
onClick = { actions.onSave(retry.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.RETRY_SAVE),
|
||||
) { Text("Try saving again") }
|
||||
// Start over still deletes the file this state is carrying, and that
|
||||
// is deliberate: `reset()` is what stops a full-size output sitting in
|
||||
// cache until the sweep. What makes the delete acceptable is the
|
||||
// button above it. Deletion is the user's choice only once the
|
||||
// alternative has been offered -- and until that button existed, this
|
||||
// one was the only thing a failed save could lead to.
|
||||
OutlinedButton(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,42 +68,64 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
): Unit = suspendCancellableCoroutine { cont ->
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
handler.post {
|
||||
val transformer = runCatching { buildTransformer(plan, cont) }
|
||||
.getOrElse {
|
||||
cont.resumeWithException(it)
|
||||
return@post
|
||||
}
|
||||
|
||||
// Dropping the tracks the target does not have is what stops an audio-only export
|
||||
// from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A
|
||||
// produced an HEVC stream in a file named .m4a.
|
||||
val item = EditedMediaItem.Builder(MediaItem.fromUri(input))
|
||||
.setRemoveVideo(plan.video == VideoPlan.Drop)
|
||||
.setRemoveAudio(plan.audio == AudioPlan.Drop)
|
||||
.build()
|
||||
|
||||
// A Composition is the only way to ask for transmuxing; the plain
|
||||
// start(EditedMediaItem, path) overload always re-encodes. This is the remux path.
|
||||
val composition = Composition.Builder(EditedMediaItemSequence.Builder(item).build())
|
||||
.setTransmuxVideo(plan.video == VideoPlan.Copy)
|
||||
.setTransmuxAudio(plan.audio == AudioPlan.Copy)
|
||||
.build()
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
// cancel() has the same single-thread requirement as start().
|
||||
handler.post { runCatching { transformer.cancel() } }
|
||||
}
|
||||
|
||||
runCatching { transformer.start(composition, output.absolutePath) }
|
||||
.onFailure {
|
||||
cont.resumeWithException(it)
|
||||
return@post
|
||||
}
|
||||
|
||||
pollProgress(transformer, cont, onProgress)
|
||||
// One guard around the whole body, deliberately.
|
||||
//
|
||||
// This used to be two narrow ones — around `buildTransformer` and around
|
||||
// `transformer.start` — with the two Media3 builders sitting unguarded between them.
|
||||
// On this thread that is not a small gap: nothing here has a caller to throw back to,
|
||||
// so an escaping exception reaches the HandlerThread's uncaught handler and takes the
|
||||
// process down, while [cont] is never resumed either way. `EditedMediaItem.Builder`
|
||||
// does exactly that for a plan that drops both tracks
|
||||
// ("Audio and video cannot both be removed"), which a queued job can still carry.
|
||||
// Widening the guard costs nothing on success and turns every such refusal into a
|
||||
// failed job with a reason.
|
||||
runCatching { startExport(input, output, plan, cont, onProgress) }
|
||||
.onFailure { if (cont.isActive) cont.resumeWithException(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the export and hands it to Transformer. Runs on the HandlerThread; may throw.
|
||||
*
|
||||
* Everything Transformer's single-thread contract covers lives here, so that the caller has
|
||||
* exactly one place to catch. Returning normally means the export is running and [cont] belongs
|
||||
* to the listener; throwing means it never started and the caller owns resuming.
|
||||
*/
|
||||
private fun startExport(
|
||||
input: Uri,
|
||||
output: File,
|
||||
plan: ConversionPlan,
|
||||
cont: CancellableContinuation<Unit>,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
val transformer = buildTransformer(plan, cont)
|
||||
|
||||
// Dropping the tracks the target does not have is what stops an audio-only export
|
||||
// from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A
|
||||
// produced an HEVC stream in a file named .m4a.
|
||||
val item = EditedMediaItem.Builder(MediaItem.fromUri(input))
|
||||
.setRemoveVideo(plan.video == VideoPlan.Drop)
|
||||
.setRemoveAudio(plan.audio == AudioPlan.Drop)
|
||||
.build()
|
||||
|
||||
// A Composition is the only way to ask for transmuxing; the plain
|
||||
// start(EditedMediaItem, path) overload always re-encodes. This is the remux path.
|
||||
val composition = Composition.Builder(EditedMediaItemSequence.Builder(item).build())
|
||||
.setTransmuxVideo(plan.video == VideoPlan.Copy)
|
||||
.setTransmuxAudio(plan.audio == AudioPlan.Copy)
|
||||
.build()
|
||||
|
||||
// Registered before start(), so a cancellation racing the export always finds a
|
||||
// transformer to cancel.
|
||||
cont.invokeOnCancellation {
|
||||
// cancel() has the same single-thread requirement as start().
|
||||
handler.post { runCatching { transformer.cancel() } }
|
||||
}
|
||||
|
||||
transformer.start(composition, output.absolutePath)
|
||||
pollProgress(transformer, cont, onProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException if [plan] names a container Media3 cannot mux. That is a
|
||||
* routing bug rather than a runtime condition — [org.libremediaconverter.model.ConversionRouter]
|
||||
|
||||
@@ -50,19 +50,42 @@ object MediaProbe {
|
||||
)
|
||||
|
||||
fun probe(context: Context, uri: Uri): InputProbe {
|
||||
val extracted = probeWithExtractor(context, uri)
|
||||
val info = probeWithFFprobe(context, uri)
|
||||
val merged = merge(probeWithExtractor(context, uri), probeWithFFprobe(context, uri))
|
||||
if (merged.kind == InputKind.UNPARSEABLE) {
|
||||
// Not a failure: an unparseable input is a strong signal that this job belongs on
|
||||
// FFmpeg. Reporting an unknown codec makes the router say so.
|
||||
Log.i(TAG, "Neither MediaExtractor nor FFprobe could read $uri; routing to FFmpeg.")
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* What the two probes together say about one input.
|
||||
*
|
||||
* A pure function, and `internal` for the same reason [extractedFrom] is: the precedence rules
|
||||
* below are the answer to "which probe wins", and until this was pulled out of [probe] the only
|
||||
* way to ask was to have a real `MediaExtractor` and a real FFprobe **disagree**, which nothing
|
||||
* on any source set can arrange. `RemuxTest` drives this on a device against committed
|
||||
* fixtures, but only ever with one probe answering and the other agreeing or also failing --
|
||||
* so every elvis here was taken in one direction and never the other.
|
||||
*
|
||||
* The rules, each of which is a decision rather than an accident:
|
||||
*
|
||||
* - **The extractor wins on codecs.** It is the platform's own view of what it can decode,
|
||||
* which is the thing the router is about to ask about. FFprobe's name for the same track can
|
||||
* differ, and the copy planner keys off these strings.
|
||||
* - **FFprobe alone reports the container.** `MediaExtractor` cannot, which is why [InputProbe]
|
||||
* carries a nullable one and `CopyPlanner` treats null as "container unknown".
|
||||
* - **Duration is the larger of the two**, not the first non-zero. Either probe can report zero
|
||||
* for a file the other times correctly, and a zero duration makes the FFmpeg progress
|
||||
* percentage undefined.
|
||||
*/
|
||||
internal fun merge(extracted: Extracted?, info: FFprobeInfo?): InputProbe {
|
||||
val videoCodec = extracted?.videoCodec ?: info?.videoCodec
|
||||
val audioCodec = extracted?.audioCodec ?: info?.audioCodec
|
||||
val kind = classify(extracted, info)
|
||||
|
||||
if (kind == InputKind.UNPARSEABLE) {
|
||||
// Not a failure: an unparseable input is a strong signal that this job belongs on
|
||||
// FFmpeg. Reporting an unknown codec makes the router say so.
|
||||
Log.i(TAG, "Neither MediaExtractor nor FFprobe could read $uri; routing to FFmpeg.")
|
||||
return UNREADABLE
|
||||
}
|
||||
if (kind == InputKind.UNPARSEABLE) return UNREADABLE
|
||||
|
||||
return InputProbe(
|
||||
videoCodec = videoCodec,
|
||||
@@ -83,7 +106,7 @@ object MediaProbe {
|
||||
* audio file and a corrupt file indistinguishable. The source-info card cannot describe either
|
||||
* honestly until they are separate, and neither can the copy planner.
|
||||
*/
|
||||
private fun classify(extracted: Extracted?, info: FFprobeInfo?): InputKind = when {
|
||||
internal fun classify(extracted: Extracted?, info: FFprobeInfo?): InputKind = when {
|
||||
info?.isImage == true -> InputKind.IMAGE
|
||||
extracted == null && info == null -> InputKind.UNPARSEABLE
|
||||
(extracted?.videoCodec ?: info?.videoCodec) != null -> InputKind.VIDEO
|
||||
@@ -92,7 +115,12 @@ object MediaProbe {
|
||||
else -> InputKind.UNPARSEABLE
|
||||
}
|
||||
|
||||
private class Extracted(
|
||||
/**
|
||||
* `internal` rather than `private` so [extractedFrom] can be named from a test. The JVM test
|
||||
* source set is a friend of `main`, so this stays invisible outside the module — the precedent
|
||||
* is `MainActivity`'s `Destination`, and [containerFrom] beside it.
|
||||
*/
|
||||
internal class Extracted(
|
||||
val videoCodec: String?,
|
||||
val audioCodec: String?,
|
||||
val durationMs: Long,
|
||||
@@ -100,33 +128,55 @@ object MediaProbe {
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a set of track formats says about a file.
|
||||
*
|
||||
* Split out of [probeWithExtractor] so the rules below can be tested against tracks a test
|
||||
* *chooses*, rather than against whatever the committed fixtures happen to contain. The device
|
||||
* tests exercise this through real files; none of them can construct a two-video-track input,
|
||||
* a track that omits its duration, or an audio-before-video ordering on purpose.
|
||||
*
|
||||
* Three rules live here, and each is a decision rather than plumbing:
|
||||
*
|
||||
* - **First track of a type wins.** `video == null` is the whole guard. A file with two video
|
||||
* tracks must report the first, because that is the one an engine will transcode.
|
||||
* - **Duration is the maximum across tracks**, not the first one found or the last. A file
|
||||
* whose audio outlasts its video is ordinary, and reporting the video's length would cut the
|
||||
* progress bar short.
|
||||
* - **A track that omits `KEY_DURATION` contributes nothing** rather than zero. `MediaExtractor`
|
||||
* omits it for plenty of real tracks — see `MediaProbeTrackFieldsTest` — and `maxOf` against a
|
||||
* fabricated 0 would still be correct here, but reading a key that is absent is not.
|
||||
*/
|
||||
internal fun extractedFrom(formats: List<MediaFormat>): Extracted {
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var durationUs = 0L
|
||||
var width = 0
|
||||
var height = 0
|
||||
|
||||
for (format in formats) {
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (format.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
|
||||
}
|
||||
when {
|
||||
mime.startsWith("video/") && video == null -> {
|
||||
video = shortName(mime)
|
||||
width = format.intOr(MediaFormat.KEY_WIDTH)
|
||||
height = format.intOr(MediaFormat.KEY_HEIGHT)
|
||||
}
|
||||
|
||||
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
return Extracted(video, audio, durationUs / US_PER_MS, width, height)
|
||||
}
|
||||
|
||||
private fun probeWithExtractor(context: Context, uri: Uri): Extracted? {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var durationUs = 0L
|
||||
var width = 0
|
||||
var height = 0
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (format.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
|
||||
}
|
||||
when {
|
||||
mime.startsWith("video/") && video == null -> {
|
||||
video = shortName(mime)
|
||||
width = format.intOr(MediaFormat.KEY_WIDTH)
|
||||
height = format.intOr(MediaFormat.KEY_HEIGHT)
|
||||
}
|
||||
|
||||
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
Extracted(video, audio, durationUs / US_PER_MS, width, height)
|
||||
extractedFrom(extractor.trackFormats())
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Platform extractor could not read $uri.", e)
|
||||
null
|
||||
@@ -135,7 +185,11 @@ object MediaProbe {
|
||||
}
|
||||
}
|
||||
|
||||
private class FFprobeInfo(
|
||||
/**
|
||||
* `internal` rather than `private` for the same reason [Extracted] is, and it should have been
|
||||
* from the start: [merge] cannot be named from a test while half its signature is private.
|
||||
*/
|
||||
internal class FFprobeInfo(
|
||||
val container: Container?,
|
||||
val videoCodec: String?,
|
||||
val audioCodec: String?,
|
||||
@@ -167,12 +221,39 @@ object MediaProbe {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readMediaInformation(path: String): FFprobeInfo? {
|
||||
// ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these have to go
|
||||
// through the Java getters rather than property syntax.
|
||||
val info: MediaInformation = FFprobeKit.getMediaInformation(path).getMediaInformation()
|
||||
?: return null
|
||||
/**
|
||||
* The thin edge: spawn FFprobe, hand what it said to [ffprobeInfoFrom].
|
||||
*
|
||||
* Everything device-bound is on this line and the null check under it. What FFprobe *said* is a
|
||||
* `MediaInformation`, which is an ordinary object over a `JSONObject` — so the reading of it is
|
||||
* a decision a test can choose the inputs for, and it lives below rather than here.
|
||||
*/
|
||||
private fun readMediaInformation(path: String): FFprobeInfo? =
|
||||
FFprobeKit.getMediaInformation(path).getMediaInformation()?.let(::ffprobeInfoFrom)
|
||||
|
||||
/**
|
||||
* What FFprobe's answer means, as a function of the answer alone.
|
||||
*
|
||||
* `internal` for the same reason [Extracted] and [FFprobeInfo] are: a test cannot name it
|
||||
* otherwise, and the JVM test source set is a friend of `main`.
|
||||
*
|
||||
* **JVM-safe, verified rather than assumed.** `javap` over the committed AAR's runtime jar:
|
||||
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
|
||||
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
|
||||
* touches the native library — so a test builds its own without `libffmpegkit` being present.
|
||||
* That is the whole reason this split is worth making: `readMediaInformation` was 114 missed
|
||||
* instructions and 24 missed branches, of which exactly one line needed a device.
|
||||
*
|
||||
* The subtle part is the **second argument to [containerFrom]**. `matroska,webm` is reported
|
||||
* for both MKV and WebM — they share a demuxer — so the video codec is the only thing that
|
||||
* separates them, and dropping it silently turns every VP9 WebM into an MKV. `containerFrom`
|
||||
* has thirty-three covered branches of its own and none of them can notice that, because the
|
||||
* mistake is at the call rather than in the callee.
|
||||
*
|
||||
* ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these go through the
|
||||
* Java getters rather than property syntax.
|
||||
*/
|
||||
internal fun ffprobeInfoFrom(info: MediaInformation): FFprobeInfo {
|
||||
val streams = info.getStreams().orEmpty()
|
||||
val video = streams.firstOrNull { it.getType() == "video" }
|
||||
val audio = streams.firstOrNull { it.getType() == "audio" }
|
||||
@@ -269,25 +350,7 @@ object MediaProbe {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var width = 0
|
||||
var height = 0
|
||||
var fps = 0
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (mime.startsWith("video/") && video == null) {
|
||||
video = shortName(mime)
|
||||
width = format.intOr(MediaFormat.KEY_WIDTH)
|
||||
height = format.intOr(MediaFormat.KEY_HEIGHT)
|
||||
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
|
||||
} else if (mime.startsWith("audio/") && audio == null) {
|
||||
audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
ConcatInput(video, audio, width, height, fps)
|
||||
concatInputFrom(extractor.trackFormats())
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Could not probe $uri for concat; will re-encode.", e)
|
||||
ConcatInput(null, null, 0, 0, 0)
|
||||
@@ -296,6 +359,45 @@ object MediaProbe {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The join flow's read of the same track formats. See [extractedFrom] for why this is separate
|
||||
* from the extractor.
|
||||
*
|
||||
* Deliberately **not** folded into [extractedFrom] despite the overlap. This one reads frame
|
||||
* rate and does not read duration; that one reads duration and does not read frame rate. A
|
||||
* merged version would have to compute both for every caller, and `ConcatPlanner` treats an
|
||||
* unknown frame rate as "cannot prove a match" — so a field this flow does not need must not
|
||||
* start arriving as a number.
|
||||
*/
|
||||
internal fun concatInputFrom(formats: List<MediaFormat>): ConcatInput {
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var width = 0
|
||||
var height = 0
|
||||
var fps = 0
|
||||
|
||||
for (format in formats) {
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (mime.startsWith("video/") && video == null) {
|
||||
video = shortName(mime)
|
||||
width = format.intOr(MediaFormat.KEY_WIDTH)
|
||||
height = format.intOr(MediaFormat.KEY_HEIGHT)
|
||||
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
|
||||
} else if (mime.startsWith("audio/") && audio == null) {
|
||||
audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
return ConcatInput(video, audio, width, height, fps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every track format this extractor holds, read once.
|
||||
*
|
||||
* The thin edge the two pure functions above leave behind: a `trackCount` and a
|
||||
* `getTrackFormat` per index, which is the whole of what needs a real `MediaExtractor`.
|
||||
*/
|
||||
private fun MediaExtractor.trackFormats(): List<MediaFormat> = (0 until trackCount).map(::getTrackFormat)
|
||||
|
||||
/**
|
||||
* One track property as an Int, or [fallback] when the format has no Int to give.
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* What a save has to say when the staged file is not there any more.
|
||||
@@ -23,6 +24,38 @@ const val STAGED_FILE_GONE_MESSAGE: String =
|
||||
"The finished file is no longer in the cache, so there is nothing left to save. " +
|
||||
"Start over to make it again."
|
||||
|
||||
/**
|
||||
* What to tell the user when the copy into their chosen destination did not finish.
|
||||
*
|
||||
* A fallback, not the usual message: `publish` throws with a real reason most of the time — the
|
||||
* volume filled, the provider revoked the grant — and that reason is better than this. This is for
|
||||
* the exception that arrives with nothing to say, which would otherwise reach the screen as an
|
||||
* empty failure.
|
||||
*
|
||||
* Kept next to [STAGED_FILE_GONE_MESSAGE] for exactly the reason that one names: **both ViewModels
|
||||
* need it**, and saving is what it is about. It was written out twice before — `ConversionViewModel`
|
||||
* and `JoinViewModel` each carried their own copy of the literal, agreeing by coincidence.
|
||||
*/
|
||||
const val SAVE_FAILED_MESSAGE: String = "Could not save the file."
|
||||
|
||||
/**
|
||||
* A staged file that is still there to be saved, and everything the save dialog needs to offer it.
|
||||
*
|
||||
* The three travel together because a save cannot be repeated without all of them: the file to
|
||||
* copy, the name to suggest, and the MIME type `CreateDocument` has to be registered with. None of
|
||||
* them can be rederived from the pickers once the job is over -- they come from the job's own
|
||||
* output `Data`, and a reattached job's spec was never in the current settings at all.
|
||||
*
|
||||
* Kept next to [STAGED_FILE_GONE_MESSAGE] for the same reason it is: both ViewModels need it and
|
||||
* staging is what it is about.
|
||||
*
|
||||
* **A view of the staged file, never an owner of it.** The delete still runs through each
|
||||
* ViewModel's own `pendingStaged` field, so a state carrying one of these can be dropped without
|
||||
* losing the only reference -- which is what keeps "a `Failed` that carries a file" from being a
|
||||
* new way to leak one.
|
||||
*/
|
||||
data class PendingSave(val staged: File, val suggestedName: String, val mimeType: String)
|
||||
|
||||
/**
|
||||
* Staging and publication of conversion output.
|
||||
*
|
||||
@@ -152,7 +185,7 @@ open class OutputPublisher(private val context: Context) {
|
||||
open fun publish(staged: File, destination: Uri) {
|
||||
val destinationWasEmpty = destinationIsKnownEmpty(destination)
|
||||
try {
|
||||
val out = context.contentResolver.openOutputStream(destination)
|
||||
val out = openDestination(destination)
|
||||
?: error("Could not open destination for writing: $destination")
|
||||
out.use { sink -> staged.inputStream().use { source -> source.copyTo(sink) } }
|
||||
} catch (failure: Throwable) {
|
||||
@@ -161,6 +194,22 @@ open class OutputPublisher(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens [destination] for writing, or null when the provider will not.
|
||||
*
|
||||
* A seam, and a narrow one: it exists because `openOutputStream` has **two** ways of refusing
|
||||
* and only one of them is reachable from a test otherwise. A provider that has gone away throws
|
||||
* `FileNotFoundException` from inside the call; a provider that is present and declines returns
|
||||
* null. The two are not interchangeable here — the `?: error(...)` above is the only thing that
|
||||
* turns the second into a failure rather than an NPE further down — and no fake provider can be
|
||||
* asked to produce a null return on demand.
|
||||
*
|
||||
* `protected open` rather than injected, matching `hasSpaceFor` and `createStagingFile`:
|
||||
* `WorkerStubs.kt`'s publishers already override one method to force one condition.
|
||||
*/
|
||||
protected open fun openDestination(destination: Uri): OutputStream? =
|
||||
context.contentResolver.openOutputStream(destination)
|
||||
|
||||
/**
|
||||
* True only when the destination is *positively known* to hold no bytes yet.
|
||||
*
|
||||
@@ -238,7 +287,7 @@ open class OutputPublisher(private val context: Context) {
|
||||
open fun sweepStaging(nowMs: Long = System.currentTimeMillis()) {
|
||||
val dir = stagingDir
|
||||
val listing = dir.listFiles() ?: return
|
||||
val entries = listing.map { StagingSweep.Entry(it.name, it.lastModified()) }
|
||||
val entries = snapshot(listing)
|
||||
StagingSweep.collectable(entries, nowMs).forEach { name ->
|
||||
val file = File(dir, name)
|
||||
// Re-read the timestamp rather than trusting the snapshot above. Between the
|
||||
@@ -250,6 +299,22 @@ open class OutputPublisher(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The name and age of everything [sweepStaging] found, read once.
|
||||
*
|
||||
* A seam for the *race*, not for the clock — [sweepStaging] already takes `nowMs`, so the clock
|
||||
* is the caller's. What has no seam otherwise is the window between this snapshot and the
|
||||
* per-file re-read below it, and that window is the entire reason the re-read exists.
|
||||
*
|
||||
* **It has to be here and not around `listFiles()`.** A test that changes a file before the
|
||||
* listing, or during it, changes what `StagingSweep.collectable` is given — so the file is
|
||||
* never proposed for deletion and the re-read is never reached. The race being modelled is a
|
||||
* file that *was* collectable when the snapshot was taken and is not by the time the delete
|
||||
* comes round, which is exactly one worker resuming in this same process.
|
||||
*/
|
||||
protected open fun snapshot(listing: Array<File>): List<StagingSweep.Entry> =
|
||||
listing.map { StagingSweep.Entry(it.name, it.lastModified()) }
|
||||
|
||||
private fun File.canonicalOrAbsolute(): File = runCatching { canonicalFile }.getOrDefault(absoluteFile)
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
/**
|
||||
* Which of the things writing to a screen is still allowed to.
|
||||
*
|
||||
* Both ViewModels are a state machine written to from several coroutines that each suspend before
|
||||
* they write: a pick hops to a dispatcher for the metadata query, a reattachment hops for the tag
|
||||
* query, and an observation of a WorkManager job cannot write at all until its `collect` has
|
||||
* resumed with a `WorkInfo`. Whoever resumes last wins, which is how issue #49 let a finished job
|
||||
* from an earlier session take a screen the user had already picked a file on.
|
||||
*
|
||||
* The rule this makes enforceable is one line: **every write that lands after a suspension point
|
||||
* checks the claim it was made under, and drops itself if that claim has been superseded.** The
|
||||
* claim is taken synchronously, when the user acts; the check happens immediately before the
|
||||
* write. Superseded work is *dropped*, not reordered — a dropped write cannot come back later.
|
||||
*
|
||||
* Cancelling the superseded coroutine is not a substitute and was never going to be. `Job.cancel`
|
||||
* is a request, honoured at the next suspension point; a collector that has already resumed and is
|
||||
* on its way to `_state.value = …` has no suspension point left to honour it at, so the write
|
||||
* lands anyway. Cancellation also cannot help at all in the case #49 actually reported, where
|
||||
* nothing supersedes the observation until after it has been launched. Both ViewModels still
|
||||
* cancel their old observer, because leaving a collector running is a leak — but the guarantee
|
||||
* does not rest on it.
|
||||
*
|
||||
* **Confined to the main dispatcher, and that confinement is the atomicity argument.** Every
|
||||
* claim and every check runs there, with no suspension point between a check and the write it
|
||||
* guards, so a claim can never land between the two. Nothing here is synchronized and nothing is
|
||||
* `@Volatile`: making the field visible across threads would invite exactly the off-main use this
|
||||
* cannot support, and would replace an argument that holds with one that only looks like it does.
|
||||
*/
|
||||
internal class ScreenOwnership {
|
||||
|
||||
private var claims = 0L
|
||||
|
||||
/**
|
||||
* The claim in force now.
|
||||
*
|
||||
* Read by work that is about to suspend and will want to know, when it comes back, whether
|
||||
* the screen it was reading is still the screen it is writing to. Read it *before* the
|
||||
* suspension, not after — reading it afterwards would return whatever claim superseded it,
|
||||
* which is the bug rather than the check for it.
|
||||
*/
|
||||
val current: Long get() = claims
|
||||
|
||||
/**
|
||||
* Takes the screen, invalidating every write still in flight under an older claim.
|
||||
*
|
||||
* Called synchronously from the user's action rather than from inside the coroutine it
|
||||
* starts. A claim made inside a `launch` is only immediate while the dispatcher happens to
|
||||
* run it inline, and a deferred claim is no claim at all: it would leave the same gap this
|
||||
* exists to close.
|
||||
*/
|
||||
fun claim(): Long = ++claims
|
||||
|
||||
/** Whether [token] is still the claim in force, and may therefore write. */
|
||||
fun stillHeldBy(token: Long): Boolean = token == claims
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.ffmpeg.FFmpegEngine
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
@@ -40,6 +41,27 @@ interface SoftwareTranscoder {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The join path. Implemented by [org.libremediaconverter.ffmpeg.ConcatEngine].
|
||||
*
|
||||
* Added last of the three, and the gap it closes was measured rather than guessed:
|
||||
* `PerJobStagingTest`'s KDoc records that reverting `ConcatWorker` to a constant staging name left
|
||||
* all 257 tests green, because nothing in the JVM suite can get past a `ConcatEngine` constructed
|
||||
* in place. Everything after that line -- the failure mapping, the message fallback, the staged
|
||||
* delete -- was untested on every source set.
|
||||
*
|
||||
* The result type stays nested in the implementation rather than being lifted here. Moving it would
|
||||
* touch every call site to buy nothing: what a test needs is the ability to *not* run FFmpeg, and
|
||||
* that is the method, not the type.
|
||||
*/
|
||||
interface ConcatJoiner {
|
||||
suspend fun join(
|
||||
inputs: List<Uri>,
|
||||
output: File,
|
||||
format: OutputFormat = OutputFormat.MP4_H264,
|
||||
): ConcatEngine.Result
|
||||
}
|
||||
|
||||
/**
|
||||
* The seam that lets tests force failure paths.
|
||||
*
|
||||
@@ -69,6 +91,9 @@ object ConversionDependencies {
|
||||
@Volatile
|
||||
var software: () -> SoftwareTranscoder = { FFmpegEngine() }
|
||||
|
||||
@Volatile
|
||||
var concat: (Context) -> ConcatJoiner = { ConcatEngine(it) }
|
||||
|
||||
@Volatile
|
||||
var publisher: (Context) -> OutputPublisher = { OutputPublisher(it) }
|
||||
|
||||
@@ -103,6 +128,7 @@ object ConversionDependencies {
|
||||
fun reset() {
|
||||
hardware = { Media3Engine(it) }
|
||||
software = { FFmpegEngine() }
|
||||
concat = { ConcatEngine(it) }
|
||||
publisher = { OutputPublisher(it) }
|
||||
deviceCodecs = { AndroidDeviceCodecs.get() }
|
||||
probe = { context, uri -> MediaProbe.probe(context, uri) }
|
||||
|
||||
@@ -5,8 +5,8 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.libremediaconverter.convert.ConcatJoiner
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
import org.libremediaconverter.convert.StagingNames
|
||||
import org.libremediaconverter.model.ConcatPlanner
|
||||
@@ -24,11 +24,11 @@ import kotlin.coroutines.resumeWithException
|
||||
* reliably fail when they differ — it can emit a file whose later segments are
|
||||
* garbled. See [ConcatPlanner].
|
||||
*/
|
||||
class ConcatEngine(private val context: Context) {
|
||||
class ConcatEngine(private val context: Context) : ConcatJoiner {
|
||||
|
||||
data class Result(val strategy: ConcatStrategy, val output: File)
|
||||
|
||||
suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat = OutputFormat.MP4_H264): Result {
|
||||
override suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat): Result {
|
||||
require(inputs.size >= 2) { "Joining needs at least two files." }
|
||||
|
||||
val paths = inputs.map { uri ->
|
||||
@@ -65,16 +65,16 @@ class ConcatEngine(private val context: Context) {
|
||||
private suspend fun execute(args: List<String>) = suspendCancellableCoroutine { cont ->
|
||||
Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
|
||||
val session = FFmpegKit.executeWithArgumentsAsync(args.toTypedArray()) { completed ->
|
||||
val rc = completed.getReturnCode()
|
||||
when {
|
||||
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
|
||||
ReturnCode.isCancel(rc) -> cont.cancel()
|
||||
else -> cont.resumeWithException(
|
||||
FFmpegEngine.FFmpegException(
|
||||
"Joining failed (${rc?.value}): " +
|
||||
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty(),
|
||||
),
|
||||
)
|
||||
val outcome = sessionOutcome(
|
||||
rc = completed.getReturnCode(),
|
||||
prefix = "Joining",
|
||||
failStackTrace = { completed.getFailStackTrace() },
|
||||
logTail = { completed.getAllLogsAsString(LOG_TAIL_LIMIT) },
|
||||
)
|
||||
when (outcome) {
|
||||
SessionOutcome.Success -> cont.resume(Unit)
|
||||
SessionOutcome.Cancelled -> cont.cancel()
|
||||
is SessionOutcome.Failed -> cont.resumeWithException(FFmpegEngine.FFmpegException(outcome.message))
|
||||
}
|
||||
}
|
||||
cont.invokeOnCancellation { FFmpegKit.cancel(session.getSessionId()) }
|
||||
|
||||
@@ -35,6 +35,21 @@ object FFmpegConcatCommand {
|
||||
add("concat")
|
||||
add("-safe")
|
||||
add("0")
|
||||
// And -protocol_whitelist permits the *scheme* those paths carry, which is a
|
||||
// separate gate (#238). Every input the user actually picks is a content:// URI --
|
||||
// JoinScreen uses OpenMultipleDocuments -- so ConcatEngine maps it through
|
||||
// FFmpegKitConfig.getSafParameterForRead and writes an `ffkitsaf:` path into the
|
||||
// list file. The concat demuxer applies its own whitelist, defaulting to
|
||||
// "file,crypto,data", and refused every one of them:
|
||||
//
|
||||
// [ffkitsaf @ ...] Protocol 'ffkitsaf' not on whitelist 'file,crypto,data'!
|
||||
//
|
||||
// This only widens that default. It is on the stream-copy branch alone because it
|
||||
// is the only one that feeds the demuxer a list file -- REENCODE passes each input
|
||||
// with its own -i, where the whitelist does not apply, which is why joining over SAF
|
||||
// worked for mismatched clips and failed for matching ones.
|
||||
add("-protocol_whitelist")
|
||||
add(PROTOCOL_WHITELIST)
|
||||
add("-i")
|
||||
add(listFile.absolutePath)
|
||||
add("-c")
|
||||
@@ -84,4 +99,12 @@ object FFmpegConcatCommand {
|
||||
add(output.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The concat demuxer's protocol whitelist: FFmpeg's own default, plus ffmpeg-kit's SAF scheme.
|
||||
*
|
||||
* Spelled out rather than appended to an unknown default, because the default is FFmpeg's and
|
||||
* could change under us; naming all four keeps the command self-describing. See #238.
|
||||
*/
|
||||
private const val PROTOCOL_WHITELIST = "file,crypto,data,ffkitsaf"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.Level
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
@@ -51,19 +50,16 @@ class FFmpegEngine : SoftwareTranscoder {
|
||||
val session = FFmpegKit.executeWithArgumentsAsync(
|
||||
args.toTypedArray(),
|
||||
{ completed ->
|
||||
val rc = completed.getReturnCode()
|
||||
when {
|
||||
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
|
||||
ReturnCode.isCancel(rc) ->
|
||||
cont.cancel()
|
||||
else -> cont.resumeWithException(
|
||||
FFmpegException(
|
||||
"FFmpeg failed (${rc?.value}): " +
|
||||
completed.getFailStackTrace().orEmpty().ifBlank {
|
||||
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
|
||||
},
|
||||
),
|
||||
)
|
||||
val outcome = sessionOutcome(
|
||||
rc = completed.getReturnCode(),
|
||||
prefix = "FFmpeg",
|
||||
failStackTrace = { completed.getFailStackTrace() },
|
||||
logTail = { completed.getAllLogsAsString(LOG_TAIL_LIMIT) },
|
||||
)
|
||||
when (outcome) {
|
||||
SessionOutcome.Success -> cont.resume(Unit)
|
||||
SessionOutcome.Cancelled -> cont.cancel()
|
||||
is SessionOutcome.Failed -> cont.resumeWithException(FFmpegException(outcome.message))
|
||||
}
|
||||
},
|
||||
{ log -> Log.d(TAG, log.message.trimEnd()) },
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
|
||||
/**
|
||||
* What a finished FFmpegKit session means, as a function of its return code.
|
||||
*
|
||||
* Both engines had their own copy of this `when`, twelve lines apart in two files, and the copies
|
||||
* had drifted: [FFmpegEngine] preferred the fail stack trace and fell back to the log tail, while
|
||||
* [ConcatEngine] only ever read the log tail. Neither was tested — both live inside a callback
|
||||
* handed to `FFmpegKit`, which does not run on the JVM — so the divergence was invisible.
|
||||
*
|
||||
* #203 decided to unify on the stack trace, so a join failure now carries the diagnostics a
|
||||
* conversion failure always did. The *prefix* stays per-engine: unifying the strategy must not
|
||||
* unify the sentence, since "FFmpeg failed" and "Joining failed" describe different jobs.
|
||||
*/
|
||||
internal sealed interface SessionOutcome {
|
||||
|
||||
/** rc 0. The suspension resumes normally. */
|
||||
data object Success : SessionOutcome
|
||||
|
||||
/** rc 255. The suspension is cancelled rather than failed — the user asked for this. */
|
||||
data object Cancelled : SessionOutcome
|
||||
|
||||
/** Anything else, with the sentence the user is shown. */
|
||||
data class Failed(val message: String) : SessionOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a return code onto the outcome, and builds the failure sentence when there is one.
|
||||
*
|
||||
* **The two message parts arrive as lambdas, deliberately.** `getAllLogsAsString` and
|
||||
* `getFailStackTrace` are calls onto a native session, and only the failure arm needs either. Taking
|
||||
* them by value would put both on the happy path of every successful conversion, which is a cost the
|
||||
* shape this replaced did not have — the old code read them inside the `else` branch. That is the
|
||||
* same reason [org.libremediaconverter.codec.AndroidDeviceCodecs.capabilitiesFrom] takes a
|
||||
* `Sequence`: a seam should not change what runs when.
|
||||
*
|
||||
* A null [rc] is a real input rather than a defensive one — `getReturnCode()` is nullable, and a
|
||||
* session killed before it reported anything has none. It is neither success nor cancellation, so
|
||||
* it fails, and the sentence says `null` where the number would be.
|
||||
*/
|
||||
internal fun sessionOutcome(
|
||||
rc: ReturnCode?,
|
||||
prefix: String,
|
||||
failStackTrace: () -> String?,
|
||||
logTail: () -> String?,
|
||||
): SessionOutcome = when {
|
||||
ReturnCode.isSuccess(rc) -> SessionOutcome.Success
|
||||
ReturnCode.isCancel(rc) -> SessionOutcome.Cancelled
|
||||
else -> SessionOutcome.Failed(
|
||||
"$prefix failed (${rc?.value}): " + failStackTrace().orEmpty().ifBlank { logTail().orEmpty() },
|
||||
)
|
||||
}
|
||||
@@ -46,26 +46,48 @@ fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewMod
|
||||
|
||||
// The contract's MIME type comes from the finished job rather than from a literal: some
|
||||
// providers rewrite a document's extension to match it, so naming MP4 for a join that is not
|
||||
// one can hand the user a file the extension lies about. Remembered against that type so the
|
||||
// launcher re-registers only when it actually changes.
|
||||
val destinationMime = (state as? JoinState.Joined)?.mimeType ?: ConcatWorker.DEFAULT_FORMAT.mimeType
|
||||
// one can hand the user a file the extension lies about. Through pendingSave() rather than a
|
||||
// cast to Joined, so a retry after a failed save opens with the type its first attempt used.
|
||||
// Remembered against that type so the launcher re-registers only when it actually changes.
|
||||
val destinationMime = state.pendingSave()?.mimeType ?: ConcatWorker.DEFAULT_FORMAT.mimeType
|
||||
val chooseDestination = rememberLauncherForActivityResult(
|
||||
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
|
||||
JoinScreenContent(
|
||||
state = state,
|
||||
actions = JoinActions(
|
||||
actions = joinActions(
|
||||
viewModel = viewModel,
|
||||
onPickInputs = { pickInputs.launch(arrayOf("video/*")) },
|
||||
onJoin = viewModel::join,
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
||||
onReset = viewModel::reset,
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the ViewModel's methods each affordance on the join screen calls.
|
||||
*
|
||||
* The join-side twin of `converterActions`, and the transposition risk here is worse: **three**
|
||||
* `() -> Unit` bindings rather than two. `onJoin`, `onCancel` and `onReset` are mutually
|
||||
* interchangeable as far as the compiler is concerned, so a Join button that cancels, or a Cancel
|
||||
* button that starts the job, is a swap nothing but a test would catch.
|
||||
*
|
||||
* See `converterActions` for why the launcher-backed actions stay parameters.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal fun joinActions(
|
||||
viewModel: JoinViewModel,
|
||||
onPickInputs: () -> Unit,
|
||||
onSave: (suggestedName: String) -> Unit,
|
||||
): JoinActions = JoinActions(
|
||||
onPickInputs = onPickInputs,
|
||||
onJoin = viewModel::join,
|
||||
onCancel = viewModel::cancel,
|
||||
onSave = onSave,
|
||||
onReset = viewModel::reset,
|
||||
)
|
||||
|
||||
/**
|
||||
* Everything [JoinScreenContent] can ask for, in one value.
|
||||
*
|
||||
@@ -226,13 +248,32 @@ internal fun JoinScreenContent(state: JoinState, actions: JoinActions, modifier:
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
val retry = s.retry
|
||||
if (retry == null) {
|
||||
// Nothing staged, so "Start over" is all there is and stays primary.
|
||||
Button(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
} else {
|
||||
Button(
|
||||
onClick = { actions.onSave(retry.suggestedName) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight)
|
||||
.testTag(TestTags.RETRY_SAVE),
|
||||
) { Text("Try saving again") }
|
||||
// Start over still deletes the carried file, for the reason the
|
||||
// converter screen writes out next to the same pair of buttons:
|
||||
// the delete is a choice only once the alternative is on screen.
|
||||
OutlinedButton(
|
||||
onClick = actions.onReset,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -18,7 +19,10 @@ import kotlinx.coroutines.withContext
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.convert.InputQuery
|
||||
import org.libremediaconverter.convert.PendingSave
|
||||
import org.libremediaconverter.convert.SAVE_FAILED_MESSAGE
|
||||
import org.libremediaconverter.convert.STAGED_FILE_GONE_MESSAGE
|
||||
import org.libremediaconverter.convert.ScreenOwnership
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.JobTags
|
||||
@@ -27,6 +31,108 @@ import org.libremediaconverter.work.jobSnapshots
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* One update about a running join, as WorkManager last reported it.
|
||||
*
|
||||
* The join-side twin of `ConversionUpdate`, and deliberately the same shape: this pair of seams is
|
||||
* one refactor done twice, and letting them diverge would make the two flows harder to compare than
|
||||
* the duplication costs. There is no `progressPercent` here because `ConcatWorker` publishes none —
|
||||
* a join is indeterminate.
|
||||
*/
|
||||
internal data class JoinUpdate(val state: WorkInfo.State, val runAttemptCount: Int, val outputData: Data)
|
||||
|
||||
/**
|
||||
* What the join screen should show, given what WorkManager last said about the job.
|
||||
*
|
||||
* The join-side twin of `conversionStateFrom`, extracted for the same reason and with the same two
|
||||
* exclusions: the ownership check stays at the call site, and this takes no responsibility for the
|
||||
* staged file. See that function's KDoc for the argument in full.
|
||||
*
|
||||
* Five arms had never been chosen by any test before this was cut out, because a real `ConcatWorker`
|
||||
* only ever produces a terminal state with well-formed output.
|
||||
*
|
||||
* @param cancelled where a cancellation lands, which differs for a reattached job — see [observe].
|
||||
*/
|
||||
@UnstableApi
|
||||
internal fun joinStateFrom(update: JoinUpdate, inputs: List<InputFile>, cancelled: JoinState): JoinState =
|
||||
when (update.state) {
|
||||
// BLOCKED is a job waiting on a prerequisite, which the user has nothing to do about and
|
||||
// nothing useful to be told about. It reads as "starting", like a fresh ENQUEUED.
|
||||
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
||||
|
||||
// ENQUEUED after a run means a retry is pending -- the same rule, and the same reasoning, as
|
||||
// the convert side. See `conversionStateFrom`.
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (update.runAttemptCount > 0) {
|
||||
JoinState.Waiting(inputs)
|
||||
} else {
|
||||
JoinState.Joining(inputs)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> joinedFrom(update.outputData)
|
||||
|
||||
// A worker that dies before it can report anything leaves no output data at all, and an
|
||||
// exception's message can be an empty string. Both would read as a failure with nothing said,
|
||||
// so blank falls back like missing does.
|
||||
WorkInfo.State.FAILED -> JoinState.Failed(
|
||||
update.outputData.getString(ConcatWorker.KEY_ERROR)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.GENERIC_FAILURE_MESSAGE,
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> cancelled
|
||||
}
|
||||
|
||||
/**
|
||||
* The `SUCCEEDED` arm. A join that reported success and named no file has nothing to offer.
|
||||
*/
|
||||
@UnstableApi
|
||||
private fun joinedFrom(outputData: Data): JoinState {
|
||||
val path = outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
||||
?: return JoinState.Failed(JOINED_WITHOUT_A_FILE_MESSAGE)
|
||||
return JoinState.Joined(
|
||||
staged = File(path),
|
||||
strategy = strategyFrom(outputData.getString(ConcatWorker.KEY_STRATEGY)),
|
||||
// A join enqueued before the worker reported these carries neither, and the fallback is
|
||||
// the format such a job really used -- ConcatWorker.request has always defaulted to it,
|
||||
// and the join screen has never offered a choice.
|
||||
suggestedName = outputData.getString(ConcatWorker.KEY_SUGGESTED_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT),
|
||||
mimeType = outputData.getString(ConcatWorker.KEY_MIME_TYPE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.DEFAULT_FORMAT.mimeType,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The strategy a finished join reported, or [ConcatStrategy.REENCODE] when it named none.
|
||||
*
|
||||
* **Looked up rather than `valueOf`, and that is a fix rather than a style choice.** `valueOf`
|
||||
* throws `IllegalArgumentException` on a name this build does not define, and this runs inside a
|
||||
* `viewModelScope` collect with no handler -- so the throw does not become a `Failed` state, it
|
||||
* takes the process down.
|
||||
*
|
||||
* Reachable for the reason `WorkerEnumFallbackTest` and `JobTags` are both written on: WorkManager
|
||||
* keeps finished work for about a week, so a downgrade or a rollback hands this build a job
|
||||
* enqueued by another one. `ConcatWorker` writes `result.strategy.name` into the output `Data`, so
|
||||
* a build that added a third strategy would leave this one crashing on its own completed joins.
|
||||
*
|
||||
* `ConcatWorker.kt` already made exactly this change for `KEY_FORMAT`, and says why in as many
|
||||
* words: *"Looked up rather than `valueOf` … a format name this build does not define used to throw
|
||||
* past the catch."* The same read on this side had not been changed with it.
|
||||
*
|
||||
* REENCODE is the safe default rather than an arbitrary one: it is the answer for inputs that do
|
||||
* not match, so a job whose strategy cannot be read is described as the more conservative of the
|
||||
* two rather than being claimed as a lossless stream copy.
|
||||
*/
|
||||
private fun strategyFrom(name: String?): ConcatStrategy =
|
||||
ConcatStrategy.entries.firstOrNull { it.name == name } ?: ConcatStrategy.REENCODE
|
||||
|
||||
/** A join that reported success and named no file. There is nothing to offer the user to save. */
|
||||
internal const val JOINED_WITHOUT_A_FILE_MESSAGE: String =
|
||||
"Joining reported success but produced no file."
|
||||
|
||||
sealed interface JoinState {
|
||||
data object Idle : JoinState
|
||||
data class Ready(val inputs: List<InputFile>) : JoinState
|
||||
@@ -44,7 +150,30 @@ sealed interface JoinState {
|
||||
val mimeType: String,
|
||||
) : JoinState
|
||||
data class Saved(val displayName: String) : JoinState
|
||||
data class Failed(val message: String) : JoinState
|
||||
|
||||
/**
|
||||
* The join, or the save that followed it, could not be finished.
|
||||
*
|
||||
* [retry] is non-null for exactly one cause, and for the same reason as on
|
||||
* `ConversionState.Failed`: a [JoinViewModel.save] whose copy to the user's destination threw
|
||||
* keeps the staged file, and this is what lets the screen offer it again. Every other failure
|
||||
* leaves it null — a join that died produced no output, and a save that found the file gone
|
||||
* has nothing left to save.
|
||||
*/
|
||||
data class Failed(val message: String, val retry: PendingSave? = null) : JoinState
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged output a save would target from this state, or null when there is nothing to save.
|
||||
*
|
||||
* The join tab's half of `ConversionState.pendingSave`, and it exists for the same reason: `save`
|
||||
* and `JoinScreen`'s `CreateDocument` registration both have to answer this question, and answering
|
||||
* it twice is how a retry ends up opening the dialog with a type the finished job never chose.
|
||||
*/
|
||||
internal fun JoinState.pendingSave(): PendingSave? = when (this) {
|
||||
is JoinState.Joined -> PendingSave(staged, suggestedName, mimeType)
|
||||
is JoinState.Failed -> retry
|
||||
else -> null
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@@ -52,6 +181,15 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
app: Application,
|
||||
/** Where [reset] runs its delete. See the same parameter on `ConversionViewModel`. */
|
||||
private val cleanupDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
/**
|
||||
* Where the metadata query behind a pick runs. See the same parameter on `ConversionViewModel`.
|
||||
*
|
||||
* The join side had no such seam, and the gap was not cosmetic: the one write `onInputsPicked`
|
||||
* makes lands *after* this hop, so a test that wants to ask what happens while a pick is still
|
||||
* in flight had no way to hold one there. Issue #49's race is exactly that question, and it
|
||||
* went unasked on this side for as long as the dispatcher was a literal.
|
||||
*/
|
||||
private val pickDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : AndroidViewModel(app) {
|
||||
|
||||
private val workManager = WorkManager.getInstance(app)
|
||||
@@ -66,13 +204,28 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
private var observer: Job? = null
|
||||
private var activeWorkId: UUID? = null
|
||||
|
||||
/**
|
||||
* Who is allowed to write to this screen -- see [ScreenOwnership], which carries the rule and
|
||||
* the reason cancelling the superseded coroutine is not one.
|
||||
*
|
||||
* The convert side had issue #49 reported against it four times in two days; this side has the
|
||||
* identical shape and was never reported, because nothing was watching. Every write below that
|
||||
* lands after a suspension point is guarded: the one in [onInputsPicked] and the one in
|
||||
* [observe]. [save] is the one left out, deliberately and with the same limit its counterpart
|
||||
* in `ConversionViewModel` spells out: nothing can overwrite what it writes, but it can still
|
||||
* land on top of a [reset] taken while its copy was in flight. Which way that should go is a
|
||||
* question about the save screen rather than about this race -- issue #123.
|
||||
*/
|
||||
private val ownership = ScreenOwnership()
|
||||
|
||||
/**
|
||||
* The staged output this ViewModel is responsible for deleting.
|
||||
*
|
||||
* Held here rather than read back out of [_state] for the same reason as in
|
||||
* `ConversionViewModel`: a failed [save] lands on [JoinState.Failed], which carries a
|
||||
* message and no file, so the state machine cannot answer this on the one path that
|
||||
* most needs it.
|
||||
* `ConversionViewModel`, and still held here now that [JoinState.Failed] carries a
|
||||
* [PendingSave] after a failed [save]: that handle is a view for the screen to offer a retry
|
||||
* through, this one is the single reference [reset] deletes through, and keeping the two
|
||||
* apart is what stops a second owner of the file appearing.
|
||||
*/
|
||||
private var pendingStaged: File? = null
|
||||
|
||||
@@ -91,6 +244,10 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
* the rules about which job and why.
|
||||
*/
|
||||
private fun reattach() {
|
||||
// Read before the launch, and before the query it is about to suspend in: this is the
|
||||
// claim the answer belongs to. Reading it on the far side of the query would read whatever
|
||||
// superseded it, which is the bug rather than the check for it.
|
||||
val token = ownership.current
|
||||
viewModelScope.launch {
|
||||
val reattachment = Reattachment.choose(
|
||||
workManager.jobSnapshots(
|
||||
@@ -100,8 +257,15 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
) ?: return@launch
|
||||
|
||||
// The query suspends, so the user may have picked files or started a join in the
|
||||
// meantime. Theirs wins. No suspension point between this check and the assignment
|
||||
// below, and both run on the main dispatcher, so nothing can interleave.
|
||||
// meantime. Theirs wins.
|
||||
//
|
||||
// This catches a pick that has already *landed*, and only that. It used to claim there
|
||||
// was "no suspension point between this check and the assignment below", which was the
|
||||
// opposite of what happens: there is no assignment below, only observe(), which
|
||||
// launches a separate coroutine that cannot write until its `collect` resumes. The
|
||||
// check happens at one moment and the write lands at another, with a whole pick able
|
||||
// to fit in between -- issue #49. The token observe() carries is what holds that line;
|
||||
// see [ScreenOwnership].
|
||||
if (_state.value !is JoinState.Idle || activeWorkId != null) return@launch
|
||||
|
||||
// Joins used to stage under one constant name, so two finished joins always reported
|
||||
@@ -121,19 +285,29 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
InputFile(Uri.EMPTY, "", sizeBytes = null)
|
||||
}
|
||||
activeWorkId = reattachment.job.id
|
||||
observe(reattachment.job.id, inputs, cancelled = JoinState.Idle)
|
||||
observe(reattachment.job.id, inputs, cancelled = JoinState.Idle, token = token)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tap is the claim, which is why it is taken here rather than inside the `launch` -- and
|
||||
* above the early return, so the refusal below is covered by it too. A claim made in the
|
||||
* coroutine is only immediate while `Dispatchers.Main.immediate` happens to run it inline, and
|
||||
* a deferred claim leaves exactly the gap this closes.
|
||||
*/
|
||||
fun onInputsPicked(uris: List<Uri>) {
|
||||
val token = ownership.claim()
|
||||
if (uris.size < 2) {
|
||||
_state.value = JoinState.Failed("Pick at least two files to join.")
|
||||
_state.value = JoinState.Failed(ConcatWorker.TOO_FEW_INPUTS_MESSAGE)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val files = withContext(Dispatchers.IO) {
|
||||
val files = withContext(pickDispatcher) {
|
||||
uris.map { InputQuery.describe(getApplication(), it) }
|
||||
}
|
||||
// Guarded like every other write that lands after a hop: two picks in quick succession
|
||||
// suspend here together, and the slower one would otherwise land last.
|
||||
if (!ownership.stillHeldBy(token)) return@launch
|
||||
_state.value = JoinState.Ready(files)
|
||||
}
|
||||
}
|
||||
@@ -147,10 +321,13 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
// did answer would hand the space check a lower bound it would read as a total.
|
||||
totalBytes = InputQuery.total(inputs.map { it.sizeBytes }),
|
||||
)
|
||||
// Tapping Join claims the screen for this job, superseding any reattachment that has not
|
||||
// finished asking.
|
||||
val token = ownership.claim()
|
||||
activeWorkId = request.id
|
||||
workManager.enqueue(request)
|
||||
_state.value = JoinState.Joining(inputs)
|
||||
observe(request.id, inputs)
|
||||
observe(request.id, inputs, token = token)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,62 +335,38 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
* files, ready to join again. For one picked up by [reattach] there are no picked files —
|
||||
* what that job holds are URIs granted to a process that no longer exists — so it lands on
|
||||
* Idle rather than offering to re-join files nothing can open.
|
||||
* @param token the claim this observation belongs to. Nothing here can write until `collect`
|
||||
* has resumed with a `WorkInfo`, which is some time after the caller decided to observe, so
|
||||
* the claim is checked again at the last possible moment rather than trusted from then. See
|
||||
* [ScreenOwnership], and issue #49.
|
||||
*/
|
||||
private fun observe(id: UUID, inputs: List<InputFile>, cancelled: JoinState = JoinState.Ready(inputs)) {
|
||||
private fun observe(
|
||||
id: UUID,
|
||||
inputs: List<InputFile>,
|
||||
cancelled: JoinState = JoinState.Ready(inputs),
|
||||
token: Long,
|
||||
) {
|
||||
observer?.cancel()
|
||||
observer = viewModelScope.launch {
|
||||
workManager.getWorkInfoByIdFlow(id).collect { info ->
|
||||
if (info == null) return@collect
|
||||
_state.value = when (info.state) {
|
||||
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (info.runAttemptCount > 0) {
|
||||
JoinState.Waiting(inputs)
|
||||
} else {
|
||||
JoinState.Joining(inputs)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
||||
val strategy = info.outputData.getString(ConcatWorker.KEY_STRATEGY)
|
||||
?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
|
||||
if (path == null) {
|
||||
JoinState.Failed("Joining reported success but produced no file.")
|
||||
} else {
|
||||
val staged = File(path)
|
||||
// Take responsibility for the file at the same moment the state
|
||||
// starts referring to it, so the two cannot disagree.
|
||||
pendingStaged = staged
|
||||
JoinState.Joined(
|
||||
staged = staged,
|
||||
strategy = strategy,
|
||||
// A join enqueued before the worker reported these carries
|
||||
// neither, and the fallback is the format such a job really
|
||||
// used -- ConcatWorker.request has always defaulted to it, and
|
||||
// the join screen has never offered a choice.
|
||||
suggestedName = info.outputData
|
||||
.getString(ConcatWorker.KEY_SUGGESTED_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT),
|
||||
mimeType = info.outputData
|
||||
.getString(ConcatWorker.KEY_MIME_TYPE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ConcatWorker.DEFAULT_FORMAT.mimeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A worker that dies before it can report anything leaves no output data at
|
||||
// all, and an exception's message can be an empty string. Both would read as
|
||||
// a failure with nothing said, so blank falls back like missing does.
|
||||
WorkInfo.State.FAILED -> JoinState.Failed(
|
||||
info.outputData.getString(ConcatWorker.KEY_ERROR)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Joining failed.",
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> cancelled
|
||||
}
|
||||
// Ahead of the `when`, not merely ahead of the assignment: the SUCCEEDED branch
|
||||
// takes ownership of the staged file, and a superseded observation must not do
|
||||
// that either.
|
||||
if (!ownership.stillHeldBy(token)) return@collect
|
||||
val next = joinStateFrom(
|
||||
JoinUpdate(
|
||||
state = info.state,
|
||||
runAttemptCount = info.runAttemptCount,
|
||||
outputData = info.outputData,
|
||||
),
|
||||
inputs = inputs,
|
||||
cancelled = cancelled,
|
||||
)
|
||||
// Read off the result rather than assigned inside the mapping -- see the same
|
||||
// three lines in ConversionViewModel for why that is the better half of the swap.
|
||||
if (next is JoinState.Joined) pendingStaged = next.staged
|
||||
_state.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,29 +382,38 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
* join offered by reattachment was last seen during a tag query that may be hours old, and
|
||||
* `cacheDir` is reclaimed by the OS and swept by this app. Without it the file's absence
|
||||
* reached the screen as a raw ENOENT path.
|
||||
*
|
||||
* Reached from [JoinState.Joined] and again from a [JoinState.Failed] an earlier save left
|
||||
* carrying its file; [pendingSave] is what makes those the same call.
|
||||
*/
|
||||
fun save(destination: Uri) {
|
||||
val joined = _state.value as? JoinState.Joined ?: return
|
||||
if (!joined.staged.isFile) {
|
||||
val pending = _state.value.pendingSave() ?: return
|
||||
if (!pending.staged.isFile) {
|
||||
// No retry handle -- the file it would offer again is the one that has gone.
|
||||
_state.value = JoinState.Failed(STAGED_FILE_GONE_MESSAGE)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
publisher.publish(joined.staged, destination)
|
||||
joined.staged.delete()
|
||||
publisher.publish(pending.staged, destination)
|
||||
pending.staged.delete()
|
||||
}
|
||||
}.onSuccess {
|
||||
// publish() already deleted it; nothing left to clean up.
|
||||
pendingStaged = null
|
||||
_state.value = JoinState.Saved(joined.suggestedName)
|
||||
_state.value = JoinState.Saved(pending.suggestedName)
|
||||
}.onFailure { e ->
|
||||
// Deliberately NOT cleared -- see the same branch in ConversionViewModel.
|
||||
// A failed save can leave the staged file as the only copy of the work, so
|
||||
// it is left for a later reset() or for the sweep to collect once its age
|
||||
// makes it certain nobody is coming back for it.
|
||||
_state.value = JoinState.Failed(e.message ?: "Could not save the file.")
|
||||
//
|
||||
// `pending` travels on the state so the screen can offer the file again rather
|
||||
// than leaving "Start over" -- which deletes it -- as the only thing on offer.
|
||||
// Passing `pending` rather than rebuilding it is what keeps a retry that fails
|
||||
// again on a carrying Failed instead of a bare one.
|
||||
_state.value = JoinState.Failed(e.message ?: SAVE_FAILED_MESSAGE, pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,8 +423,16 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
*
|
||||
* Best effort, not a guarantee: the delete is cancelled with [viewModelScope] if the
|
||||
* Activity finishes first. `OutputPublisher.sweepStaging` is the backstop.
|
||||
*
|
||||
* It deletes from a [JoinState.Failed] carrying a [PendingSave] too, deliberately and for the
|
||||
* reason `ConversionViewModel.reset` writes out: the screen offers "Try saving again" above
|
||||
* this button, so deletion is what the user chose rather than all this state could do.
|
||||
*/
|
||||
fun reset() {
|
||||
// Start over is a claim like any other. The cancel below is a request honoured at the next
|
||||
// suspension point, so a collector already on its way to a write has nothing left to
|
||||
// honour it at; the claim is what stops that write landing on top of Idle.
|
||||
ownership.claim()
|
||||
observer?.cancel()
|
||||
observer = null
|
||||
activeWorkId = null
|
||||
|
||||
@@ -129,9 +129,25 @@ object ContainerCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.videoCodec == VideoCodec.NONE && spec.audioCodec == AudioCodec.NONE) {
|
||||
// Two faces of one rule: the output would carry no tracks at all.
|
||||
//
|
||||
// The first is visible in the spec alone — NONE on both axes. The second only emerges once
|
||||
// the spec meets the probe, because [CopyPlanner] drops a video track the *input* does not
|
||||
// have no matter which codec was named for it, so "H.265 + no audio" on an MP3 plans to
|
||||
// (Drop, Drop) exactly as "None + None" does. Asking the spec alone answered the first and
|
||||
// missed the second, and the miss was not cosmetic: `EditedMediaItem.Builder` refuses that
|
||||
// composition with IllegalStateException("Audio and video cannot both be removed"), on
|
||||
// Transformer's own thread, where the user would have seen a dead app rather than a reason.
|
||||
if (spec.audioCodec == AudioCodec.NONE && (spec.videoCodec == VideoCodec.NONE || !probe.hasVideo)) {
|
||||
return Validation.Invalid(
|
||||
"This would produce an empty file — keep at least one track.",
|
||||
if (spec.videoCodec == VideoCodec.NONE) {
|
||||
"This would produce an empty file — keep at least one track."
|
||||
} else {
|
||||
// Names both halves. "No video track" alone reads as though the video setting
|
||||
// were the only thing wrong, and the user would fix that and still be stuck.
|
||||
"This file has no video track, so turning the audio off too would produce an " +
|
||||
"empty file."
|
||||
},
|
||||
suggestions(
|
||||
// Ask for both tracks back, then let repair settle what this container and
|
||||
// this input can actually give.
|
||||
@@ -162,7 +178,12 @@ object ContainerCapabilities {
|
||||
if (!probe.hasVideo) {
|
||||
return Validation.Invalid(
|
||||
"This file has no video track to copy.",
|
||||
listOf(spec.copy(videoCodec = VideoCodec.NONE)),
|
||||
// Dropping the video is the right shape of answer, but it is only half of one:
|
||||
// `spec.copy(videoCodec = NONE)` is valid exactly when the audio axis already
|
||||
// happened to be fine, and refused otherwise — a Vorbis or PCM source into MP4,
|
||||
// an MP3 into WebM. Handing it to the shared path repairs both axes and drops
|
||||
// anything that still fails, so the chip cannot lead to a second error.
|
||||
suggestions(spec.copy(videoCodec = VideoCodec.NONE), probe, exclude = spec),
|
||||
)
|
||||
}
|
||||
val source = CodecNames.videoFromName(probe.videoCodec)
|
||||
@@ -284,8 +305,12 @@ object ContainerCapabilities {
|
||||
private fun repairVideo(spec: OutputSpec, probe: InputProbe): VideoCodec {
|
||||
val container = spec.container
|
||||
if (spec.videoCodec == VideoCodec.NONE || !container.canHoldVideo) return VideoCodec.NONE
|
||||
// There is no video track to make one out of, so naming a codec would be a suggestion
|
||||
// [CopyPlanner] drops on the floor. It also read as a non-sequitur: before this line, the
|
||||
// repair offered for an MP3 was "H.264", the first codec MP4 happens to encode.
|
||||
if (!probe.hasVideo) return VideoCodec.NONE
|
||||
|
||||
val source = CodecNames.videoFromName(probe.videoCodec).takeIf { probe.hasVideo }
|
||||
val source = CodecNames.videoFromName(probe.videoCodec)
|
||||
val copyable = source != null && accepts(container, source, CodecMode.COPY)
|
||||
|
||||
return when {
|
||||
|
||||
@@ -45,6 +45,31 @@ object TestTags {
|
||||
|
||||
const val SAVE_FILE: String = "action.saveFile"
|
||||
|
||||
/**
|
||||
* The retry a `Failed` offers after a save that threw, on both screens.
|
||||
*
|
||||
* Its own tag rather than [SAVE_FILE], because the two are different claims about the screen.
|
||||
* [SAVE_FILE] is the first attempt from a finished job; this one may appear only where a staged
|
||||
* file survived a failed save. Sharing a tag would collapse "a transcode failure offers nothing
|
||||
* to save" and "a failed save offers the file again" into one query, and that first assertion
|
||||
* is the one stopping a Save button from appearing where there is nothing to save.
|
||||
*/
|
||||
const val RETRY_SAVE: String = "action.retrySave"
|
||||
|
||||
/**
|
||||
* The adaptive shell around both screens -- `AppRoot`'s two layouts.
|
||||
*
|
||||
* Named because there is no other way to tell them apart from a test. Both render the same two
|
||||
* destinations with the same labels and the same selection state, so every assertion that could
|
||||
* be written without these tags is satisfied by either layout, and transposing the two bodies
|
||||
* passed the whole suite. Exactly one of the two exists at a time, which is what makes
|
||||
* `assertExists` / `assertDoesNotExist` on this pair a statement about the width class.
|
||||
*/
|
||||
object Shell {
|
||||
const val NAVIGATION_RAIL: String = "shell.navigationRail"
|
||||
const val NAVIGATION_BAR: String = "shell.navigationBar"
|
||||
}
|
||||
|
||||
/** `ConverterScreen`. */
|
||||
object Converter {
|
||||
const val CHOOSE_FILE: String = "converter.chooseFile"
|
||||
|
||||
@@ -25,8 +25,18 @@ private val LightColorScheme = lightColorScheme(
|
||||
* Material 3 theme.
|
||||
*
|
||||
* Dynamic color (Material You) needs API 31+; minSdk is 33, so it is available
|
||||
* unconditionally and no version guard is required. It stays switchable so users can
|
||||
* opt back to the brand palette.
|
||||
* unconditionally and no version guard is required.
|
||||
*
|
||||
* [dynamicColor] has no caller. `MainActivity` is the single call site and takes the
|
||||
* default, so the parameter is always `true`, the two dynamic branches always win, and
|
||||
* [DarkColorScheme] and [LightColorScheme] are dead: nothing in the app can opt back to the
|
||||
* brand palette. `ThemeColorSchemeTest` reaches those two branches only by passing
|
||||
* [dynamicColor] explicitly -- a test doing it, not a feature.
|
||||
*
|
||||
* That is known rather than an oversight. #68 holds the choice between adding a switch,
|
||||
* deleting the dead branches together with the template palette, and replacing that palette
|
||||
* first; it is undecided, so nothing here should be read as a promise that any of them
|
||||
* happens.
|
||||
*/
|
||||
@Composable
|
||||
fun LibreMediaConverterTheme(
|
||||
|
||||
@@ -15,7 +15,6 @@ import kotlinx.coroutines.CancellationException
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.InputQuery
|
||||
import org.libremediaconverter.convert.StagingNames
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
|
||||
/**
|
||||
@@ -37,9 +36,9 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val uris = inputData.getStringArray(KEY_INPUT_URIS)?.map(Uri::parse)
|
||||
?: return Result.failure(workDataOf(KEY_ERROR to "No input files."))
|
||||
?: return Result.failure(workDataOf(KEY_ERROR to NO_INPUTS_MESSAGE))
|
||||
if (uris.size < 2) {
|
||||
return Result.failure(workDataOf(KEY_ERROR to "Pick at least two files to join."))
|
||||
return Result.failure(workDataOf(KEY_ERROR to TOO_FEW_INPUTS_MESSAGE))
|
||||
}
|
||||
// Absent, not zero, when the picker could not size every input -- see the same read in
|
||||
// ConversionWorker and InputQuery for why the two are no longer one number.
|
||||
@@ -77,7 +76,7 @@ class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker
|
||||
),
|
||||
)
|
||||
|
||||
val result = ConcatEngine(applicationContext).join(uris, staged, format)
|
||||
val result = ConversionDependencies.concat(applicationContext).join(uris, staged, format)
|
||||
Result.success(
|
||||
workDataOf(
|
||||
KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
@@ -107,7 +106,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 +136,44 @@ 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.
|
||||
*/
|
||||
/**
|
||||
* A job carrying no input array at all -- a downgrade, or a queue entry from a build that
|
||||
* spelled the key differently.
|
||||
*
|
||||
* A constant rather than the literal it was, for the convention #158 established: a message
|
||||
* the user can see is named once, so a test asserts the same string the worker writes
|
||||
* rather than a copy of it that can drift.
|
||||
*/
|
||||
const val NO_INPUTS_MESSAGE: String = "No input files."
|
||||
|
||||
const val TOO_FEW_INPUTS_MESSAGE: String = "Pick at least two files to join."
|
||||
|
||||
/**
|
||||
* The last resort when a join fails and the exception says nothing.
|
||||
*
|
||||
* Shared with `JoinViewModel`, whose `FAILED` arm falls back to the same sentence when the
|
||||
* output `Data` carries no error at all — a worker killed before it could write one. The two
|
||||
* are a chain rather than a coincidence: this is what the worker puts *in* `KEY_ERROR`, and
|
||||
* that is what the ViewModel says when `KEY_ERROR` never arrived. The user cannot tell the
|
||||
* two apart and should not have to, so they are one sentence.
|
||||
*
|
||||
* The `Log.e` above deliberately keeps its own literal. A log line has a different audience
|
||||
* and carries the exception with it; coupling it to the user-facing wording would mean
|
||||
* rewording the screen to change a log.
|
||||
*/
|
||||
const val GENERIC_FAILURE_MESSAGE: String = "Joining failed."
|
||||
|
||||
const val KEY_INPUT_URIS = "input_uris"
|
||||
const val KEY_TOTAL_BYTES = "total_bytes"
|
||||
const val KEY_FORMAT = "format"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package org.libremediaconverter
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* Which navigation affordance the shell actually renders, and which screen it actually shows.
|
||||
*
|
||||
* Assertion gaps rather than coverage gaps, both of them, and that is why they lasted.
|
||||
* `AppRootRestorationTest` already drives `AppRoot` at `Compact` and `Expanded`, so JaCoCo is green
|
||||
* on `useRail` -- but it asserts only that the *selected tab* survives recreation, through a stub
|
||||
* `content` composable. Nothing anywhere queried for a rail or a bar, and nothing rendered the real
|
||||
* screens. Two consequences, both measured before this file existed:
|
||||
*
|
||||
* - **Transposing the `NavigationRail` and `NavigationBar` bodies passed the entire suite.**
|
||||
* - **Transposing `Content`'s two arms passed it too** -- a tablet showing the phone chrome, or the
|
||||
* Convert tab opening the Join screen, and 546 tests with nothing to say about either.
|
||||
*
|
||||
* `AppRoot`'s own KDoc is why this matters more than it looks: from targetSdk 37 the app is resized
|
||||
* and rotated whether or not it is ready, so the width class is not a preference, it is whatever
|
||||
* the system hands over.
|
||||
*
|
||||
* ## Two things this needed that the rest of the suite does not
|
||||
*
|
||||
* **`createAndroidComposeRule`, not `createComposeRule`.** Rendering `AppRoot` with its *default*
|
||||
* content reaches `ConverterScreen`'s `viewModel = viewModel()`, which needs a
|
||||
* `ViewModelStoreOwner`; the plain rule supplies none. It works because both ViewModels are
|
||||
* `@JvmOverloads constructor(app: Application, …)`, so `AndroidViewModelFactory` can build them,
|
||||
* and because `app/build.gradle.kts` already puts `ui-test-manifest`'s `ComponentActivity` in the
|
||||
* merged manifest the unit tests build against -- which that file says in terms.
|
||||
*
|
||||
* **Tags on the two bars.** They are in `TestTags`, applied inside `main`, for the reason that
|
||||
* file's KDoc gives: a tag the test hands down proves only that the test set it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AdaptiveShellTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
// The real screens are composed here, so their ViewModels are real too. Neither test is
|
||||
// about probing or publishing; left alone they would reach the FFprobe loader and this
|
||||
// machine's codec list, and decide things no assertion mentions.
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone gets the bottom bar and a tablet gets the rail`() {
|
||||
setShell(WindowWidthSizeClass.Compact)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expanded window gets the rail`() {
|
||||
setShell(WindowWidthSizeClass.Expanded)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The width class no test had ever passed.
|
||||
*
|
||||
* `useRail` is `!= Compact`, so Medium takes the rail with Expanded. Narrowing it to
|
||||
* `== Expanded` is a one-character change that breaks every tablet and unfolded foldable and
|
||||
* nothing else -- and until this test, nothing in either source set used `Medium` at all.
|
||||
*/
|
||||
@Test
|
||||
fun `a medium window is a rail window, not a phone`() {
|
||||
setShell(WindowWidthSizeClass.Medium)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapping every other test stubs out: which screen each destination actually opens.
|
||||
*
|
||||
* Matched on each screen's own "choose a file" affordance rather than on a title, because those
|
||||
* tags are applied by the screens themselves -- so this fails if the destinations are
|
||||
* transposed, and it fails for the right reason.
|
||||
*/
|
||||
@Test
|
||||
fun `Convert opens the converter and Join opens the join screen`() {
|
||||
setShell(WindowWidthSizeClass.Compact)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).assertDoesNotExist()
|
||||
|
||||
composeRule.onNodeWithText(Destination.JOIN.label).performClick()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** [AppRoot] with its real content, which is the half nothing else composes. */
|
||||
private fun setShell(width: WindowWidthSizeClass) {
|
||||
composeRule.setContent { AppRoot(width) }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package org.libremediaconverter
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -10,7 +10,6 @@ import org.libremediaconverter.convert.StagingSweep
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* That process start actually sweeps.
|
||||
@@ -23,8 +22,19 @@ import java.util.concurrent.TimeUnit
|
||||
* output ever became a `Converted` state, a `reset()` whose delete was cancelled with the Activity.
|
||||
*
|
||||
* `onCreate()` is called again rather than a second Application being built: it is what the
|
||||
* framework calls at process start, the scope it launches on is already there, and the first test
|
||||
* below is what pins that the framework calls it on *this* class.
|
||||
* framework calls at process start, and the scope it launches on is already there.
|
||||
*
|
||||
* **What this class stopped covering in #159, deliberately.** It used to open by asserting that
|
||||
* `RuntimeEnvironment.getApplication()` is a [LibreMediaConverterApp] — that the manifest's
|
||||
* `android:name` points here, so the sweep is code that actually runs. That assertion cannot exist
|
||||
* on the JVM any more: `robolectric.properties` now names [TestLibreMediaConverterApp] for the
|
||||
* whole suite, and an `application=` override replaces the manifest rather than being checked
|
||||
* against it — `applicationInfo.className` reports the override too, measured. So the manifest is
|
||||
* not merely unasserted here, it is unobservable from this source set, and a rewritten version of
|
||||
* that test would have asserted the override against itself. **The manifest link is a device-only
|
||||
* guarantee now**, and it was traded knowingly for the race that override fixes. The cast in
|
||||
* [setUp] still fails if [TestLibreMediaConverterApp] stops extending the real class, which is a
|
||||
* smaller claim than the one withdrawn.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AppStartSweepTest {
|
||||
@@ -34,17 +44,35 @@ class AppStartSweepTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// The cast is an assertion in itself: Robolectric builds the Application named in the
|
||||
// merged manifest, so this fails if `android:name` ever stops pointing here -- in which
|
||||
// case the sweep below would be perfectly correct code that never runs.
|
||||
app = RuntimeEnvironment.getApplication() as LibreMediaConverterApp
|
||||
stagingDir = File(app.cacheDir, "conversions").apply { mkdirs() }
|
||||
stagingDir.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The property the whole substitution exists for, asserted directly rather than waited on.
|
||||
*
|
||||
* #159 is not "the sweep is slow", it is "the sweep is still running while some later test
|
||||
* reads the directory". [TestLibreMediaConverterApp] answers that by finishing the sweep before
|
||||
* `onCreate()` returns, and this is the only place that claim is checked -- every other test in
|
||||
* the suite benefits from it silently and would go back to racing without saying why.
|
||||
*
|
||||
* Deterministic in the direction that matters: `Dispatchers.Unconfined` runs a `launch` whose
|
||||
* body never suspends to completion inline, so this cannot flake green-to-red. Putting the test
|
||||
* app back on `Dispatchers.IO` makes it a race that the assertion loses essentially every time,
|
||||
* which is what a six-run suite comparison could not show -- at the rate #159 was observed at,
|
||||
* a clean six-run arm is a coin flip.
|
||||
*/
|
||||
@Test
|
||||
fun `the application the manifest starts is the one that sweeps`() {
|
||||
assertEquals(LibreMediaConverterApp::class.java, RuntimeEnvironment.getApplication().javaClass)
|
||||
fun `the sweep is finished before onCreate returns`() {
|
||||
app.onCreate()
|
||||
|
||||
val sweep = app.startupSweep
|
||||
assertNotNull("onCreate() started no sweep", sweep)
|
||||
assertTrue(
|
||||
"the JVM suite's sweep outlived onCreate(), so it is in flight during test bodies again",
|
||||
sweep?.isCompleted == true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -64,35 +92,23 @@ class AppStartSweepTest {
|
||||
|
||||
app.onCreate()
|
||||
|
||||
awaitGone(abandoned)
|
||||
// Joined rather than polled. `onCreate` publishes the sweep it started, so this waits for
|
||||
// that exact sweep -- where a timed poll could not tell "swept" from "not started yet", and
|
||||
// answered the second case by failing after ten seconds.
|
||||
val sweep = app.startupSweep
|
||||
assertNotNull("onCreate() started no sweep to wait for", sweep)
|
||||
runBlocking { sweep?.join() }
|
||||
|
||||
assertTrue("process start left ${abandoned.name} in staging; nothing swept it", !abandoned.exists())
|
||||
// The other half, and the one that says the sweep is a sweep rather than a
|
||||
// `clearStaging()`: the directory is shared by the convert tab, the join tab and
|
||||
// ConcatEngine's list file, so deleting everything could take a file from a running job.
|
||||
assertTrue("a file written moments ago belongs to a live job", live.exists())
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for [file] to be deleted.
|
||||
*
|
||||
* The sweep runs on `Dispatchers.IO`, deliberately: it lists a directory and stats every entry
|
||||
* on the path that decides how long the launcher icon stays unresponsive. So there is nothing
|
||||
* to join, and the wait is a bounded poll — long enough for a directory listing, short enough
|
||||
* that a sweep which never happens fails rather than hangs.
|
||||
*/
|
||||
private fun awaitGone(file: File) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(AWAIT_TIMEOUT_SECONDS)
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (!file.exists()) return
|
||||
Thread.sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
fail("process start left ${file.name} in staging; nothing swept it")
|
||||
}
|
||||
|
||||
private fun stagedFile(name: String): File = File(stagingDir, name).apply { writeBytes(ByteArray(4096)) }
|
||||
|
||||
private companion object {
|
||||
const val ONE_MINUTE_MS = 60L * 1000
|
||||
const val AWAIT_TIMEOUT_SECONDS = 10L
|
||||
const val POLL_INTERVAL_MS = 5L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.libremediaconverter
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
/**
|
||||
* The [LibreMediaConverterApp] the JVM suite runs, differing from it in exactly one thing: the
|
||||
* startup sweep runs inline on the thread that builds the Application instead of on
|
||||
* `Dispatchers.Unconfined`.
|
||||
*
|
||||
* **This is #159.** Robolectric builds an `Application` per test class that asks for one, and each
|
||||
* one launches a sweep over the shared `<cacheDir>/conversions/`. Nothing joins them, so a test
|
||||
* asserting about a staged file is racing however many sweeps the classes before it left in
|
||||
* flight — `OutputPublisherStagingTest` being the one that lost, at roughly one local run in six
|
||||
* once wave 4 added ten more Robolectric classes. Making the sweep finish before `onCreate()`
|
||||
* returns removes the race for every test at once rather than asking each to opt in; 27 of the
|
||||
* suite's 58 Robolectric classes touch that directory, so opting in was not a real option.
|
||||
*
|
||||
* `Dispatchers.Unconfined` is what makes it inline: `sweepStaging()` is a plain function, so an
|
||||
* `Unconfined` `launch` runs it to completion before returning. The `SupervisorJob` is kept so this
|
||||
* differs from production in the dispatcher alone — a sweep that throws is logged and swallowed
|
||||
* here exactly as it is there, rather than taking Application construction down with it and failing
|
||||
* every test in the class for an unrelated reason.
|
||||
*/
|
||||
class TestLibreMediaConverterApp : LibreMediaConverterApp() {
|
||||
override val sweepScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.libremediaconverter.ci
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* That a hung unit-test run still ends by itself, and still says why.
|
||||
*
|
||||
* `:app:testDebugUnitTest` had no timeout of any kind until #125 was filed. That ticket is a real
|
||||
* Java-level deadlock between Room's `TransactionExecutor` and WorkManager's `SerialExecutorImpl`,
|
||||
* reached through the WorkInfo flow the ViewModel collects, and one local run sat in it for 47
|
||||
* minutes. Nothing inside the suite could break it: the deadlock is monitor contention, which is
|
||||
* not interruptible, so it runs until something outside the JVM gives up.
|
||||
*
|
||||
* Two numbers in `app/build.gradle.kts` are what bound it now, and neither compiles, so nothing
|
||||
* else would notice their removal:
|
||||
*
|
||||
* - `timeout.set(...)` on every `Test` task, which stops the forked test JVM.
|
||||
* - the watchdog's `dumpAfterNanos`, which jstacks that JVM *before* the timeout kills it.
|
||||
*
|
||||
* The second is the one worth guarding hardest, and the one that most looks like stray config.
|
||||
* Gradle's timeout kills without a thread dump, and the jstack -- with its "Found one Java-level
|
||||
* deadlock" section naming both monitors -- is the only reason #125 could be described at all.
|
||||
* The ordering between the two numbers is what makes it work: dump first, kill second. Reverse
|
||||
* them, or delete the watchdog, and the suite still stops hanging but every hang from then on
|
||||
* reports as a bare "Timeout has been exceeded" with nothing to read. Measured against a probe
|
||||
* that hung one test: no test XML was written for the class that hung, so the hanging test itself
|
||||
* gets no attribution from the report at all.
|
||||
*
|
||||
* The range on the timeout is not decoration either, and it is the half a future edit is most
|
||||
* likely to get wrong. Below it, a healthy-but-slow runner trips the bound and a real signal
|
||||
* becomes noise people learn to re-run through; above it, CI's 30-minute job cap fires first and
|
||||
* the bound never gets to say anything.
|
||||
*
|
||||
* `ReleasePermissionTest` is the precedent and its caveat applies here too. This asserts the two
|
||||
* numbers are present, sanely sized and correctly ordered. It cannot assert that the timeout
|
||||
* fires -- that needs a hang, which is what the whole change exists to prevent. Refs #125.
|
||||
*/
|
||||
class HangBoundTest {
|
||||
|
||||
@Test
|
||||
fun `every Test task is bounded, and bounded between the slow runner and the job cap`() {
|
||||
assertTrue(
|
||||
"app/build.gradle.kts sets its Test task timeout to ${timeoutMinutes}m, which is " +
|
||||
"outside $SANE_MINUTES. Under that range a slow CI runner trips a bound meant for " +
|
||||
"deadlocks -- the slowest observed passing run of the whole invocation was 90s. " +
|
||||
"Over it, the Unit tests job's own 30-minute cap kills the job first and the " +
|
||||
"timeout never reports. `null` means the line is gone or the block was rewritten, " +
|
||||
"and without it #125's deadlock has nothing to stop it: monitor contention breaks " +
|
||||
"no interrupt, so it runs until CI gives up and reports a timeout with no cause.",
|
||||
timeoutMinutes in SANE_MINUTES,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the thread dump is taken before the timeout kills the JVM it would dump`() {
|
||||
assertTrue(
|
||||
"app/build.gradle.kts takes its hang thread dump after ${dumpAfterMinutes}m but times " +
|
||||
"the task out at ${timeoutMinutes}m, so the JVM is already dead when jstack runs " +
|
||||
"and every future hang reports as a bare `Timeout has been exceeded`. The dump " +
|
||||
"has to come first -- it is the only attribution a hanging test gets, since the " +
|
||||
"test XML never names it.",
|
||||
(dumpAfterMinutes ?: 0) < (timeoutMinutes ?: 0),
|
||||
)
|
||||
}
|
||||
|
||||
/** Minutes given to a whole `Test` task before Gradle stops the forked JVM. */
|
||||
private val timeoutMinutes: Int?
|
||||
get() = minutesIn("""timeout\.set\(Duration\.ofMinutes\((\d+)\)\)""")
|
||||
|
||||
/** Minutes the watchdog waits before jstacking the forked JVM. */
|
||||
private val dumpAfterMinutes: Int?
|
||||
get() = minutesIn("""val dumpAfterNanos = Duration\.ofMinutes\((\d+)\)""")
|
||||
|
||||
/**
|
||||
* Read out of the build script rather than from a model: the numbers live in a Kotlin DSL block
|
||||
* that no unit test can instantiate, and a scan that reports `null` when the shape changes is a
|
||||
* better trade than not checking them at all.
|
||||
*/
|
||||
private fun minutesIn(pattern: String): Int? =
|
||||
Regex(pattern).find(buildScript.readText())?.groupValues?.get(1)?.toInt()
|
||||
|
||||
/**
|
||||
* Found by walking up rather than by a fixed relative path: Gradle's working directory for the
|
||||
* unit tests is the module, but that is a default rather than a promise.
|
||||
*/
|
||||
private val buildScript: File
|
||||
get() = generateSequence(File(".").absoluteFile) { it.parentFile }
|
||||
.map { File(it, "app/build.gradle.kts") }
|
||||
.firstOrNull { it.isFile }
|
||||
?: error("could not find app/build.gradle.kts above ${File(".").absolutePath}")
|
||||
|
||||
private companion object {
|
||||
/** Above the slowest observed passing run, below the Unit tests job's `timeout-minutes`. */
|
||||
val SANE_MINUTES = 3..29
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.libremediaconverter.ci
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* That the release job still holds the one permission it needs to publish.
|
||||
*
|
||||
* `build.yml`'s `release` job declares `contents: write`, and nothing was checking it. Deleting
|
||||
* those two lines leaves actionlint clean and CodeQL silent — a *narrower* permission is not an
|
||||
* alert — and the job is `if: startsWith(github.ref, 'refs/tags/v')`, so no pull request and no
|
||||
* merge to `main` can exercise it. Measured: with the declaration removed, every gating check
|
||||
* still passes. The first thing that would notice is a release failing to publish, at the moment
|
||||
* someone is trying to cut one.
|
||||
*
|
||||
* The deletion also looks like tidying. A top-level `permissions: contents: read` now sits
|
||||
* directly above it, so a reader could reasonably take the job-level block for a duplicate. It is
|
||||
* an override, not a duplicate, and a comment saying so is not a check.
|
||||
*
|
||||
* `BackupExclusionsTest` is the precedent: a file that is configuration rather than code, load
|
||||
* bearing, and unguarded because nothing compiles it.
|
||||
*
|
||||
* **What this pins, and what it does not.** It asserts the declaration exists in the `release`
|
||||
* job's block. It cannot assert that a release actually publishes — that needs a tag push, which
|
||||
* is the thing no PR can do. So this is a tripwire against silent removal, not proof the release
|
||||
* path works.
|
||||
*/
|
||||
class ReleasePermissionTest {
|
||||
|
||||
@Test
|
||||
fun `the release job declares the write permission it needs to publish`() {
|
||||
val release = jobBlock("release")
|
||||
assertTrue(
|
||||
"build.yml's `release` job no longer declares `contents: write`. It is the only " +
|
||||
"permission that lets the job create a release, the top-level block above it is " +
|
||||
"`contents: read`, and nothing else in CI would catch this until a tag failed to " +
|
||||
"publish. If the release moved elsewhere, delete this test deliberately.",
|
||||
release.any { it.trimStart().startsWith("contents: write") },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines of one top-level job, from its ` <name>:` header to the next job at that indent.
|
||||
*
|
||||
* Line-based rather than parsed: the module has no YAML dependency, and adding one to read two
|
||||
* lines would be a worse trade than a scan that fails loudly when the shape changes.
|
||||
*/
|
||||
private fun jobBlock(name: String): List<String> {
|
||||
val lines = workflow.readLines()
|
||||
val start = lines.indexOfFirst { it == " $name:" }
|
||||
check(start >= 0) { "no ` $name:` job in ${workflow.path} — has the file been restructured?" }
|
||||
val rest = lines.drop(start + 1)
|
||||
val end = rest.indexOfFirst { it.matches(Regex("^ {2}[A-Za-z0-9_-]+:.*")) }
|
||||
return if (end < 0) rest else rest.take(end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Found by walking up rather than by a fixed relative path: Gradle's working directory for the
|
||||
* unit tests is the module, but that is a default rather than a promise.
|
||||
*/
|
||||
private val workflow: File
|
||||
get() = generateSequence(File(".").absoluteFile) { it.parentFile }
|
||||
.map { File(it, ".github/workflows/build.yml") }
|
||||
.firstOrNull { it.isFile }
|
||||
?: error("could not find .github/workflows/build.yml above ${File(".").absolutePath}")
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.libremediaconverter.codec
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The rules `AndroidDeviceCodecs.probe()` applies to the platform's codec list.
|
||||
*
|
||||
* ## Why this is not a third run of the #86/#133 spike
|
||||
*
|
||||
* #86 closed `probe()` as device-bound. #133 re-opened the question with
|
||||
* `ShadowMediaCodecList` in hand and closed it again, for a reason that was right about what it
|
||||
* was answering: `MediaCodecInfoBuilder` "has no `setIsAlias` and no `setCanonicalName`, so the
|
||||
* alias skip and the canonical-name dedup — the two things the class's KDoc calls out as easy to
|
||||
* get wrong — are not reachable through it."
|
||||
*
|
||||
* **That objection is about the shadow.** It does not apply to a function that takes its own entry
|
||||
* type, which is what `capabilitiesFrom` now does. The half #133 named as unreachable is the half
|
||||
* this file spends most of its cases on.
|
||||
*
|
||||
* ## What made the seam worth cutting, which is not coverage
|
||||
*
|
||||
* The `runCatching` fallback logged *"assuming permissive"* and returned empty sets — and empty
|
||||
* sets are **restrictive**: `"video/avc" in emptySet()` is `false`, so `canEncode` and `canDecode`
|
||||
* both answer no and every job routes to FFmpeg. The code was right and the message described the
|
||||
* opposite of it. That is pinned below, so whichever reading a future change takes, it has to say
|
||||
* so out loud.
|
||||
*
|
||||
* Robolectric only because `capabilitiesFrom` logs what it found; the rules themselves are pure.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CodecEnumerationTest {
|
||||
|
||||
/**
|
||||
* The alias skip, in the one arrangement where it is observable — and finding that arrangement
|
||||
* is the whole of this test.
|
||||
*
|
||||
* A first attempt listed the alias *after* the codec it aliases and passed with the skip
|
||||
* deleted, because `canonicalName` is shared and the dedup below catches the second entry
|
||||
* either way. The two rules overlap, so a fixture that does not separate them tests neither.
|
||||
*
|
||||
* What separates them is **order**. `MediaCodecInfo.getCanonicalName()` on an alias returns the
|
||||
* underlying codec's name, so an alias arriving first claims that name in `seen` and has its
|
||||
* own `supportedTypes` credited — and then the real codec is dropped by the dedup. Without the
|
||||
* alias skip the device is described by whichever entry the platform happened to list first.
|
||||
*
|
||||
* That also says what the rule is worth. With a `Set` accumulator, an alias declaring the same
|
||||
* types as its codec changes nothing whichever order they arrive in; the skip earns its place
|
||||
* only when the two disagree, which is exactly when believing the wrong one matters.
|
||||
*/
|
||||
@Test
|
||||
fun `an alias listed before the codec it aliases does not describe the device`() {
|
||||
val codecs = capabilities(
|
||||
entry("c2.qti.avc.encoder", encoder = true, types = listOf(HEVC), alias = true),
|
||||
entry("c2.qti.avc.encoder", encoder = true, types = listOf(AVC)),
|
||||
)
|
||||
|
||||
assertTrue("the real codec's types are the device's", codecs.canEncode(VideoCodec.H264))
|
||||
assertFalse(
|
||||
"an alias must not be credited with types the codec it aliases never claimed",
|
||||
codecs.canEncode(VideoCodec.H265),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two entries sharing a canonical name are read once`() {
|
||||
val codecs = capabilities(
|
||||
entry("c2.qti.avc.encoder", encoder = true, types = listOf(AVC)),
|
||||
entry("c2.qti.avc.encoder", encoder = true, types = listOf(HEVC)),
|
||||
)
|
||||
|
||||
assertEquals(setOf(AVC), codecs.hardwareEncoders())
|
||||
}
|
||||
|
||||
/**
|
||||
* Both halves of the hardware predicate, one arm at a time.
|
||||
*
|
||||
* A vendor may declare a codec hardware-accelerated *and* software-only; the class KDoc is
|
||||
* explicit that the first flag "cannot be tested for correctness", so the second is what stops
|
||||
* a mislabelled software encoder being treated as the fast path.
|
||||
*/
|
||||
@Test
|
||||
fun `an encoder counts as hardware only when it is accelerated and not software-only`() {
|
||||
assertEquals(
|
||||
setOf(AVC),
|
||||
capabilities(entry("hw", encoder = true, accelerated = true, types = listOf(AVC))).hardwareEncoders(),
|
||||
)
|
||||
assertEquals(
|
||||
emptySet<String>(),
|
||||
capabilities(entry("sw", encoder = true, accelerated = false, types = listOf(AVC))).hardwareEncoders(),
|
||||
)
|
||||
assertEquals(
|
||||
"a codec claiming both must not be trusted as hardware",
|
||||
emptySet<String>(),
|
||||
capabilities(
|
||||
entry("both", encoder = true, accelerated = true, softwareOnly = true, types = listOf(AVC)),
|
||||
).hardwareEncoders(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoders are collected regardless of the hardware flags, and that asymmetry is the design.
|
||||
*
|
||||
* `canDecode` asks whether the platform can read the input at all — a software decoder answers
|
||||
* that as well as a hardware one. `canEncode` asks whether the *fast path* exists, which is a
|
||||
* different question and why only encoders are filtered.
|
||||
*/
|
||||
@Test
|
||||
fun `a software decoder still counts as something the platform can read`() {
|
||||
val codecs = capabilities(
|
||||
entry(
|
||||
"c2.android.avc.decoder",
|
||||
encoder = false,
|
||||
accelerated = false,
|
||||
softwareOnly = true,
|
||||
types = listOf(AVC),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(codecs.canDecode("h264"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio types are ignored on both sides`() {
|
||||
val codecs = capabilities(
|
||||
entry("aac.encoder", encoder = true, accelerated = true, types = listOf("audio/mp4a-latm")),
|
||||
entry("aac.decoder", encoder = false, types = listOf("audio/mp4a-latm")),
|
||||
)
|
||||
|
||||
assertEquals(emptySet<String>(), codecs.hardwareEncoders())
|
||||
// Not "the platform cannot decode AAC" -- `canDecode` is asked about *video* codec names,
|
||||
// and an unknown name is answered permissively. The point is that nothing audio reached
|
||||
// either set.
|
||||
assertTrue("an unknown name stays permissive", codecs.canDecode("something-nobody-named"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure fallback, pinned as the restrictive answer it actually is.
|
||||
*
|
||||
* #194 decided this rather than assuming it: the code stays, the message changes. If a later
|
||||
* change wants the permissive reading its old log line described, this test is what makes that
|
||||
* a decision instead of a drift.
|
||||
*/
|
||||
@Test
|
||||
fun `an enumeration that fails sends every job to FFmpeg`() {
|
||||
val codecs = AndroidDeviceCodecs.capabilitiesFrom { error("MediaCodecList exploded") }
|
||||
|
||||
assertFalse("a failed enumeration must not claim a hardware encoder", codecs.canEncode(VideoCodec.H264))
|
||||
assertFalse(codecs.canDecode("h264"))
|
||||
assertEquals(emptySet<String>(), codecs.hardwareEncoders())
|
||||
}
|
||||
|
||||
/**
|
||||
* A list that throws partway keeps what it already read.
|
||||
*
|
||||
* This predates the seam — `runCatching` has always wrapped the iteration rather than a list
|
||||
* built before it — and it is asserted here because the seam is where it could quietly have
|
||||
* been lost. Taking a `List` instead of a `Sequence` would move the throw outside the loop and
|
||||
* turn this partial answer into an empty one, with no test to notice.
|
||||
*/
|
||||
@Test
|
||||
fun `codecs read before a failing entry are kept`() {
|
||||
val codecs = AndroidDeviceCodecs.capabilitiesFrom {
|
||||
sequence {
|
||||
yield(entry("good", encoder = true, accelerated = true, types = listOf(AVC)))
|
||||
error("the sixth codec's properties threw")
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(setOf(AVC), codecs.hardwareEncoders())
|
||||
}
|
||||
|
||||
private fun capabilities(vararg entries: AndroidDeviceCodecs.Companion.CodecEntry) =
|
||||
AndroidDeviceCodecs.capabilitiesFrom { entries.asSequence() }
|
||||
|
||||
private fun entry(
|
||||
canonicalName: String,
|
||||
encoder: Boolean,
|
||||
accelerated: Boolean = true,
|
||||
softwareOnly: Boolean = false,
|
||||
alias: Boolean = false,
|
||||
types: List<String>,
|
||||
) = AndroidDeviceCodecs.Companion.CodecEntry(
|
||||
canonicalName = canonicalName,
|
||||
isAlias = alias,
|
||||
isEncoder = encoder,
|
||||
isHardwareAccelerated = accelerated,
|
||||
isSoftwareOnly = softwareOnly,
|
||||
supportedTypes = types,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val AVC = "video/avc"
|
||||
const val HEVC = "video/hevc"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.model.CodecNames
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
|
||||
/**
|
||||
@@ -133,6 +134,38 @@ class CodecVocabularyTest {
|
||||
* landed, a device with no HEVC decoder answered true for `x265` and Media3 was handed a job it
|
||||
* could not do; now the router sends it to FFmpeg without spending the attempt.
|
||||
*/
|
||||
/**
|
||||
* The sentinel is not just another unknown name, and the difference is the whole guard.
|
||||
*
|
||||
* `canDecode` ends `?: true` -- a name neither table knows keeps the permissive answer, because
|
||||
* the app would rather try than refuse a file it might handle. `InputProbe.UNPARSEABLE` has to
|
||||
* be the exception: the platform has *already* failed to parse the input, so there is nothing
|
||||
* for a decoder to be permissive about, and waving it through spends a Media3 attempt on a job
|
||||
* that cannot start.
|
||||
*
|
||||
* The `cinepak` line is what makes the sentinel line mean something. Without it, deleting the
|
||||
* early return leaves this test green -- both names would fall through to the same `?: true`.
|
||||
* The pair is the assertion.
|
||||
*
|
||||
* `DeviceCodecs.PERMISSIVE` carries the same rule and `ConversionRouterTest` pins its routing
|
||||
* consequence. This is the implementation that runs on a device.
|
||||
*/
|
||||
@Test
|
||||
fun `the unparseable sentinel is refused even where an unknown name is waved through`() {
|
||||
val everything = AndroidDeviceCodecs.forTesting(
|
||||
encoders = emptySet(),
|
||||
decoders = setOf("video/avc", "video/hevc"),
|
||||
)
|
||||
assertFalse(
|
||||
"the platform could not parse this input, so there is nothing to decode with",
|
||||
everything.canDecode(InputProbe.UNPARSEABLE),
|
||||
)
|
||||
assertTrue(
|
||||
"a merely unknown name still keeps the permissive answer",
|
||||
everything.canDecode("cinepak"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device without the decoder now says so for the aliases it used to wave through`() {
|
||||
val hevcOnly = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/hevc"))
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinState
|
||||
import org.libremediaconverter.join.JoinViewModel
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.libremediaconverter.work.JobTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* That `cancel()` cancels the job, on both screens.
|
||||
*
|
||||
* ## Why this was missing, which is the interesting part
|
||||
*
|
||||
* Both `cancel()` methods are one line — `activeWorkId?.let(workManager::cancelWorkById)` — and
|
||||
* **JaCoCo reports every line of both as covered**. `SettingsEditsTest`'s
|
||||
* `cancelling with no active job does nothing rather than throwing` runs the method, and its own
|
||||
* comment names which half it drives: "`activeWorkId?.let(...)` -- the null side". The other side
|
||||
* had never been entered, and `JoinViewModel.cancel()` had no test at all.
|
||||
*
|
||||
* So no line-level coverage filter could see this. What surfaces it is a method-level read —
|
||||
* `mi=11, ci=7, mb=1, cb=1` on both — a covered method with an arm nothing takes. That is the
|
||||
* second of the two filters #194 records, and this is the gap that argued for it.
|
||||
*
|
||||
* The affordance tests are not this. `ConverterStateAffordancesTest` and `JoinStateAffordancesTest`
|
||||
* click `TestTags.CANCEL` and assert the *action* fires into a stub; `ScreenWiringTest` asserts the
|
||||
* action calls `viewModel.cancel()`. Both halves were pinned and the join between them was not, so
|
||||
* nothing in 584 tests connected the button to WorkManager.
|
||||
*
|
||||
* ## Why the job is enqueued with a delay
|
||||
*
|
||||
* The test WorkManager runs on a `SynchronousExecutor`, so an ordinary request finishes inline —
|
||||
* which is exactly why only the null half was ever covered: by the time a test could call
|
||||
* `cancel()`, `convert()`'s job was already terminal. `setInitialDelay` is what `TestScheduler`
|
||||
* honours, so the job sits in `ENQUEUED` until the test lets it go, and it never does.
|
||||
*
|
||||
* **Production never sets a delay**, so the request is built here rather than through
|
||||
* `ConversionWorker.request`. The *state* is not synthetic: `ENQUEUED` at `runAttemptCount == 0` is
|
||||
* what every job passes through before the scheduler picks it up, `Reattachment.choose` ranks it
|
||||
* `QUEUED`, and `conversionStateFrom` maps it to `Converting(input, 0)`. The delay changes how long
|
||||
* the job stays in a real state, not which state it is in.
|
||||
*
|
||||
* ## What is asserted, and in which order
|
||||
*
|
||||
* WorkManager's own record first, then the screen. The screen alone would be a weaker claim than it
|
||||
* looks: `CANCELLED` maps to `Idle` for a reattached job, and `Idle` is also where a ViewModel that
|
||||
* did nothing at all would sit.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CancelReachesWorkManagerTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var workManager: WorkManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
installTestWorkManager(app, workDataOf())
|
||||
workManager = WorkManager.getInstance(app)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling a queued conversion cancels that job`() {
|
||||
val id = enqueueQueuedConversion()
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
awaitState(viewModel.state, "Converting") { it is ConversionState.Converting }
|
||||
|
||||
viewModel.cancel()
|
||||
|
||||
assertEquals(
|
||||
"Cancel must reach WorkManager, not just the screen",
|
||||
WorkInfo.State.CANCELLED,
|
||||
stateOf(id),
|
||||
)
|
||||
awaitState(viewModel.state, "Idle") { it is ConversionState.Idle }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling a queued join cancels that job`() {
|
||||
val id = enqueueQueuedJoin()
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
awaitState(viewModel.state, "Joining") { it is JoinState.Joining }
|
||||
|
||||
viewModel.cancel()
|
||||
|
||||
assertEquals(
|
||||
"Cancel must reach WorkManager, not just the screen",
|
||||
WorkInfo.State.CANCELLED,
|
||||
stateOf(id),
|
||||
)
|
||||
awaitState(viewModel.state, "Idle") { it is JoinState.Idle }
|
||||
}
|
||||
|
||||
/**
|
||||
* The negative that bounds both: cancelling must cancel the job the screen is showing, and only
|
||||
* that one.
|
||||
*
|
||||
* Without this, `cancel()` could cancel everything in the queue — `cancelAllWork()` in place of
|
||||
* `cancelWorkById(activeWorkId)` — and both tests above would still pass.
|
||||
*/
|
||||
@Test
|
||||
fun `cancelling one conversion leaves another queued job alone`() {
|
||||
val bystander = enqueueQueuedConversion(displayName = "beach.mp4")
|
||||
val id = enqueueQueuedConversion(displayName = "holiday.mp4")
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
val converting = awaitState(viewModel.state, "Converting") { it is ConversionState.Converting }
|
||||
val onScreen = (converting as ConversionState.Converting).input.displayName
|
||||
|
||||
viewModel.cancel()
|
||||
|
||||
// Which of the two the ViewModel reattached to is the query's business, not this test's --
|
||||
// the comparator leaves queued jobs tied deliberately, per Reattachment's ordering notes.
|
||||
// So assert the shape rather than the identity: exactly one is cancelled, and the other is
|
||||
// untouched.
|
||||
val cancelled = listOf(id, bystander).filter { stateOf(it) == WorkInfo.State.CANCELLED }
|
||||
assertEquals(
|
||||
"exactly one job may be cancelled, with $onScreen on screen",
|
||||
1,
|
||||
cancelled.size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun stateOf(id: UUID): WorkInfo.State =
|
||||
requireNotNull(workManager.getWorkInfoById(id).get()) { "no WorkInfo for $id" }.state
|
||||
|
||||
/**
|
||||
* A conversion sitting in the queue, which is where every job starts.
|
||||
*
|
||||
* Built by hand rather than through `ConversionWorker.request` for the reason in the class
|
||||
* KDoc; the display-name tag is included because `reattach()` reads it for the file card, and a
|
||||
* job without one would exercise the `UNKNOWN_INPUT_NAME` fallback instead of this test's
|
||||
* subject.
|
||||
*/
|
||||
private fun enqueueQueuedConversion(displayName: String = "holiday.mp4"): UUID {
|
||||
val request = OneTimeWorkRequestBuilder<ConversionWorker>()
|
||||
.addTag(JobTags.displayName(displayName))
|
||||
.setInitialDelay(QUEUE_HOLD_HOURS, TimeUnit.HOURS)
|
||||
.build()
|
||||
workManager.enqueue(request).result.get()
|
||||
return request.id
|
||||
}
|
||||
|
||||
private fun enqueueQueuedJoin(inputCount: Int = 2): UUID {
|
||||
val request = OneTimeWorkRequestBuilder<ConcatWorker>()
|
||||
.addTag(JobTags.inputCount(inputCount))
|
||||
.setInitialDelay(QUEUE_HOLD_HOURS, TimeUnit.HOURS)
|
||||
.build()
|
||||
workManager.enqueue(request).result.get()
|
||||
return request.id
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Long enough that `TestScheduler` never releases the job during a test run. */
|
||||
const val QUEUE_HOLD_HOURS = 1L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Every answer [conversionStateFrom] can give, chosen rather than stumbled into.
|
||||
*
|
||||
* ## What this revises
|
||||
*
|
||||
* The mapping is not cold code and never was: `ConversionViewModel$observe$1$1` reported 28 covered
|
||||
* lines before this file existed, because every test that drives a real worker runs it. What no
|
||||
* test did was **choose which arm it took**. A real worker reaches a terminal state with
|
||||
* well-formed output, so `SUCCEEDED`-with-a-path and `FAILED`-with-a-message were the only arms any
|
||||
* test had ever produced — the other six ran never.
|
||||
*
|
||||
* A `grep` for `WorkInfo.State.` across the JVM suite makes that look untrue: all six constants are
|
||||
* there. They are in `ReattachmentTest`, driven into **`Reattachment.choose`** — a different
|
||||
* function that encodes the same enqueued-means-retry rule. So that rule had a test in one of its
|
||||
* two homes, and the copy the user's screen reads had none.
|
||||
*
|
||||
* ## Why the seam, and why these assertions
|
||||
*
|
||||
* `WorkManager.getInstance` is called in the ViewModel's constructor and `observe` is private, so
|
||||
* nothing could hand this a chosen `WorkInfo`. Cutting the `when` out as a pure function over
|
||||
* [ConversionUpdate] is the answer #141 took for `MediaProbe`, and `JobSnapshot` beside
|
||||
* `Reattachment.choose` is the same shape again.
|
||||
*
|
||||
* The assertions are on the whole state, not on its type. `Converting(input, 40)` and
|
||||
* `Converting(input, 0)` are both `Converting`, and a mapping that dropped the progress read would
|
||||
* pass any test that only asked which class came back.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConversionStateMappingTest {
|
||||
|
||||
// --- running ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a running job reports the progress it published`() {
|
||||
// The percent is read from `progress`, not from `outputData`, and not from the settings.
|
||||
// A mapping that returned Converting(input, 0) for every RUNNING would leave the bar
|
||||
// pinned at zero for the whole conversion.
|
||||
val state = map(WorkInfo.State.RUNNING, progress = 40)
|
||||
|
||||
assertEquals(ConversionState.Converting(INPUT, 40), state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running job with no published progress reports zero rather than failing`() {
|
||||
// getInt's default. A worker that has started but not yet called setProgress is ordinary,
|
||||
// and must not read as an error.
|
||||
val state = map(WorkInfo.State.RUNNING, progress = null)
|
||||
|
||||
assertEquals(ConversionState.Converting(INPUT, 0), state)
|
||||
}
|
||||
|
||||
// --- enqueued: the rule that had a test only in its other home ----------
|
||||
|
||||
@Test
|
||||
fun `an enqueued job that has already run is waiting to retry`() {
|
||||
val state = map(WorkInfo.State.ENQUEUED, runAttemptCount = 1)
|
||||
|
||||
assertEquals(
|
||||
"an ENQUEUED after a run is a pending retry, which the user is told about",
|
||||
ConversionState.Waiting(INPUT),
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an enqueued job that has never run is simply starting`() {
|
||||
// The other side, and the reason the test above is not enough on its own: a mapping that
|
||||
// ignored runAttemptCount and always answered Waiting would pass that one and fail this.
|
||||
val state = map(WorkInfo.State.ENQUEUED, runAttemptCount = 0)
|
||||
|
||||
assertEquals(ConversionState.Converting(INPUT, 0), state)
|
||||
}
|
||||
|
||||
// --- succeeded ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a success that named no file is a failure, not an empty success`() {
|
||||
// The job said it finished and named nothing. There is no file to offer, so `Converted`
|
||||
// would put a Save button over a path that does not exist.
|
||||
val state = map(WorkInfo.State.SUCCEEDED, data = Data.EMPTY)
|
||||
|
||||
assertEquals(ConversionState.Failed(SUCCEEDED_WITHOUT_A_FILE_MESSAGE), state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success carries the worker's own name and type, not the current settings`() {
|
||||
val state = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to "/cache/conversions/out.mkv",
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mkv",
|
||||
ConversionWorker.KEY_MIME_TYPE to "video/x-matroska",
|
||||
ConversionWorker.KEY_ENGINE_USED to "FFMPEG",
|
||||
ConversionWorker.KEY_ROUTE_REASON to "container needs FFmpeg",
|
||||
),
|
||||
)
|
||||
|
||||
val converted = state as ConversionState.Converted
|
||||
assertEquals("holiday.mkv", converted.suggestedName)
|
||||
assertEquals("video/x-matroska", converted.mimeType)
|
||||
assertEquals("FFMPEG", converted.engineUsed)
|
||||
assertEquals("container needs FFmpeg", converted.routeReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success from older work falls back to the current settings for name and type`() {
|
||||
// WorkManager keeps finished work about a week, so a job enqueued before the worker
|
||||
// reported these is ordinary for a few days rather than a corner case.
|
||||
val state = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(ConversionWorker.KEY_OUTPUT_PATH to "/cache/conversions/out.mp4"),
|
||||
)
|
||||
|
||||
val converted = state as ConversionState.Converted
|
||||
assertEquals(FALLBACK_SPEC.mimeType, converted.mimeType)
|
||||
assertEquals(
|
||||
ConversionWorker.outputNameFor(INPUT.displayName, FALLBACK_SPEC),
|
||||
converted.suggestedName,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank name or type falls back the same way a missing one does`() {
|
||||
// A blank string is not an answer. Without takeIf, the save dialog opens named "" and
|
||||
// registered for a MIME type of "", which no provider will accept.
|
||||
val state = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to "/cache/conversions/out.mp4",
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to "",
|
||||
ConversionWorker.KEY_MIME_TYPE to " ",
|
||||
),
|
||||
)
|
||||
|
||||
val converted = state as ConversionState.Converted
|
||||
assertEquals(FALLBACK_SPEC.mimeType, converted.mimeType)
|
||||
assertEquals(
|
||||
ConversionWorker.outputNameFor(INPUT.displayName, FALLBACK_SPEC),
|
||||
converted.suggestedName,
|
||||
)
|
||||
}
|
||||
|
||||
// --- failed -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a failure carries the reason the worker gave`() {
|
||||
val state = map(
|
||||
WorkInfo.State.FAILED,
|
||||
data = workDataOf(ConversionWorker.KEY_ERROR to "Not enough free space to convert."),
|
||||
)
|
||||
|
||||
assertEquals(ConversionState.Failed("Not enough free space to convert."), state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure with nothing said still says something`() {
|
||||
// A worker killed before it could write output data leaves none at all -- a refused
|
||||
// foreground start after a process restart is one way. Failed("") would render as a blank
|
||||
// error card.
|
||||
val state = map(WorkInfo.State.FAILED, data = Data.EMPTY)
|
||||
|
||||
assertEquals(ConversionState.Failed(ConversionWorker.GENERIC_FAILURE_MESSAGE), state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure whose message is blank falls back like a missing one`() {
|
||||
val state = map(
|
||||
WorkInfo.State.FAILED,
|
||||
data = workDataOf(ConversionWorker.KEY_ERROR to " "),
|
||||
)
|
||||
|
||||
assertEquals(ConversionState.Failed(ConversionWorker.GENERIC_FAILURE_MESSAGE), state)
|
||||
}
|
||||
|
||||
// --- cancelled and blocked ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a cancellation lands wherever the caller said it should`() {
|
||||
// Not a fixed state: a conversion started here goes back to Ready with the picked file,
|
||||
// while one picked up by reattach goes to Idle, because the URI that job holds belongs to
|
||||
// a process that no longer exists. `observe`'s KDoc is where that distinction is set.
|
||||
val toReady = map(WorkInfo.State.CANCELLED, cancelled = ConversionState.Ready(INPUT))
|
||||
val toIdle = map(WorkInfo.State.CANCELLED, cancelled = ConversionState.Idle)
|
||||
|
||||
assertEquals(ConversionState.Ready(INPUT), toReady)
|
||||
assertEquals(ConversionState.Idle, toIdle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blocked job looks like one that is starting`() {
|
||||
// BLOCKED is a job waiting on a prerequisite. There is nothing useful to say about it that
|
||||
// differs from "starting", and inventing a state for it would put a word on screen the
|
||||
// user cannot act on.
|
||||
val state = map(WorkInfo.State.BLOCKED)
|
||||
|
||||
assertEquals(ConversionState.Converting(INPUT, 0), state)
|
||||
}
|
||||
|
||||
private fun map(
|
||||
state: WorkInfo.State,
|
||||
progress: Int? = null,
|
||||
runAttemptCount: Int = 0,
|
||||
data: Data = Data.EMPTY,
|
||||
cancelled: ConversionState = ConversionState.Ready(INPUT),
|
||||
): ConversionState = conversionStateFrom(
|
||||
ConversionUpdate(
|
||||
state = state,
|
||||
// Modelled on the call site, which reads `getInt(KEY_PROGRESS, 0)` -- so "no progress
|
||||
// published" is the default reaching the mapping, not a null it has to handle.
|
||||
progressPercent = progress ?: 0,
|
||||
runAttemptCount = runAttemptCount,
|
||||
outputData = data,
|
||||
),
|
||||
input = INPUT,
|
||||
cancelled = cancelled,
|
||||
fallbackSpec = FALLBACK_SPEC,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val INPUT = InputFile(Uri.parse("content://test/holiday.mov"), "holiday.mov", 4096L)
|
||||
val FALLBACK_SPEC = OutputFormat.MP4_H265.spec
|
||||
}
|
||||
}
|
||||
@@ -93,8 +93,12 @@ class ConversionViewModelCleanupTest {
|
||||
assertTrue("a failed save must not destroy the only copy", staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
|
||||
// Failed carries no file reference at all, so this only works because the handle is
|
||||
// a ViewModel field rather than something read back out of the state machine.
|
||||
// The handle is a ViewModel field rather than something read back out of the state
|
||||
// machine, and stays one now that a save-failed `Failed` also carries a `PendingSave`:
|
||||
// that is a view for the screen to offer a retry through, never a second owner of the
|
||||
// file. This delete goes through the field, which is what keeps a state that is dropped
|
||||
// rather than read from taking the only reference with it. What the state carries, and
|
||||
// what the screen then does with it, are `FailedSaveRetryTest`'s.
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
|
||||
@@ -53,10 +53,11 @@ import java.io.File
|
||||
* it -- so it is unobservable from a JVM test, the same limit `FileCardTest` records for
|
||||
* `HorizontalDivider`. The message text itself is asserted; the colour would need a screenshot.
|
||||
* - **The three `assertDoesNotExist` checks on [TestTags.Converter.FILE_CARD] are compile-guarded,
|
||||
* not guarded by this file.** `Idle` is a `data object`, and `Saved` and `Failed` carry only a
|
||||
* `displayName` and a `message`; none of the three has an `input`, so `FileCard(s.input)` does not
|
||||
* compile in those arms. The lines stay because they state the intent cheaply, but they are not
|
||||
* what stops a `FileCard` appearing there and this file does not claim they are.
|
||||
* not guarded by this file.** `Idle` is a `data object`, `Saved` carries a `displayName`, and
|
||||
* `Failed` carries a message and -- after a failed save only -- the staged file it left behind;
|
||||
* none of the three has an `input`, so `FileCard(s.input)` does not compile in those arms. The
|
||||
* lines stay because they state the intent cheaply, but they are not what stops a `FileCard`
|
||||
* appearing there and this file does not claim they are.
|
||||
* - **Which constant each chip hands back** belongs to `ConverterPickerSelectionTest`, and **what
|
||||
* the file card says about an unknown size** to `FileCardTest`. This file asserts that `Ready`
|
||||
* puts those leaves on screen at all, not what they then do.
|
||||
@@ -326,6 +327,22 @@ class ConverterStateAffordancesTest {
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* **The assertion that bounds #30's whole change**, and the one worth breaking things to keep.
|
||||
*
|
||||
* A transcode that died staged nothing, so its `Failed` carries no [PendingSave] and there is
|
||||
* nothing for a save dialog to be handed. Making the retry unconditional -- or making the
|
||||
* `WorkInfo.State.FAILED` arm of `ConversionViewModel.observe` carry a handle it has no file
|
||||
* for -- puts a button on screen that can only fail, and this is what notices.
|
||||
*/
|
||||
@Test
|
||||
fun `a transcode failure offers no way to save`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over after a failure resets and does nothing else`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
@@ -335,6 +352,50 @@ class ConverterStateAffordancesTest {
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- Failed, carrying a file
|
||||
|
||||
/**
|
||||
* The defect in #30, stated as what the branch must render.
|
||||
*
|
||||
* `save()` keeps the staged file on a failure deliberately -- it can be the only copy of an
|
||||
* hour of transcoding -- and before this the only control here was "Start over", wired to
|
||||
* `reset()`, which deletes exactly that file. Both buttons, not one: the restart has to stay
|
||||
* reachable, because leaving a full-size file in cache is the outcome it exists to avoid.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed save offers the file again as well as a restart`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* The name, not just that something fired: it comes from the finished job, and a retry wired to
|
||||
* a literal or to the picker's current guess would hand the dialog a name the job never chose.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping try saving again hands back the name the job chose`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("save:holiday.mp4"), fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start over from here still resets, and resetting still deletes -- see `reset()`'s KDoc for
|
||||
* why that is acceptable now and was not before. What it must not do is save on the way past.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping start over after a failed save resets and does not save`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Harness
|
||||
|
||||
private fun input() = InputFile(
|
||||
@@ -348,6 +409,21 @@ class ConverterStateAffordancesTest {
|
||||
* missing file rather than throwing, so the size line reads `0 B` and no temporary folder is
|
||||
* needed to render the arm.
|
||||
*/
|
||||
/**
|
||||
* A `Failed` an earlier save left carrying its file, which is the only way [retry] is non-null.
|
||||
*
|
||||
* The same missing `staged` path as [converted], and for the same reason: this arm renders no
|
||||
* size line at all, so nothing here ever touches the filesystem.
|
||||
*/
|
||||
private fun failedSave() = ConversionState.Failed(
|
||||
message = "There was not enough room on the destination.",
|
||||
retry = PendingSave(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
suggestedName = "holiday.mp4",
|
||||
mimeType = "video/mp4",
|
||||
),
|
||||
)
|
||||
|
||||
private fun converted(routeReason: String = "") = ConversionState.Converted(
|
||||
input = input(),
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import com.arthenica.ffmpegkit.MediaInformation
|
||||
import com.arthenica.ffmpegkit.StreamInformation
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* What FFprobe's answer means, read as a function of the answer alone.
|
||||
*
|
||||
* `readMediaInformation` was 114 missed instructions and 24 missed branches — the second-biggest
|
||||
* block on the wave-4 report — of which **exactly one line needed a device**:
|
||||
*
|
||||
* ```kotlin
|
||||
* FFprobeKit.getMediaInformation(path).getMediaInformation()
|
||||
* ```
|
||||
*
|
||||
* Everything after it reads an ordinary object. `javap` over the committed AAR's runtime jar:
|
||||
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
|
||||
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
|
||||
* loads the native library — so the fixtures below are built without `libffmpegkit` present.
|
||||
*
|
||||
* ## The one that matters
|
||||
*
|
||||
* `containerFrom(formatName, video?.getCodec())`. FFprobe reports `matroska,webm` for **both** MKV
|
||||
* and WebM, because they share a demuxer, so the video codec is the only thing separating them.
|
||||
* `containerFrom` has thirty-three covered branches of its own and not one of them can notice the
|
||||
* argument being dropped — the mistake would be at the call, not in the callee, and every existing
|
||||
* `containerFrom` test would stay green while every VP9 WebM quietly became an MKV.
|
||||
*
|
||||
* Robolectric only for `org.json`, which is a stub in a plain JVM test.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FFprobeMappingTest {
|
||||
|
||||
@Test
|
||||
fun `the video codec decides between matroska and webm`() {
|
||||
assertEquals(
|
||||
Container.WEBM,
|
||||
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "vp9"))).container,
|
||||
)
|
||||
assertEquals(
|
||||
Container.MKV,
|
||||
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "h264"))).container,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same format name with no video stream at all, which is what makes the case above about
|
||||
* the *argument* rather than about the format string.
|
||||
*/
|
||||
@Test
|
||||
fun `a matroska container with no video track cannot be told from webm and is not guessed`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("audio", "opus")))
|
||||
|
||||
assertEquals(Container.MKV, read.container)
|
||||
assertNull(read.videoCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first stream of each type wins`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(
|
||||
info(
|
||||
"mov,mp4,m4a,3gp,3g2,mj2",
|
||||
stream("video", "h264", width = 1920, height = 1080),
|
||||
stream("video", "hevc", width = 640, height = 480),
|
||||
stream("audio", "aac"),
|
||||
stream("audio", "mp3"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", read.videoCodec)
|
||||
assertEquals("aac", read.audioCodec)
|
||||
assertEquals(1920, read.width)
|
||||
assertEquals(1080, read.height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions come from the stream the codec came from, not from whichever stream has some.
|
||||
*
|
||||
* The fixture is deliberately awkward: the chosen video stream carries **no** dimensions and a
|
||||
* later one does. That is a real shape — FFprobe omits `width`/`height` for a stream it could
|
||||
* not measure — and it is the only arrangement that separates the two readings.
|
||||
*
|
||||
* A first version of this file asserted the dimensions inside the case above, where the chosen
|
||||
* stream was also the first one carrying any. Replacing `video?.getWidth()` with
|
||||
* `streams.firstNotNullOfOrNull { it.getWidth() }` gave the same answer there and **the
|
||||
* mutation survived**. It reddens here.
|
||||
*/
|
||||
@Test
|
||||
fun `a video stream with no dimensions reports none rather than borrowing another stream's`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(
|
||||
info(
|
||||
"mov,mp4,m4a,3gp,3g2,mj2",
|
||||
stream("video", "h264"),
|
||||
stream("video", "hevc", width = 640, height = 480),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", read.videoCodec)
|
||||
assertEquals(0, read.width)
|
||||
assertEquals(0, read.height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream order is the file's, not a promise. An audio-first container must read the same as a
|
||||
* video-first one.
|
||||
*/
|
||||
@Test
|
||||
fun `an audio track listed first does not become the video track`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(
|
||||
info("mov,mp4,m4a,3gp,3g2,mj2", stream("audio", "aac"), stream("video", "h264")),
|
||||
)
|
||||
|
||||
assertEquals("h264", read.videoCodec)
|
||||
assertEquals("aac", read.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration in seconds becomes milliseconds`() {
|
||||
assertEquals(12_345L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "12.345")).durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Both ways a duration can be absent, and neither may throw.
|
||||
*
|
||||
* FFprobe reports `"N/A"` for a stream it could not measure, and omits the key entirely for
|
||||
* some containers. `toDoubleOrNull` is what keeps the second from being an exception on the
|
||||
* file-pick path, where there is no user-visible failure to report it as.
|
||||
*/
|
||||
@Test
|
||||
fun `a duration that is not a number is no duration rather than a crash`() {
|
||||
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "N/A")).durationMs)
|
||||
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = null)).durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no streams reports nothing rather than defaults that look measured`() {
|
||||
val read = MediaProbe.ffprobeInfoFrom(info("mp4"))
|
||||
|
||||
assertNull(read.videoCodec)
|
||||
assertNull(read.audioCodec)
|
||||
assertEquals(0, read.width)
|
||||
assertEquals(0, read.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image format is reported as one`() {
|
||||
assertTrue(MediaProbe.ffprobeInfoFrom(info("png_pipe", stream("video", "png"))).isImage)
|
||||
assertFalse(MediaProbe.ffprobeInfoFrom(info("mp4", stream("video", "h264"))).isImage)
|
||||
}
|
||||
|
||||
private fun stream(type: String, codec: String, width: Int? = null, height: Int? = null) = StreamInformation(
|
||||
JSONObject().apply {
|
||||
put(StreamInformation.KEY_TYPE, type)
|
||||
put(StreamInformation.KEY_CODEC, codec)
|
||||
width?.let { put(StreamInformation.KEY_WIDTH, it) }
|
||||
height?.let { put(StreamInformation.KEY_HEIGHT, it) }
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* The format properties are **nested** under `"format"`, which is how FFprobe reports them and
|
||||
* what `MediaInformation` reads: `getFormat()` resolves through `getStringFormatProperty`, not
|
||||
* off the top-level object. A first version of this helper put the keys at the top level and
|
||||
* every format-dependent case failed with a null container, which is worth recording here so
|
||||
* the next fixture does not have to rediscover it.
|
||||
*
|
||||
* Streams are the other half and are *not* nested — they come from the constructor argument.
|
||||
*/
|
||||
private fun info(formatName: String, vararg streams: StreamInformation, duration: String? = "1.0") =
|
||||
MediaInformation(
|
||||
JSONObject().apply {
|
||||
put(
|
||||
MediaInformation.KEY_FORMAT_PROPERTIES,
|
||||
JSONObject().apply {
|
||||
put(MediaInformation.KEY_FORMAT, formatName)
|
||||
duration?.let { put(MediaInformation.KEY_DURATION, it) }
|
||||
},
|
||||
)
|
||||
},
|
||||
streams.toList(),
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinState
|
||||
import org.libremediaconverter.join.JoinViewModel
|
||||
import org.libremediaconverter.join.pendingSave
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A failed save has to leave the file *offerable*, not merely undeleted.
|
||||
*
|
||||
* The defect is #30, and both halves of it were already written down in `main`. `save()`'s
|
||||
* `onFailure` kept the staged file on purpose -- "deleting here would destroy the work to tidy up
|
||||
* a cache directory" -- and then handed the screen a `Failed` carrying a message and nothing else,
|
||||
* so the single control that branch rendered was "Start over", wired to `reset()`, which deletes
|
||||
* exactly that file. The intent and the affordance disagreed, and the affordance won.
|
||||
*
|
||||
* `ConversionViewModelCleanupTest` already pins the *keeping*: after a failed save the file is
|
||||
* still on disk and nothing has been discarded. It stays green with the state carrying nothing,
|
||||
* because it reads the filesystem rather than the state. This file asserts the other half -- that
|
||||
* the handle reaches the state a screen can read -- and the negative that bounds it: a failure
|
||||
* with nothing staged behind it must not sprout a save button.
|
||||
*
|
||||
* Both ViewModels in one class, following `MissingStagedFileTest`. They are separate state
|
||||
* machines that can each hold a staged file at once, but this defect and its fix are the same
|
||||
* shape in both, and splitting them would put the two halves of one invariant in two files.
|
||||
*
|
||||
* ### Not asserted here, so each is a decision rather than an omission
|
||||
*
|
||||
* - **That the destination received the bytes.** [RecordingPublisher.publish] is a stub, which is
|
||||
* the only way to make a save fail deterministically -- and making it fail is what every case
|
||||
* here needs. `OutputPublisherPublishTest` owns what a real publish writes.
|
||||
* - **The screen's two buttons.** `ConverterStateAffordancesTest` and `JoinStateAffordancesTest`
|
||||
* own what each state renders; this file owns what each state carries.
|
||||
* - ~~**`ConverterScreen`'s `destinationMime` line itself.**~~ **Withdrawn 2026-09-02 (#201).** The
|
||||
* exemption read: "it lives in the entry point, above the `ScreenContent` seam, and reaching it
|
||||
* needs a real ViewModel inside a composition". That was true when written and is no longer:
|
||||
* `AdaptiveShellTest` (#173) established composing the real screens with real ViewModels, and
|
||||
* #200 added the `ShadowActivity` mechanics for reading what a launcher launched. `RetrySaveMimeTest`
|
||||
* now asserts the line directly. What this file still owns is the half below the seam -- what each
|
||||
* state *carries* -- which is why `pendingSave()?.mimeType` is also asserted here.
|
||||
* - **Picking a new input while a `Failed` carries a file.** `onInputPicked` overwrites the state
|
||||
* without discarding, from `Converted` exactly as much as from a carrying `Failed`, and neither
|
||||
* branch renders a picker. It is a pre-existing path this change neither opens nor widens: the
|
||||
* carried handle is a view of `pendingStaged`, never a second owner of the file.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FailedSaveRetryTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
// MediaProbe spawns FFprobe, whose loader throws with no native library present.
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
|
||||
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Convert
|
||||
|
||||
/**
|
||||
* The bite named in #30's fix. Emitting a plain `Failed` from `save()`'s `onFailure` -- which
|
||||
* is what `main` did -- reddens this case on the null handle, and nothing else in the suite.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed save leaves the staged file offerable, not merely undeleted`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
val failed = viewModel.state.value as ConversionState.Failed
|
||||
val retry = failed.retry
|
||||
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
|
||||
assertEquals("the retry must name the file the conversion actually produced", staged, retry?.staged)
|
||||
// Both from the job's own output Data rather than from the pickers, so a retry opens the
|
||||
// same dialog the first attempt did.
|
||||
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
|
||||
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
|
||||
assertTrue("a failed save must not destroy the only copy", staged.exists())
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of carrying the handle: the second attempt is a real save, not a new job.
|
||||
*
|
||||
* `publishFailure` is cleared between the two calls, so one `RecordingPublisher` plays both a
|
||||
* full destination and an empty one -- which is exactly the user's situation.
|
||||
*/
|
||||
@Test
|
||||
fun `retrying a failed save publishes the file and leaves nothing staged`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
publisher.publishFailure = null
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val saved = awaitState(viewModel.state, "Saved") { it is ConversionState.Saved }
|
||||
assertEquals(SUGGESTED_NAME, (saved as ConversionState.Saved).displayName)
|
||||
assertFalse("a successful retry should have removed the staged file", staged.exists())
|
||||
// Nothing to collect afterwards: the retry published it, so reset() has no work left.
|
||||
viewModel.reset()
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
/**
|
||||
* The second failure must not eat the file the first one kept.
|
||||
*
|
||||
* A `Failed` built fresh from `e.message` alone would drop the handle here while every other
|
||||
* assertion in this file stayed green -- the file is still on disk, and the first failure
|
||||
* already proved the state can carry it.
|
||||
*/
|
||||
@Test
|
||||
fun `a retry that fails again still carries the file rather than dropping it`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume still full")
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
// Waited for by the *second* message rather than by `is Failed`: the state was already
|
||||
// Failed when the retry started, so the type alone would be satisfied before it ran.
|
||||
val failed = awaitState(viewModel.state, "the second failure") {
|
||||
it is ConversionState.Failed && it.message == "destination volume still full"
|
||||
} as ConversionState.Failed
|
||||
assertEquals("the second failure must offer the same file the first one did", staged, failed.retry?.staged)
|
||||
assertTrue(staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Start over" still deletes, and that is the decision `reset()`'s KDoc records: acceptable
|
||||
* only because "Try saving again" is on screen beside it. Exactly once, through the publisher.
|
||||
*/
|
||||
@Test
|
||||
fun `start over from a failed save discards the carried file exactly once`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
/**
|
||||
* A retry meets the same existence check the first attempt did, so a file collected by the
|
||||
* sweep or by the OS in between is reported as a sentence rather than as a raw ENOENT path.
|
||||
* And the state that reports it carries nothing: there is no file left to offer.
|
||||
*/
|
||||
@Test
|
||||
fun `a retry whose staged file has gone says so and offers nothing further`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
assertTrue("the fixture must start with a real staged file", staged.delete())
|
||||
|
||||
publisher.publishFailure = null
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val failed = viewModel.state.value as ConversionState.Failed
|
||||
assertEquals(STAGED_FILE_GONE_MESSAGE, failed.message)
|
||||
assertNull("a file that has gone cannot be offered again", failed.retry)
|
||||
}
|
||||
|
||||
/**
|
||||
* The negative that bounds the whole change, and the reason `retry` is nullable.
|
||||
*
|
||||
* A transcode that died staged nothing, so there is no file to hand back -- and a `Failed`
|
||||
* that carried one anyway would put a save button on a screen with nothing to save. Driven
|
||||
* through a worker that really fails rather than by constructing the state, because the line
|
||||
* under test is the `WorkInfo.State.FAILED` arm of `observe`.
|
||||
*/
|
||||
@Test
|
||||
fun `a transcode failure carries nothing to save`() {
|
||||
installFailingTestWorkManager(app, workDataOf(ConversionWorker.KEY_ERROR to "The encoder gave up."))
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mp4"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
|
||||
viewModel.convert()
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed } as ConversionState.Failed
|
||||
assertEquals("The encoder gave up.", failed.message)
|
||||
assertNull("a transcode failure has nothing staged, so it must offer no save", failed.retry)
|
||||
assertNull("and nothing for the save dialog to open with either", failed.pendingSave())
|
||||
}
|
||||
|
||||
/**
|
||||
* What the save dialog reopens with, which is the entry point's only reader of this state.
|
||||
*
|
||||
* The pickers are moved *after* the job finishes, which is what makes this bite: a retry that
|
||||
* asked the current settings would offer `audio/mpeg` for a file the job wrote as MP4. The
|
||||
* same gap is permanent for a reattached job, whose spec was never in these settings at all.
|
||||
*/
|
||||
@Test
|
||||
fun `a retry offers the type the job chose, not the one the pickers now show`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
viewModel.setPreset(OutputFormat.MP3)
|
||||
|
||||
assertEquals(
|
||||
"the fixture needs the pickers to disagree with the job",
|
||||
"audio/mpeg",
|
||||
viewModel.settings.value.spec.mimeType,
|
||||
)
|
||||
assertEquals(JOB_MIME_TYPE, viewModel.state.value.pendingSave()?.mimeType)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- Join
|
||||
|
||||
@Test
|
||||
fun `a failed join save leaves the staged file offerable, not merely undeleted`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
val failed = viewModel.state.value as JoinState.Failed
|
||||
val retry = failed.retry
|
||||
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
|
||||
assertEquals(staged, retry?.staged)
|
||||
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
|
||||
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
|
||||
assertTrue(staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrying a failed join save publishes the file and leaves nothing staged`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
publisher.publishFailure = null
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val saved = awaitState(viewModel.state, "Saved") { it is JoinState.Saved }
|
||||
assertEquals(SUGGESTED_NAME, (saved as JoinState.Saved).displayName)
|
||||
assertFalse(staged.exists())
|
||||
viewModel.reset()
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join retry that fails again still carries the file rather than dropping it`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume still full")
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
// By the second message, not by `is Failed` -- see the converter case above.
|
||||
val failed = awaitState(viewModel.state, "the second failure") {
|
||||
it is JoinState.Failed && it.message == "destination volume still full"
|
||||
} as JoinState.Failed
|
||||
assertEquals(staged, failed.retry?.staged)
|
||||
assertTrue(staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start over from a failed join save discards the carried file exactly once`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(JoinState.Idle, viewModel.state.value)
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join failure carries nothing to save`() {
|
||||
installFailingTestWorkManager(app, workDataOf(ConcatWorker.KEY_ERROR to "The files could not be joined."))
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
|
||||
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
|
||||
viewModel.join()
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed } as JoinState.Failed
|
||||
assertEquals("The files could not be joined.", failed.message)
|
||||
assertNull("a join failure has nothing staged, so it must offer no save", failed.retry)
|
||||
assertNull(failed.pendingSave())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Harness
|
||||
|
||||
/**
|
||||
* A ViewModel driven to `Converted` and then through a save that threw.
|
||||
*
|
||||
* The WorkManager is installed here rather than in `@Before`, because two cases in this class
|
||||
* need one whose workers fail instead.
|
||||
*/
|
||||
private fun failedSaveViewModel(): ConversionViewModel {
|
||||
installTestWorkManager(app, conversionOutput())
|
||||
// Unconfined so reset()'s delete runs inline instead of on a real IO thread.
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
viewModel.convert()
|
||||
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume full")
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Failed") { it is ConversionState.Failed }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
/** The join tab's equivalent, driven to `Joined` and then through a save that threw. */
|
||||
private fun failedJoinSaveViewModel(): JoinViewModel {
|
||||
installTestWorkManager(app, joinOutput())
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
|
||||
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
viewModel.join()
|
||||
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume full")
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Failed") { it is JoinState.Failed }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
/**
|
||||
* The output `Data` a finished conversion reports.
|
||||
*
|
||||
* The name and type are set rather than left out, so the assertions above are about what the
|
||||
* *job* chose. Both ViewModels fall back to a derivation when they are missing, and a fixture
|
||||
* that omitted them would be asserting the fallback while looking like it asserted the job.
|
||||
*
|
||||
* Spelled out per worker rather than shared with [joinOutput], even though the two constants
|
||||
* hold the same strings today. A test that leaned on that would be asserting a coincidence.
|
||||
*/
|
||||
private fun conversionOutput() = workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConversionWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
/** The output `Data` a finished join reports. See [conversionOutput]. */
|
||||
private fun joinOutput() = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConcatWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConcatWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
|
||||
|
||||
const val SUGGESTED_NAME = "holiday.mp4"
|
||||
|
||||
/** What the job wrote. [OutputFormat.MP3]'s `audio/mpeg` is what the pickers move to. */
|
||||
const val JOB_MIME_TYPE = "video/mp4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ProviderInfo
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Content providers more than one test needs, and the registration dance they all repeat.
|
||||
*
|
||||
* Only that. A stub that serves one test stays in that test, next to the assertion it exists for —
|
||||
* the rule `work/WorkerStubs.kt` states, and the reason `UnreliableOutputStream` is still private to
|
||||
* `OutputPublisherPublishTest`.
|
||||
*
|
||||
* These started life inside `OutputPublisherPublishTest`, which is the only thing that needed a
|
||||
* provider at all. They moved here when `InputQuery`'s cursor reads turned out to need the same
|
||||
* provider answering *badly* — see [RowShape].
|
||||
*/
|
||||
|
||||
internal const val DOCUMENTS_AUTHORITY = "org.libremediaconverter.test.documents"
|
||||
internal const val PLAIN_AUTHORITY = "org.libremediaconverter.test.plain"
|
||||
|
||||
/**
|
||||
* How [FakeSafProvider] answers a metadata query.
|
||||
*
|
||||
* A provider is another app. It can be uninstalled, revoke its grant, crash, or simply answer
|
||||
* something the caller did not expect — and "answered something unexpected" is not one case but
|
||||
* several, which is why this is an enum rather than a boolean.
|
||||
*
|
||||
* The distinction that matters most to callers is **null versus missing versus zero versus
|
||||
* negative**. `InputQuery` exists to stop the last three being conflated: `hasSpaceFor(0)` is only
|
||||
* "is there 128 MB free", so a size nobody could determine must not arrive as `0`, and
|
||||
* `OutputPublisher.destinationIsKnownEmpty` must answer `false` — never "empty, go ahead and
|
||||
* delete" — for every one of them.
|
||||
*
|
||||
* Column-level granularity is deliberate. `OutputPublisher` reads only `SIZE`; `InputQuery` reads
|
||||
* both, and reaches a different answer depending on which one is bad.
|
||||
*/
|
||||
internal enum class RowShape {
|
||||
/** What a healthy provider answers: the file's real name and real length. */
|
||||
NORMAL,
|
||||
|
||||
/** A row is present and its `DISPLAY_NAME` cell is null. */
|
||||
NULL_DISPLAY_NAME,
|
||||
|
||||
/** A row is present and its `SIZE` cell is null. */
|
||||
NULL_SIZE,
|
||||
|
||||
/** The cursor carries no `DISPLAY_NAME` column at all — `getColumnIndex` gives `-1`. */
|
||||
NO_DISPLAY_NAME_COLUMN,
|
||||
|
||||
/** The cursor carries no `SIZE` column at all — `getColumnIndex` gives `-1`. */
|
||||
NO_SIZE_COLUMN,
|
||||
|
||||
/**
|
||||
* A size of `-1`.
|
||||
*
|
||||
* Not a corrupt provider: it is what anything without a fixed length reports — a pipe, or a
|
||||
* provider streaming its answer — and it is a third way of saying "unknown", distinct from a
|
||||
* null cell and from a missing column.
|
||||
*/
|
||||
NEGATIVE_SIZE,
|
||||
|
||||
/**
|
||||
* A cursor with the right columns and no rows in it.
|
||||
*
|
||||
* Distinct from returning `null`, which is what a provider that does not recognise the URI
|
||||
* does. Both mean "no answer", and code that treats one as an answer and the other as an
|
||||
* absence is wrong about one of them.
|
||||
*/
|
||||
NO_ROWS,
|
||||
|
||||
/**
|
||||
* The query itself throws.
|
||||
*
|
||||
* A resolver call is a call into another app, and that app can have been uninstalled, revoked
|
||||
* its grant, or simply crashed. `InputQuery.firstRow`'s KDoc is explicit that "a file picker is
|
||||
* not a place to bring the process down from", so this is the shape that proves the guard is
|
||||
* one.
|
||||
*/
|
||||
QUERY_THROWS,
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in for the provider behind a SAF destination.
|
||||
*
|
||||
* It answers only what its callers ask of a document -- how many bytes are already there, what it
|
||||
* is called, and delete it -- backed by a real file so the assertions are about the filesystem
|
||||
* rather than about a mock's call log alone. The rest of the `ContentProvider` surface is stubbed.
|
||||
*
|
||||
* Writing is deliberately NOT routed through it. Robolectric's `ShadowContentResolver`
|
||||
* consults its registered-stream map before it reaches any provider, which is what lets a
|
||||
* test hand out a stream that writes some bytes and then fails -- a condition a real provider
|
||||
* cannot be asked to produce on demand.
|
||||
*/
|
||||
internal open class FakeSafProvider : ContentProvider() {
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor? {
|
||||
if (rowShape == RowShape.QUERY_THROWS) throw SecurityException("provider revoked the grant")
|
||||
val file = backingFile(uri)
|
||||
if (!file.exists()) return null
|
||||
return MatrixCursor(columnsFor(rowShape)).apply {
|
||||
if (rowShape != RowShape.NO_ROWS) addRow(cellsFor(rowShape, file))
|
||||
}
|
||||
}
|
||||
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
||||
if (method != METHOD_DELETE_DOCUMENT) return null
|
||||
val target = extras?.getParcelable(EXTRA_URI, Uri::class.java) ?: return null
|
||||
deleteRequests += target
|
||||
deleteFailure?.let { throw it }
|
||||
backingFile(target).delete()
|
||||
return Bundle()
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri) = "video/mp4"
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
companion object {
|
||||
// DocumentsContract.METHOD_DELETE_DOCUMENT and EXTRA_URI are hidden from the public
|
||||
// SDK, so they cannot be referenced. These are the wire names
|
||||
// DocumentsContract.deleteDocument() actually sends, which is what a provider sees.
|
||||
const val METHOD_DELETE_DOCUMENT = "android:deleteDocument"
|
||||
const val EXTRA_URI = "uri"
|
||||
|
||||
/** Where the "documents" really live. Set per test to a Robolectric temp path. */
|
||||
lateinit var root: File
|
||||
|
||||
/** Every delete this provider was asked for, in order. Empty is an assertion too. */
|
||||
val deleteRequests = mutableListOf<Uri>()
|
||||
|
||||
/** Armed by the test that needs the cleanup itself to fail. */
|
||||
var deleteFailure: RuntimeException? = null
|
||||
|
||||
/**
|
||||
* How the next query answers. [reset] puts it back to [RowShape.NORMAL], so a test that
|
||||
* does not care never has to think about it.
|
||||
*/
|
||||
var rowShape: RowShape = RowShape.NORMAL
|
||||
|
||||
fun backingFile(uri: Uri) = File(root, uri.lastPathSegment.orEmpty())
|
||||
|
||||
fun reset(directory: File) {
|
||||
root = directory
|
||||
deleteRequests.clear()
|
||||
deleteFailure = null
|
||||
rowShape = RowShape.NORMAL
|
||||
}
|
||||
|
||||
private fun columnsFor(shape: RowShape): Array<String> = when (shape) {
|
||||
RowShape.NO_DISPLAY_NAME_COLUMN -> arrayOf(OpenableColumns.SIZE)
|
||||
RowShape.NO_SIZE_COLUMN -> arrayOf(OpenableColumns.DISPLAY_NAME)
|
||||
else -> arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||
}
|
||||
|
||||
private fun cellsFor(shape: RowShape, file: File): Array<Any?> = when (shape) {
|
||||
RowShape.NO_DISPLAY_NAME_COLUMN -> arrayOf(file.length())
|
||||
RowShape.NO_SIZE_COLUMN -> arrayOf<Any?>(file.name)
|
||||
RowShape.NULL_DISPLAY_NAME -> arrayOf(null, file.length())
|
||||
RowShape.NULL_SIZE -> arrayOf(file.name, null)
|
||||
RowShape.NEGATIVE_SIZE -> arrayOf(file.name, UNKNOWN_LENGTH)
|
||||
else -> arrayOf(file.name, file.length())
|
||||
}
|
||||
|
||||
/** What `statSize` reports for anything without a fixed length. See [RowShape.NEGATIVE_SIZE]. */
|
||||
private const val UNKNOWN_LENGTH = -1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same provider, registered WITHOUT the documents-provider intent filter.
|
||||
*
|
||||
* A separate class because the package manager keys providers by component name, so two
|
||||
* authorities need two components. It exists to prove the guard is a guard: a content URI
|
||||
* from something that is not a documents provider must not be handed to `deleteDocument`.
|
||||
*/
|
||||
internal class FakePlainProvider : FakeSafProvider()
|
||||
|
||||
/**
|
||||
* Stands [provider] up on [authority] so `contentResolver` and the package manager both know it.
|
||||
*
|
||||
* `isDocumentUri()` does not look at the URI alone: it asks the package manager whether anything
|
||||
* answers `ACTION_DOCUMENTS_PROVIDER` for that authority. Registering the provider with the
|
||||
* resolver is not enough, which is the whole reason [asDocumentsProvider] is a parameter rather
|
||||
* than always true — the negative case is a test.
|
||||
*/
|
||||
internal fun registerProvider(
|
||||
context: Context,
|
||||
provider: Class<out FakeSafProvider>,
|
||||
authority: String,
|
||||
asDocumentsProvider: Boolean,
|
||||
) {
|
||||
val info = ProviderInfo().apply {
|
||||
this.authority = authority
|
||||
packageName = context.packageName
|
||||
name = provider.name
|
||||
exported = true
|
||||
grantUriPermissions = true
|
||||
}
|
||||
Robolectric.buildContentProvider(provider).create(info)
|
||||
|
||||
val packageManager = shadowOf(context.packageManager)
|
||||
packageManager.addOrUpdateProvider(info)
|
||||
if (asDocumentsProvider) {
|
||||
packageManager.addIntentFilterForProvider(
|
||||
ComponentName(context.packageName, provider.name),
|
||||
IntentFilter(DocumentsContract.PROVIDER_INTERFACE),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,34 @@ class FileCardTest {
|
||||
assertNoRow("Length")
|
||||
}
|
||||
|
||||
/**
|
||||
* A video the app knows a great deal about and cannot name the container of.
|
||||
*
|
||||
* Not an edge case. `InputProbe.container`'s own KDoc says `MediaExtractor` cannot report a
|
||||
* container at all -- it comes from FFprobe -- so any run where FFprobe did not answer produces
|
||||
* exactly this: real codec, real dimensions, real duration, `container = null`.
|
||||
*
|
||||
* **The twin was already tested and this one was not**, which is the argument for adding it.
|
||||
* `FileCard` renders `probe.container?.label ?: "Unknown"` twice, once in the `AUDIO_ONLY`
|
||||
* branch (`ConverterScreen.kt:660`) and once in the `VIDEO` branch (`:668`), and
|
||||
* `an audio-only file nothing else could describe degrades one row at a time` drives only the
|
||||
* first. Same expression, same fallback, one kind covered. That asymmetry is the same one
|
||||
* `CLAUDE.md` records for including `ContainerCapabilities:94`.
|
||||
*
|
||||
* The other rows are asserted alongside so this is not a copy of the audio-only case: there,
|
||||
* everything is unknown at once; here, one field is missing from a probe that is otherwise
|
||||
* complete, and the rest must be unaffected by it.
|
||||
*/
|
||||
@Test
|
||||
fun `a video file whose container nothing identified says so and keeps its other rows`() {
|
||||
setFileCard(input(probe = VIDEO_PROBE.copy(container = null)))
|
||||
|
||||
assertRow("Container", "Unknown")
|
||||
assertRow("Video", "${VideoCodec.H264.label} · 1920×1080")
|
||||
assertRow("Audio", AudioCodec.AAC.label)
|
||||
assertRow("Length", "1:30")
|
||||
}
|
||||
|
||||
/**
|
||||
* The row is one node, not a label node beside a value node. A test matching on `"Container"`
|
||||
* alone would pass against either shape.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What [InputQuery] makes of a metadata row.
|
||||
*
|
||||
* ## Why this is a separate file from `UnknownInputSizeTest`
|
||||
*
|
||||
* That test drives the case where **no provider is registered** — the query returns null and
|
||||
* `measure()` answers instead — and it drives it thoroughly. What it never does is hand `InputQuery`
|
||||
* a row. Before this file, nothing did: `firstRow`'s body, `displayNameOrNull` and `sizeOrNull` had
|
||||
* never executed in the JVM suite, so every branch inside them was untested.
|
||||
*
|
||||
* ## What is actually being pinned
|
||||
*
|
||||
* Not "does it read a cursor" — that would pass against almost any implementation. The rule is that
|
||||
* **a size nobody could determine must not arrive as a number**, and there are four separate ways a
|
||||
* provider fails to determine one: a null cell, a missing column, a negative value, and no row at
|
||||
* all. `InputQuery`'s KDoc states the stake:
|
||||
*
|
||||
* > a worker's input `Data` carries the size the *picker* found … `hasSpaceFor(0)` is only "is there
|
||||
* > 128 MB free".
|
||||
*
|
||||
* So each of those four must produce `null`, and `null` specifically — not `0`, not `-1`. A test
|
||||
* that asserted only "not the file's length" would pass on `0`, which is the exact conflation the
|
||||
* class exists to end.
|
||||
*
|
||||
* ## Why every fall-through lands on null here
|
||||
*
|
||||
* [FakeSafProvider] does not implement `openFile`, so `measure()` cannot answer for these URIs
|
||||
* either. That is deliberate: it isolates the cursor half. The other direction — the cursor says
|
||||
* nothing and `measure()` succeeds — is `UnknownInputSizeTest`'s
|
||||
* `a picked file no provider describes is measured rather than reported as empty`, and is not
|
||||
* repeated here.
|
||||
*
|
||||
* ## What the mutations say, including the one that does not bite
|
||||
*
|
||||
* Measured against `MatrixCursor`, which is what these tests drive:
|
||||
*
|
||||
* | call on a null cell | result |
|
||||
* |---|---|
|
||||
* | `getString` | returns `null` |
|
||||
* | `getLong` | returns **`0`** |
|
||||
*
|
||||
* That second row is why `sizeOrNull`'s `!isNull(it)` guard is load-bearing and why these tests
|
||||
* bite: remove it and a null size arrives as `0`, a real number indistinguishable from an empty
|
||||
* file, which is the precise conflation this class exists to end. Removing it reddens
|
||||
* `a null size is unknown rather than zero`. Removing the trailing `takeIf { it >= 0 }` reddens
|
||||
* `a negative size is unknown rather than reported`.
|
||||
*
|
||||
* **Named exemption: `displayNameOrNull`'s `!isNull(it)` guard is not pinned by anything here, and
|
||||
* cannot be.** `getString` returns null for a null cell, so the fallback applies with or without
|
||||
* the guard — removing it leaves every test in this file green. The guard is not redundant in
|
||||
* production: `Cursor.getString`'s contract states that whether it throws on a null column is
|
||||
* *implementation-defined*, and a real `ContentProvider` is free to throw where `MatrixCursor`
|
||||
* returns null. It should stay. It simply cannot be falsified with this cursor, and saying so is
|
||||
* better than implying `a null display name falls back without disturbing the size` covers it —
|
||||
* that test pins the behaviour, not the guard.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class InputQueryCursorTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var uri: Uri
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
FakeSafProvider.reset(File(context.cacheDir, "picked").apply { mkdirs() })
|
||||
registerProvider(context, FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
|
||||
uri = Uri.parse("content://$DOCUMENTS_AUTHORITY/document/holiday.mp4")
|
||||
FakeSafProvider.backingFile(uri).writeBytes(ByteArray(PAYLOAD_BYTES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider that answers properly supplies both the name and the size`() {
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals("holiday.mp4", described.displayName)
|
||||
assertEquals(PAYLOAD_BYTES.toLong(), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null display name falls back without disturbing the size`() {
|
||||
FakeSafProvider.rowShape = RowShape.NULL_DISPLAY_NAME
|
||||
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
|
||||
// The two columns are read independently. A provider that cannot name the file can still
|
||||
// size it, and losing the size here would be a bug the name assertion alone would miss.
|
||||
assertEquals(PAYLOAD_BYTES.toLong(), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor with no display name column falls back rather than throwing`() {
|
||||
// getColumnIndex returns -1 rather than throwing, so the `it >= 0` guard is the only thing
|
||||
// between this and an IllegalArgumentException out of getString.
|
||||
FakeSafProvider.rowShape = RowShape.NO_DISPLAY_NAME_COLUMN
|
||||
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
|
||||
assertEquals(PAYLOAD_BYTES.toLong(), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null size is unknown rather than zero`() {
|
||||
FakeSafProvider.rowShape = RowShape.NULL_SIZE
|
||||
|
||||
assertNull(unknownSizeMessage("a null cell"), InputQuery.sizeOf(context, uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor with no size column is unknown rather than zero`() {
|
||||
FakeSafProvider.rowShape = RowShape.NO_SIZE_COLUMN
|
||||
|
||||
assertNull(unknownSizeMessage("a missing column"), InputQuery.sizeOf(context, uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negative size is unknown rather than reported`() {
|
||||
// What anything without a fixed length reports -- a pipe, or a provider streaming its
|
||||
// answer. Passing -1 through would be worse than passing 0: hasSpaceFor compares it
|
||||
// against free space, so it would read as "needs less than nothing".
|
||||
FakeSafProvider.rowShape = RowShape.NEGATIVE_SIZE
|
||||
|
||||
assertNull(unknownSizeMessage("a negative size"), InputQuery.sizeOf(context, uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor with no rows is unknown rather than zero`() {
|
||||
// Distinct from the provider returning null, which UnknownInputSizeTest covers. A cursor
|
||||
// that exists and holds nothing still has to reach the same answer.
|
||||
FakeSafProvider.rowShape = RowShape.NO_ROWS
|
||||
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
|
||||
assertNull(unknownSizeMessage("an empty cursor"), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider that throws is survived rather than propagated`() {
|
||||
// The guard firstRow's KDoc exists for: "a resolver call is a call into another app ... and
|
||||
// a file picker is not a place to bring the process down from". Without the runCatching,
|
||||
// this SecurityException reaches the caller and takes the pick with it.
|
||||
FakeSafProvider.rowShape = RowShape.QUERY_THROWS
|
||||
|
||||
val described = InputQuery.describe(context, uri)
|
||||
|
||||
assertEquals(InputQuery.FALLBACK_DISPLAY_NAME, described.displayName)
|
||||
assertNull(unknownSizeMessage("a provider that threw"), described.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join total is unknown when any one input could not be sized`() {
|
||||
// The consequence the four cases above exist for, asserted once at the place it lands.
|
||||
// Summing the inputs that did answer would produce a lower bound indistinguishable from a
|
||||
// real total, which is what the space check cannot tell apart.
|
||||
FakeSafProvider.rowShape = RowShape.NULL_SIZE
|
||||
val unsizable = InputQuery.sizeOf(context, uri)
|
||||
FakeSafProvider.rowShape = RowShape.NORMAL
|
||||
val sizable = InputQuery.sizeOf(context, uri)
|
||||
|
||||
assertEquals(PAYLOAD_BYTES.toLong(), sizable)
|
||||
assertNull(unsizable)
|
||||
assertNull("one unknown input makes the whole total unknown", InputQuery.total(listOf(sizable, unsizable)))
|
||||
}
|
||||
|
||||
private fun unknownSizeMessage(cause: String) =
|
||||
"$cause means nobody could size the file; that must be null, not 0 -- hasSpaceFor(0) is only a headroom check"
|
||||
|
||||
private companion object {
|
||||
const val PAYLOAD_BYTES = 4096
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinScreen
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.shadows.ShadowActivity
|
||||
|
||||
/**
|
||||
* The launcher layer above the `ScreenContent` seam — registered, and until now never resulted.
|
||||
*
|
||||
* ## The hazard this exists for
|
||||
*
|
||||
* `ConversionViewModel.onInputPicked(uri: Uri)` and `.save(destination: Uri)` are **both
|
||||
* `(Uri) -> Unit`**, so swapping the two launcher callbacks at `ConverterScreen.kt:70` and `:83`
|
||||
* compiles, renders, and passes the entire suite. Picking a file would attempt a save to it, and
|
||||
* choosing a destination would load it as input.
|
||||
*
|
||||
* That is precisely the defect class `ScreenWiringTest` exists for, on the one pair it declines to
|
||||
* cover: it drives `converterActions` directly and says the launcher-backed actions stay
|
||||
* parameters. Correct for the `actions` seam, and it leaves the edge above that seam unpinned.
|
||||
*
|
||||
* Join's equivalents (`JoinScreen.kt:45`, `:55`) are `List<Uri>` and `Uri`, so they are **not**
|
||||
* transposable and need no such test. The picker filter is a different matter and is covered below
|
||||
* for both screens.
|
||||
*
|
||||
* ## The two mechanics, verified before the assertions were written
|
||||
*
|
||||
* Neither is used anywhere else in the suite, so both were spiked first:
|
||||
*
|
||||
* - **Reading what was launched** — `shadowOf(activity).nextStartedActivityForResult`, which returns
|
||||
* the `Intent` with its `EXTRA_MIME_TYPES` intact.
|
||||
* - **Delivering a result** — `shadowOf(activity).receiveResult(...)`, which reaches
|
||||
* `ComponentActivity`'s `ActivityResultRegistry` and fires the `rememberLauncherForActivityResult`
|
||||
* callback.
|
||||
*
|
||||
* `createAndroidComposeRule`, as `AdaptiveShellTest` uses and for the reason it gives: the screens
|
||||
* compose real ViewModels through `viewModel()`, and the plain rule supplies no `ViewModelStoreOwner`.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class LauncherWiringTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
// The real screen composes a real ViewModel; neither test here is about probing.
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = ConversionDependencies.reset()
|
||||
|
||||
/**
|
||||
* The transposition guard. A picked file has to reach `onInputPicked`, which is observable as
|
||||
* the screen arriving at `Ready` with the file card showing — `save()` from `Idle` returns at
|
||||
* its own guard and leaves nothing behind.
|
||||
*
|
||||
* ## Why this waits rather than asserting straight away (#220)
|
||||
*
|
||||
* `onInputPicked` does not reach `Ready` on the calling thread. It hops twice —
|
||||
* `withContext(pickDispatcher) { InputQuery.describe(...) }` and then the probe — and
|
||||
* `pickDispatcher` defaults to `Dispatchers.IO`, a real background thread that Compose's
|
||||
* idling does not know about. `deliver` therefore returns with the state still `Idle` more
|
||||
* often than not, and asserting immediately was a race the test usually won.
|
||||
*
|
||||
* It lost five times on CI in one day, on PRs whose diffs were instrumented tests and
|
||||
* documentation, which is what #220 was filed for. `waitUntil` polls through
|
||||
* `waitForIdle`, so it drains the main looper each time round and sees the recomposition that
|
||||
* the IO hop eventually posts back.
|
||||
*
|
||||
* **Injecting the dispatcher would be better and is not available here.** `pickDispatcher` is
|
||||
* a constructor parameter precisely so a test can pin it, but this test composes the real
|
||||
* `ConverterScreen`, which resolves its own ViewModel through `viewModel()` — the seam exists
|
||||
* one layer below the thing under test. Pinning it would mean not testing the launcher edge,
|
||||
* which is the whole point of this class.
|
||||
*
|
||||
* The wait does not weaken the assertion: transposing the two callbacks leaves the screen in
|
||||
* `Idle` forever, so it fails on the timeout with the same meaning it failed with before.
|
||||
*/
|
||||
@Test
|
||||
fun `a picked document is loaded as input rather than saved to`() {
|
||||
composeRule.setContent { ConverterScreen() }
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
deliver(Uri.parse("content://test/holiday.mkv"))
|
||||
|
||||
composeRule.waitUntil(PICK_TIMEOUT_MS) {
|
||||
composeRule.onAllNodesWithTag(TestTags.Converter.FILE_CARD_NAME)
|
||||
.fetchSemanticsNodes()
|
||||
.isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* `ConverterScreen.kt:65-67` records why the all-types wildcard is load-bearing rather than lazy:
|
||||
*
|
||||
* > the picker is images and video only, offers no audio at all, and will not reliably surface
|
||||
* > .mkv/.flac/.webm
|
||||
*
|
||||
* Narrowing it would make every audio conversion unreachable from the file picker, and nothing
|
||||
* would have gone red. (The literal is spelled only in the assertion below: a KDoc cannot
|
||||
* contain it, because the wildcard's second half closes the comment.)
|
||||
*/
|
||||
@Test
|
||||
fun `the converter picker asks for every type, not just the ones a photo picker offers`() {
|
||||
composeRule.setContent { ConverterScreen() }
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
|
||||
val intent = launched().intent
|
||||
assertEquals(Intent.ACTION_OPEN_DOCUMENT, intent.action)
|
||||
assertEquals(listOf("*/*"), intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the join picker asks for video and accepts more than one file`() {
|
||||
composeRule.setContent { JoinScreen() }
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).performClick()
|
||||
|
||||
val intent = launched().intent
|
||||
assertEquals(Intent.ACTION_OPEN_DOCUMENT, intent.action)
|
||||
assertEquals(listOf("video/*"), intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList())
|
||||
// A join of one file is not a join; the contract is what asks for several.
|
||||
assertEquals(true, intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false))
|
||||
}
|
||||
|
||||
private fun launched(): ShadowActivity.IntentForResult {
|
||||
composeRule.waitForIdle()
|
||||
return requireNotNull(shadowOf(composeRule.activity).nextStartedActivityForResult) {
|
||||
"nothing was launched for a result"
|
||||
}
|
||||
}
|
||||
|
||||
private fun deliver(uri: Uri) {
|
||||
val started = launched()
|
||||
shadowOf(composeRule.activity).receiveResult(
|
||||
started.intent,
|
||||
Activity.RESULT_OK,
|
||||
Intent().setData(uri),
|
||||
)
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Long enough that a slow CI runner is not the reason this fails, short enough that a
|
||||
* genuinely transposed callback does not stall the suite. The pick normally lands in
|
||||
* single-digit milliseconds.
|
||||
*/
|
||||
const val PICK_TIMEOUT_MS = 10_000L
|
||||
}
|
||||
}
|
||||
+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,99 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.AudioPlan
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.CopyPlanner
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.VideoPlan
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
/**
|
||||
* A job that reached Media3 with a container Media3 cannot mux.
|
||||
*
|
||||
* [Media3Muxers]' own KDoc names the defect this guards: *"the router claimed five containers while
|
||||
* the engine silently wrote MP4 for all of them."* `factoryFor` answers null for fourteen of the
|
||||
* app's containers, and `buildTransformer` turns that null into a failed job rather than letting
|
||||
* `Transformer` fall back to its default muxer.
|
||||
*
|
||||
* The guard had never fired. `Media3Engine$buildTransformer$3` -- the `requireNotNull` message
|
||||
* lambda -- was four lines and four branches at 0%, which is to say the entire repair for a defect
|
||||
* the codebase went to the trouble of writing down was untested. Weakening it would restore that
|
||||
* bug silently, because the wrong output is a *playable file with the wrong container*, not a crash.
|
||||
*
|
||||
* Same harness and same two disciplines as [Media3EngineEmptyCompositionTest]: assert the plan
|
||||
* really is the one the test needs before driving the engine, and rule out
|
||||
* `CancellationException` so an unresumed continuation cannot read as a pass.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class Media3MuxerGuardTest {
|
||||
|
||||
@Test
|
||||
fun `a container Media3 cannot mux fails the job rather than silently writing MP4`() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val engine = Media3Engine(context)
|
||||
val request = ConversionRequest(
|
||||
spec = OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS),
|
||||
probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MP4),
|
||||
)
|
||||
|
||||
// The premise, asserted rather than assumed -- three separate ways this test could pass
|
||||
// over a path it never entered.
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
assertEquals("the plan has to still be WebM by the time the engine sees it", Container.WEBM, plan.container)
|
||||
assertNull("...and Media3 really has no muxer for it", Media3Muxers.factoryFor(plan.container))
|
||||
// Not the empty-composition refusal, which fires earlier and is a different test's subject.
|
||||
assertNotEquals(VideoPlan.Drop, plan.video)
|
||||
assertNotEquals(AudioPlan.Drop, plan.audio)
|
||||
|
||||
val failure = try {
|
||||
runCatching {
|
||||
runBlocking {
|
||||
withTimeout(TIMEOUT_MS) {
|
||||
engine.transcode(Uri.parse("file:///dev/null"), File(context.cacheDir, "guard.webm"), request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
} finally {
|
||||
engine.close()
|
||||
}
|
||||
|
||||
assertFalse(
|
||||
"the continuation was never resumed -- the refusal escaped instead of failing the job: $failure",
|
||||
failure is CancellationException,
|
||||
)
|
||||
// Type *and* message, and the message half is the load-bearing one. Replacing the
|
||||
// requireNotNull with a fallback factory does not make the export succeed here: it lets it
|
||||
// run on and fail some other way, which a bare type assertion would happily accept.
|
||||
assertTrue("expected the muxer guard to refuse the job, got $failure", failure is IllegalArgumentException)
|
||||
assertTrue(
|
||||
"the refusal has to name the container it could not mux, got: ${failure?.message}",
|
||||
failure?.message.orEmpty().contains("cannot mux") &&
|
||||
failure?.message.orEmpty().contains(Container.WEBM.name),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Nothing is decoded or muxed on this path -- the guard refuses before any of that. */
|
||||
const val TIMEOUT_MS = 10_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.InputKind
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
|
||||
/**
|
||||
* Which of the two probes wins, when they disagree.
|
||||
*
|
||||
* [MediaProbe.probe] runs `MediaExtractor` and FFprobe independently and then merges the two, and
|
||||
* every rule in that merge is a decision. None of them had a test, for a reason that is structural
|
||||
* rather than an oversight: `RemuxTest` drives the whole thing on a device against committed
|
||||
* fixtures, but only ever with **one probe answering and the other agreeing or also failing**.
|
||||
* Nothing on any source set can arrange for a real extractor and a real FFprobe to disagree, so
|
||||
* every elvis in the merge was taken in one direction and never the other.
|
||||
*
|
||||
* Cutting `merge` out of `probe` is what makes the question askable. Both halves of its signature
|
||||
* had to become `internal` for that -- `Extracted` already was, with a KDoc giving this exact
|
||||
* reason; `FFprobeInfo` simply never got the same treatment.
|
||||
*/
|
||||
class MediaProbeMergeTest {
|
||||
|
||||
/**
|
||||
* The rule with the loudest failure mode, and `isImageFormat`'s own KDoc names it: a false
|
||||
* positive here "makes the source-info card describe a video as an image". So the image verdict
|
||||
* has to beat a real video codec from the extractor, and the ordering that makes it do so is
|
||||
* the first arm of `classify` rather than anything a reader would infer from the fields.
|
||||
*/
|
||||
@Test
|
||||
fun `an image verdict from FFprobe beats a video codec from the extractor`() {
|
||||
val merged = MediaProbe.merge(
|
||||
extracted = extracted(video = "h264"),
|
||||
info = info(video = "mjpeg", isImage = true),
|
||||
)
|
||||
|
||||
assertEquals(InputKind.IMAGE, merged.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the extractor wins on codecs, because it is the view the router will act on`() {
|
||||
val merged = MediaProbe.merge(
|
||||
extracted = extracted(video = "h264", audio = "aac"),
|
||||
info = info(video = "hevc", audio = "mp3"),
|
||||
)
|
||||
|
||||
assertEquals("h264", merged.videoCodec)
|
||||
assertEquals("aac", merged.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FFprobe answers for a file the extractor could not open`() {
|
||||
val merged = MediaProbe.merge(extracted = null, info = info(video = "vp9", audio = "opus"))
|
||||
|
||||
assertEquals("vp9", merged.videoCodec)
|
||||
assertEquals("opus", merged.audioCodec)
|
||||
assertEquals(InputKind.VIDEO, merged.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the extractor answers for a file FFprobe could not read`() {
|
||||
val merged = MediaProbe.merge(extracted = extracted(video = "h264", audio = "aac"), info = null)
|
||||
|
||||
assertEquals("h264", merged.videoCodec)
|
||||
assertEquals("aac", merged.audioCodec)
|
||||
assertNull("only FFprobe can name the container, so it stays unknown here", merged.container)
|
||||
}
|
||||
|
||||
/**
|
||||
* The larger of the two, not the first non-zero.
|
||||
*
|
||||
* Either probe can report zero for a file the other times correctly, and a zero duration makes
|
||||
* the FFmpeg progress percentage undefined -- `FFmpegEngine` divides by it. Both orderings are
|
||||
* asserted because "take the extractor's" and "take the larger" agree in one direction and not
|
||||
* the other, and only one of them is the rule.
|
||||
*/
|
||||
@Test
|
||||
fun `duration is the longer of the two readings, whichever probe supplied it`() {
|
||||
assertEquals(
|
||||
5_000L,
|
||||
MediaProbe.merge(extracted(duration = 0L), info(duration = 5_000L)).durationMs,
|
||||
)
|
||||
assertEquals(
|
||||
5_000L,
|
||||
MediaProbe.merge(extracted(duration = 5_000L), info(duration = 0L)).durationMs,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dimensions come from the extractor, and from FFprobe only when it has none`() {
|
||||
assertEquals(1920, MediaProbe.merge(extracted(width = 1920), info(width = 640)).width)
|
||||
assertEquals(640, MediaProbe.merge(extracted = null, info = info(width = 640)).width)
|
||||
assertEquals(0, MediaProbe.merge(extracted(width = 0), info(width = 0)).width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the container comes from FFprobe, which is the only probe that can name one`() {
|
||||
val merged = MediaProbe.merge(extracted(video = "h264"), info(container = Container.MKV))
|
||||
|
||||
assertEquals(Container.MKV, merged.container)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with audio and no video is audio-only, not unparseable`() {
|
||||
val merged = MediaProbe.merge(extracted(video = null, audio = "mp3"), info = null)
|
||||
|
||||
assertEquals(InputKind.AUDIO_ONLY, merged.kind)
|
||||
assertFalse(merged.hasVideo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file neither probe could open is the one unreadable answer`() {
|
||||
val merged = MediaProbe.merge(extracted = null, info = null)
|
||||
|
||||
assertEquals(MediaProbe.UNREADABLE, merged)
|
||||
assertEquals(InputProbe.UNPARSEABLE, merged.videoCodec)
|
||||
}
|
||||
|
||||
/**
|
||||
* The arm the ticket was filed for: parsed, and carrying no stream either probe recognised.
|
||||
*
|
||||
* Distinct from "neither probe could open it" -- here the extractor opened the file happily and
|
||||
* found nothing convertible, which is what a container holding only subtitles looks like. It
|
||||
* has to reach the same [MediaProbe.UNREADABLE] answer, because the router keys off that and
|
||||
* there is nothing here for Media3 to do either way.
|
||||
*
|
||||
* Its input was already being built elsewhere in the suite -- `MediaProbeTrackWalkTest` calls
|
||||
* `extractedFrom(emptyList())` and gets exactly this -- and had simply never been handed to the
|
||||
* merge.
|
||||
*/
|
||||
@Test
|
||||
fun `a file that parsed but carries no recognised stream is unreadable too`() {
|
||||
val merged = MediaProbe.merge(extracted = MediaProbe.extractedFrom(emptyList()), info = null)
|
||||
|
||||
assertEquals(InputKind.UNPARSEABLE, merged.kind)
|
||||
assertEquals(MediaProbe.UNREADABLE, merged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasVideo follows the codec that survived the merge, not either probe alone`() {
|
||||
assertTrue(MediaProbe.merge(extracted(video = null), info(video = "vp9")).hasVideo)
|
||||
assertFalse(MediaProbe.merge(extracted(video = null, audio = "aac"), info(video = null)).hasVideo)
|
||||
}
|
||||
|
||||
private fun extracted(
|
||||
video: String? = "h264",
|
||||
audio: String? = "aac",
|
||||
duration: Long = 1_000L,
|
||||
width: Int = 1280,
|
||||
height: Int = 720,
|
||||
) = MediaProbe.Extracted(video, audio, duration, width, height)
|
||||
|
||||
private fun info(
|
||||
container: Container? = null,
|
||||
video: String? = "h264",
|
||||
audio: String? = "aac",
|
||||
duration: Long = 1_000L,
|
||||
width: Int = 1280,
|
||||
height: Int = 720,
|
||||
isImage: Boolean = false,
|
||||
) = MediaProbe.FFprobeInfo(container, video, audio, duration, width, height, isImage)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.media.MediaFormat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The rules `MediaProbe` applies to a set of track formats.
|
||||
*
|
||||
* ## Why this exists, and what it revises
|
||||
*
|
||||
* Issue #84 classified `probeWithExtractor` and `probeForConcat` as device-bound and explicitly not
|
||||
* a gap:
|
||||
*
|
||||
* > These are exercised by `RemuxTest`, `ConcatEngineTest` and `RealMediaBenchmark` in
|
||||
* > `androidTest` … **Do not read their 0% as untested.**
|
||||
*
|
||||
* That was right about the measurement boundary and right about FFprobe. It was not right that
|
||||
* these are only orchestration. The track walk is a **branch matrix**, and `androidTest` reaches it
|
||||
* only through whatever the committed fixtures happen to contain — so none of the rules below is
|
||||
* *chosen* by any test there. A fixture with two video tracks, a track that omits its duration, or
|
||||
* an audio-before-video ordering is not something a device test would produce on purpose.
|
||||
*
|
||||
* The seam is the answer #133 preferred over driving `ShadowMediaExtractor`: the walk is a pure
|
||||
* function over `List<MediaFormat>`, and what is left needing a device — `setDataSource`,
|
||||
* `getTrackFormat`, `release` — is the thin edge `androidTest` should be covering. This is the
|
||||
* `work/FailureOutcome.kt` pattern `CLAUDE.md` names.
|
||||
*
|
||||
* `MediaFormat` is a real one throughout, not a stub. `MediaProbeTrackFieldsTest` records why that
|
||||
* matters: it is a heterogeneous map whose getters throw rather than coerce, and a hand-rolled
|
||||
* double would not reproduce that.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MediaProbeTrackWalkTest {
|
||||
|
||||
// --- extractedFrom: the conversion flow's read ---------------------------
|
||||
|
||||
@Test
|
||||
fun `the first video track wins when a file carries two`() {
|
||||
// `video == null` is the entire guard. A file with two video tracks must report the first,
|
||||
// because that is the one an engine will transcode -- and the width and height must come
|
||||
// from the same track, not be mixed across them.
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1920, height = 1080),
|
||||
video(MediaFormat.MIMETYPE_VIDEO_HEVC, width = 640, height = 480),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", extracted.videoCodec)
|
||||
assertEquals(1920, extracted.width)
|
||||
assertEquals(1080, extracted.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first audio track wins when a file carries two`() {
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_OPUS),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("aac", extracted.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duration is the longest track, not the first or the last`() {
|
||||
// A file whose audio outlasts its video is ordinary. Taking the video's length would cut
|
||||
// the progress bar short; taking the last track's would be right only by accident of order.
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, durationUs = 10_000_000),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC, durationUs = 12_500_000),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_OPUS, durationUs = 1_000_000),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(12_500L, extracted.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track that does not declare its duration contributes nothing to it`() {
|
||||
// MediaExtractor omits KEY_DURATION for plenty of real tracks -- MediaProbeTrackFieldsTest
|
||||
// records the same for KEY_FRAME_RATE. Reading a key that is absent is what containsKey
|
||||
// stands between us and.
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC, durationUs = 7_000_000),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(7_000L, extracted.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declaring audio before video changes nothing`() {
|
||||
// Track order is a property of the container, not of the content. Both orderings have to
|
||||
// reach the same answer or the same file remuxed twice would probe differently.
|
||||
val videoFirst = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1280, height = 720),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
|
||||
),
|
||||
)
|
||||
val audioFirst = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1280, height = 720),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(videoFirst.videoCodec, audioFirst.videoCodec)
|
||||
assertEquals(videoFirst.audioCodec, audioFirst.audioCodec)
|
||||
assertEquals(videoFirst.width, audioFirst.width)
|
||||
assertEquals(videoFirst.height, audioFirst.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track that is neither audio nor video is ignored`() {
|
||||
// Subtitle and timed-metadata tracks are common in MKV and MP4. Neither prefix matches, so
|
||||
// neither slot is filled -- and, importantly, a subtitle track must not be mistaken for the
|
||||
// absence of an audio track by some later `else`.
|
||||
val extracted = MediaProbe.extractedFrom(
|
||||
listOf(
|
||||
MediaFormat().apply { setString(MediaFormat.KEY_MIME, "text/vtt") },
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", extracted.videoCodec)
|
||||
assertNull(extracted.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no tracks reports nothing rather than zero-width video`() {
|
||||
val extracted = MediaProbe.extractedFrom(emptyList())
|
||||
|
||||
assertNull(extracted.videoCodec)
|
||||
assertNull(extracted.audioCodec)
|
||||
assertEquals(0L, extracted.durationMs)
|
||||
assertEquals(0, extracted.width)
|
||||
assertEquals(0, extracted.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio-only file reports no video codec at all`() {
|
||||
// The distinction MediaProbe.classify turns into InputKind.AUDIO_ONLY, and the reason
|
||||
// `hasVideo` exists: an audio file and a corrupt file must not look alike.
|
||||
val extracted = MediaProbe.extractedFrom(listOf(audio(MediaFormat.MIMETYPE_AUDIO_AAC)))
|
||||
|
||||
assertNull(extracted.videoCodec)
|
||||
assertEquals("aac", extracted.audioCodec)
|
||||
assertEquals(0, extracted.width)
|
||||
}
|
||||
|
||||
// --- concatInputFrom: the join flow's read -------------------------------
|
||||
|
||||
@Test
|
||||
fun `the join read takes frame rate from the first video track`() {
|
||||
val input = MediaProbe.concatInputFrom(
|
||||
listOf(
|
||||
video(MediaFormat.MIMETYPE_VIDEO_AVC, width = 1920, height = 1080, frameRate = 30),
|
||||
video(MediaFormat.MIMETYPE_VIDEO_HEVC, width = 640, height = 480, frameRate = 60),
|
||||
audio(MediaFormat.MIMETYPE_AUDIO_AAC),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264", input.videoCodec)
|
||||
assertEquals("aac", input.audioCodec)
|
||||
assertEquals(1920, input.width)
|
||||
assertEquals(1080, input.height)
|
||||
assertEquals(30, input.frameRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a video track with no declared frame rate reports zero rather than guessing`() {
|
||||
// ConcatPlanner treats 0 as "cannot prove a match" and re-encodes. A guessed 30 would read
|
||||
// as agreement and produce a stream copy of clips that do not actually match -- the failure
|
||||
// its KDoc says the whole flow is arranged to avoid.
|
||||
val input = MediaProbe.concatInputFrom(listOf(video(MediaFormat.MIMETYPE_VIDEO_AVC)))
|
||||
|
||||
assertEquals(0, input.frameRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no tracks joins as entirely unknown`() {
|
||||
val input = MediaProbe.concatInputFrom(emptyList())
|
||||
|
||||
assertNull(input.videoCodec)
|
||||
assertNull(input.audioCodec)
|
||||
assertEquals(0, input.width)
|
||||
assertEquals(0, input.height)
|
||||
assertEquals(0, input.frameRate)
|
||||
}
|
||||
|
||||
private fun video(
|
||||
mime: String,
|
||||
width: Int = 1920,
|
||||
height: Int = 1080,
|
||||
durationUs: Long? = null,
|
||||
frameRate: Int? = null,
|
||||
): MediaFormat = MediaFormat.createVideoFormat(mime, width, height).apply {
|
||||
durationUs?.let { setLong(MediaFormat.KEY_DURATION, it) }
|
||||
frameRate?.let { setInteger(MediaFormat.KEY_FRAME_RATE, it) }
|
||||
}
|
||||
|
||||
private fun audio(mime: String, durationUs: Long? = null): MediaFormat =
|
||||
MediaFormat.createAudioFormat(mime, SAMPLE_RATE, CHANNELS).apply {
|
||||
durationUs?.let { setLong(MediaFormat.KEY_DURATION, it) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SAMPLE_RATE = 48_000
|
||||
const val CHANNELS = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinState
|
||||
import org.libremediaconverter.join.JoinViewModel
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* A failure that says nothing still has to say something.
|
||||
*
|
||||
* Three sites, all `ci == 0` before this file, and all the same rule:
|
||||
*
|
||||
* ```
|
||||
* work/ConversionWorker.kt:316 cause.message ?: GENERIC_FAILURE_MESSAGE
|
||||
* convert/ConversionViewModel.kt:631 e.message ?: SAVE_FAILED_MESSAGE
|
||||
* join/JoinViewModel.kt:416 e.message ?: SAVE_FAILED_MESSAGE
|
||||
* ```
|
||||
*
|
||||
* Every existing test throws *with* a message, so the right-hand side had never been evaluated
|
||||
* anywhere in the suite. A `Throwable` carrying none is not exotic — `RuntimeException()`,
|
||||
* `IOException()` and most platform exceptions raised without an argument all have a null message.
|
||||
*
|
||||
* ## Held in one class, against the ticket's suggestion
|
||||
*
|
||||
* #193 proposed putting each case beside the behaviour it neighbours. They are together instead,
|
||||
* because they are one rule at three layers and because the trap below has to be explained once
|
||||
* rather than three times. `FailedSaveRetryTest` sets the precedent for both ViewModels in one
|
||||
* file; this extends it by one worker.
|
||||
*
|
||||
* ## The trap, which is why the worker case asserts what it does
|
||||
*
|
||||
* `ConversionStateMappingTest`'s *"a failure with nothing said still says something"* looks like it
|
||||
* already covers the worker site. It does not: it drives the **read** side, `map(FAILED, Data.EMPTY)`,
|
||||
* and that side has a fallback of its own (`ConversionViewModel.kt:147-149`):
|
||||
*
|
||||
* ```kotlin
|
||||
* update.outputData.getString(ConversionWorker.KEY_ERROR)
|
||||
* ?.takeIf { it.isNotBlank() }
|
||||
* ?: ConversionWorker.GENERIC_FAILURE_MESSAGE
|
||||
* ```
|
||||
*
|
||||
* So mutating the worker's fallback to `.orEmpty()` writes `KEY_ERROR to ""`, and the ViewModel
|
||||
* turns that straight back into the same constant. **A test asserting on the resulting `Failed`
|
||||
* state stays green under the mutation**, which is most likely why the write-side fallback survived
|
||||
* three waves of test work. The worker case therefore reads `KEY_ERROR` off the worker's own
|
||||
* `Result`, before anything downstream can repair it.
|
||||
*
|
||||
* The two save cases have no such second line: both write `_state.value` directly, so the state is
|
||||
* the right thing to assert there.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MessagelessFailureTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine gives up without saying why, which is what a native crash looks like from here.
|
||||
*
|
||||
* Asserted on the worker's own output `Data` rather than on a screen — see the class KDoc.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversion that fails without a message still reports one`() {
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
ConversionDependencies.software = { MessagelessTranscoder }
|
||||
|
||||
val result = runBlocking { failingWorker().doWork() }
|
||||
|
||||
assertTrue("the job must fail rather than retry, got $result", result is ListenableWorker.Result.Failure)
|
||||
assertEquals(
|
||||
"a failure with no message must still put something on screen",
|
||||
ConversionWorker.GENERIC_FAILURE_MESSAGE,
|
||||
(result as ListenableWorker.Result.Failure).outputData.getString(ConversionWorker.KEY_ERROR),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save that fails without a message still reports one`() {
|
||||
installTestWorkManager(app, conversionOutput())
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
viewModel.convert()
|
||||
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
|
||||
|
||||
publisher.publishFailure = RuntimeException()
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed } as ConversionState.Failed
|
||||
assertEquals(SAVE_FAILED_MESSAGE, failed.message)
|
||||
// The handle travels even on the wordless path. Without this, a fallback that also dropped
|
||||
// `pending` would pass -- and the file would be unreachable from the screen that just said
|
||||
// the save failed.
|
||||
assertNotNull("a wordless failure must still offer the file again", failed.retry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join save that fails without a message still reports one`() {
|
||||
installTestWorkManager(app, joinOutput())
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
|
||||
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
viewModel.join()
|
||||
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
|
||||
|
||||
publisher.publishFailure = RuntimeException()
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed } as JoinState.Failed
|
||||
assertEquals(SAVE_FAILED_MESSAGE, failed.message)
|
||||
assertNotNull("a wordless failure must still offer the file again", failed.retry)
|
||||
}
|
||||
|
||||
/**
|
||||
* `FORCE_SOFTWARE` so the failure comes straight out of `runFFmpeg`.
|
||||
*
|
||||
* `AUTO` would enter `runMedia3OrFallBack`, whose catch runs the job a second time in software
|
||||
* — the same exception would arrive, but through a path this test is not about and which
|
||||
* `HardwareFallbackTest` already owns.
|
||||
*/
|
||||
private fun failingWorker(): ConversionWorker {
|
||||
val spec = OutputFormat.MP4_H265.spec
|
||||
return TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConversionWorker.KEY_INPUT_URI to "file:///tmp/holiday.mp4",
|
||||
ConversionWorker.KEY_DISPLAY_NAME to "holiday.mp4",
|
||||
ConversionWorker.KEY_CONTAINER to spec.container.name,
|
||||
ConversionWorker.KEY_VIDEO_CODEC to spec.videoCodec.name,
|
||||
ConversionWorker.KEY_AUDIO_CODEC to spec.audioCodec.name,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID).build()
|
||||
}
|
||||
|
||||
private fun conversionOutput() = workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConversionWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
private fun joinOutput() = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConcatWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConcatWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-00000000019a")
|
||||
const val SUGGESTED_NAME = "holiday.mp4"
|
||||
const val JOB_MIME_TYPE = "video/mp4"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An engine that gives up without saying why.
|
||||
*
|
||||
* `RuntimeException()` rather than a subclass with a blank message: `Throwable.message` is *null*
|
||||
* here, which is the case the elvis exists for. A blank-but-present message takes the left-hand
|
||||
* side and is a different path — `ConversionStateMappingTest` covers that one, on the read side.
|
||||
*/
|
||||
@UnstableApi
|
||||
private object MessagelessTranscoder : SoftwareTranscoder {
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
): Unit = throw RuntimeException()
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ProviderInfo
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -20,7 +10,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
@@ -31,90 +20,8 @@ import java.io.OutputStream
|
||||
/** What a destination volume says when it fills up mid-write. */
|
||||
private const val NO_SPACE = "No space left on device"
|
||||
|
||||
private const val DOCUMENTS_AUTHORITY = "org.libremediaconverter.test.documents"
|
||||
private const val PLAIN_AUTHORITY = "org.libremediaconverter.test.plain"
|
||||
|
||||
/**
|
||||
* A stand-in for the provider behind a SAF destination.
|
||||
*
|
||||
* It answers only what `publish()` asks of a destination -- how many bytes are already there,
|
||||
* and delete it -- backed by a real file so the assertions are about the filesystem rather
|
||||
* than about a mock's call log alone. The rest of the `ContentProvider` surface is stubbed.
|
||||
*
|
||||
* Writing is deliberately NOT routed through it. Robolectric's `ShadowContentResolver`
|
||||
* consults its registered-stream map before it reaches any provider, which is what lets a
|
||||
* test hand out a stream that writes some bytes and then fails -- the condition this whole
|
||||
* file exists for, and one a real provider cannot be asked to produce on demand.
|
||||
*/
|
||||
internal open class FakeSafProvider : ContentProvider() {
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor? {
|
||||
val file = backingFile(uri)
|
||||
if (!file.exists()) return null
|
||||
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply {
|
||||
addRow(arrayOf<Any?>(file.name, file.length()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
||||
if (method != METHOD_DELETE_DOCUMENT) return null
|
||||
val target = extras?.getParcelable(EXTRA_URI, Uri::class.java) ?: return null
|
||||
deleteRequests += target
|
||||
deleteFailure?.let { throw it }
|
||||
backingFile(target).delete()
|
||||
return Bundle()
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri) = "video/mp4"
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
companion object {
|
||||
// DocumentsContract.METHOD_DELETE_DOCUMENT and EXTRA_URI are hidden from the public
|
||||
// SDK, so they cannot be referenced. These are the wire names
|
||||
// DocumentsContract.deleteDocument() actually sends, which is what a provider sees.
|
||||
const val METHOD_DELETE_DOCUMENT = "android:deleteDocument"
|
||||
const val EXTRA_URI = "uri"
|
||||
|
||||
/** Where the "documents" really live. Set per test to a Robolectric temp path. */
|
||||
lateinit var root: File
|
||||
|
||||
/** Every delete this provider was asked for, in order. Empty is an assertion too. */
|
||||
val deleteRequests = mutableListOf<Uri>()
|
||||
|
||||
/** Armed by the test that needs the cleanup itself to fail. */
|
||||
var deleteFailure: RuntimeException? = null
|
||||
|
||||
fun backingFile(uri: Uri) = File(root, uri.lastPathSegment.orEmpty())
|
||||
|
||||
fun reset(directory: File) {
|
||||
root = directory
|
||||
deleteRequests.clear()
|
||||
deleteFailure = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same provider, registered WITHOUT the documents-provider intent filter.
|
||||
*
|
||||
* A separate class because the package manager keys providers by component name, so two
|
||||
* authorities need two components. It exists to prove the guard is a guard: a content URI
|
||||
* from something that is not a documents provider must not be handed to `deleteDocument`.
|
||||
*/
|
||||
internal class FakePlainProvider : FakeSafProvider()
|
||||
/** How far a failing copy gets before the volume "fills up". Any value below the payload does. */
|
||||
private const val PARTIAL_BYTES = 512
|
||||
|
||||
/**
|
||||
* A sink that behaves like a volume filling up.
|
||||
@@ -171,6 +78,8 @@ class OutputPublisherPublishTest {
|
||||
|
||||
private val payload = ByteArray(8192) { (it % 251).toByte() }
|
||||
|
||||
/** How far a failing copy gets before the volume "fills up". Any value below the payload does. */
|
||||
|
||||
private val documentUri: Uri = Uri.parse("content://$DOCUMENTS_AUTHORITY/document/holiday.mp4")
|
||||
private val plainUri: Uri = Uri.parse("content://$PLAIN_AUTHORITY/document/holiday_plain.mp4")
|
||||
private val deadUri: Uri = Uri.parse("content://org.libremediaconverter.nonexistent/document/gone.mp4")
|
||||
@@ -179,8 +88,8 @@ class OutputPublisherPublishTest {
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
FakeSafProvider.reset(File(context.cacheDir, "destinations").apply { mkdirs() })
|
||||
register(FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
|
||||
register(FakePlainProvider::class.java, PLAIN_AUTHORITY, asDocumentsProvider = false)
|
||||
registerProvider(context, FakeSafProvider::class.java, DOCUMENTS_AUTHORITY, asDocumentsProvider = true)
|
||||
registerProvider(context, FakePlainProvider::class.java, PLAIN_AUTHORITY, asDocumentsProvider = false)
|
||||
|
||||
// SAF's CreateDocument contract hands back a document that already exists and is
|
||||
// empty, so that is the state every destination starts in here.
|
||||
@@ -299,6 +208,76 @@ class OutputPublisherPublishTest {
|
||||
assertEquals(emptyList<Uri>(), FakeSafProvider.deleteRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a destination whose size cannot be determined is never deleted`() {
|
||||
// The three short-circuits in destinationIsKnownEmpty, and the reason its KDoc gives for
|
||||
// each of them answering false:
|
||||
//
|
||||
// "this decides whether a delete is allowed and 'I could not tell' must never authorise
|
||||
// one."
|
||||
//
|
||||
// The contrast is `a copy that fails partway leaves nothing at the destination` above: a
|
||||
// provider that *does* say zero gets the delete. These say nothing, so they must not.
|
||||
// Getting this backwards costs the user a file they already had, on a save that failed.
|
||||
//
|
||||
// Named exemption: of the three conjuncts, `size >= 0` cannot be falsified behaviourally.
|
||||
// Measured -- getColumnIndex returns -1 for an absent column, and isNull(-1) throws
|
||||
// CursorIndexOutOfBoundsException, which the surrounding runCatching already turns into
|
||||
// `?: false`. So relaxing it to `size >= -1` leaves this test green: same answer, reached
|
||||
// by the exception path instead. The guard should stay -- control flow through an exception
|
||||
// is worse than a comparison, and another Cursor implementation need not throw -- but no
|
||||
// assertion here pins it, and saying so beats implying the missing-column case covers it.
|
||||
// `!row.isNull(size)` and `row.moveToFirst()` do both bite.
|
||||
listOf(
|
||||
RowShape.NO_SIZE_COLUMN to "a cursor with no SIZE column",
|
||||
RowShape.NULL_SIZE to "a cursor whose SIZE cell is null",
|
||||
RowShape.NO_ROWS to "a cursor holding no rows",
|
||||
// The third case the KDoc names -- "a resolver call that throws" -- and the one the
|
||||
// list was missing. It reaches `?: false` through `runCatching` rather than through a
|
||||
// cursor answer, so it is the only one of the four that proves the catch is load
|
||||
// bearing: a provider that revokes its grant between the picker and the write must not
|
||||
// have its document deleted on the way out.
|
||||
RowShape.QUERY_THROWS to "a provider that throws out of query",
|
||||
).forEach { (shape, description) ->
|
||||
FakeSafProvider.deleteRequests.clear()
|
||||
FakeSafProvider.backingFile(documentUri).writeBytes(ByteArray(0))
|
||||
FakeSafProvider.rowShape = shape
|
||||
failMidCopy(documentUri, afterBytes = PARTIAL_BYTES)
|
||||
|
||||
assertThrows(IOException::class.java) { publisher.publish(staged, documentUri) }
|
||||
|
||||
assertEquals(
|
||||
"$description must not authorise a delete",
|
||||
emptyList<Uri>(),
|
||||
FakeSafProvider.deleteRequests,
|
||||
)
|
||||
assertTrue(
|
||||
"$description must leave the destination where it was",
|
||||
FakeSafProvider.backingFile(documentUri).exists(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider that declines by returning null fails with the destination named`() {
|
||||
// openOutputStream has two ways of refusing, and only one of them is otherwise reachable.
|
||||
// `a destination the provider will not open...` above drives the throwing one -- a provider
|
||||
// that has gone away. This is the other: a provider that is present, answers, and hands
|
||||
// back null. Without the `?: error(...)` that becomes an NPE inside `use`, which reaches
|
||||
// the user as "Conversion failed." with a null message.
|
||||
val nullOpening = object : OutputPublisher(context) {
|
||||
override fun openDestination(destination: Uri): OutputStream? = null
|
||||
}
|
||||
|
||||
val failure = runCatching { nullOpening.publish(staged, documentUri) }.exceptionOrNull()
|
||||
|
||||
assertTrue("a null stream must not appear to succeed, got $failure", failure != null)
|
||||
assertTrue(
|
||||
"the failure must name the destination rather than being a bare NPE; got ${failure?.message}",
|
||||
failure?.message?.contains("Could not open destination for writing") == true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy that succeeds delivers every byte and deletes nothing`() {
|
||||
shadowOf(context.contentResolver).registerOutputStreamSupplier(documentUri) {
|
||||
@@ -326,28 +305,4 @@ class OutputPublisherPublishTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun register(provider: Class<out FakeSafProvider>, authority: String, asDocumentsProvider: Boolean) {
|
||||
val info = ProviderInfo().apply {
|
||||
this.authority = authority
|
||||
packageName = context.packageName
|
||||
name = provider.name
|
||||
exported = true
|
||||
grantUriPermissions = true
|
||||
}
|
||||
Robolectric.buildContentProvider(provider).create(info)
|
||||
|
||||
// isDocumentUri() does not look at the URI alone: it asks the package manager whether
|
||||
// anything answers ACTION_DOCUMENTS_PROVIDER for that authority. Registering the
|
||||
// provider with the resolver is not enough, which is the whole reason the negative
|
||||
// case above can exist.
|
||||
val packageManager = shadowOf(context.packageManager)
|
||||
packageManager.addOrUpdateProvider(info)
|
||||
if (asDocumentsProvider) {
|
||||
packageManager.addIntentFilterForProvider(
|
||||
ComponentName(context.packageName, provider.name),
|
||||
IntentFilter(DocumentsContract.PROVIDER_INTERFACE),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -26,14 +28,18 @@ import java.util.UUID
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class OutputPublisherStagingTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var cacheDir: File
|
||||
private lateinit var publisher: OutputPublisher
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
cacheDir = context.cacheDir
|
||||
publisher = OutputPublisher(context)
|
||||
// Held as a field rather than a local: the race test below builds an anonymous
|
||||
// OutputPublisher, and inside that `object` expression a bare `context` resolves to the
|
||||
// superclass's own constructor property, which is not initialised at the super call.
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
cacheDir = app.cacheDir
|
||||
publisher = OutputPublisher(app)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -99,4 +105,104 @@ class OutputPublisherStagingTest {
|
||||
|
||||
publisher.sweepStaging()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sweep tolerates a staging path that is not a directory`() {
|
||||
// The other half of `listFiles() ?: return`, and not the same as the case above: a missing
|
||||
// directory is created by `stagingDir`'s own mkdirs() and lists as empty. Only a path that
|
||||
// cannot be a directory makes listFiles() answer null, and a sweep that dereferenced that
|
||||
// would take the app down on a launch rather than on a conversion -- AppStartSweepTest is
|
||||
// where this runs from.
|
||||
val stagingPath = stagingPathAsRegularFile()
|
||||
|
||||
publisher.sweepStaging()
|
||||
|
||||
assertTrue("the sweep must not have replaced the fixture", stagingPath.isFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes `cacheDir/conversions` a regular file, which is the whole precondition of the test
|
||||
* above -- and does it in a loop, because a single delete-then-write once lost a race that CI
|
||||
* caught and this machine did not reproduce.
|
||||
*
|
||||
* **That race is closed at the source as of #159, and the loop is kept anyway.**
|
||||
* `LibreMediaConverterApp.onCreate` launched its staging sweep on `Dispatchers.IO`, and
|
||||
* `sweepStaging` reads `stagingDir`, whose getter calls `mkdirs()`. Robolectric builds an
|
||||
* application for every test class that asks for one, so that background `mkdirs()` was in
|
||||
* flight across the whole suite, on a thread the paused main looper does not control. Between
|
||||
* deleting this path and writing it there is a window where the path does not exist and that
|
||||
* `mkdirs()` could win -- `FileNotFoundException: ... (Is a directory)` out of `writeBytes`,
|
||||
* run 33069641674 on #149, once, against 468 tests that passed here. The JVM suite now runs
|
||||
* `TestLibreMediaConverterApp`, whose sweep finishes before `onCreate()` returns, so nothing is
|
||||
* sweeping while a test body runs.
|
||||
*
|
||||
* The loop stays because it is what would catch that substitution being undone. Without it the
|
||||
* regression returns as this one class failing rarely on CI -- the exact shape that took #159
|
||||
* from a single run on #149 to a wave-4 flake before anyone chased it. Retrying closes the
|
||||
* window rather than narrowing it, because the race is not symmetric: `mkdirs()` fails on an
|
||||
* existing regular file, so the invariant only has to survive being *established*.
|
||||
*/
|
||||
private fun stagingPathAsRegularFile(): File {
|
||||
val stagingPath = File(cacheDir, "conversions")
|
||||
repeat(FIXTURE_ATTEMPTS) {
|
||||
if (stagingPath.isFile) return stagingPath
|
||||
stagingPath.deleteRecursively()
|
||||
runCatching { stagingPath.writeBytes(ByteArray(FIXTURE_BYTES)) }
|
||||
}
|
||||
check(stagingPath.isFile) {
|
||||
"the fixture needs $stagingPath to be a regular file and it is a directory; " +
|
||||
"something recreated it $FIXTURE_ATTEMPTS times -- see #159"
|
||||
}
|
||||
return stagingPath
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file that stops being collectable between the listing and the delete survives`() {
|
||||
// The race the second timestamp read exists for, and the only branch of it that had never
|
||||
// run. The comment in sweepStaging states the cost precisely: a worker resumed by
|
||||
// WorkManager -- in this same process -- could have started writing this very file, and
|
||||
// unlinking an inode a running job still holds open ends with the job reporting success for
|
||||
// a path that no longer exists.
|
||||
//
|
||||
// So: a file old enough to collect at listing time, touched to now before the delete is
|
||||
// reached. StagingSweep.collectable already said yes; isCollectable has to say no.
|
||||
val orphan = publisher.createStagingFile(
|
||||
StagingNames.forJob(UUID.randomUUID(), "mp4"),
|
||||
).apply { writeBytes(ByteArray(4096)) }
|
||||
assertTrue(orphan.setLastModified(System.currentTimeMillis() - StagingSweep.GRACE_PERIOD_MS - 60_000))
|
||||
|
||||
// Touched *after* the snapshot is taken, which is the only window that reaches the
|
||||
// re-read. Doing it around listFiles() instead changes what StagingSweep.collectable is
|
||||
// given, so the file is never proposed for deletion and the guard is never exercised --
|
||||
// measured, and the reason the seam sits where it does.
|
||||
val racing = object : OutputPublisher(app) {
|
||||
override fun snapshot(listing: Array<File>): List<StagingSweep.Entry> =
|
||||
super.snapshot(listing).also { orphan.setLastModified(System.currentTimeMillis()) }
|
||||
}
|
||||
|
||||
racing.sweepStaging()
|
||||
|
||||
assertTrue(
|
||||
"a file a live job started writing after the listing must not be unlinked",
|
||||
orphan.exists(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discarding a file with no parent at all is refused`() {
|
||||
// A relative name has no parent directory, so `staged.parentFile` is null. The handle
|
||||
// reaches the ViewModel as a path string out of WorkInfo.outputData and is turned straight
|
||||
// into a File, so this is not a shape the caller can rule out -- and the guard has to
|
||||
// answer false rather than dereference it.
|
||||
val parentless = File("holiday.mp4")
|
||||
assertNull("the fixture is supposed to have no parent", parentless.parentFile)
|
||||
|
||||
assertFalse("a file with no parent is not in staging", publisher.discardStaged(parentless))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Enough to outlast a burst of application-scope sweeps; one attempt is what CI lost. */
|
||||
const val FIXTURE_ATTEMPTS = 50
|
||||
const val FIXTURE_BYTES = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* A dispatcher that holds a pick in flight until the test lets it finish.
|
||||
*
|
||||
* Issue #49 is about what a ViewModel does *while* a pick is between the tap and the write it
|
||||
* eventually makes. Both ViewModels put a blocking hop there — the metadata query, and on the
|
||||
* convert side the probe as well — and both hops go through an injectable dispatcher. Handing
|
||||
* them this one turns "the pick has been made but has not landed yet" from a window a test has
|
||||
* to race into a state it can simply sit in.
|
||||
*
|
||||
* Nothing here is a fake pick. The real `InputQuery.describe` still runs, on this thread,
|
||||
* whenever [runAll] is called; the only thing under the test's control is *when*.
|
||||
*
|
||||
* Confined to the thread that drives the test. Both ViewModels reach `withContext(pickDispatcher)`
|
||||
* from a coroutine on the main dispatcher, so [dispatch] is only ever called from there — the
|
||||
* queue is concurrent anyway, because a dispatcher that quietly dropped a block from another
|
||||
* thread would fail as a hang rather than as an assertion.
|
||||
*/
|
||||
class ParkedPickDispatcher : CoroutineDispatcher() {
|
||||
|
||||
private val parked = ConcurrentLinkedQueue<Runnable>()
|
||||
|
||||
/**
|
||||
* How many blocks are waiting.
|
||||
*
|
||||
* Asserted on before the interesting part of a test, because "the pick was in flight" is a
|
||||
* premise rather than a detail: a zero here means the pick had already landed and whatever
|
||||
* the test went on to prove was proved about a different situation.
|
||||
*/
|
||||
val parkedCount: Int get() = parked.size
|
||||
|
||||
override fun dispatch(context: CoroutineContext, block: Runnable) {
|
||||
parked += block
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes everything parked, oldest first, and hands it to the caller to run.
|
||||
*
|
||||
* What [runAll] cannot express: two picks are two hops through this dispatcher, and the defect
|
||||
* they can produce is the *first* one finishing last. Running them in the order they arrived
|
||||
* is the one order in which nothing goes wrong, so a test has to be able to choose.
|
||||
*/
|
||||
fun takeParked(): List<Runnable> = generateSequence { parked.poll() }.toList()
|
||||
|
||||
/**
|
||||
* Runs everything parked, and everything that parks as a result.
|
||||
*
|
||||
* The loop is not defensive: `ConversionViewModel.onInputPicked` makes two hops through this
|
||||
* dispatcher — the metadata query, then the probe — and the second is only enqueued once the
|
||||
* first has run. Draining once would leave the probe parked for the rest of the process.
|
||||
*/
|
||||
fun runAll() {
|
||||
while (true) {
|
||||
val next = parked.poll() ?: return
|
||||
next.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The half of issue #49 that is not about reattachment at all.
|
||||
*
|
||||
* `onInputPicked` makes two writes and both of them land after a hop off the main thread, so both
|
||||
* belong to whichever pick was in flight rather than to whichever pick the user last made. Nothing
|
||||
* was enforcing that. Two taps in quick succession — an easy thing to do while a `content://`
|
||||
* metadata query is slow — put the loser's file on screen if its query happened to come back
|
||||
* second, which is the same defect the ticket reported against reattachment with a different
|
||||
* coroutine on the losing side.
|
||||
*
|
||||
* Both cases below were measured rather than assumed: deleting either guard turns the matching
|
||||
* test red, and deleting the probe one turns nine other tests red with it. Neither was ever
|
||||
* reported, because a pick that loses to another pick still shows *a* file the user chose -- which
|
||||
* is what made it worth closing alongside #49 rather than leaving as a second thing to find.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PickOwnershipTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var parkedPick: ParkedPickDispatcher
|
||||
private lateinit var viewModel: ConversionViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
||||
ConversionDependencies.probe = { _, _ -> PROBE }
|
||||
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
|
||||
parkedPick = ParkedPickDispatcher()
|
||||
viewModel = ConversionViewModel(app, pickDispatcher = parkedPick)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Two taps, and the first one's metadata query is the slow one.
|
||||
*
|
||||
* The order is chosen rather than raced: both queries are parked, and this runs the second
|
||||
* before the first. Without an ownership check the straggler writes last and the screen ends
|
||||
* up showing a file the user moved off two taps ago.
|
||||
*/
|
||||
@Test
|
||||
fun `the slower of two picks does not land on top of the faster one`() {
|
||||
viewModel.onInputPicked(FIRST)
|
||||
viewModel.onInputPicked(SECOND)
|
||||
|
||||
val queries = parkedPick.takeParked()
|
||||
assertEquals("both picks should be in flight", 2, queries.size)
|
||||
// The second pick's query comes back first; the first pick's is the straggler.
|
||||
queries[1].run()
|
||||
queries[0].run()
|
||||
|
||||
val current = viewModel.state.value
|
||||
assertEquals(
|
||||
"a pick the user has already replaced took the screen: $current",
|
||||
SECOND,
|
||||
(current as ConversionState.Ready).input.uri,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The second of `onInputPicked`'s two writes, which lands a whole probe later.
|
||||
*
|
||||
* The probe hop is a native process spawn, so it is the longest gap in a pick and the easiest
|
||||
* one to pick again during. This used to be guarded by comparing URIs against the state, which
|
||||
* answers a narrower question than the one that matters — it cannot tell a second pick of the
|
||||
* same file from the first, and it reads a state that a later claim may not have written yet,
|
||||
* which is exactly this case: the newer pick has claimed the screen but its own query has not
|
||||
* come back, so the state still names the older file and the comparison waves it through.
|
||||
*/
|
||||
@Test
|
||||
fun `a probe from a pick the user has moved off does not fill the card in`() {
|
||||
viewModel.onInputPicked(FIRST)
|
||||
parkedPick.takeParked().single().run()
|
||||
assertEquals(FIRST, (viewModel.state.value as ConversionState.Ready).input.uri)
|
||||
|
||||
// The user picks again while the first pick is still probing.
|
||||
viewModel.onInputPicked(SECOND)
|
||||
val pending = parkedPick.takeParked()
|
||||
assertEquals("the first probe and the second query should both be waiting", 2, pending.size)
|
||||
pending[0].run()
|
||||
|
||||
assertNull(
|
||||
"a probe belonging to a pick the user replaced must not reach the card",
|
||||
(viewModel.state.value as ConversionState.Ready).input.probe,
|
||||
)
|
||||
|
||||
// And the pick that did win still fills its own card in, probe included.
|
||||
pending[1].run()
|
||||
parkedPick.runAll()
|
||||
val settled = viewModel.state.value as ConversionState.Ready
|
||||
assertEquals(SECOND, settled.input.uri)
|
||||
assertNotNull("the winning pick's own probe still has to land", settled.input.probe)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val FIRST: Uri = Uri.fromFile(File("/tmp/first.mp4"))
|
||||
val SECOND: Uri = Uri.fromFile(File("/tmp/second.mp4"))
|
||||
val PROBE = InputProbe(videoCodec = "h264")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Issue #49, on the JVM and without the race.
|
||||
*
|
||||
* `ReattachOnLaunchTest.doesNotOverwriteAPickTheUserHasAlreadyMade` has been catching this on
|
||||
* devices since 2026-08-24 — four occurrences, spread across API 33, 35 and 36, which is what
|
||||
* ruled out an emulator-image quirk. Every one was on attempt 1 and every one passed on re-run,
|
||||
* which is why it was read as flaky infrastructure for two days. It is not. The assertion it fails
|
||||
* on is `expected null, but was:<Converted>`: a finished job from an earlier session taking a
|
||||
* screen the user had already picked a file on.
|
||||
*
|
||||
* The defect is a check-then-act whose act is deferred into another coroutine. `reattach()` reads
|
||||
* `_state.value` and then calls `observe()`, which *launches* a collector that has to suspend on
|
||||
* `getWorkInfoByIdFlow(...).collect` before it can write anything. So the check happens at one
|
||||
* moment and the write lands at another:
|
||||
*
|
||||
* 1. `init` starts the tag query and suspends in it.
|
||||
* 2. The user picks a file; `onInputPicked` suspends in its metadata query.
|
||||
* 3. The query comes back. `_state.value` is still `Idle` — step 2 has not written yet — so the
|
||||
* guard passes and an observation of the old job is launched.
|
||||
* 4. The pick lands. `Ready(picked)`. The user owns the screen.
|
||||
* 5. The observation's first `WorkInfo` arrives and writes `Converted(yesterday)` over it.
|
||||
*
|
||||
* The comment above that guard claimed "no suspension point between this check and the assignment
|
||||
* below, so nothing can interleave". There is no assignment below, and the check and the write
|
||||
* are in different coroutines.
|
||||
*
|
||||
* [ReattachGuardsTest] covers the case where the pick has already *landed*, which the plain guard
|
||||
* does catch. This covers the one where it is still in flight, which it does not.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ReattachmentOwnershipTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var workManager: WorkManager
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
|
||||
staged = publisher.createStagingFile("holiday_converted.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath))
|
||||
workManager = WorkManager.getInstance(app)
|
||||
// The situation reattachment exists for: a conversion that finished in a process that is
|
||||
// gone, with its output still in the cache and nothing in the UI holding its id.
|
||||
workManager.enqueue(
|
||||
ConversionWorker.request(
|
||||
inputUri = Uri.parse("content://test/holiday.mp4"),
|
||||
displayName = "holiday.mp4",
|
||||
sizeBytes = 4_096L,
|
||||
),
|
||||
).result.get()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* The race, made into a state the test can sit in rather than one it has to catch.
|
||||
*
|
||||
* The pick is parked on a dispatcher this test owns, so it stays in flight — issued, not yet
|
||||
* written — for as long as the assertions need it to be. Everything else is production: a
|
||||
* real `WorkManager` holding a real finished job, the real `reattach`, the real `observe`.
|
||||
*
|
||||
* Determinism comes from where Robolectric leaves the main looper. `reattach`'s tag query hops
|
||||
* to a real [kotlinx.coroutines.Dispatchers.IO] thread, so its continuation can only come back
|
||||
* as a message posted to the main looper — and that looper is paused, so it cannot run until
|
||||
* something pumps it. `onInputPicked` is an ordinary synchronous call from this thread. The
|
||||
* pick is therefore *always* issued before the guard runs; none of it is left to timing, which
|
||||
* is the whole point of writing it here rather than relying on the rare device sighting.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversion found while the user was picking never reaches the screen`() {
|
||||
val picked = Uri.fromFile(File(app.cacheDir, "beach.mp4").apply { writeBytes(ByteArray(2048)) })
|
||||
val parkedPick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = parkedPick)
|
||||
viewModel.onInputPicked(picked)
|
||||
|
||||
assertEquals(
|
||||
"the pick must still be in flight, or this proves something about a different situation",
|
||||
1,
|
||||
parkedPick.parkedCount,
|
||||
)
|
||||
|
||||
// The control, and the reason this test does not rest on a settle window being long
|
||||
// enough. A second ViewModel with nothing to supersede it reattaches to the same job
|
||||
// through the same code; when it has arrived, the whole query-guard-observe-write path has
|
||||
// demonstrably run to completion. `viewModel` started its own reattachment first, so it
|
||||
// has had at least as long. Waiting on this rather than on a sleep is what makes the
|
||||
// assertion below "it did not happen" rather than "it had not happened yet".
|
||||
reattachmentHasRunToCompletion()
|
||||
|
||||
val current = viewModel.state.value
|
||||
assertTrue("reattachment took the screen from the user: $current", current is ConversionState.Idle)
|
||||
|
||||
// And the pick, when it lands, is what stays there.
|
||||
parkedPick.runAll()
|
||||
val ready = awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
assertEquals(picked, (ready as ConversionState.Ready).input.uri)
|
||||
reattachmentHasRunToCompletion()
|
||||
assertEquals("the user's pick must survive a late reattachment", ready, viewModel.state.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the contract: a reattachment nobody has superseded still takes the screen.
|
||||
*
|
||||
* Without this, dropping every reattachment on the floor would pass the test above. Same job,
|
||||
* same WorkManager, same production path — only the pick is missing.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversion nobody has superseded still reaches the screen`() {
|
||||
val converted = awaitState(ConversionViewModel(app).state, "Converted") {
|
||||
it is ConversionState.Converted
|
||||
}
|
||||
|
||||
assertEquals(staged.absolutePath, (converted as ConversionState.Converted).staged.absolutePath)
|
||||
assertEquals("holiday.mp4", converted.input.displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a throwaway ViewModel through a whole reattachment, and returns once it has landed.
|
||||
*
|
||||
* [awaitState] pumps the main looper, which is what runs every reattachment continuation
|
||||
* waiting on it — this one's, and the one belonging to the ViewModel under test, which was
|
||||
* posted earlier and therefore runs first.
|
||||
*/
|
||||
private fun reattachmentHasRunToCompletion() {
|
||||
awaitState(ConversionViewModel(app).state, "Converted") { it is ConversionState.Converted }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The save dialog opens with the type the *job* produced, not the type the picker is showing now.
|
||||
*
|
||||
* `ConverterScreen.kt:80` — `state.pendingSave()?.mimeType ?: settings.spec.mimeType` — had never
|
||||
* taken its left-hand side. Its comment records what the line is for:
|
||||
*
|
||||
* > a retry offered after a failed save opens the dialog with the type its first attempt used —
|
||||
* > the cast answered null for a `Failed`, and the fallback below is the current picker, which a
|
||||
* > reattached job never set.
|
||||
*
|
||||
* So the untested half is the fix, and the tested half is the fallback it was added to stop being
|
||||
* used.
|
||||
*
|
||||
* ## This revises a named exemption, deliberately
|
||||
*
|
||||
* `FailedSaveRetryTest`'s KDoc lists this line under "Not asserted here, so each is a decision
|
||||
* rather than an omission":
|
||||
*
|
||||
* > It lives in the entry point, above the `ScreenContent` seam, and reaching it needs a real
|
||||
* > ViewModel inside a composition.
|
||||
*
|
||||
* That was true when written. `AdaptiveShellTest` (#173) then established exactly that capability,
|
||||
* and #200 added the two `ShadowActivity` mechanics that let a test read what a launcher launched.
|
||||
* The reason the exemption gave no longer holds, so the exemption is withdrawn rather than left to
|
||||
* be taken at face value — the same shape as #141 revising #84's boundary. That KDoc is corrected
|
||||
* in this change.
|
||||
*
|
||||
* ## Why the job is reattached rather than run
|
||||
*
|
||||
* The screen composes its own ViewModel through `viewModel()`, so nothing can be injected into it.
|
||||
* A job finished before the composition is the one route to a `Converted` state carrying output
|
||||
* `Data` this test chose — and it is also the case the line exists for, since a reattached job's
|
||||
* spec "was never in these settings at all".
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class RetrySaveMimeTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
staged = OutputPublisher(app).createStagingFile("holiday.mkv").apply { writeBytes(ByteArray(4096)) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = ConversionDependencies.reset()
|
||||
|
||||
@Test
|
||||
fun `the save dialog offers the type the job produced, not the one the picker is showing`() {
|
||||
finishAJobProducing(JOB_MIME_TYPE)
|
||||
composeRule.setContent { ConverterScreen() }
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
val intent = requireNotNull(shadowOf(composeRule.activity).nextStartedActivityForResult) {
|
||||
"the save dialog was never launched"
|
||||
}.intent
|
||||
assertEquals(Intent.ACTION_CREATE_DOCUMENT, intent.action)
|
||||
assertEquals(JOB_MIME_TYPE, intent.type)
|
||||
// The fixture is only meaningful while the two differ; without this the assertion above
|
||||
// would pass just as well against the fallback.
|
||||
assertNotEquals(
|
||||
"the picker's own type must differ, or this test proves nothing",
|
||||
JOB_MIME_TYPE,
|
||||
OutputFormat.MP4_H265.spec.mimeType,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversion that finished while nothing was watching, which is what `reattach()` picks up.
|
||||
*
|
||||
* `SucceedingWorkerFactory` reports this output `Data` for whatever is enqueued, so the job
|
||||
* lands `SUCCEEDED` carrying a staged path that exists — the two things `Reattachment.choose`
|
||||
* requires of a finished job.
|
||||
*/
|
||||
private fun finishAJobProducing(mimeType: String) {
|
||||
installTestWorkManager(
|
||||
app,
|
||||
workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mkv",
|
||||
ConversionWorker.KEY_MIME_TYPE to mimeType,
|
||||
),
|
||||
)
|
||||
WorkManager.getInstance(app).enqueue(
|
||||
ConversionWorker.request(
|
||||
inputUri = Uri.parse("content://test/holiday.mkv"),
|
||||
displayName = "holiday.mkv",
|
||||
sizeBytes = 4_096L,
|
||||
),
|
||||
).result.get()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Matroska, against the MP4 the picker defaults to. */
|
||||
const val JOB_MIME_TYPE = "video/x-matroska"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinState
|
||||
import org.libremediaconverter.join.JoinViewModel
|
||||
import org.libremediaconverter.join.joinActions
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* That each affordance is wired to the ViewModel method it is named after.
|
||||
*
|
||||
* ## What this covers that no other test can
|
||||
*
|
||||
* `ConverterScreenContentTest`, `ConverterStateAffordancesTest` and `JoinScreenContentTest` all
|
||||
* drive the **stateless** content composables, which build their own `ConverterActions`. So the
|
||||
* wiring — the list of `viewModel::` references the stateful outer hands down — was seen by nothing
|
||||
* in the suite.
|
||||
*
|
||||
* ## The hazard is narrower than "seventeen bindings", and this says so
|
||||
*
|
||||
* #156 was filed claiming a transposition of any two bindings would survive the suite. That is not
|
||||
* true, and it was worth checking rather than testing on the assumption:
|
||||
*
|
||||
* | swap | result |
|
||||
* |---|---|
|
||||
* | `onVideoCodec` ↔ `onAudioCodec` | **rejected by the compiler** |
|
||||
* | `onCancel` ↔ `onReset` | **compiles** |
|
||||
*
|
||||
* Every typed binding — container, both codecs, preset, suggestion, quality, engine preference —
|
||||
* takes a distinct parameter type, so the compiler is already the test. Writing assertions for
|
||||
* those would be theatre.
|
||||
*
|
||||
* **The `() -> Unit` bindings are the real gap**, because they are interchangeable to the compiler:
|
||||
* two on the converter screen (`onCancel`, `onReset`) and three on the join screen (`onJoin`,
|
||||
* `onCancel`, `onReset`). A Cancel that discards the finished file, or a Join that cancels, is a
|
||||
* one-character mistake that ships.
|
||||
*
|
||||
* ## How they are told apart
|
||||
*
|
||||
* By effect, not by a recording double. `reset()` sets the state to `Idle`; `cancel()` with no
|
||||
* active job leaves it alone (`ConversionViewModel.cancel` is `activeWorkId?.let(...)`, and
|
||||
* `SettingsEditsTest` pins that). Driving each from a non-`Idle` state is therefore enough to say
|
||||
* which one ran.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ScreenWiringTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
||||
ConversionDependencies.probe = { _, _ -> org.libremediaconverter.model.InputProbe() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
// --- the converter screen ----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `Start over resets, and Cancel does not`() {
|
||||
// The transposition that compiles. If onReset were bound to cancel, this stays on Ready.
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
|
||||
val actions = converterActions(viewModel, onPickInput = {}, onConvert = {}, onSave = {})
|
||||
viewModel.onInputPicked(INPUT_URI)
|
||||
pick.runAll()
|
||||
assertNotEquals(
|
||||
"the fixture needs a non-Idle state or neither action is observable",
|
||||
ConversionState.Idle,
|
||||
viewModel.state.value,
|
||||
)
|
||||
|
||||
actions.onReset()
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancel leaves the picked file on screen`() {
|
||||
// The other half. Without it, a wiring with BOTH actions bound to reset passes the test
|
||||
// above -- and that is exactly what a copy-paste of the wrong line produces.
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
|
||||
val actions = converterActions(viewModel, onPickInput = {}, onConvert = {}, onSave = {})
|
||||
viewModel.onInputPicked(INPUT_URI)
|
||||
pick.runAll()
|
||||
val before = viewModel.state.value
|
||||
|
||||
actions.onCancel()
|
||||
|
||||
assertEquals(
|
||||
"Cancel must not throw away the pick the way Start over does",
|
||||
before,
|
||||
viewModel.state.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each settings affordance reaches the setting it is named after`() {
|
||||
// The typed bindings. The compiler already rejects a transposition among these, so this is
|
||||
// not that assertion -- it is the cheaper one that each is bound to *something*, and that a
|
||||
// binding dropped to `{}` during an edit would be caught.
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
|
||||
val actions = converterActions(viewModel, onPickInput = {}, onConvert = {}, onSave = {})
|
||||
|
||||
actions.onPreset(OutputFormat.WEBM_VP9)
|
||||
assertEquals(OutputFormat.WEBM_VP9.spec, viewModel.settings.value.spec)
|
||||
|
||||
actions.onContainer(Container.MKV)
|
||||
assertEquals(Container.MKV, viewModel.settings.value.spec.container)
|
||||
|
||||
actions.onVideoCodec(VideoCodec.H264)
|
||||
assertEquals(VideoCodec.H264, viewModel.settings.value.spec.videoCodec)
|
||||
|
||||
actions.onAudioCodec(AudioCodec.FLAC)
|
||||
assertEquals(AudioCodec.FLAC, viewModel.settings.value.spec.audioCodec)
|
||||
|
||||
actions.onQuality(QualityTier.BEST)
|
||||
assertEquals(QualityTier.BEST, viewModel.settings.value.quality)
|
||||
|
||||
actions.onEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
||||
assertEquals(EnginePreference.FORCE_SOFTWARE, viewModel.settings.value.enginePreference)
|
||||
|
||||
actions.onSuggestion(OutputFormat.MP4_H264.spec)
|
||||
assertEquals(OutputFormat.MP4_H264.spec, viewModel.settings.value.spec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the launcher-backed actions are the ones the screen supplies`() {
|
||||
// Not wired to the ViewModel at all, deliberately -- they need an ActivityResultLauncher.
|
||||
// Asserted so that a later edit routing one of them at the ViewModel is noticed.
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = ConversionViewModel(app, pickDispatcher = pick)
|
||||
val called = mutableListOf<String>()
|
||||
val actions = converterActions(
|
||||
viewModel,
|
||||
onPickInput = { called += "pick" },
|
||||
onConvert = { called += "convert" },
|
||||
onSave = { called += "save:$it" },
|
||||
)
|
||||
|
||||
actions.onPickInput()
|
||||
actions.onConvert()
|
||||
actions.onSave("holiday.mp4")
|
||||
|
||||
assertEquals(listOf("pick", "convert", "save:holiday.mp4"), called)
|
||||
}
|
||||
|
||||
// --- the join screen, where three are interchangeable -------------------
|
||||
|
||||
@Test
|
||||
fun `Start over resets the join, and Cancel does not`() {
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = JoinViewModel(app, pickDispatcher = pick)
|
||||
val actions = joinActions(viewModel, onPickInputs = {}, onSave = {})
|
||||
viewModel.onInputsPicked(TWO_INPUTS)
|
||||
pick.runAll()
|
||||
assertTrue(
|
||||
"the fixture needs a non-Idle state: ${viewModel.state.value}",
|
||||
viewModel.state.value !is JoinState.Idle,
|
||||
)
|
||||
|
||||
actions.onReset()
|
||||
|
||||
assertEquals(JoinState.Idle, viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancel leaves the picked files on screen`() {
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = JoinViewModel(app, pickDispatcher = pick)
|
||||
val actions = joinActions(viewModel, onPickInputs = {}, onSave = {})
|
||||
viewModel.onInputsPicked(TWO_INPUTS)
|
||||
pick.runAll()
|
||||
val before = viewModel.state.value
|
||||
|
||||
actions.onCancel()
|
||||
|
||||
assertEquals(before, viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Join starts the job rather than cancelling or resetting it`() {
|
||||
// The third of the join screen's interchangeable trio, and the one whose transposition is
|
||||
// worst: a Join button bound to cancel does nothing at all, which reads as a dead button.
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
val pick = ParkedPickDispatcher()
|
||||
val viewModel = JoinViewModel(app, pickDispatcher = pick)
|
||||
val actions = joinActions(viewModel, onPickInputs = {}, onSave = {})
|
||||
viewModel.onInputsPicked(TWO_INPUTS)
|
||||
pick.runAll()
|
||||
|
||||
actions.onJoin()
|
||||
|
||||
assertTrue(
|
||||
"Join must leave Ready for a running state, not sit still and not go Idle: " +
|
||||
"${viewModel.state.value}",
|
||||
viewModel.state.value is JoinState.Joining || viewModel.state.value is JoinState.Joined,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val INPUT_URI: Uri = Uri.parse("content://test/holiday.mov")
|
||||
val TWO_INPUTS = listOf(
|
||||
Uri.parse("content://test/a.mp4"),
|
||||
Uri.parse("content://test/b.mp4"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* The seven one-line edits the settings sheet makes, and what each one leaves alone.
|
||||
*
|
||||
* ## Why these needed a file of their own
|
||||
*
|
||||
* `setPreset` was covered. The six beside it — `setContainer`, `setVideoCodec`, `setAudioCodec`,
|
||||
* `applySuggestion`, `setQuality`, `setEnginePreference` — and `cancel()` had **no coverage at
|
||||
* all**, which is the tell: they are reachable from the JVM suite by exactly the route `setPreset`
|
||||
* already takes, and nothing had asked.
|
||||
*
|
||||
* ## What is actually being asserted
|
||||
*
|
||||
* Not "the setter sets something". Each of these copies into a nested `OutputSpec`, so the failure
|
||||
* worth catching is **a setter that writes the right value into the wrong field, or that rebuilds
|
||||
* the spec and silently discards the other two**. So every test here asserts the field it changed
|
||||
* *and* that the rest of the spec survived — a `setContainer` implemented as
|
||||
* `it.copy(spec = OutputFormat.MP4_H265.spec.copy(container = container))` would pass a test that
|
||||
* only checked the container.
|
||||
*
|
||||
* `ConverterScreenContentTest` cannot cover this: it builds `ConverterActions` itself and never
|
||||
* touches the ViewModel. That the *screen* calls these is #156's, and neither implies the other.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SettingsEditsTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var viewModel: ConversionViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
viewModel = ConversionViewModel(app)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing a preset replaces the whole spec`() {
|
||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
||||
|
||||
assertEquals(OutputFormat.WEBM_VP9.spec, viewModel.settings.value.spec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the container leaves both codecs alone`() {
|
||||
// Moved off the default spec first, and that is load-bearing rather than tidiness. The
|
||||
// default IS `OutputFormat.MP4_H265.spec`, so a `setContainer` that rebuilt the spec from
|
||||
// that preset instead of from the current one produced an identical answer and the
|
||||
// mutation went green. Editing the codecs away from the default first is what makes
|
||||
// "the other two survived" an assertion rather than a coincidence.
|
||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setContainer(Container.MKV)
|
||||
|
||||
val after = viewModel.settings.value.spec
|
||||
assertEquals(Container.MKV, after.container)
|
||||
assertEquals("the video codec is not the container's to change", before.videoCodec, after.videoCodec)
|
||||
assertEquals("the audio codec is not the container's to change", before.audioCodec, after.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the video codec leaves the container and the audio codec alone`() {
|
||||
// The transposition this guards against is real: setVideoCodec and setAudioCodec take
|
||||
// different enum types, but a copy(...) naming the wrong field compiles wherever the types
|
||||
// happen to line up, and the picker would silently set the other one.
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setVideoCodec(VideoCodec.VP9)
|
||||
|
||||
val after = viewModel.settings.value.spec
|
||||
assertEquals(VideoCodec.VP9, after.videoCodec)
|
||||
assertEquals(before.container, after.container)
|
||||
assertEquals(before.audioCodec, after.audioCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the audio codec leaves the container and the video codec alone`() {
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setAudioCodec(AudioCodec.OPUS)
|
||||
|
||||
val after = viewModel.settings.value.spec
|
||||
assertEquals(AudioCodec.OPUS, after.audioCodec)
|
||||
assertEquals(before.container, after.container)
|
||||
assertEquals(before.videoCodec, after.videoCodec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applying a suggestion replaces the spec without disturbing quality or engine`() {
|
||||
// A suggestion comes from ContainerCapabilities when the current spec is invalid, so it is
|
||||
// a whole spec by construction. What it must not do is reset the two settings beside it.
|
||||
viewModel.setQuality(QualityTier.BEST)
|
||||
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
||||
|
||||
viewModel.applySuggestion(OutputFormat.MKV_H264.spec)
|
||||
|
||||
val settings = viewModel.settings.value
|
||||
assertEquals(OutputFormat.MKV_H264.spec, settings.spec)
|
||||
assertEquals(QualityTier.BEST, settings.quality)
|
||||
assertEquals(EnginePreference.FORCE_SOFTWARE, settings.enginePreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the quality leaves the spec and the engine preference alone`() {
|
||||
// Both neighbours are moved off their defaults first. Asserting against AUTO -- which is
|
||||
// what `ConversionSettings` starts with -- let a `setQuality` that also reset the engine
|
||||
// preference to AUTO pass, because the reset and the survival looked identical.
|
||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
||||
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setQuality(QualityTier.BEST)
|
||||
|
||||
val settings = viewModel.settings.value
|
||||
assertEquals(QualityTier.BEST, settings.quality)
|
||||
assertEquals(before, settings.spec)
|
||||
assertEquals(
|
||||
"quality is not the engine preference's to change",
|
||||
EnginePreference.FORCE_SOFTWARE,
|
||||
settings.enginePreference,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the engine preference leaves the spec and the quality alone`() {
|
||||
// Off the defaults for the same reason as the test above: QualityTier.FAST is the starting
|
||||
// value, so asserting it here would have been satisfied by a reset as readily as by a
|
||||
// survival.
|
||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
||||
viewModel.setQuality(QualityTier.BEST)
|
||||
val before = viewModel.settings.value.spec
|
||||
|
||||
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
||||
|
||||
val settings = viewModel.settings.value
|
||||
assertEquals(EnginePreference.FORCE_SOFTWARE, settings.enginePreference)
|
||||
assertEquals(before, settings.spec)
|
||||
assertEquals(
|
||||
"the engine preference is not the quality's to change",
|
||||
QualityTier.BEST,
|
||||
settings.quality,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing past every preset leaves no matching preset`() {
|
||||
// `matchingPreset` is what the settings sheet reads to decide whether to show a preset as
|
||||
// selected or to say "Custom". Editing one field of a preset must drop it out of the list
|
||||
// rather than leaving the old one highlighted.
|
||||
viewModel.setPreset(OutputFormat.MP4_H265)
|
||||
assertEquals(OutputFormat.MP4_H265, viewModel.settings.value.matchingPreset)
|
||||
|
||||
viewModel.setAudioCodec(AudioCodec.FLAC)
|
||||
|
||||
assertNull(
|
||||
"an edited spec is no longer any preset, and the sheet says Custom",
|
||||
viewModel.settings.value.matchingPreset,
|
||||
)
|
||||
assertNotEquals(OutputFormat.MP4_H265.spec, viewModel.settings.value.spec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling with no active job does nothing rather than throwing`() {
|
||||
// `activeWorkId?.let(...)` -- the null side. A user can reach Cancel through a state that
|
||||
// has already finished, and taking the app down for it would be worse than doing nothing.
|
||||
viewModel.cancel()
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,28 @@ class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for a worker that died, reporting [outputData] on the way out.
|
||||
*
|
||||
* The counterpart to [SucceedingWorkerFactory], and needed for the same reason: the real workers
|
||||
* cannot run on the JVM, so the only way to ask what a ViewModel does with a `FAILED` `WorkInfo` is
|
||||
* to produce one. A `Result.failure` carrying `KEY_ERROR` is exactly what both real workers report
|
||||
* when their engine gives up, and it is the one path where nothing has ever been staged.
|
||||
*
|
||||
* `runAttemptCount` is irrelevant here: `Result.failure` is terminal, so WorkManager does not retry
|
||||
* it and the state goes straight to `Failed` rather than through `Waiting`.
|
||||
*/
|
||||
class FailingWorkerFactory(private val outputData: Data) : WorkerFactory() {
|
||||
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker = object : Worker(appContext, workerParameters) {
|
||||
override fun doWork(): Result = Result.failure(outputData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a synchronous test WorkManager whose workers succeed with [outputData].
|
||||
*
|
||||
@@ -89,6 +111,16 @@ class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
|
||||
*/
|
||||
fun installTestWorkManager(context: Context, outputData: Data): SucceedingWorkerFactory {
|
||||
val factory = SucceedingWorkerFactory(outputData)
|
||||
installWorkManager(context, factory)
|
||||
return factory
|
||||
}
|
||||
|
||||
/** Installs a synchronous test WorkManager whose workers fail, reporting [outputData]. */
|
||||
fun installFailingTestWorkManager(context: Context, outputData: Data) {
|
||||
installWorkManager(context, FailingWorkerFactory(outputData))
|
||||
}
|
||||
|
||||
private fun installWorkManager(context: Context, factory: WorkerFactory) {
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(
|
||||
context,
|
||||
Configuration.Builder()
|
||||
@@ -98,7 +130,6 @@ fun installTestWorkManager(context: Context, outputData: Data): SucceedingWorker
|
||||
.setWorkerFactory(factory)
|
||||
.build(),
|
||||
)
|
||||
return factory
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinState
|
||||
import org.libremediaconverter.join.JoinViewModel
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* An answer that arrives after the screen has moved on does nothing.
|
||||
*
|
||||
* Four refusal arms, cold before this file:
|
||||
*
|
||||
* ```
|
||||
* convert/ConversionViewModel.kt:513 currentInput() ?: return
|
||||
* convert/ConversionViewModel.kt:600 pendingSave() ?: return
|
||||
* join/JoinViewModel.kt:316 (as? Ready)?.inputs ?: return
|
||||
* join/JoinViewModel.kt:390 pendingSave() ?: return
|
||||
* ```
|
||||
*
|
||||
* They are not merely defensive. `ConverterScreen.kt:91` wires `convert()` to the
|
||||
* **POST_NOTIFICATIONS result**, and `:83` wires `save()` to the CreateDocument result — so both
|
||||
* are entered by a system callback rather than by a tap, and a result redelivered after process
|
||||
* death arrives at a brand-new ViewModel sitting on `Idle`.
|
||||
*
|
||||
* ## The production change that came with this
|
||||
*
|
||||
* `currentInput()` used to answer for `Converting`, `Waiting` and `Converted` as well as `Ready`.
|
||||
* Those arms were unreachable by tapping Convert but reachable through that permission callback,
|
||||
* and reaching one enqueued a **second** job over a live one — `activeWorkId` overwritten, the
|
||||
* first job still running with an orphaned notification and nothing holding its id.
|
||||
*
|
||||
* #202 decided to narrow rather than to test it as it stood, because a test written against the old
|
||||
* shape would have frozen the double-enqueue as intended behaviour. `JoinViewModel.join()` has been
|
||||
* `(_state.value as? JoinState.Ready)?.inputs ?: return` all along; the two screens are the same
|
||||
* shape and only one was over-general.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class StaleLauncherResultTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var workManager: WorkManager
|
||||
private lateinit var staged: java.io.File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
val publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
// A real staged file, because a SUCCEEDED job with no output path maps to Failed rather
|
||||
// than Converted -- and Converted is the state this file's second case has to reach.
|
||||
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
installTestWorkManager(
|
||||
app,
|
||||
workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mp4",
|
||||
ConversionWorker.KEY_MIME_TYPE to "video/mp4",
|
||||
),
|
||||
)
|
||||
workManager = WorkManager.getInstance(app)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = ConversionDependencies.reset()
|
||||
|
||||
@Test
|
||||
fun `a permission answer arriving on an empty screen enqueues nothing`() {
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
awaitState(viewModel.state, "Idle") { it is ConversionState.Idle }
|
||||
|
||||
viewModel.convert()
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
assertEquals("nothing may be enqueued for a file that is not there", 0, conversionJobs())
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrowing itself: a permission answer that arrives while a conversion is already running
|
||||
* must not start a second one.
|
||||
*
|
||||
* Reached by converting once — the synchronous test WorkManager finishes it inline, so the
|
||||
* screen is `Converted`, which is one of the three arms `currentInput()` used to answer for.
|
||||
* Calling `convert()` again from there is precisely what the permission callback can do.
|
||||
*/
|
||||
@Test
|
||||
fun `a permission answer arriving after the job finished does not start a second one`() {
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
viewModel.convert()
|
||||
val converted = awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
|
||||
assertEquals("the fixture needs exactly one job to start with", 1, conversionJobs())
|
||||
|
||||
viewModel.convert()
|
||||
|
||||
assertEquals("a second job must not be enqueued over the first", 1, conversionJobs())
|
||||
assertEquals("and the screen must not move", converted, viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save answer arriving on an empty screen does nothing`() {
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
awaitState(viewModel.state, "Idle") { it is ConversionState.Idle }
|
||||
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join answer arriving on an empty screen enqueues nothing`() {
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
awaitState(viewModel.state, "Idle") { it is JoinState.Idle }
|
||||
|
||||
viewModel.join()
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
assertEquals(JoinState.Idle, viewModel.state.value)
|
||||
assertEquals(0, joinJobs())
|
||||
}
|
||||
|
||||
private fun conversionJobs() = jobsTagged(ConversionWorker::class.java.name)
|
||||
|
||||
private fun joinJobs() = jobsTagged(ConcatWorker::class.java.name)
|
||||
|
||||
private fun jobsTagged(tag: String) = workManager.getWorkInfosByTag(tag).get().size
|
||||
|
||||
private companion object {
|
||||
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.ConcatPlanner
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* A clip in a join that nothing could read, from the probe all the way to the strategy.
|
||||
*
|
||||
* Both halves of this are covered already, and separately: `MediaProbeTrackWalkTest` pins what
|
||||
* `concatInputFrom` makes of a track list, and `ConcatPlannerTest`'s
|
||||
* `an unknown codec is not treated as a match` pins what the planner does with a hand-built
|
||||
* `ConcatInput(video = null)`. **Nothing spanned the two**, and the span is the load-bearing part:
|
||||
* the planner's safety rests on the probe really producing that shape, and the hand-built fixture
|
||||
* would go on passing if it stopped.
|
||||
*
|
||||
* Measured rather than asserted: mutating `concatInputFrom`'s initial `video` to a non-null
|
||||
* placeholder leaves `ConcatPlannerTest` green and turns this red.
|
||||
*
|
||||
* ## The asymmetry this protects
|
||||
*
|
||||
* `ConcatPlanner` guards its video check against a null codec (`ConcatStrategy.kt:51`) and its
|
||||
* audio check not at all (`:54`). **That is correct, not an oversight.** `MediaProbe.shortName`
|
||||
* returns a non-null `String`, so in `concatInputFrom` a null `audioCodec` means the track is
|
||||
* *absent* — and two clips with no audio genuinely do match. A null `videoCodec` carries both
|
||||
* meanings, absent or unreadable, which is why only that one is guarded.
|
||||
*
|
||||
* So the audio check is safe *because* the video guard fires first on a clip nothing could read.
|
||||
* Nothing wrote that coupling down and nothing held it.
|
||||
*
|
||||
* ## What this deliberately does not cover
|
||||
*
|
||||
* `probeForConcat`'s `catch` arm (`MediaProbe.kt:300-302`). It is **not reachable on the JVM**:
|
||||
* Robolectric's `MediaExtractor` never throws from `setDataSource`, measured across an
|
||||
* unregistered `content://` authority, a missing `file://`, a file of garbage bytes and an `http://`
|
||||
* URL — all four returned normally with `trackCount = 0`. So the failure arrives here as an empty
|
||||
* track list rather than as an exception, which reaches the same `ConcatInput(null, null, 0, 0, 0)`
|
||||
* by the other road. The catch stays device-only, and this file does not pretend otherwise.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class UnreadableJoinInputTest {
|
||||
|
||||
@Test
|
||||
fun `a clip nothing could read probes as unknown, and an unknown clip is re-encoded`() {
|
||||
val unreadable = MediaProbe.probeForConcat(RuntimeEnvironment.getApplication(), UNREADABLE)
|
||||
|
||||
assertNull("an unreadable clip proves nothing about its video codec", unreadable.videoCodec)
|
||||
assertNull("nor about its audio codec", unreadable.audioCodec)
|
||||
assertEquals("nor about its dimensions", 0, unreadable.width)
|
||||
assertEquals(0, unreadable.height)
|
||||
assertEquals(0, unreadable.frameRate)
|
||||
|
||||
assertEquals(
|
||||
"a clip nothing could read is not evidence of a match with anything",
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(unreadable, unreadable)),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** `content://` so the probe takes the SAF branch a real pick takes. Nothing answers it. */
|
||||
val UNREADABLE: Uri = Uri.parse("content://test/vanished.mp4")
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,54 @@ class FFmpegCommandBuilderTest {
|
||||
assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
|
||||
}
|
||||
|
||||
/**
|
||||
* The arm most conversions actually take, and the only one in `audioArgs` with no test.
|
||||
*
|
||||
* `flac wav and opus select the right encoders` above covers the three named arms; MP3 has its
|
||||
* own. AAC arrives through the `else`, so nothing named it and nothing pinned either half of
|
||||
* what it emits -- neither `aac` nor `192k` appeared anywhere in this file. Both are shipped
|
||||
* defaults: MP4 and M4A are the formats the picker offers first, so this is the audio
|
||||
* every ordinary conversion gets.
|
||||
*
|
||||
* The bitrate is asserted as well as the encoder because it is the half a refactor is likelier
|
||||
* to lose. An `-b:a` that quietly changed would not fail anything, would not look wrong in a
|
||||
* command line, and would show up only as files that sound different from the ones the app
|
||||
* produced last month.
|
||||
*/
|
||||
@Test
|
||||
fun `aac is the default encoder, at the bitrate the app ships`() {
|
||||
assertPair(cmd(OutputFormat.MP4_H264), "-c:a", "aac")
|
||||
assertPair(cmd(OutputFormat.MP4_H264), "-b:a", "192k")
|
||||
// Through the `else` rather than through a named arm, so an AAC branch added above it later
|
||||
// has to keep answering the same way.
|
||||
assertPair(cmd(OutputFormat.M4A_AAC), "-c:a", "aac")
|
||||
assertPair(cmd(OutputFormat.M4A_AAC), "-b:a", "192k")
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning audio off, which the Advanced picker offers and nothing had ever built a command for.
|
||||
*
|
||||
* `audioArgs`' `Drop` arm was `ci == 0`. The suite's only `-an` assertion is in
|
||||
* `gif generates a palette to avoid banding and drops audio`, and that one comes from the image
|
||||
* path (`FFmpegCommandBuilder.kt:79`/`:90`), which emits `-an` directly and never reaches
|
||||
* `audioArgs`. Two sites, one string, one tested.
|
||||
*
|
||||
* It is a live path rather than defensive code: `AdvancedPicker` renders all of
|
||||
* `AudioCodec.entries` including `NONE`, `ContainerCapabilities.validate` permits audio-off
|
||||
* whenever the input has video, and MKV routes the job to FFmpeg.
|
||||
*
|
||||
* Both halves are asserted. `-an` alone would still pass if the arm fell through to the `else`
|
||||
* and emitted an AAC encoder beside it -- a file that is silent because the flag won, carrying
|
||||
* an encoder nobody asked for.
|
||||
*/
|
||||
@Test
|
||||
fun `turning audio off drops the track instead of encoding one`() {
|
||||
val args = cmd(OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.NONE))
|
||||
|
||||
assertTrue("audio turned off must emit -an, got $args", args.contains("-an"))
|
||||
assertFalse("a dropped track must not also carry an encoder, got $args", args.contains("-c:a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio only formats never carry a video encoder`() {
|
||||
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
|
||||
|
||||
@@ -56,6 +56,33 @@ class FFmpegConcatCommandTest {
|
||||
assertEquals("0", args[args.indexOf("-safe") + 1])
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate that `-safe 0` does not open, and the one every real join needs (#238).
|
||||
*
|
||||
* `-safe 0` permits absolute *paths*; the concat demuxer separately whitelists the *protocol*,
|
||||
* defaulting to `file,crypto,data`. `JoinScreen` picks with `OpenMultipleDocuments`, so real
|
||||
* inputs are `content://` and `ConcatEngine` writes `ffkitsaf:` paths into the list file — which
|
||||
* the demuxer refused outright, failing every stream-copy join a user could actually start.
|
||||
*
|
||||
* The re-encode strategy has no equivalent assertion because it needs none: it passes each
|
||||
* input with its own `-i` and never feeds the demuxer a list file. That asymmetry is exactly
|
||||
* why the defect survived — joining mismatched clips over SAF worked.
|
||||
*/
|
||||
@Test
|
||||
fun `stream copy whitelists the protocol its list file entries actually use`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY,
|
||||
inputs,
|
||||
listFile,
|
||||
output,
|
||||
OutputFormat.MP4_H264,
|
||||
)
|
||||
val whitelist = args[args.indexOf("-protocol_whitelist") + 1].split(",")
|
||||
assertTrue("ffmpeg-kit's SAF scheme must be permitted, got $whitelist", "ffkitsaf" in whitelist)
|
||||
// The defaults have to survive too: the list file itself is opened over `file`.
|
||||
assertTrue("the demuxer still reads the list file itself, got $whitelist", "file" in whitelist)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-encode passes every input separately and builds a filter graph`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* What a finished FFmpegKit session means, for both engines at once.
|
||||
*
|
||||
* `FFmpegEngine` and `ConcatEngine` each carried their own copy of this `when`, and the copies had
|
||||
* drifted: one preferred the fail stack trace and fell back to the log tail, the other only ever
|
||||
* read the log tail. Neither was tested, because both live inside a callback handed to `FFmpegKit`,
|
||||
* which does not run on the JVM — so nothing could see that the two disagreed.
|
||||
*
|
||||
* **JVM-safe, verified rather than assumed.** `javap` over the committed AAR's runtime jar shows
|
||||
* `ReturnCode(int)` as a plain public constructor with `SUCCESS`/`CANCEL` int constants and pure
|
||||
* static `isSuccess`/`isCancel`; its `<clinit>` is constant initialisation and loads no native
|
||||
* library.
|
||||
*
|
||||
* The unification is #203's decision, so the tests pin it as one: a join failure now carries the
|
||||
* stack trace a conversion failure always did, while the two prefixes stay distinct.
|
||||
*/
|
||||
class SessionOutcomeTest {
|
||||
|
||||
@Test
|
||||
fun `a return code of zero is success`() {
|
||||
assertEquals(SessionOutcome.Success, outcome(ReturnCode(ReturnCode.SUCCESS)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation is a separate outcome from failure, and the distinction is the point: the engines
|
||||
* resume the continuation *cancelled* rather than exceptionally, so a user who pressed Cancel
|
||||
* does not get an error card.
|
||||
*/
|
||||
@Test
|
||||
fun `a return code of 255 is a cancellation, not a failure`() {
|
||||
assertEquals(SessionOutcome.Cancelled, outcome(ReturnCode(ReturnCode.CANCEL)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any other return code fails, and the sentence carries the number`() {
|
||||
val failed = outcome(ReturnCode(1), stackTrace = "boom") as SessionOutcome.Failed
|
||||
|
||||
assertTrue("the code belongs in the message, got: ${failed.message}", failed.message.contains("(1)"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The half that was different between the two engines before #203, now the same in both.
|
||||
*/
|
||||
@Test
|
||||
fun `the stack trace is preferred over the log tail`() {
|
||||
val failed = outcome(ReturnCode(1), stackTrace = "the real cause", logTail = "…noise…")
|
||||
as SessionOutcome.Failed
|
||||
|
||||
assertTrue(failed.message.contains("the real cause"))
|
||||
assertTrue("the log tail must not be appended as well", !failed.message.contains("noise"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank stack trace falls back to the log tail`() {
|
||||
val blank = outcome(ReturnCode(1), stackTrace = " ", logTail = "the last few lines") as SessionOutcome.Failed
|
||||
val absent = outcome(ReturnCode(1), stackTrace = null, logTail = "the last few lines") as SessionOutcome.Failed
|
||||
|
||||
assertTrue(blank.message.contains("the last few lines"))
|
||||
assertTrue("a null stack trace is a blank one", absent.message.contains("the last few lines"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Both sources empty still has to produce a sentence. A message ending in a dangling colon is
|
||||
* thin, but it is what the user gets when FFmpeg said nothing at all, and it must not be an
|
||||
* exception on the way to the screen.
|
||||
*/
|
||||
@Test
|
||||
fun `a failure with nothing to say still names the code`() {
|
||||
val failed = outcome(ReturnCode(1), stackTrace = null, logTail = null) as SessionOutcome.Failed
|
||||
|
||||
assertEquals("FFmpeg failed (1): ", failed.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* `getReturnCode()` is nullable and a session killed before it reported anything has none.
|
||||
* Neither success nor cancellation, so it fails — and the sentence says so rather than throwing.
|
||||
*/
|
||||
@Test
|
||||
fun `a session with no return code at all fails`() {
|
||||
val failed = outcome(null, logTail = "whatever was logged") as SessionOutcome.Failed
|
||||
|
||||
assertTrue("got: ${failed.message}", failed.message.startsWith("FFmpeg failed (null): "))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unifying the *strategy* must not unify the *sentence*: the two engines describe different
|
||||
* jobs, and a join that reports "FFmpeg failed" is a worse message than the one it replaced.
|
||||
*/
|
||||
@Test
|
||||
fun `each engine keeps its own prefix`() {
|
||||
val join = sessionOutcome(ReturnCode(1), "Joining", { "cause" }, { null }) as SessionOutcome.Failed
|
||||
|
||||
assertTrue(join.message.startsWith("Joining failed (1): "))
|
||||
}
|
||||
|
||||
/**
|
||||
* Neither message source is read unless the outcome is a failure.
|
||||
*
|
||||
* They are calls onto a native session, and reading them on the happy path is work every
|
||||
* successful conversion would do for nothing — which the shape this replaced did not, since it
|
||||
* read them inside the `else` branch. That is why the parameters are lambdas, and this is what
|
||||
* would notice if they stopped being.
|
||||
*/
|
||||
@Test
|
||||
fun `a session that succeeded reads neither the stack trace nor the log`() {
|
||||
var reads = 0
|
||||
fun counted(): String? {
|
||||
reads++
|
||||
return null
|
||||
}
|
||||
|
||||
sessionOutcome(ReturnCode(ReturnCode.SUCCESS), "FFmpeg", ::counted, ::counted)
|
||||
sessionOutcome(ReturnCode(ReturnCode.CANCEL), "FFmpeg", ::counted, ::counted)
|
||||
|
||||
assertEquals("neither source may be touched unless the session failed", 0, reads)
|
||||
}
|
||||
|
||||
private fun outcome(rc: ReturnCode?, stackTrace: String? = null, logTail: String? = null) =
|
||||
sessionOutcome(rc, "FFmpeg", { stackTrace }, { logTail })
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.ParkedPickDispatcher
|
||||
import org.libremediaconverter.convert.RecordingPublisher
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* `PickOwnershipTest`'s case on the join side.
|
||||
*
|
||||
* `onInputsPicked` makes one write and it lands after a hop off the main thread, so it belongs to
|
||||
* whichever pick was in flight rather than to whichever set of files the user last chose. Two
|
||||
* selections in quick succession — likelier here than on the convert side, since a join picks
|
||||
* several files at a time and the metadata query is per file — put the loser's files on screen if
|
||||
* its query came back second.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinPickOwnershipTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var parkedPick: ParkedPickDispatcher
|
||||
private lateinit var viewModel: JoinViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
|
||||
parkedPick = ParkedPickDispatcher()
|
||||
viewModel = JoinViewModel(app, pickDispatcher = parkedPick)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Two selections, with the first one's metadata query the slow one.
|
||||
*
|
||||
* The order is chosen rather than raced: both queries are parked, and this runs the second
|
||||
* before the first.
|
||||
*/
|
||||
@Test
|
||||
fun `the slower of two selections does not land on top of the faster one`() {
|
||||
viewModel.onInputsPicked(FIRST)
|
||||
viewModel.onInputsPicked(SECOND)
|
||||
|
||||
val queries = parkedPick.takeParked()
|
||||
assertEquals("both selections should be in flight", 2, queries.size)
|
||||
// The second selection's query comes back first; the first one's is the straggler.
|
||||
queries[1].run()
|
||||
queries[0].run()
|
||||
|
||||
val current = viewModel.state.value
|
||||
assertEquals(
|
||||
"a selection the user has already replaced took the screen: $current",
|
||||
SECOND,
|
||||
(current as JoinState.Ready).inputs.map { it.uri },
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val FIRST = listOf(
|
||||
Uri.parse("content://test/first-a.mp4"),
|
||||
Uri.parse("content://test/first-b.mp4"),
|
||||
)
|
||||
val SECOND = listOf(
|
||||
Uri.parse("content://test/second-a.mp4"),
|
||||
Uri.parse("content://test/second-b.mp4"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.ParkedPickDispatcher
|
||||
import org.libremediaconverter.convert.RecordingPublisher
|
||||
import org.libremediaconverter.convert.awaitState
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Issue #49 on the join side, where nothing was watching for it.
|
||||
*
|
||||
* `reattach()` checks that the screen is still free, and then hands the answer to `observe()`,
|
||||
* which writes from a *different* coroutine that has to suspend on `collect` before it can write
|
||||
* anything at all. So the check happens at one moment and the write lands at another, with a
|
||||
* whole pick able to fit in between:
|
||||
*
|
||||
* 1. `init` starts the tag query and suspends in it.
|
||||
* 2. The user picks files; `onInputsPicked` suspends in its metadata query.
|
||||
* 3. The query comes back. The screen is still `Idle` — step 2 has not written yet — so the
|
||||
* guard passes and an observation of the old job is launched.
|
||||
* 4. The pick lands. `Ready(picked)`. The user owns the screen.
|
||||
* 5. The observation's first `WorkInfo` arrives and writes `Joined(yesterday's file)` over it.
|
||||
*
|
||||
* The comment above that guard used to say "no suspension point between this check and the
|
||||
* assignment below, so nothing can interleave". There is no assignment below, and the two lines
|
||||
* are in different coroutines.
|
||||
*
|
||||
* The convert side has been failing this on CI for two days — four occurrences across three API
|
||||
* levels, each read as flaky infrastructure. `JoinViewModel` has the identical shape and no test
|
||||
* at all, which is why this one was written before the fix rather than after it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinReattachmentOwnershipTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var workManager: WorkManager
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
|
||||
staged = publisher.createStagingFile("joined-yesterday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
installTestWorkManager(
|
||||
app,
|
||||
workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
|
||||
),
|
||||
)
|
||||
workManager = WorkManager.getInstance(app)
|
||||
// The situation reattachment exists for: a join that finished in a process that is gone,
|
||||
// with its output still in the cache and nothing in the UI holding its id.
|
||||
workManager.enqueue(
|
||||
ConcatWorker.request(
|
||||
inputs = listOf(
|
||||
Uri.parse("content://test/yesterday-a.mp4"),
|
||||
Uri.parse("content://test/yesterday-b.mp4"),
|
||||
),
|
||||
totalBytes = 8_192L,
|
||||
),
|
||||
).result.get()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* The race, made into a state the test can sit in rather than one it has to catch.
|
||||
*
|
||||
* The pick is parked on a dispatcher this test owns, so it is in flight — issued, not yet
|
||||
* written — for as long as the assertions need it to be. Everything else is real: a real
|
||||
* `WorkManager` holding a real finished job, the production `reattach`, the production
|
||||
* `observe`.
|
||||
*
|
||||
* Determinism comes from where Robolectric leaves the main looper. `reattach`'s tag query
|
||||
* hops to a real [kotlinx.coroutines.Dispatchers.IO] thread, so its continuation can only
|
||||
* come back as a message posted to the main looper — and that looper is paused, so it cannot
|
||||
* run until something pumps it. `onInputsPicked` is an ordinary synchronous call from this
|
||||
* thread. The pick is therefore always issued before the guard runs, with nothing left to
|
||||
* timing.
|
||||
*/
|
||||
@Test
|
||||
fun `a join found while the user was picking never reaches the screen`() {
|
||||
val parkedPick = ParkedPickDispatcher()
|
||||
val viewModel = JoinViewModel(app, pickDispatcher = parkedPick)
|
||||
viewModel.onInputsPicked(PICKED)
|
||||
|
||||
assertEquals(
|
||||
"the pick must still be in flight, or this proves something about a different situation",
|
||||
1,
|
||||
parkedPick.parkedCount,
|
||||
)
|
||||
|
||||
// The control, and the reason this test does not rest on a settle window being long
|
||||
// enough. A second ViewModel with nothing to supersede it reattaches to the same job
|
||||
// through the same code; when it has arrived, the whole query-guard-observe-write path
|
||||
// has demonstrably run to completion. `viewModel` started its own reattachment first, so
|
||||
// it has had at least as long. Waiting on this rather than on a sleep is what makes the
|
||||
// assertion below "it did not happen" instead of "it had not happened yet".
|
||||
reattachmentHasRunToCompletion()
|
||||
|
||||
val current = viewModel.state.value
|
||||
assertTrue("reattachment took the screen from the user: $current", current is JoinState.Idle)
|
||||
|
||||
// And the pick, when it lands, is what stays there.
|
||||
parkedPick.runAll()
|
||||
val ready = awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
assertEquals(PICKED, (ready as JoinState.Ready).inputs.map { it.uri })
|
||||
reattachmentHasRunToCompletion()
|
||||
assertEquals("the user's pick must survive a late reattachment", ready, viewModel.state.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the contract: a reattachment nobody has superseded still takes the screen.
|
||||
*
|
||||
* Without this, dropping every reattachment on the floor would pass the test above. It is the
|
||||
* same job, the same WorkManager and the same production path — only the pick is missing.
|
||||
*/
|
||||
@Test
|
||||
fun `a join nobody has superseded still reaches the screen`() {
|
||||
val joined = awaitState(JoinViewModel(app).state, "Joined") { it is JoinState.Joined }
|
||||
|
||||
assertEquals(staged.absolutePath, (joined as JoinState.Joined).staged.absolutePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a throwaway ViewModel through a whole reattachment, and returns once it has landed.
|
||||
*
|
||||
* [awaitState] pumps the main looper, which is what runs every reattachment continuation
|
||||
* waiting on it — this one's and the one belonging to the ViewModel under test, which was
|
||||
* posted earlier and therefore runs first.
|
||||
*/
|
||||
private fun reattachmentHasRunToCompletion() {
|
||||
awaitState(JoinViewModel(app).state, "Joined") { it is JoinState.Joined }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PICKED = listOf(
|
||||
Uri.parse("content://test/clip-one.mp4"),
|
||||
Uri.parse("content://test/clip-two.mp4"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.convert.PendingSave
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -219,6 +220,51 @@ class JoinStateAffordancesTest {
|
||||
assertEquals(listOf("reset"), events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The negative that bounds #30 on this screen. A join that died staged nothing, so its `Failed`
|
||||
* carries no [PendingSave] and there is nothing a save dialog could be handed. A retry button
|
||||
* rendered unconditionally here could only fail, and this is what notices.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed join offers no way to save`() {
|
||||
setContent(JoinState.Failed(message = "The second file has no audio track, so joining stopped."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* #30 on this screen: `save()` keeps the staged file when the copy out throws, and until this
|
||||
* branch grew a second button the only control it rendered was "Start over" -- `reset()`, which
|
||||
* deletes exactly that file. Both, not one: the restart still has to be reachable.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed join save offers the file again as well as a restart`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
}
|
||||
|
||||
/** The name comes from the job, so a retry wired to a literal would hand back the wrong one. */
|
||||
@Test
|
||||
fun `tapping try saving again hands back the name the join chose`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("save:joined.mp4"), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over after a failed join save resets and does not save`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), events)
|
||||
}
|
||||
|
||||
/** Anything `FileRow` tagged, whichever file it is showing. The prefix comes from the table. */
|
||||
private val isFileRow = SemanticsMatcher("is a join file row") { node ->
|
||||
node.config.getOrNull(SemanticsProperties.TestTag)?.startsWith(TestTags.Join.fileRow("")) == true
|
||||
@@ -230,6 +276,19 @@ class JoinStateAffordancesTest {
|
||||
sizeBytes = 4_000_000L,
|
||||
)
|
||||
|
||||
/**
|
||||
* A `Failed` an earlier save left carrying its file, which is the only way `retry` is non-null.
|
||||
* `staged` names a missing file for the same reason [joined] does -- this arm reads no length.
|
||||
*/
|
||||
private fun failedSave() = JoinState.Failed(
|
||||
message = "There was not enough room on the destination.",
|
||||
retry = PendingSave(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
suggestedName = "joined.mp4",
|
||||
mimeType = "video/mp4",
|
||||
),
|
||||
)
|
||||
|
||||
/** `staged` names a missing file deliberately -- see the same helper in `JoinScreenContentTest`. */
|
||||
private fun joined(strategy: ConcatStrategy) = JoinState.Joined(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.workDataOf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Every answer [joinStateFrom] can give, chosen rather than stumbled into.
|
||||
*
|
||||
* The join-side twin of `ConversionStateMappingTest`, and the argument is the same one: the mapping
|
||||
* ran on every test that drove a real `ConcatWorker`, but a real worker only ever reaches a terminal
|
||||
* state with well-formed output, so five arms had never been *chosen* by anything.
|
||||
*
|
||||
* ## The one that is not just coverage
|
||||
*
|
||||
* `an unknown strategy name is read as a re-encode rather than thrown` covers a real defect this
|
||||
* seam exposed. The line it replaces was:
|
||||
*
|
||||
* ```kotlin
|
||||
* .getString(ConcatWorker.KEY_STRATEGY)?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
|
||||
* ```
|
||||
*
|
||||
* `valueOf` throws on a name this build does not define, and this runs inside a `viewModelScope`
|
||||
* collect with no handler — so it does not become a `Failed` state, it takes the process down.
|
||||
* `ConcatWorker.kt` had already made this exact change for `KEY_FORMAT` and written down why; the
|
||||
* matching read on this side had not been changed with it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinStateMappingTest {
|
||||
|
||||
@Test
|
||||
fun `a running join is joining`() {
|
||||
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.RUNNING))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blocked join looks like one that is starting`() {
|
||||
// Folded into the RUNNING arm deliberately: a job waiting on a prerequisite is nothing the
|
||||
// user can act on, and a separate word for it would be noise.
|
||||
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.BLOCKED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an enqueued join that has already run is waiting to retry`() {
|
||||
assertEquals(JoinState.Waiting(INPUTS), map(WorkInfo.State.ENQUEUED, runAttemptCount = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an enqueued join that has never run is simply starting`() {
|
||||
// The other side. Without it, a mapping that ignored runAttemptCount passes the test above.
|
||||
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.ENQUEUED, runAttemptCount = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success that named no file is a failure, not an empty success`() {
|
||||
assertEquals(
|
||||
JoinState.Failed(JOINED_WITHOUT_A_FILE_MESSAGE),
|
||||
map(WorkInfo.State.SUCCEEDED, data = Data.EMPTY),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success carries the strategy the worker actually used`() {
|
||||
// Not cosmetic: the join screen tells the user whether their files were stream-copied or
|
||||
// re-encoded, which is the difference between lossless and lossy.
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
|
||||
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
|
||||
),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatStrategy.STREAM_COPY, joined.strategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown strategy name is read as a re-encode rather than thrown`() {
|
||||
// The defect. A build that added a third strategy leaves finished joins in the queue naming
|
||||
// it, and WorkManager keeps those about a week -- the premise WorkerEnumFallbackTest and
|
||||
// JobTags are both written on. With `valueOf` this throws IllegalArgumentException inside a
|
||||
// viewModelScope collect that has no handler, so it is not a Failed state, it is a crash.
|
||||
//
|
||||
// REENCODE rather than STREAM_COPY because it is the conservative answer: describing an
|
||||
// unknown join as lossless would be a claim the app cannot support.
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
|
||||
ConcatWorker.KEY_STRATEGY to "SMART_CONCAT_V2",
|
||||
),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatStrategy.REENCODE, joined.strategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success with no strategy at all falls back the same way`() {
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4"),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatStrategy.REENCODE, joined.strategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success from older work falls back to the format such a job really used`() {
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4"),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT), joined.suggestedName)
|
||||
assertEquals(ConcatWorker.DEFAULT_FORMAT.mimeType, joined.mimeType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank name or type falls back the same way a missing one does`() {
|
||||
val joined = map(
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
data = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
|
||||
ConcatWorker.KEY_SUGGESTED_NAME to "",
|
||||
ConcatWorker.KEY_MIME_TYPE to " ",
|
||||
),
|
||||
) as JoinState.Joined
|
||||
|
||||
assertEquals(ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT), joined.suggestedName)
|
||||
assertEquals(ConcatWorker.DEFAULT_FORMAT.mimeType, joined.mimeType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure carries the reason the worker gave`() {
|
||||
assertEquals(
|
||||
JoinState.Failed("Not enough free space to join these files."),
|
||||
map(
|
||||
WorkInfo.State.FAILED,
|
||||
data = workDataOf(
|
||||
ConcatWorker.KEY_ERROR to "Not enough free space to join these files.",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure with nothing said still says something`() {
|
||||
assertEquals(
|
||||
JoinState.Failed(ConcatWorker.GENERIC_FAILURE_MESSAGE),
|
||||
map(WorkInfo.State.FAILED, data = Data.EMPTY),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure whose message is blank falls back like a missing one`() {
|
||||
assertEquals(
|
||||
JoinState.Failed(ConcatWorker.GENERIC_FAILURE_MESSAGE),
|
||||
map(WorkInfo.State.FAILED, data = workDataOf(ConcatWorker.KEY_ERROR to " ")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancellation lands wherever the caller said it should`() {
|
||||
// A join started here goes back to Ready with the picked files; one picked up by reattach
|
||||
// goes to Idle, because those URIs belong to a process that no longer exists.
|
||||
assertEquals(
|
||||
JoinState.Ready(INPUTS),
|
||||
map(WorkInfo.State.CANCELLED, cancelled = JoinState.Ready(INPUTS)),
|
||||
)
|
||||
assertEquals(JoinState.Idle, map(WorkInfo.State.CANCELLED, cancelled = JoinState.Idle))
|
||||
}
|
||||
|
||||
private fun map(
|
||||
state: WorkInfo.State,
|
||||
runAttemptCount: Int = 0,
|
||||
data: Data = Data.EMPTY,
|
||||
cancelled: JoinState = JoinState.Ready(INPUTS),
|
||||
): JoinState = joinStateFrom(
|
||||
JoinUpdate(state = state, runAttemptCount = runAttemptCount, outputData = data),
|
||||
inputs = INPUTS,
|
||||
cancelled = cancelled,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val INPUTS = listOf(
|
||||
InputFile(Uri.parse("content://test/a.mp4"), "a.mp4", 1024L),
|
||||
InputFile(Uri.parse("content://test/b.mp4"), "b.mp4", 2048L),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.RecordingPublisher
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* The two layers that refuse a short join, refusing it with one sentence.
|
||||
*
|
||||
* ## Why this is not "assert a constant equals itself"
|
||||
*
|
||||
* `ConcatWorker` and `JoinViewModel` both reject a join of fewer than two files, and before #158
|
||||
* each carried **its own copy of the literal**. Only the worker's was pinned — by `RefusedJobTest`,
|
||||
* added in #139 — so the wording on the screen could drift away from the wording in the job with no
|
||||
* test saying anything, for one message the user sees from one condition.
|
||||
*
|
||||
* Sharing a constant makes them agree by construction. What it does *not* do is prove that both
|
||||
* layers still reach it: a refactor that stops `JoinViewModel` refusing at all, or that gives it a
|
||||
* different message, passes any test that only reads `TOO_FEW_INPUTS_MESSAGE`. So each layer is
|
||||
* driven for real here — the ViewModel through `onInputsPicked`, the worker through `doWork` — and
|
||||
* the assertion is that the two answers are **the same string**, taken from two running layers
|
||||
* rather than from one declaration.
|
||||
*
|
||||
* That is the shape `CLAUDE.md` asks for: revert the sharing and this goes red, because the two
|
||||
* sites drift the moment they are allowed to.
|
||||
*
|
||||
* ## Scope
|
||||
*
|
||||
* The arity guard's own behaviour on the ViewModel side — that it refuses one file, that it accepts
|
||||
* two, that it claims ownership first — is #155's, and this deliberately does not duplicate it.
|
||||
* This file is about the *agreement between layers*, which is what #158 changed.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SharedFailureMessagesTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var viewModel: JoinViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
ConversionDependencies.publisher = { RecordingPublisher(app) }
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/dev/null"))
|
||||
viewModel = JoinViewModel(app)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both layers refuse a one-file join with the same sentence`() {
|
||||
// The ViewModel, refusing before anything is enqueued.
|
||||
viewModel.onInputsPicked(listOf(ONE_FILE))
|
||||
val fromScreen = (viewModel.state.value as JoinState.Failed).message
|
||||
|
||||
// The worker, refusing a job that reached the queue anyway -- which it can, because
|
||||
// ConcatWorker.request(...) takes a List<Uri> and checks nothing about its length.
|
||||
val result = runBlocking { worker(ONE_FILE).doWork() }
|
||||
val fromJob = (result as ListenableWorker.Result.Failure)
|
||||
.outputData.getString(ConcatWorker.KEY_ERROR)
|
||||
|
||||
assertEquals(
|
||||
"the screen and the job must say the same thing about the same refusal",
|
||||
fromScreen,
|
||||
fromJob,
|
||||
)
|
||||
// And that the shared sentence is the one either layer would have written on its own,
|
||||
// rather than both having drifted together to something else.
|
||||
assertEquals(ConcatWorker.TOO_FEW_INPUTS_MESSAGE, fromScreen)
|
||||
}
|
||||
|
||||
private fun worker(vararg inputs: Uri): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to inputs.map(Uri::toString).toTypedArray(),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to 1024L,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).build()
|
||||
|
||||
private companion object {
|
||||
val ONE_FILE: Uri = Uri.parse("content://test/holiday.mp4")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import org.junit.Test
|
||||
* `OutputFormat` used to be twelve hand-picked triples, and its KDoc defended that on the grounds
|
||||
* that a closed set was what made routing decidable. Opening it up moves that burden here, so this
|
||||
* is where decidability now has to be proven.
|
||||
*
|
||||
* That includes what a refusal offers instead. `Validation.Invalid` promises every suggestion is
|
||||
* itself valid and names this class as the proof, so a branch that assembles its own suggestion
|
||||
* list rather than going through `suggestions()` is only checked here if some row happens to reach
|
||||
* it — which is how a dead-end chip survived two widenings of that table.
|
||||
*/
|
||||
class ContainerCapabilitiesTest {
|
||||
|
||||
@@ -20,6 +25,44 @@ class ContainerCapabilitiesTest {
|
||||
container = Container.MP4,
|
||||
)
|
||||
|
||||
/**
|
||||
* An MP3, and the reason several rules below need a second probe.
|
||||
*
|
||||
* `hasVideo = false` is the load-bearing field. Every rule that reads only the spec answers the
|
||||
* same for this input as for a video file, which is exactly how a spec naming a video codec was
|
||||
* called valid for a file with no video track to put in it.
|
||||
*/
|
||||
private val mp3Source = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
container = Container.MP3,
|
||||
)
|
||||
|
||||
/**
|
||||
* An audio-only input carrying a codec MP4 has no place for at all.
|
||||
*
|
||||
* Vorbis lives in Ogg and Matroska; MP4 carries AAC, MP3, Opus and FLAC. That gap is what turns
|
||||
* a suggestion which merely drops the video track into a second refusal.
|
||||
*/
|
||||
private val vorbisSource = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "vorbis",
|
||||
hasVideo = false,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
container = Container.OGG,
|
||||
)
|
||||
|
||||
/** The same shape, for the other codec MP4 refuses. One case is a coincidence; two is the rule. */
|
||||
private val pcmSource = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "pcm_s16le",
|
||||
hasVideo = false,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
container = Container.WAV,
|
||||
)
|
||||
|
||||
// --- copy and encode are different questions ----------------------------
|
||||
|
||||
/**
|
||||
@@ -82,20 +125,56 @@ class ContainerCapabilitiesTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** A suggestion that is itself invalid is worse than no suggestion. */
|
||||
/**
|
||||
* A suggestion that is itself invalid is worse than no suggestion.
|
||||
*
|
||||
* Only a branch that assembles its own suggestion list can break that promise: [suggestions]
|
||||
* ends by filtering on `validate(...).isValid`, so everything routed through it is valid by
|
||||
* construction. Those branches are what this table has to cover — the image output, and copy
|
||||
* the video from a file that has none, which built its list by hand and came back refused for
|
||||
* a Vorbis or PCM source into MP4 and an MP3 into WebM. The Advanced picker showed a one-tap
|
||||
* fix that led straight to a second error, through two widenings of this table that never
|
||||
* reached the branch.
|
||||
*/
|
||||
@Test
|
||||
fun `every suggestion is itself valid`() {
|
||||
val broken = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC)
|
||||
val result = ContainerCapabilities.validate(broken, h264Source)
|
||||
val cases = listOf(
|
||||
OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC) to h264Source,
|
||||
// The audio-only input. Every rejection it can reach used to hand back `None + None`
|
||||
// — a spec validation refuses in the next breath — because these branches built their
|
||||
// suggestion by hand instead of going through the repair-and-filter path.
|
||||
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE) to mp3Source,
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE) to mp3Source,
|
||||
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE) to mp3Source,
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.AAC) to mp3Source,
|
||||
// Copy-the-video-from-a-file-with-no-video, the last branch that built its offer by
|
||||
// hand. It escaped the five rows above because `spec.copy(videoCodec = NONE)` is valid
|
||||
// exactly when the audio axis happens to be fine — true for the AAC and MP3 sources
|
||||
// used there, false for any audio the target container cannot carry.
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY) to vorbisSource,
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY) to pcmSource,
|
||||
OutputSpec(Container.WEBM, VideoCodec.COPY, AudioCodec.COPY) to mp3Source,
|
||||
// The same branch with audio the container *can* hold, which is the half that already
|
||||
// worked and must keep working: the repair here is a copy, so the offer is the very
|
||||
// spec the caller handed to `suggestions`. It survives only because the exclusion is
|
||||
// against what the user asked for rather than against the repair.
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY) to mp3Source,
|
||||
// The one branch that still builds its list by hand, so that it is asserted rather
|
||||
// than merely reasoned about: an image container takes `None + None` and nothing else,
|
||||
// which makes its single offer valid by construction.
|
||||
OutputSpec(Container.GIF, VideoCodec.H264, AudioCodec.AAC) to h264Source,
|
||||
)
|
||||
|
||||
val invalid = result as? Validation.Invalid
|
||||
?: throw AssertionError("expected H.264 in WebM to be rejected")
|
||||
assertTrue("no alternatives offered", invalid.suggestions.isNotEmpty())
|
||||
invalid.suggestions.forEach { suggestion ->
|
||||
assertTrue(
|
||||
"suggested $suggestion is itself invalid",
|
||||
ContainerCapabilities.validate(suggestion, h264Source).isValid,
|
||||
)
|
||||
cases.forEach { (spec, probe) ->
|
||||
val invalid = ContainerCapabilities.validate(spec, probe) as? Validation.Invalid
|
||||
?: throw AssertionError("expected $spec to be rejected")
|
||||
assertTrue("no alternatives offered for $spec on $probe", invalid.suggestions.isNotEmpty())
|
||||
invalid.suggestions.forEach { suggestion ->
|
||||
assertTrue(
|
||||
"suggested $suggestion for $spec on $probe is itself invalid",
|
||||
ContainerCapabilities.validate(suggestion, probe).isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +206,117 @@ class ContainerCapabilitiesTest {
|
||||
assertTrue((result as Validation.Invalid).suggestions.isNotEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule, seen only against the probe.
|
||||
*
|
||||
* A video codec named for a file with no video track is dropped, not encoded — so
|
||||
* MP4/H.265/None on an MP3 empties the output exactly as None/None does. Reading the spec
|
||||
* alone answered "valid" because the spec names a video codec, and the job went to Media3,
|
||||
* where `EditedMediaItem.Builder` refuses a composition with both tracks removed by throwing
|
||||
* on Transformer's own HandlerThread.
|
||||
*/
|
||||
@Test
|
||||
fun `a video codec named for a file with no video track and no audio is refused`() {
|
||||
ContainerCapabilities.encodableVideo(Container.MP4).forEach { codec ->
|
||||
val spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE)
|
||||
val result = ContainerCapabilities.validate(spec, mp3Source)
|
||||
|
||||
assertFalse(
|
||||
"MP4/${codec.label}/None on an audio-only input plans to (Drop, Drop) and would " +
|
||||
"produce an empty file; it must be refused. Got $result",
|
||||
result.isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal is only worth having if it leads somewhere.
|
||||
*
|
||||
* The COPY form of this was already refused, but its one hand-built suggestion was
|
||||
* `None + None` — which validation refuses in the next breath, so the Advanced picker offered
|
||||
* a one-tap fix that fixed nothing. Every face of the rule now goes through the shared
|
||||
* suggestion path, so the offer keeps the one track the input actually has.
|
||||
*/
|
||||
@Test
|
||||
fun `refusing an empty output still offers a way to keep the audio`() {
|
||||
listOf(VideoCodec.H265, VideoCodec.H264, VideoCodec.COPY, VideoCodec.NONE).forEach { codec ->
|
||||
val spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE)
|
||||
val invalid = ContainerCapabilities.validate(spec, mp3Source) as? Validation.Invalid
|
||||
?: throw AssertionError("expected MP4/${codec.label}/None to be rejected")
|
||||
|
||||
assertTrue(
|
||||
"a refusal with no way out is a dead end in the Advanced picker",
|
||||
invalid.suggestions.isNotEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
"every suggestion must keep a track, got ${invalid.suggestions}",
|
||||
invalid.suggestions.all { it.audioCodec != AudioCodec.NONE },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A repair must not name a track the input does not have.
|
||||
*
|
||||
* `repairVideo` used to fall through to "the first codec this container can encode" whenever
|
||||
* nothing else fitted, and for an MP3 that produced the non-sequitur `MP4 · H.264 · Copy`.
|
||||
* It validated, so nothing caught it — but [CopyPlanner] drops that video track anyway, which
|
||||
* makes the codec in the offer a fiction.
|
||||
*/
|
||||
@Test
|
||||
fun `a repair for a file with no video track never names a video codec`() {
|
||||
listOf(
|
||||
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE),
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE),
|
||||
).forEach { spec ->
|
||||
val invalid = ContainerCapabilities.validate(spec, mp3Source) as Validation.Invalid
|
||||
invalid.suggestions.forEach {
|
||||
assertEquals(
|
||||
"offering ${it.videoCodec.label} for a file with no video track is a fiction; " +
|
||||
"CopyPlanner drops it. Suggested $it for $spec",
|
||||
VideoCodec.NONE,
|
||||
it.videoCodec,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule stated as the property it is, over the whole matrix.
|
||||
*
|
||||
* A plan of (Drop, Drop) is precisely the composition `EditedMediaItem.Builder` refuses to
|
||||
* build, so no non-image spec that reaches it may be called valid. Sweeping every container ×
|
||||
* codec × codec against both probes is what stops the next container or codec from
|
||||
* reintroducing the gap on an axis nobody thought to write a case for.
|
||||
*
|
||||
* Image outputs are exempt and deliberately so: GIF and PNG frames carry no codecs at all, and
|
||||
* `None + None` is the only spec they accept — but they never reach Media3, because the router
|
||||
* sends every image output to FFmpeg.
|
||||
*/
|
||||
@Test
|
||||
fun `no valid non-image spec plans to remove both tracks`() {
|
||||
val specs = Container.entries
|
||||
.filterNot { it == Container.GIF || it == Container.IMAGE_SEQUENCE }
|
||||
.flatMap { container -> VideoCodec.entries.map { container to it } }
|
||||
.flatMap { (container, video) -> AudioCodec.entries.map { OutputSpec(container, video, it) } }
|
||||
val cases = specs.flatMap { spec -> listOf(h264Source, mp3Source).map { spec to it } }
|
||||
|
||||
val empties = cases.filter { (spec, probe) ->
|
||||
val plan = CopyPlanner.plan(spec, probe)
|
||||
plan.video == VideoPlan.Drop && plan.audio == AudioPlan.Drop
|
||||
}
|
||||
|
||||
assertTrue("the sweep found nothing to check — the filter has gone wrong", empties.isNotEmpty())
|
||||
empties.forEach { (spec, probe) ->
|
||||
assertFalse(
|
||||
"$spec on $probe plans to (Drop, Drop) — an empty file, and the composition " +
|
||||
"Media3 cannot build — so it must not validate",
|
||||
ContainerCapabilities.validate(spec, probe).isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying is offered as the fix when the codec is right but unencodable`() {
|
||||
val av1Source = InputProbe(videoCodec = "av1", audioCodec = "aac", container = Container.MKV)
|
||||
@@ -168,4 +358,213 @@ class ContainerCapabilitiesTest {
|
||||
assertEquals(emptyList<VideoCodec>(), ContainerCapabilities.encodableVideo(container))
|
||||
}
|
||||
}
|
||||
|
||||
// --- the audio axis -----------------------------------------------------
|
||||
//
|
||||
// Every rule below has a video twin already tested above. The two halves of `validate` were
|
||||
// written together and only one of them was ever checked, so these are deliberately shaped like
|
||||
// their twins rather than as a fresh idea about what to assert.
|
||||
|
||||
@Test
|
||||
fun `an unidentifiable source audio codec cannot be copied`() {
|
||||
// The audio twin of `an unidentifiable source codec cannot be copied`. Never guess: a copy
|
||||
// of an unidentified codec is how you ship a file that does not play.
|
||||
val unknownAudio = InputProbe(videoCodec = "h264", audioCodec = null, container = Container.MP4)
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.COPY)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, unknownAudio) as? Validation.Invalid
|
||||
?: throw AssertionError("copying an unidentified audio codec must be refused")
|
||||
|
||||
assertTrue(invalid.message, invalid.message.contains("could not be identified"))
|
||||
assertEverySuggestionValid(invalid, unknownAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying an audio codec the container cannot hold is refused`() {
|
||||
// MP4 carries AAC, MP3, Opus and FLAC. Vorbis lives in Ogg and Matroska, so a stream copy
|
||||
// out of a Vorbis source into MP4 has nowhere to put the track.
|
||||
val vorbisAudio = InputProbe(videoCodec = "h264", audioCodec = "vorbis", container = Container.MKV)
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.COPY)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, vorbisAudio) as? Validation.Invalid
|
||||
?: throw AssertionError("Vorbis copied into MP4 must be refused")
|
||||
|
||||
assertEquals("MP4 cannot hold Vorbis audio.", invalid.message)
|
||||
assertEverySuggestionValid(invalid, vorbisAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio codec the container cannot hold is refused on the encode path too`() {
|
||||
// WAV carries PCM and nothing else. The twin is `H265 in AVI is refused`.
|
||||
val spec = OutputSpec(Container.WAV, VideoCodec.NONE, AudioCodec.AAC)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, mp3Source) as? Validation.Invalid
|
||||
?: throw AssertionError("AAC in WAV must be refused")
|
||||
|
||||
assertEquals("WAV cannot hold AAC audio.", invalid.message)
|
||||
assertEverySuggestionValid(invalid, mp3Source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio codec this app cannot encode is refused, and copying is offered instead`() {
|
||||
// Matroska carries Vorbis; nothing here encodes it. The refusal has to say so *and* say
|
||||
// what would work, which is the audio twin of `copying is offered as the fix when the codec
|
||||
// is right but unencodable`.
|
||||
val spec = OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.VORBIS)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, h264Source) as? Validation.Invalid
|
||||
?: throw AssertionError("encoding Vorbis must be refused")
|
||||
|
||||
assertEquals(
|
||||
"This app cannot encode Vorbis audio. It can still be copied from a Vorbis source.",
|
||||
invalid.message,
|
||||
)
|
||||
assertEverySuggestionValid(invalid, h264Source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying a video codec the container cannot hold is refused`() {
|
||||
// Not the audio axis, but the one video refusal with no test: AVI predates H.265, so a
|
||||
// stream copy out of an HEVC source into AVI has nowhere to put the track. `H265 in AVI is
|
||||
// refused` covers the matrix; this covers what validate() does with it.
|
||||
val h265Source = InputProbe(videoCodec = "hevc", audioCodec = "mp3", container = Container.MP4)
|
||||
val spec = OutputSpec(Container.AVI, VideoCodec.COPY, AudioCodec.MP3)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, h265Source) as? Validation.Invalid
|
||||
?: throw AssertionError("H.265 copied into AVI must be refused")
|
||||
|
||||
assertEquals("AVI cannot hold H.265 video.", invalid.message)
|
||||
assertEverySuggestionValid(invalid, h265Source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no audio track is accepted by every container in both modes`() {
|
||||
// The audio twin of VideoCodec.NONE -> true. A container that refused "no audio" would make
|
||||
// every video-only output invalid.
|
||||
Container.entries.forEach { container ->
|
||||
listOf(CodecMode.COPY, CodecMode.ENCODE).forEach { mode ->
|
||||
assertTrue(
|
||||
"$container should accept no audio track ($mode)",
|
||||
ContainerCapabilities.accepts(container, AudioCodec.NONE, mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The video twin of `no audio track is accepted by every container in both modes`.
|
||||
*
|
||||
* Dead in production today, and deliberately so: every caller guards `NONE` before asking the
|
||||
* matrix, so nothing reaches this arm through the app. **The asymmetry is the argument, not the
|
||||
* reachability** -- its audio counterpart at the top of the same `when` has had a dedicated
|
||||
* test since #136, and one of a matched pair being covered is how a later reader concludes the
|
||||
* other was considered and exempted. It was not; it was simply missed.
|
||||
*
|
||||
* Not the same shape as the two `COPY -> error(...)` arms, which `docs/coverage-read-findings.md`
|
||||
* records as a named exemption (F4). Those are guards that must not be provokable. This is a
|
||||
* documented answer -- "no video track fits anywhere" -- and an answer is a thing to pin.
|
||||
*/
|
||||
@Test
|
||||
fun `no video track is accepted by every container in both modes`() {
|
||||
Container.entries.forEach { container ->
|
||||
listOf(CodecMode.COPY, CodecMode.ENCODE).forEach { mode ->
|
||||
assertTrue(
|
||||
"$container should accept no video track ($mode)",
|
||||
ContainerCapabilities.accepts(container, VideoCodec.NONE, mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggestion that keeps the codec the user asked for, rather than falling back to the
|
||||
* container's first encodable one.
|
||||
*
|
||||
* `repairVideo`'s third arm -- "the request is not a copy, and this container can encode it" --
|
||||
* is the one that preserves intent, and it was the only arm of the four nothing reached. The
|
||||
* property test above executes `repairVideo` on every case it walks and lands elsewhere each
|
||||
* time: an explicit COPY that works, a source the container can carry untouched, or no video
|
||||
* track at all.
|
||||
*
|
||||
* The route is indirect because it is the only one the app has. VP9 into WebM is a perfectly
|
||||
* good video request; what makes it invalid is the *audio* -- WebM carries Opus and Vorbis, not
|
||||
* AAC. So `validateAudio` refuses, `suggestions` looks for a container that can hold what was
|
||||
* asked for, and MP4 can encode VP9. The suggestion has to come back carrying VP9: swapping to
|
||||
* the container's first encodable codec would discard the choice the user made.
|
||||
*/
|
||||
@Test
|
||||
fun `a repaired suggestion keeps the video codec the user chose`() {
|
||||
val invalid = ContainerCapabilities.validate(
|
||||
OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.AAC),
|
||||
h264Source,
|
||||
)
|
||||
|
||||
assertTrue("WebM cannot hold AAC, so this spec is invalid", invalid is Validation.Invalid)
|
||||
val suggestions = (invalid as Validation.Invalid).suggestions
|
||||
assertTrue(
|
||||
"expected a suggestion that still encodes VP9, got $suggestions",
|
||||
suggestions.any { it.videoCodec == VideoCodec.VP9 },
|
||||
)
|
||||
assertEverySuggestionValid(invalid, h264Source)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback in `firstContainerHolding`: when the input's own container cannot hold the
|
||||
* codec the user asked for, any container that can will do.
|
||||
*
|
||||
* The preferred half -- "the container the input already uses" -- is what every other case
|
||||
* reaches, because they all start from a file whose own container carries the codec in
|
||||
* question. The elvis after it had never run.
|
||||
*
|
||||
* AVI is the input that makes it run: AVI predates H.265 and has no mapping for it, so asking
|
||||
* an AVI for H.265 is refused, and the container the input already uses cannot be part of the
|
||||
* answer. Without the fallback the only candidates left are AVI itself and the container
|
||||
* holding the *source* codec -- also AVI -- so the refusal still offers something, but what it
|
||||
* offers is H.264: the app quietly declines the codec the user asked for instead of moving them
|
||||
* to a container that supports it.
|
||||
*
|
||||
* That is why this asserts the codec survives rather than that the list is non-empty. A
|
||||
* non-empty assertion passes with the fallback deleted -- measured, not assumed.
|
||||
*/
|
||||
@Test
|
||||
fun `an input whose container cannot hold the requested codec is moved, not downgraded`() {
|
||||
val aviSource = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.AVI)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(
|
||||
OutputSpec(Container.AVI, VideoCodec.H265, AudioCodec.AAC),
|
||||
aviSource,
|
||||
)
|
||||
|
||||
assertTrue("AVI has no mapping for H.265", invalid is Validation.Invalid)
|
||||
val suggestions = (invalid as Validation.Invalid).suggestions
|
||||
assertTrue(
|
||||
"expected a container that can actually hold H.265, got $suggestions",
|
||||
suggestions.any { it.videoCodec == VideoCodec.H265 },
|
||||
)
|
||||
assertEverySuggestionValid(invalid, aviSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolving audio COPY before asking the matrix is required`() {
|
||||
// The audio twin of `resolving COPY before asking the matrix is required`, and the reason is
|
||||
// identical: silently answering "false" would refuse a perfectly good remux.
|
||||
runCatching { ContainerCapabilities.accepts(Container.MP4, AudioCodec.COPY, CodecMode.COPY) }
|
||||
.onSuccess { throw AssertionError("expected audio COPY to be rejected by the matrix") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every alternative a refusal offers has to be one the same input could actually take.
|
||||
*
|
||||
* `Validation.Invalid` promises exactly this and names this class as the proof. The global
|
||||
* property test walks the presets; these paths reach `suggestions()` through `validateAudio`,
|
||||
* which no preset does.
|
||||
*/
|
||||
private fun assertEverySuggestionValid(invalid: Validation.Invalid, probe: InputProbe) {
|
||||
invalid.suggestions.forEach {
|
||||
assertTrue(
|
||||
"suggestion $it is itself invalid, so the chip leads to a second error",
|
||||
ContainerCapabilities.validate(it, probe).isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +350,34 @@ class ConversionRouterTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `Media3Engine` still needs a guard of its own.
|
||||
*
|
||||
* `ContainerCapabilities.validate` now refuses "a video codec with the audio off" for an input
|
||||
* with no video track, so neither the picker nor `ConversionWorker` will start one. Routing is
|
||||
* a separate question and still answers MEDIA3 — nothing about a dropped track makes the job
|
||||
* un-hardware-able — so a request that skips validation, from a direct
|
||||
* `ConversionWorker.request(...)` or a job queued before the settings changed, arrives at the
|
||||
* engine with a plan Media3 cannot build. That has to fail the job, not the process.
|
||||
*/
|
||||
@Test
|
||||
fun `a plan that drops both tracks still routes to media3`() {
|
||||
val audioOnly = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
)
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE)
|
||||
|
||||
val plan = CopyPlanner.plan(spec, audioOnly)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
|
||||
assertEquals(Engine.MEDIA3, route(spec, probe = audioOnly).engine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio-only formats are flagged as such`() {
|
||||
assertEquals(true, OutputFormat.MP3.isAudioOnly)
|
||||
|
||||
@@ -146,6 +146,33 @@ class CopyPlannerTest {
|
||||
assertTrue("copying the only track is still a remux", plan.isPureRemux)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one plan `Media3Engine` cannot be handed.
|
||||
*
|
||||
* `EditedMediaItem.Builder` refuses a composition with both tracks removed —
|
||||
* checkState("Audio and video cannot both be removed") — and this is how an ordinary-looking
|
||||
* spec reaches it: a video codec named for a file that has no video, with the audio switched
|
||||
* off. Neither half is unusual on its own, which is why validation could read the spec, see a
|
||||
* video codec, and call it fine.
|
||||
*/
|
||||
@Test
|
||||
fun `an audio-only source with the audio dropped removes both tracks`() {
|
||||
val audioOnly = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
)
|
||||
val plan = CopyPlanner.plan(
|
||||
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
audioOnly,
|
||||
)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
assertTrue("an empty plan is not a remux", !plan.isPureRemux)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying one track and encoding the other is not a pure remux`() {
|
||||
val plan = CopyPlanner.plan(
|
||||
|
||||
@@ -28,6 +28,7 @@ class TagTableUniquenessTest {
|
||||
fun `every tag constant has its own value`() {
|
||||
val tags = tagsIn(
|
||||
TestTags::class.java,
|
||||
TestTags.Shell::class.java,
|
||||
TestTags.Converter::class.java,
|
||||
TestTags.Join::class.java,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.libremediaconverter.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* The theme has to resolve the scheme its arguments name, and `ThemeKt` had no test at all --
|
||||
* 23 lines, none of them covered, which is how #68 was found.
|
||||
*
|
||||
* Only two of the four branches in [LibreMediaConverterTheme]'s `when` are reachable from the
|
||||
* app. `MainActivity` is the single call site and passes no arguments, so `dynamicColor` is
|
||||
* always `true` and the live choice is between the dynamic dark and dynamic light schemes.
|
||||
* Those two are what ships, and asserting on them survives whichever way #68 is decided.
|
||||
*
|
||||
* **The other two branches have no caller.** `dynamicColor = false` is passed below by this
|
||||
* test and by nothing else in `app/src`, so the coverage it produces is not evidence that a
|
||||
* switch exists -- misreading it that way is the whole reason #68 was filed. #68 is the open
|
||||
* decision about whether one ever will exist.
|
||||
*
|
||||
* What the assertions distinguish the branches on was measured under Robolectric `sdk=36`
|
||||
* rather than assumed. The dynamic palette resolves to the platform's own default there --
|
||||
* dark background `#121318` against light `#FAF8FF`, dark primary `#B0C6FF` -- and that is a
|
||||
* different hue from the brand palette's [Purple80] / [Purple40]. A dynamic scheme reads the
|
||||
* device, so those exact values belong to the Robolectric stub and to no particular phone,
|
||||
* which is why the live-branch tests compare the two resolved schemes against each other
|
||||
* instead of hard-coding either one.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ThemeColorSchemeTest {
|
||||
|
||||
// The **v2** rule (`androidx.compose.ui.test.junit4.v2`), as everywhere else in this
|
||||
// source set.
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/**
|
||||
* Every scheme the `when` can produce, read out of [MaterialTheme] inside the content
|
||||
* lambda -- the only place that shows what the theme actually chose, rather than what the
|
||||
* caller hoped for.
|
||||
*
|
||||
* All four are resolved in one composition because `setContent` may be called once per
|
||||
* test, and a comparison needs at least two of them.
|
||||
*/
|
||||
private fun resolveAll(): Schemes {
|
||||
lateinit var dynamicDark: ColorScheme
|
||||
lateinit var dynamicLight: ColorScheme
|
||||
lateinit var brandDark: ColorScheme
|
||||
lateinit var brandLight: ColorScheme
|
||||
composeRule.setContent {
|
||||
LibreMediaConverterTheme(darkTheme = true) { dynamicDark = MaterialTheme.colorScheme }
|
||||
LibreMediaConverterTheme(darkTheme = false) { dynamicLight = MaterialTheme.colorScheme }
|
||||
LibreMediaConverterTheme(darkTheme = true, dynamicColor = false) {
|
||||
brandDark = MaterialTheme.colorScheme
|
||||
}
|
||||
LibreMediaConverterTheme(darkTheme = false, dynamicColor = false) {
|
||||
brandLight = MaterialTheme.colorScheme
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
return Schemes(dynamicDark, dynamicLight, brandDark, brandLight)
|
||||
}
|
||||
|
||||
private class Schemes(
|
||||
val dynamicDark: ColorScheme,
|
||||
val dynamicLight: ColorScheme,
|
||||
val brandDark: ColorScheme,
|
||||
val brandLight: ColorScheme,
|
||||
)
|
||||
|
||||
/**
|
||||
* The live branches, and the one assertion that catches them being swapped: both dynamic
|
||||
* schemes come from the same device palette, so they are similar enough that identity or a
|
||||
* bare inequality would prove nothing. Background luminance is not similar -- it is the
|
||||
* thing dark mode is for.
|
||||
*/
|
||||
@Test
|
||||
fun `dark mode resolves a darker scheme than light mode`() {
|
||||
val schemes = resolveAll()
|
||||
|
||||
val dark = schemes.dynamicDark.background.luminance()
|
||||
val light = schemes.dynamicLight.background.luminance()
|
||||
assertTrue(
|
||||
"darkTheme = true should resolve the dynamic dark scheme, whose background " +
|
||||
"luminance ($dark) is below the light scheme's ($light)",
|
||||
dark < light,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the two dark branches ran, and which of the two light ones: the scheme a live
|
||||
* call resolves is the dynamic one, not the brand palette sitting next to it.
|
||||
*/
|
||||
@Test
|
||||
fun `the live branches take the dynamic palette rather than the brand one`() {
|
||||
val schemes = resolveAll()
|
||||
|
||||
assertNotEquals(
|
||||
"the default dynamicColor = true should not resolve the brand dark palette",
|
||||
Purple80,
|
||||
schemes.dynamicDark.primary,
|
||||
)
|
||||
assertNotEquals(
|
||||
"the default dynamicColor = true should not resolve the brand light palette",
|
||||
Purple40,
|
||||
schemes.dynamicLight.primary,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The two dead branches. Nothing in `app/src` passes `dynamicColor = false`; this test
|
||||
* does it directly, because the parameter is public, and that is the only way either
|
||||
* branch runs. Covered so a later decision on #68 starts from a tested `when` -- not
|
||||
* because the brand palette is reachable in the app.
|
||||
*/
|
||||
@Test
|
||||
fun `the brand palette branches run only when dynamicColor is passed explicitly`() {
|
||||
val schemes = resolveAll()
|
||||
|
||||
assertEquals(Purple80, schemes.brandDark.primary)
|
||||
assertEquals(Purple40, schemes.brandLight.primary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.libremediaconverter.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* The theme called the way the app calls it: with no arguments at all.
|
||||
*
|
||||
* [ThemeColorSchemeTest] resolves every branch of the `when` and always passes `darkTheme`
|
||||
* explicitly, so the `$default` bridge is never entered and **`isSystemInDarkTheme()` is never
|
||||
* called**. `MainActivity.kt:79` is its only default-argument caller and does not execute on the
|
||||
* JVM, which left the app's actual call shape the one nothing exercised —
|
||||
* `LibreMediaConverterTheme` reported `mi=21, mb=6, cb=12` at method level.
|
||||
*
|
||||
* ## Not #68
|
||||
*
|
||||
* #68 is about the two **unreachable** arms, `DarkColorScheme` and `LightColorScheme`, which cannot
|
||||
* run because `dynamicColor` is always `true` and nothing can flip it. That is an open product
|
||||
* decision. This is the reachable half — whether the default follows the system — and closing it
|
||||
* does not close that.
|
||||
*
|
||||
* ## Why the assertion compares schemes rather than reading a number
|
||||
*
|
||||
* A luminance threshold would be a guess about the device palette. What is asserted instead is that
|
||||
* the no-argument call resolves to **the same scheme** an explicit `darkTheme` of the matching
|
||||
* value does, and a different one from its opposite. That holds whatever palette the platform
|
||||
* hands back, and it is exactly the claim: the default reads the system rather than picking a side.
|
||||
*
|
||||
* Both schemes are resolved in one composition because `setContent` may be called once per test.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ThemeFollowsSystemTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "+night")
|
||||
fun `with no arguments the theme follows a system in dark mode`() {
|
||||
val resolved = resolve()
|
||||
|
||||
assertEquals("the default must resolve what darkTheme = true does", resolved.dark, resolved.bare)
|
||||
assertNotEquals(resolved.light, resolved.bare)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "+notnight")
|
||||
fun `with no arguments the theme follows a system in light mode`() {
|
||||
val resolved = resolve()
|
||||
|
||||
assertEquals("the default must resolve what darkTheme = false does", resolved.light, resolved.bare)
|
||||
assertNotEquals(resolved.dark, resolved.bare)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three colours are read together as one value, because any single one could coincide
|
||||
* between the two schemes on some palette while the schemes themselves differ. Background is
|
||||
* what dark mode is chiefly about; primary and surface are along to make a coincidence
|
||||
* implausible rather than merely unlikely.
|
||||
*/
|
||||
private data class Fingerprint(val background: Long, val primary: Long, val surface: Long)
|
||||
|
||||
private fun ColorScheme.fingerprint() =
|
||||
Fingerprint(background.value.toLong(), primary.value.toLong(), surface.value.toLong())
|
||||
|
||||
private class Resolved(val bare: Fingerprint, val dark: Fingerprint, val light: Fingerprint)
|
||||
|
||||
private fun resolve(): Resolved {
|
||||
lateinit var bare: Fingerprint
|
||||
lateinit var dark: Fingerprint
|
||||
lateinit var light: Fingerprint
|
||||
composeRule.setContent {
|
||||
// No arguments — the call MainActivity makes, and the one nothing exercised.
|
||||
LibreMediaConverterTheme { bare = MaterialTheme.colorScheme.fingerprint() }
|
||||
LibreMediaConverterTheme(darkTheme = true) { dark = MaterialTheme.colorScheme.fingerprint() }
|
||||
LibreMediaConverterTheme(darkTheme = false) { light = MaterialTheme.colorScheme.fingerprint() }
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
return Resolved(bare, dark, light)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConcatJoiner
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.StagingNames
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* What a join does when the engine fails partway.
|
||||
*
|
||||
* Everything past `ConcatWorker`'s `setForeground` was untested on **every** source set, and the
|
||||
* repo had already measured the cost: `PerJobStagingTest`'s KDoc records that reverting
|
||||
* `ConcatWorker` to a constant staging name left all 257 tests green, because nothing in the JVM
|
||||
* suite can get past a `ConcatEngine` constructed in place. `RefusedJobTest` says the same from the
|
||||
* other side -- "the next thing past the count guard is `ConcatEngine`, which is native".
|
||||
* `ConcatEngineTest` on a device tests the engine directly, bypassing the worker, and
|
||||
* `ConcatWorkerTest` covers only the too-few-inputs guard and the happy path.
|
||||
*
|
||||
* `ConversionDependencies.concat` is the seam that closes it, added here to sit beside the
|
||||
* `.hardware` and `.software` that `ConversionWorker` has had all along -- the asymmetry between the
|
||||
* two workers was the whole reason one of them had a tested failure path and the other did not.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConcatFailureTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: AlwaysRoomPublisher
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = AlwaysRoomPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join whose engine fails reports the engine's own reason`() {
|
||||
ConversionDependencies.concat = { FailingJoiner { error(DEMUXER_MESSAGE) } }
|
||||
|
||||
val result = runBlocking { joinWorker().doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(workDataOf(ConcatWorker.KEY_ERROR to DEMUXER_MESSAGE)),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure carrying no message at all, which Kotlin and Java both allow and FFmpegKit's
|
||||
* wrappers can produce.
|
||||
*
|
||||
* Without the fallback the user is shown an empty error, and `JoinViewModel` cannot tell that
|
||||
* from a job that reported nothing -- the two would be one blank screen with different causes.
|
||||
*/
|
||||
@Test
|
||||
fun `a failure with no message of its own still says something`() {
|
||||
ConversionDependencies.concat = { FailingJoiner { throw RuntimeException() } }
|
||||
|
||||
val result = runBlocking { joinWorker().doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(
|
||||
workDataOf(ConcatWorker.KEY_ERROR to ConcatWorker.GENERIC_FAILURE_MESSAGE),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged file is deleted on the way out.
|
||||
*
|
||||
* The joiner writes before it fails, exactly as `PartialThenFailingTranscoder` does on the
|
||||
* conversion side: a stub that only threw would let a missing `staged.delete()` pass. What it
|
||||
* costs to lose is a full-size partial per failed join, sitting in cache until the sweep is old
|
||||
* enough to be sure nobody is coming back for it.
|
||||
*
|
||||
* Asserted against the file the joiner was actually handed rather than by scanning the staging
|
||||
* directory for a name. The first draft did scan, for a `"join-"` prefix that
|
||||
* `StagingNames.forJob` does not produce -- it names files `<jobId>.<ext>` -- so the assertion
|
||||
* was trivially true and the mutation walked straight through it.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed join leaves nothing behind in staging`() {
|
||||
val joiner = FailingJoiner { error(DEMUXER_MESSAGE) }
|
||||
ConversionDependencies.concat = { joiner }
|
||||
|
||||
runBlocking { joinWorker().doWork() }
|
||||
|
||||
val staged = requireNotNull(joiner.lastOutput) { "the joiner never ran, so this proves nothing" }
|
||||
assertEquals(
|
||||
"the fixture has to write before it fails, or the delete is unobservable",
|
||||
PARTIAL_BYTES,
|
||||
joiner.bytesWritten,
|
||||
)
|
||||
assertFalse("a failed join must not leave its partial behind: $staged", staged.exists())
|
||||
}
|
||||
|
||||
/**
|
||||
* The success path, and the staging name #159's fixture and `PerJobStagingTest` both care about.
|
||||
*
|
||||
* Worth its place rather than a happy-path formality: `PerJobStagingTest`'s KDoc records that
|
||||
* **reverting `ConcatWorker` to a constant staging name left all 257 tests green**, because
|
||||
* nothing could reach the line that names the file. This is the test that was missing when that
|
||||
* was written -- the join's output `Data` had never been read by anything on the JVM.
|
||||
*
|
||||
* The staged path is asserted to carry the job id, not a constant: two joins of the same format
|
||||
* sharing one name is the defect, and `ConcatEngine`'s list file collided harder still.
|
||||
*/
|
||||
@Test
|
||||
fun `a join that works reports its own staged file, strategy and name`() {
|
||||
val joiner = SucceedingJoiner()
|
||||
ConversionDependencies.concat = { joiner }
|
||||
|
||||
val result = runBlocking { joinWorker().doWork() }
|
||||
|
||||
assertTrue("got $result", result is ListenableWorker.Result.Success)
|
||||
val data = (result as ListenableWorker.Result.Success).outputData
|
||||
assertEquals(
|
||||
"the staged file has to be this job's, not a name every join shares",
|
||||
File(publisherStagingDir(), StagingNames.forJob(JOB_ID, OutputFormat.MP4_H264.extension)).absolutePath,
|
||||
data.getString(ConcatWorker.KEY_OUTPUT_PATH),
|
||||
)
|
||||
assertEquals(ConcatStrategy.STREAM_COPY.name, data.getString(ConcatWorker.KEY_STRATEGY))
|
||||
assertEquals(OutputFormat.MP4_H264.mimeType, data.getString(ConcatWorker.KEY_MIME_TYPE))
|
||||
assertTrue(
|
||||
"the save dialog needs a name with the right extension, got ${data.getString(
|
||||
ConcatWorker.KEY_SUGGESTED_NAME,
|
||||
)}",
|
||||
data.getString(ConcatWorker.KEY_SUGGESTED_NAME).orEmpty().endsWith(".${OutputFormat.MP4_H264.extension}"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The arm beside the count guard: no URI array at all.
|
||||
*
|
||||
* Covered today only by `UnopenableUriTest` on a device, although it runs before staging and
|
||||
* before any native code. It is the exact sibling of `RefusedJobTest`'s ConversionWorker twin,
|
||||
* and it belongs on the JVM with it -- a device test for a branch that needs no device is a
|
||||
* slower test that reports later.
|
||||
*/
|
||||
@Test
|
||||
fun `a join with no input array at all is refused with a message`() {
|
||||
val result = runBlocking {
|
||||
TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID).build().doWork()
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(workDataOf(ConcatWorker.KEY_ERROR to ConcatWorker.NO_INPUTS_MESSAGE)),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
private fun publisherStagingDir(): File? = publisher.createStagingFile("probe").parentFile
|
||||
|
||||
private fun joinWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to arrayOf(FIRST.toString(), SECOND.toString()),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to TOTAL_BYTES,
|
||||
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID).build()
|
||||
|
||||
private companion object {
|
||||
val FIRST: Uri = Uri.parse("file:///tmp/one.mp4")
|
||||
val SECOND: Uri = Uri.parse("file:///tmp/two.mp4")
|
||||
const val TOTAL_BYTES = 2048L
|
||||
const val DEMUXER_MESSAGE = "the demuxer rejected the input list"
|
||||
const val PARTIAL_BYTES = 2048
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-00000000000b")
|
||||
}
|
||||
}
|
||||
|
||||
/** A joiner that writes something and then fails, so a missing `staged.delete()` cannot pass. */
|
||||
private class FailingJoiner(private val failure: () -> Nothing) : ConcatJoiner {
|
||||
|
||||
/** The handle the worker created, kept so a test can ask whether it survived the failure. */
|
||||
var lastOutput: File? = null
|
||||
var bytesWritten = 0
|
||||
|
||||
override suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat): ConcatEngine.Result {
|
||||
lastOutput = output
|
||||
output.writeBytes(ByteArray(PARTIAL_BYTES))
|
||||
bytesWritten = PARTIAL_BYTES
|
||||
failure()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PARTIAL_BYTES = 2048
|
||||
}
|
||||
}
|
||||
|
||||
/** The joiner that finishes, so the success path and the output `Data` can be read on the JVM. */
|
||||
private class SucceedingJoiner : ConcatJoiner {
|
||||
override suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat): ConcatEngine.Result {
|
||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
||||
return ConcatEngine.Result(ConcatStrategy.STREAM_COPY, output)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val OUTPUT_BYTES = 4096
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,6 @@ import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* That a refused foreground-service start does not end the job.
|
||||
@@ -125,6 +122,40 @@ class DeniedForegroundStartTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join denied past the attempt bound fails with a message the user can act on`() {
|
||||
// The join twin of the conversion case above. ConcatWorker reaches the same FailureOutcome
|
||||
// through its own `when`, and that arm was the only one of its three with no test -- so a
|
||||
// join that gave up silently, or gave up with an empty Data, would have looked identical to
|
||||
// one that retried.
|
||||
val worker = concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS)
|
||||
|
||||
val result = runBlocking { worker.doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(
|
||||
workDataOf(ConcatWorker.KEY_ERROR to FailureOutcome.FOREGROUND_DENIED_MESSAGE),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join that gives up collects the partial it had already staged`() {
|
||||
// The delete lives on ConcatWorker's `catch (e: Throwable)` path, which every give-up goes
|
||||
// through. Written first so a missing delete cannot pass by asking whether a file nobody
|
||||
// wrote is absent.
|
||||
concatStagedFile().writeBytes(ByteArray(PARTIAL_BYTES))
|
||||
|
||||
runBlocking { concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS).doWork() }
|
||||
|
||||
assertEquals(
|
||||
"a join that gave up must not orphan what it staged",
|
||||
emptyList<String>(),
|
||||
stagedNames(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun conversionWorker(runAttemptCount: Int = 0): ConversionWorker =
|
||||
TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
@@ -141,18 +172,22 @@ class DeniedForegroundStartTest {
|
||||
.setForegroundUpdater(DenyingForegroundUpdater)
|
||||
.build()
|
||||
|
||||
private fun concatWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
private fun concatWorker(runAttemptCount: Int = 0): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "content://test/second.mp4"),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
|
||||
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
|
||||
ConcatWorker.KEY_FORMAT to CONCAT_FORMAT.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
runAttemptCount = runAttemptCount,
|
||||
).setId(CONCAT_ID)
|
||||
.setForegroundUpdater(DenyingForegroundUpdater)
|
||||
.build()
|
||||
|
||||
/** The staging path the join will compute, asked for rather than spelled out here. */
|
||||
private fun concatStagedFile(): File =
|
||||
publisher.createStagingFile(StagingNames.forJob(CONCAT_ID, CONCAT_FORMAT.extension))
|
||||
|
||||
/** The staging path the worker will compute, asked for rather than spelled out here. */
|
||||
private fun stagedFile(): File = publisher.createStagingFile(StagingNames.forJob(CONVERSION_ID, SPEC.extension))
|
||||
|
||||
@@ -164,6 +199,7 @@ class DeniedForegroundStartTest {
|
||||
const val INPUT_BYTES = 1024L
|
||||
const val PARTIAL_BYTES = 2048
|
||||
val SPEC = OutputFormat.MP4_H265.spec
|
||||
val CONCAT_FORMAT = OutputFormat.MP4_H264
|
||||
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000001")
|
||||
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000002")
|
||||
}
|
||||
@@ -182,18 +218,3 @@ private object DenyingForegroundUpdater : ForegroundUpdater {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An already-failed future, written out rather than pulled from a futures library.
|
||||
*
|
||||
* `await()` takes the `isDone` fast path and unwraps the `ExecutionException`, which is what puts
|
||||
* the platform's own exception in front of the worker's catch rather than a wrapper.
|
||||
*/
|
||||
private class FailedFuture(private val failure: Throwable) : ListenableFuture<Void> {
|
||||
override fun addListener(listener: Runnable, executor: Executor): Unit = executor.execute(listener)
|
||||
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
|
||||
override fun isCancelled(): Boolean = false
|
||||
override fun isDone(): Boolean = true
|
||||
override fun get(): Void = throw ExecutionException(failure)
|
||||
override fun get(timeout: Long, unit: TimeUnit): Void = throw ExecutionException(failure)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.content.pm.ServiceInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* [ConversionForegroundType.current] answers differently on each of the three API regimes, and
|
||||
* until this file only one of them was ever executed.
|
||||
*
|
||||
* `app/src/test/resources/robolectric.properties` pins the whole JVM suite to `sdk=36`, so every
|
||||
* Robolectric test that reaches a `ForegroundInfo` takes the `mediaProcessing` arm and no other.
|
||||
* The 33 and 34 arms were cold: 3 lines and 3 of 4 branches, measured on `main` at `d354f64`.
|
||||
*
|
||||
* **The instrumented test is not a substitute, and the reason is specific.**
|
||||
* `ConversionWorkerTest.foregroundTypeMatchesTheRunningApiLevel` asserts against whichever API the
|
||||
* leg happens to be — one arm per leg, never the other two — and the legs that would cover 33 and
|
||||
* 34 are the ones issue #122 wedges. `docs/coverage-read-findings.md` records an API 33 run that
|
||||
* reported `received: 60` and `failed: unknown`: the regime *was* exercised, and that leg could
|
||||
* not have said so if it had broken. Four `@Config` classes here pin all three arms
|
||||
* deterministically, in the same `./gradlew` invocation as everything else.
|
||||
*
|
||||
* `minSdk` is 33, so none of these is dead code — each is a device someone is running the app on.
|
||||
*
|
||||
* **SDK 35 is in the list for the boundary, not for the answer.** It shares its answer with 36,
|
||||
* which would make it look redundant. It is not: relaxing `>= VANILLA_ICE_CREAM` to `>` is invisible
|
||||
* at every level except exactly 35, so without this class that mutation survives the suite.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class ForegroundTypeApi33Test {
|
||||
|
||||
/**
|
||||
* Zero rather than a named constant because there is no constant to name: API 33 does not
|
||||
* require a type, and `mediaProcessing` does not exist here to pass. `ForegroundInfo` reads 0
|
||||
* as "no type at all", which is what this regime wants.
|
||||
*/
|
||||
@Test
|
||||
fun `api 33 asks for no foreground service type`() {
|
||||
assertEquals(0, ConversionForegroundType.current())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 34 makes a type mandatory and still has no `mediaProcessing`, so `dataSync` is the only
|
||||
* sensible fit. See [ForegroundTypeApi33Test] for why this file exists.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ForegroundTypeApi34Test {
|
||||
|
||||
@Test
|
||||
fun `api 34 falls back to dataSync, the only type that fits`() {
|
||||
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ConversionForegroundType.current())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first level with `mediaProcessing`, and therefore the one that tells `>=` from `>`.
|
||||
* See [ForegroundTypeApi33Test].
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class ForegroundTypeApi35Test {
|
||||
|
||||
@Test
|
||||
fun `api 35 is the first level that takes mediaProcessing`() {
|
||||
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, ConversionForegroundType.current())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The level the rest of the suite runs at, asserted here rather than assumed — it is the one arm
|
||||
* that was already covered, and leaving it out would make this file look like it is about the old
|
||||
* levels rather than about all three regimes. See [ForegroundTypeApi33Test].
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class ForegroundTypeApi36Test {
|
||||
|
||||
@Test
|
||||
fun `api 36 keeps mediaProcessing`() {
|
||||
assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, ConversionForegroundType.current())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.HardwareTranscoder
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* What happens when the hardware engine does not finish the job.
|
||||
*
|
||||
* `runMedia3OrFallBack` was eleven lines at 0% on the JVM and `isCancellation` had never been
|
||||
* called by any unit test at all. Its own KDoc calls the fallback the protection against vendor
|
||||
* hardware encoders that "cannot be tested for correctness", so it is the branch most likely to
|
||||
* matter on a device nobody here owns — and it was reachable the whole time through
|
||||
* `ConversionDependencies.hardware`, which no unit test had ever used.
|
||||
*
|
||||
* The sharp one is cancellation. `runMedia3OrFallBack` catches `Throwable`, so without the
|
||||
* `isCancellation` re-throw a user cancelling a hardware transcode would have the app quietly
|
||||
* start a *second* conversion in software — the one thing cancelling is supposed to prevent.
|
||||
*
|
||||
* `ForcedFailureTest` covers the failure half on a device. It does not cover the cancellation half,
|
||||
* and this host cannot run it either way.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class HardwareFallbackTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var hardware: RecordingHardwareTranscoder
|
||||
private lateinit var software: RecordingSoftwareTranscoder
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
hardware = RecordingHardwareTranscoder()
|
||||
software = RecordingSoftwareTranscoder()
|
||||
ConversionDependencies.publisher = { AlwaysRoomPublisher(app) }
|
||||
ConversionDependencies.hardware = { hardware }
|
||||
ConversionDependencies.software = { software }
|
||||
// A probe with real codecs, not the default: `InputProbe()` reports UNPARSEABLE, which
|
||||
// PERMISSIVE.canDecode refuses, and the router would send every job here straight to
|
||||
// FFmpeg without any of these tests mentioning why.
|
||||
ConversionDependencies.probe = { _, _ -> H264_SOURCE }
|
||||
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hardware failure runs the job again in software, on a clean staging file`() {
|
||||
hardware.failWith = { error("the vendor encoder produced nothing usable") }
|
||||
|
||||
val result = runBlocking { worker().doWork() }
|
||||
|
||||
assertTrue("the job should still succeed, got $result", result is ListenableWorker.Result.Success)
|
||||
assertEquals("the hardware engine gets exactly one attempt", 1, hardware.attempts)
|
||||
assertEquals("and the job then goes to software", 1, software.attempts)
|
||||
// The `staged.delete()` between the two, asserted where it is observable: FFmpeg must not
|
||||
// find a half-written hardware output sitting at the path it is about to write.
|
||||
assertFalse(
|
||||
"the partial hardware output must be gone before FFmpeg starts",
|
||||
software.outputExistedOnEntry,
|
||||
)
|
||||
assertEquals("the hardware engine is closed either way", 1, hardware.closes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled hardware transcode is not quietly retried in software`() {
|
||||
hardware.failWith = { throw CancellationException("the user pressed Cancel") }
|
||||
|
||||
assertThrows(CancellationException::class.java) { runBlocking { worker().doWork() } }
|
||||
|
||||
assertEquals("the hardware engine ran", 1, hardware.attempts)
|
||||
assertEquals(
|
||||
"cancelling must not start a second conversion -- that is the whole point of cancelling",
|
||||
0,
|
||||
software.attempts,
|
||||
)
|
||||
assertEquals("and the engine is still closed on the way out", 1, hardware.closes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hardware transcode that works never reaches the software engine`() {
|
||||
val result = runBlocking { worker().doWork() }
|
||||
|
||||
assertTrue("got $result", result is ListenableWorker.Result.Success)
|
||||
assertEquals(1, hardware.attempts)
|
||||
assertEquals("the fallback is a fallback, not a second pass", 0, software.attempts)
|
||||
assertEquals(1, hardware.closes)
|
||||
}
|
||||
|
||||
/**
|
||||
* #169: the display-name fallback, which reaches further than the notification title.
|
||||
*
|
||||
* `inputData.getString(KEY_DISPLAY_NAME) ?: "input"` had never taken its right-hand side. The
|
||||
* value is not only the foreground notification's title: it feeds `outputNameFor`, so it is
|
||||
* also the filename offered in the user's save dialog. A job enqueued by an older build, or
|
||||
* built by hand, carries no such key.
|
||||
*/
|
||||
@Test
|
||||
fun `a job that names no input file still suggests an output name`() {
|
||||
val result = runBlocking { worker(displayName = null).doWork() }
|
||||
|
||||
assertTrue("got $result", result is ListenableWorker.Result.Success)
|
||||
val suggested = (result as ListenableWorker.Result.Success)
|
||||
.outputData.getString(ConversionWorker.KEY_SUGGESTED_NAME)
|
||||
assertTrue(
|
||||
"expected a name built from the fallback, got $suggested",
|
||||
suggested.orEmpty().startsWith("input"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun worker(displayName: String? = DISPLAY_NAME): ConversionWorker {
|
||||
val spec = OutputFormat.MP4_H265.spec
|
||||
val entries = buildMap<String, Any> {
|
||||
put(ConversionWorker.KEY_INPUT_URI, INPUT.toString())
|
||||
displayName?.let { put(ConversionWorker.KEY_DISPLAY_NAME, it) }
|
||||
put(ConversionWorker.KEY_SIZE_BYTES, INPUT_BYTES)
|
||||
put(ConversionWorker.KEY_CONTAINER, spec.container.name)
|
||||
put(ConversionWorker.KEY_VIDEO_CODEC, spec.videoCodec.name)
|
||||
put(ConversionWorker.KEY_AUDIO_CODEC, spec.audioCodec.name)
|
||||
// AUTO rather than FORCE_SOFTWARE, which is what every other worker test uses and is
|
||||
// exactly why this path had no coverage: forcing software never enters the function.
|
||||
put(ConversionWorker.KEY_ENGINE_PREFERENCE, EnginePreference.AUTO.name)
|
||||
}
|
||||
return TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
inputData = Data.Builder().putAll(entries).build(),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID).build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
|
||||
const val DISPLAY_NAME = "holiday.mp4"
|
||||
const val INPUT_BYTES = 1024L
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000009")
|
||||
val H264_SOURCE = InputProbe(
|
||||
videoCodec = "h264",
|
||||
audioCodec = "aac",
|
||||
container = Container.MP4,
|
||||
durationMs = 1_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A hardware engine that writes something before it fails, and remembers being closed.
|
||||
*
|
||||
* Writing first is the point, exactly as it is for `PartialThenFailingTranscoder`: an engine that
|
||||
* only threw would let a missing `staged.delete()` pass unnoticed.
|
||||
*/
|
||||
@UnstableApi
|
||||
private class RecordingHardwareTranscoder : HardwareTranscoder {
|
||||
|
||||
var attempts = 0
|
||||
var closes = 0
|
||||
var failWith: (() -> Unit)? = null
|
||||
|
||||
override suspend fun transcode(input: Uri, output: File, request: ConversionRequest, onProgress: (Int) -> Unit) {
|
||||
attempts++
|
||||
output.writeBytes(ByteArray(PARTIAL_BYTES))
|
||||
failWith?.invoke()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closes++
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PARTIAL_BYTES = 2048
|
||||
}
|
||||
}
|
||||
|
||||
/** The software engine, recording whether the hardware attempt's leftovers were cleared first. */
|
||||
private class RecordingSoftwareTranscoder : SoftwareTranscoder {
|
||||
|
||||
var attempts = 0
|
||||
var outputExistedOnEntry = false
|
||||
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
attempts++
|
||||
outputExistedOnEntry = output.exists()
|
||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val OUTPUT_BYTES = 512
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -125,6 +126,39 @@ class JobSnapshotsTest {
|
||||
assertEquals(newer.absolutePath, Reattachment.choose(snapshots)?.job?.outputPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* A job in the tag query that never recorded an output path at all.
|
||||
*
|
||||
* Distinct from the three cases above, which all *have* a path and differ in what it names. A
|
||||
* job still running, or one that finished without writing its result key, carries no path at
|
||||
* all -- and `getWorkInfosByTagFlow` returns it alongside the finished ones, because the tag is
|
||||
* the worker class and every attempt ever enqueued carries it.
|
||||
*
|
||||
* The guard is the `?.` in `path?.let(::File)`. Without it the null goes straight into a `File`
|
||||
* constructor. What this pins is the consequence rather than the null check: such a job must
|
||||
* not be offered as a result, so `Reattachment.choose` has to walk past it to the job that
|
||||
* really produced a file. Choosing it would put a Converted screen in front of the user with a
|
||||
* Save button that has nothing to save.
|
||||
*/
|
||||
@Test
|
||||
fun `a job that recorded no output path is not offered as a result`() {
|
||||
val real = stagedFile("real.mp4", bytes = 4096)
|
||||
finishedWithOutput(real)
|
||||
finishedWithNoOutput()
|
||||
|
||||
val snapshots = snapshots()
|
||||
|
||||
assertEquals("both jobs carry the tag, so both come back", 2, snapshots.size)
|
||||
val silent = snapshots.single { it.outputPath == null }
|
||||
assertFalse("no path means no output, not an empty one", silent.outputExists)
|
||||
assertEquals("and no time either, for the same reason", 0L, silent.outputModifiedAt)
|
||||
assertEquals(
|
||||
"the reattachment has to walk past it to the job that really produced a file",
|
||||
real.absolutePath,
|
||||
Reattachment.choose(snapshots)?.job?.outputPath,
|
||||
)
|
||||
}
|
||||
|
||||
private fun snapshots(): List<JobSnapshot> = runBlocking {
|
||||
workManager.jobSnapshots(
|
||||
tag = ConversionWorker::class.java.name,
|
||||
@@ -152,6 +186,11 @@ class JobSnapshotsTest {
|
||||
).result.get()
|
||||
}
|
||||
|
||||
/** A job that carries the tag and no result key -- still running, or finished without one. */
|
||||
private fun finishedWithNoOutput() {
|
||||
workManager.enqueue(OneTimeWorkRequestBuilder<ConversionWorker>().build()).result.get()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Two fixed moments a day apart, so the ordering is stated rather than raced for. */
|
||||
const val OLDER_MS = 1_700_000_000_000L
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.app.Notification
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The two things a progress notification can say, and that they are not the same thing.
|
||||
*
|
||||
* An assertion gap rather than a coverage one, and the distinction is the reason this file exists.
|
||||
* JaCoCo is green on `build`'s `if (indeterminate)`, because `ProgressNotificationTest` drives it
|
||||
* through a real worker -- but that test reads only the notification id and
|
||||
* `Notification.EXTRA_PROGRESS`. **Nothing had ever read the text.** Swapping the two branches, or
|
||||
* collapsing them into one string, passed the entire suite.
|
||||
*
|
||||
* What it costs to get wrong is small and constant: a conversion that has been running for four
|
||||
* minutes still saying "Preparing", or one that has not started reporting yet claiming 0%. Neither
|
||||
* is a crash, and neither would be found by anything else here -- which is exactly the kind of
|
||||
* thing that survives for a long time.
|
||||
*
|
||||
* Nothing else in the suite constructs [ConversionNotifications] directly.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class NotificationProgressTextTest {
|
||||
|
||||
/**
|
||||
* `build` reaches `WorkManager.getInstance` for the Cancel action's PendingIntent, so the
|
||||
* notification cannot be built at all without one. That coupling is why nothing had ever
|
||||
* constructed this class directly and read what it produced.
|
||||
*/
|
||||
@Before
|
||||
fun setUp() {
|
||||
installTestWorkManager(RuntimeEnvironment.getApplication(), Data.EMPTY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an indeterminate notification says something different from a measured one`() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val notifications = ConversionNotifications(context)
|
||||
|
||||
val preparing = notifications.build(JOB_ID, TITLE, percent = 0, indeterminate = true).text()
|
||||
val measured = notifications.build(JOB_ID, TITLE, percent = 42, indeterminate = false).text()
|
||||
|
||||
assertNotEquals(
|
||||
"the two states have to read differently, or the text says nothing at all",
|
||||
preparing,
|
||||
measured,
|
||||
)
|
||||
assertTrue(
|
||||
"a measured notification has to carry its percentage, got \"$measured\"",
|
||||
measured.contains("42"),
|
||||
)
|
||||
assertTrue(
|
||||
"an indeterminate one must not invent one, got \"$preparing\"",
|
||||
!preparing.contains("42") && !preparing.contains("0"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The title is the caller's, not the builder's -- it is the file the user picked, and it is what
|
||||
* tells two simultaneous conversions apart in the shade.
|
||||
*/
|
||||
@Test
|
||||
fun `the notification is titled with the file it is converting`() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
val built = ConversionNotifications(context).build(JOB_ID, TITLE, percent = 10)
|
||||
|
||||
assertEquals(TITLE, built.extras.getString(Notification.EXTRA_TITLE))
|
||||
}
|
||||
|
||||
private fun Notification.text(): String = extras.getString(Notification.EXTRA_TEXT).orEmpty()
|
||||
|
||||
private companion object {
|
||||
const val TITLE = "holiday.mp4"
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-00000000000a")
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,10 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.HardwareTranscoder
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
@@ -129,6 +131,40 @@ class ProgressNotificationTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same plumbing on the engine most conversions actually use, which had none.
|
||||
*
|
||||
* `ConversionWorker.kt:208-210` is a second `onProgress` lambda at a second call site — the one
|
||||
* handed to `engine.transcode` — and it reported `ci == 0`. Every test above drives the FFmpeg
|
||||
* path; `HardwareFallbackTest` reaches `runMedia3OrFallBack` but its recording transcoder
|
||||
* records the call and never invokes the callback it was given. So the two engines' progress
|
||||
* wiring was one tested and one not, and the untested one is the default: `ConversionRouter`
|
||||
* sends everything it can to Media3.
|
||||
*
|
||||
* `AUTO` with a real H.264 probe, because `FORCE_SOFTWARE` is precisely what keeps the other
|
||||
* tests out of this branch. The probe and the permissive codec profile are what let the router
|
||||
* choose Media3 at all — `InputProbe()` reports `UNPARSEABLE`, which routes straight to FFmpeg.
|
||||
*
|
||||
* Asserted on the *percentage*, not merely on an update having happened: `publishProgress`
|
||||
* takes a display name and a percent, and replacing the percent with a constant compiles.
|
||||
*/
|
||||
@Test
|
||||
fun `progress from the hardware engine reaches WorkManager the same way FFmpeg's does`() {
|
||||
ConversionDependencies.probe = { _, _ -> H264_SOURCE }
|
||||
val reporting = ReportingHardwareTranscoder { onProgress -> onProgress(PERCENT) }
|
||||
ConversionDependencies.hardware = { reporting }
|
||||
|
||||
runBlocking { workerReporting(EnginePreference.AUTO) { }.doWork() }
|
||||
|
||||
assertEquals("the job must have gone to the hardware engine", 1, reporting.attempts)
|
||||
val progressUpdates = updater.infos.drop(1)
|
||||
assertEquals("one throttled progress update expected", 1, progressUpdates.size)
|
||||
assertEquals(
|
||||
PERCENT,
|
||||
progressUpdates.single().notification.extras.getInt(Notification.EXTRA_PROGRESS),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A worker routed to the software engine, whose engine is [report] and a written output.
|
||||
*
|
||||
@@ -137,7 +173,10 @@ class ProgressNotificationTest {
|
||||
* bridge, which is native. [report] is handed the worker's own progress callback, and runs with
|
||||
* the worker as its receiver so a test can stop it mid-transcode.
|
||||
*/
|
||||
private fun workerReporting(report: ConversionWorker.((Int) -> Unit) -> Unit): ConversionWorker {
|
||||
private fun workerReporting(
|
||||
enginePreference: EnginePreference = EnginePreference.FORCE_SOFTWARE,
|
||||
report: ConversionWorker.((Int) -> Unit) -> Unit,
|
||||
): ConversionWorker {
|
||||
val worker = TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
@@ -147,7 +186,7 @@ class ProgressNotificationTest {
|
||||
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,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to enginePreference.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID)
|
||||
@@ -171,6 +210,17 @@ class ProgressNotificationTest {
|
||||
const val TICKS = 50
|
||||
val SPEC = OutputFormat.MP4_H265.spec
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000021")
|
||||
|
||||
/**
|
||||
* A probe the router can actually route. `InputProbe()` reports `UNPARSEABLE`, which
|
||||
* `PERMISSIVE.canDecode` refuses, so every job would reach FFmpeg with no test saying why.
|
||||
*/
|
||||
val H264_SOURCE = InputProbe(
|
||||
videoCodec = "h264",
|
||||
audioCodec = "aac",
|
||||
container = Container.MP4,
|
||||
durationMs = 1_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,3 +261,22 @@ private class ReportingTranscoder(private val report: ((Int) -> Unit) -> Unit) :
|
||||
const val OUTPUT_BYTES = 512
|
||||
}
|
||||
}
|
||||
|
||||
/** A hardware engine that reports whatever [report] wants reported, then writes an output. */
|
||||
@UnstableApi
|
||||
private class ReportingHardwareTranscoder(private val report: ((Int) -> Unit) -> Unit) : HardwareTranscoder {
|
||||
|
||||
var attempts = 0
|
||||
|
||||
override suspend fun transcode(input: Uri, output: File, request: ConversionRequest, onProgress: (Int) -> Unit) {
|
||||
attempts++
|
||||
report(onProgress)
|
||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
|
||||
private companion object {
|
||||
const val OUTPUT_BYTES = 16
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user