Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b557409e | ||
|
|
0eb00d2003 | ||
|
|
c887af0d83 | ||
|
|
2e0c6737d6 | ||
|
|
b53f326f9e | ||
|
|
85461943d6 | ||
|
|
238142d9cc | ||
|
|
9c809d4e16 | ||
|
|
bf2214a549 | ||
|
|
989069207e | ||
|
|
72ff7adfcc | ||
|
|
994ea8a3dd | ||
|
|
3e9528454c | ||
|
|
0702916229 | ||
|
|
3fd34c24a6 | ||
|
|
d01a46a708 | ||
|
|
3806641cb2 | ||
|
|
5f9498150c | ||
|
|
8d8703ab49 | ||
|
|
27b7654418 | ||
|
|
4a8e30099e | ||
|
|
e7d84cc69f | ||
|
|
a8b494b846 | ||
|
|
865a4a7c8e | ||
|
|
e856679395 | ||
|
|
49c483d877 | ||
|
|
1b220856ab | ||
|
|
40ae524388 | ||
|
|
58a29ab093 | ||
|
|
a1d79c212a | ||
|
|
bc66906dc3 | ||
|
|
d37c391c60 | ||
|
|
3fb25235c0 | ||
|
|
c0d99f7f86 | ||
|
|
95902a7889 | ||
|
|
1535b61a96 | ||
|
|
2efd1f9a0d | ||
|
|
240528facb | ||
|
|
f98e49942f | ||
|
|
25f162923c | ||
|
|
d0b9745220 | ||
|
|
b36d56c932 | ||
|
|
3f140fc2b1 | ||
|
|
b3208ef8c7 | ||
|
|
5a8aedf53d | ||
|
|
47a423413b |
Executable
+294
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Reports the SHAPE of an instrumented run -- how many tests were expected, how many
|
||||
# reported, how many failed, and whether the run completed at all -- to the step log and to
|
||||
# the job summary. In advisory mode it also compares that shape against a committed baseline
|
||||
# and says plainly whether it matches.
|
||||
#
|
||||
# WHY THIS EXISTS (#83): the advisory API 37 leg is red on every PR by design, so a NEW failure
|
||||
# joining the known ones is invisible -- nothing in a red X distinguishes "the known ones" from
|
||||
# "the known ones plus yours". CLAUDE.md tells everyone not to read that job's red as their
|
||||
# change breaking something, which is correct, and which also means nobody looks.
|
||||
#
|
||||
# WHY NOT A BARE FAILURE COUNT, measured rather than assumed. On this image the run is usually
|
||||
# truncated: `Test run failed to complete. Expected 3 tests, received 2.` with
|
||||
# `INSTRUMENTATION_ABORTED: System has crashed.` A count taken from a truncated run misleads in
|
||||
# both directions -- a fourth marked test can still yield the same number if the abort lands
|
||||
# earlier, and the known set getting worse can LOWER it. So all four fields are recorded, and
|
||||
# the one saying the run was truncated is recorded with them.
|
||||
#
|
||||
# WHY IT IS A SEPARATE SCRIPT rather than a function inside e2e-run.sh: it is a pure seam. It
|
||||
# reads a captured log plus the test XML and writes a report, so it can be run against a REAL
|
||||
# log saved from a REAL CI run -- which is how the baseline comparison was shown to fire
|
||||
# without waiting on an emulator. `git ls-files '*.sh'` also picks it up for shellcheck for
|
||||
# free.
|
||||
#
|
||||
# THIS SCRIPT NEVER FAILS A RUN. It is a diagnostic, and e2e-run.sh's header explains why that
|
||||
# rule is absolute here. Every field defaults to `unknown` and every comparison is guarded,
|
||||
# because an unset variable under `set -u`, or a `[ "" -eq 3 ]`, is exactly how a diagnostic
|
||||
# becomes the thing that turns a leg red. It exits 0 unconditionally.
|
||||
#
|
||||
# Usage:
|
||||
# e2e-report-shape.sh <label> <gradle-log> [<baseline-file>]
|
||||
#
|
||||
# With a third argument the run is compared against the baseline in that file (advisory mode)
|
||||
# and a `::notice::` is emitted per deviation. NEVER `::error::`: the advisory job is
|
||||
# `continue-on-error: true` and stays that way, and an error annotation would be a new way for
|
||||
# a diagnostic to change a conclusion.
|
||||
set -uo pipefail
|
||||
|
||||
LABEL="${1:-unknown}"
|
||||
LOG="${2:-}"
|
||||
BASELINE_FILE="${3:-}"
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
XML_DIR="$REPO_ROOT/app/build/outputs/androidTest-results/connected/debug"
|
||||
|
||||
# Gradle colours its output even when it is piped, so `FAILED` arrives wrapped in escape codes.
|
||||
# The numeric lines parsed below are not coloured, but stripping is cheap insurance against a
|
||||
# pattern that would otherwise silently match nothing.
|
||||
ESC="$(printf '\033')"
|
||||
scan() { [ -s "$LOG" ] && sed -e "s/${ESC}\[[0-9;]*[a-zA-Z]//g" -- "$LOG"; }
|
||||
first_number() { grep -oE '[0-9]+' | head -1; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source 1: the runner's own output. This is the ONLY place a truncation is visible. The test
|
||||
# XML read below is written even for an aborted run and says nothing whatever about the abort
|
||||
# -- measured on run 32865281555, where the XML reports a tidy tests="3" failures="3" for a run
|
||||
# the runner had just described as truncated. That is the reason this parses stdout at all.
|
||||
# ---------------------------------------------------------------------------
|
||||
starting_line="$(scan | grep -aoE 'Starting [0-9]+ tests on .*' | tail -1)"
|
||||
abort_line="$(scan | grep -aoE 'Test run failed to complete\. Expected [0-9]+ tests, received [0-9]+\.' | tail -1)"
|
||||
aborted_hits="$(scan | grep -ac 'INSTRUMENTATION_ABORTED' || true)"
|
||||
failure_line="$(scan | grep -aoE 'There was [0-9]+ failure\(s\)\.' | tail -1)"
|
||||
failed_names="$(scan | grep -aoE 'Execute [A-Za-z0-9_.$]+: FAILED' | sed -e 's/^Execute //' -e 's/: FAILED$//' | sort -u)"
|
||||
|
||||
expected="$(printf '%s' "$starting_line" | first_number)"
|
||||
expected_src="\`$starting_line\`"
|
||||
abort_expected="$(printf '%s' "$abort_line" | grep -oE 'Expected [0-9]+' | first_number)"
|
||||
abort_received="$(printf '%s' "$abort_line" | grep -oE 'received [0-9]+' | first_number)"
|
||||
log_failed="$(printf '%s' "$failure_line" | first_number)"
|
||||
|
||||
# `Starting N tests` is missing when the framework restarted under the run and Gradle never got
|
||||
# a test list. The truncation line still carries the number it was told to expect.
|
||||
if [ -z "$expected" ] && [ -n "$abort_expected" ]; then
|
||||
expected="$abort_expected"
|
||||
expected_src="\`$abort_line\`"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source 2: the JUnit XML. Measured on both a truncated advisory run and a green gating leg:
|
||||
# `<testsuites tests="N" failures="M">` is present in both, and aggregates every suite. It is
|
||||
# the authority on how many results landed and how many were failures. It is NOT an authority
|
||||
# on whether the run finished, which is what source 1 is for.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Read ONLY when the runner said a test run happened. `app/build` survives between runs on a
|
||||
# developer machine -- tools/local-emulator/run-e2e.sh drives several API levels against one
|
||||
# checkout -- so a leg that never got as far as starting tests would otherwise be reported from
|
||||
# the previous leg's XML, which is a wrong answer rather than a missing one.
|
||||
xml_head=""
|
||||
xml_count=0
|
||||
if [ -n "$starting_line$abort_line" ] && [ -d "$XML_DIR" ]; then
|
||||
while IFS= read -r f; do
|
||||
xml_count=$((xml_count + 1))
|
||||
[ -z "$xml_head" ] && xml_head="$(grep -ao '<testsuites[^>]*>' "$f" | head -1)"
|
||||
done < <(find "$XML_DIR" -maxdepth 1 -name 'TEST-*.xml' -print 2> /dev/null | sort)
|
||||
fi
|
||||
xml_tests="$(printf '%s' "$xml_head" | grep -oE ' tests="[0-9]+"' | first_number)"
|
||||
xml_failed="$(printf '%s' "$xml_head" | grep -oE ' failures="[0-9]+"' | first_number)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derive the four fields, each with where its number came from. Everything stays a string, so a
|
||||
# missing source reads `unknown` rather than becoming 0 -- a report claiming 0 tests when it
|
||||
# merely could not see them would announce a deviation on every cancelled run.
|
||||
# ---------------------------------------------------------------------------
|
||||
received="unknown"
|
||||
received_src="no source"
|
||||
if [ -n "$xml_tests" ]; then
|
||||
received="$xml_tests"
|
||||
received_src="test XML \`<testsuites tests=\"$xml_tests\">\`"
|
||||
elif [ -n "$abort_received" ]; then
|
||||
received="$abort_received"
|
||||
received_src="\`$abort_line\`"
|
||||
elif [ -n "$expected" ] && [ -z "$abort_line" ]; then
|
||||
received="$expected"
|
||||
received_src="the run was not truncated, so every expected test reported"
|
||||
fi
|
||||
|
||||
failed="unknown"
|
||||
failed_src="no source"
|
||||
if [ -n "$xml_failed" ]; then
|
||||
failed="$xml_failed"
|
||||
failed_src="test XML \`<testsuites failures=\"$xml_failed\">\`"
|
||||
elif [ -n "$log_failed" ]; then
|
||||
failed="$log_failed"
|
||||
failed_src="\`$failure_line\`"
|
||||
fi
|
||||
|
||||
if [ -z "$expected" ]; then
|
||||
expected="unknown"
|
||||
expected_src="no \`Starting N tests\` line"
|
||||
fi
|
||||
|
||||
# A run whose start nobody can see is not a run of zero tests. Cancellation (this workflow sets
|
||||
# cancel-in-progress) and the `Starting 0 tests` shape a framework restart produces both land
|
||||
# here, and both have to say so rather than compare a number that does not exist.
|
||||
no_run="none"
|
||||
if [ "$expected" = "unknown" ] && [ "$received" = "unknown" ]; then
|
||||
no_run="nothing"
|
||||
elif [ "$expected" = "0" ]; then
|
||||
no_run="zero"
|
||||
fi
|
||||
|
||||
if [ -n "$abort_line" ]; then
|
||||
completed="**no**"
|
||||
completed_src="\`$abort_line\` with \`INSTRUMENTATION_ABORTED\`"
|
||||
elif [ "${aborted_hits:-0}" -gt 0 ]; then
|
||||
completed="**no**"
|
||||
completed_src="\`INSTRUMENTATION_ABORTED\` in the runner output"
|
||||
elif [ "$no_run" = "nothing" ]; then
|
||||
# "cleanly" would be a lie about a run that left no evidence it happened.
|
||||
completed="unknown"
|
||||
completed_src="no runner output to read"
|
||||
else
|
||||
completed="yes"
|
||||
completed_src="no truncation line and no \`INSTRUMENTATION_ABORTED\`"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Advisory mode: compare against the committed baseline.
|
||||
#
|
||||
# ONE number covers both compared fields, and that is deliberate rather than a shortcut. The
|
||||
# marker means "cannot pass on this image", so the number of tests carrying it is both how many
|
||||
# the advisory leg should run and how many should fail. A smaller `failed` means one now passes
|
||||
# -- which is the trigger to delete the annotation, written down in FailsOnEmulatorApi37.kt.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `advisory` and `baseline` are two variables on purpose. "A comparison was asked for" and "a
|
||||
# number was found to compare against" are different facts, and collapsing them is how this
|
||||
# report would go quietly back to being the thing #83 filed: the `sed` below is anchored, so
|
||||
# indenting the const into an object -- or renaming it, or moving it to another file -- empties
|
||||
# `baseline`, and a single flag would take the whole comparison down with it while the table
|
||||
# kept printing. An unreadable baseline is itself a deviation, and is announced as one.
|
||||
advisory="no"
|
||||
baseline=""
|
||||
marked=""
|
||||
deviations=()
|
||||
if [ -n "$BASELINE_FILE" ]; then
|
||||
advisory="yes"
|
||||
[ -f "$BASELINE_FILE" ] \
|
||||
&& baseline="$(sed -nE 's/^const val FAILS_ON_EMULATOR_API37_BASELINE = ([0-9]+).*/\1/p' "$BASELINE_FILE" | head -1)"
|
||||
# The #81 check, verbatim: what the tree actually carries. Reported next to the baseline so a
|
||||
# stale baseline shows up here rather than only once the emulator disagrees with it.
|
||||
if [ -d "$REPO_ROOT/app/src/androidTest" ]; then
|
||||
marked="$(grep -rn "@FailsOnEmulatorApi37" "$REPO_ROOT/app/src/androidTest" --include='*.kt' \
|
||||
| grep -v import | grep -c FailsOn || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$advisory" = "yes" ] && [ -z "$baseline" ]; then
|
||||
deviations+=("the committed baseline could not be read from \`$(basename -- "$BASELINE_FILE")\` — has \`FAILS_ON_EMULATOR_API37_BASELINE\` been renamed, indented into a class, or moved? Nothing was compared")
|
||||
fi
|
||||
|
||||
if [ -n "$baseline" ]; then
|
||||
if [ "$no_run" = "nothing" ]; then
|
||||
deviations+=("no test run observed — the runner never reported starting one, where the baseline expects $baseline tests carrying \`@FailsOnEmulatorApi37\`")
|
||||
elif [ "$no_run" = "zero" ]; then
|
||||
deviations+=("the runner started 0 tests, where the baseline expects $baseline — on this image that is the framework having restarted under the run, not an empty test list")
|
||||
else
|
||||
if [ "$expected" != "unknown" ] && [ "$expected" != "$baseline" ]; then
|
||||
deviations+=("the runner started $expected tests, the baseline is $baseline")
|
||||
fi
|
||||
if [ "$failed" != "unknown" ] && [ "$failed" != "$baseline" ]; then
|
||||
deviations+=("$failed tests failed, the baseline is $baseline — every test carrying the marker is expected to fail on this image, so fewer means one now passes and more means a new one joined")
|
||||
fi
|
||||
fi
|
||||
if [ -n "$marked" ] && [ "$marked" != "$baseline" ]; then
|
||||
deviations+=("the tree carries $marked tests marked \`@FailsOnEmulatorApi37\` but the baseline says $baseline — update FAILS_ON_EMULATOR_API37_BASELINE")
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emit. Step log first, so the common case needs neither the summary page nor an artifact.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "----- RUN SHAPE (api${LABEL}) -----"
|
||||
echo " expected: $expected"
|
||||
echo " received: $received"
|
||||
echo " failed: $failed"
|
||||
echo " completed cleanly: ${completed//\*/}"
|
||||
if [ -n "$abort_received" ]; then
|
||||
echo " received before the abort: $abort_received"
|
||||
fi
|
||||
if [ -n "$failed_names" ]; then
|
||||
echo " failed tests:"
|
||||
printf '%s\n' "$failed_names" | sed -e 's/^/ /'
|
||||
fi
|
||||
if [ "$advisory" = "yes" ]; then
|
||||
if [ "${#deviations[@]}" -eq 0 ]; then
|
||||
echo " baseline: matches ($baseline expected, $baseline failed)"
|
||||
else
|
||||
printf ' baseline DEVIATION: %s\n' "${deviations[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# A notice, never an error. See the header.
|
||||
if [ "${#deviations[@]}" -gt 0 ]; then
|
||||
for d in "${deviations[@]}"; do
|
||||
echo "::notice::E2E api${LABEL}: $d"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{
|
||||
echo "### E2E api${LABEL} — run shape"
|
||||
echo
|
||||
echo "| field | value | where it came from |"
|
||||
echo "| --- | --- | --- |"
|
||||
echo "| expected | $expected | $expected_src |"
|
||||
echo "| received | $received | $received_src |"
|
||||
echo "| failed | $failed | $failed_src |"
|
||||
echo "| completed cleanly | $completed | $completed_src |"
|
||||
if [ -n "$abort_received" ]; then
|
||||
echo "| received before the abort | $abort_received | the same line — the XML above counts the truncated test as a failure, this number does not |"
|
||||
fi
|
||||
echo
|
||||
if [ -n "$failed_names" ]; then
|
||||
echo "Failed:"
|
||||
echo
|
||||
printf '%s\n' "$failed_names" | sed -e 's/^/- `/' -e 's/$/`/'
|
||||
echo
|
||||
fi
|
||||
if [ "$xml_count" -gt 1 ]; then
|
||||
echo "> $xml_count test XML files were present; the counts above come from the first."
|
||||
echo
|
||||
fi
|
||||
if [ "$advisory" = "yes" ]; then
|
||||
if [ "${#deviations[@]}" -eq 0 ]; then
|
||||
echo "**Matches the committed baseline of $baseline** — $baseline tests carry \`@FailsOnEmulatorApi37\` and all $baseline failed, which is what this job is for."
|
||||
elif [ -z "$baseline" ]; then
|
||||
echo "**The committed baseline could not be read, so nothing was compared.** Announced as a notice, not an error: this job is advisory and its conclusion is unchanged by anything here."
|
||||
echo
|
||||
printf -- '- %s\n' "${deviations[@]}"
|
||||
else
|
||||
echo "**DEVIATION from the committed baseline of $baseline.** Announced as a notice, not an error: this job is advisory and its conclusion is unchanged by anything here."
|
||||
echo
|
||||
printf -- '- %s\n' "${deviations[@]}"
|
||||
fi
|
||||
echo
|
||||
echo "<sub>The baseline lives beside the marker, in \`FailsOnEmulatorApi37.kt\`. \`completed cleanly\` is recorded rather than compared: the truncation is intermittent — of eight advisory runs read on 2026-08-25, seven aborted and one did not — so comparing it would announce a deviation on a run that is fine.</sub>"
|
||||
else
|
||||
echo "<sub>No baseline comparison: that is the advisory API 37 leg only. The shape is recorded here anyway because a truncated run reports fewer results than it ran, which is what issue #108 looks like on a gating leg.</sub>"
|
||||
fi
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# The summary page is the deliverable -- "readable without opening a log" is what #83 asked
|
||||
# for -- and GitHub exposes no API for reading a job summary back, so a write that silently
|
||||
# did not happen would be invisible. This line is in the step log, which can be read.
|
||||
echo " (the table above is also on the job summary page)"
|
||||
else
|
||||
echo " (GITHUB_STEP_SUMMARY is unset -- step log only)"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -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
|
||||
@@ -214,12 +221,40 @@ status=0
|
||||
# script rather than forking it: that runs several API levels back to back against one checkout
|
||||
# and passes `--rerun`, so a level cannot be skipped as up-to-date and report the previous
|
||||
# level's results as its own. CI gets a fresh runner per level and does not need it.
|
||||
#
|
||||
# `2>&1 | tee`, and the `2>&1` is the load-bearing half. The step log merges both streams, so
|
||||
# reading one cannot tell you which stream a line came from -- and the single line the report
|
||||
# below needs most, `Test run failed to complete. ... INSTRUMENTATION_ABORTED`, is not on
|
||||
# stdout. Capturing stdout alone would leave the report saying "completed cleanly: yes" forever,
|
||||
# which is precisely the comparison that cannot fire. pipefail is already set and tee exits 0,
|
||||
# so the pipeline's status is still gradle's -- including the 124 that means the wrapper fired.
|
||||
#
|
||||
# `tee` and not `tee -a`, unlike the logcat above: CI gets a fresh runner per leg, but
|
||||
# tools/local-emulator/run-e2e.sh reuses one machine, and an appended log would have the report
|
||||
# reading the PREVIOUS run of the same API level. The console goes plain rather than showing
|
||||
# gradle's live progress bar, which is what it already did in CI.
|
||||
# shellcheck disable=SC2086
|
||||
timeout -k 30s "$WEDGE_TIMEOUT" \
|
||||
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64 --stacktrace \
|
||||
${E2E_EXTRA_GRADLE_ARGS:-} || status=$?
|
||||
${E2E_EXTRA_GRADLE_ARGS:-} 2>&1 | tee "$GRADLE_LOG" || status=$?
|
||||
echo "::endgroup::"
|
||||
|
||||
# The run-shape report: expected/received/failed and whether the run finished, every time,
|
||||
# green or red. It never changes `status` -- it is a diagnostic, and the header's rule about
|
||||
# diagnostics applies to it as much as to every probe below.
|
||||
#
|
||||
# The baseline argument, and only it, turns on the comparison, and only the advisory API 37 job
|
||||
# passes E2E_ADVISORY=1. Comparing on the gating legs would announce a deviation on all five of
|
||||
# them every run, since they run the whole suite rather than the marked three. They still get
|
||||
# the report: a truncated run reporting fewer results than it ran is what #108 looks like, and
|
||||
# `completed cleanly` is the field that shows it.
|
||||
if [ "${E2E_ADVISORY:-}" = "1" ]; then
|
||||
bash "$SCRIPT_DIR/e2e-report-shape.sh" "$LABEL" "$GRADLE_LOG" \
|
||||
"$REPO_ROOT/app/src/androidTest/java/org/libremediaconverter/FailsOnEmulatorApi37.kt" || true
|
||||
else
|
||||
bash "$SCRIPT_DIR/e2e-report-shape.sh" "$LABEL" "$GRADLE_LOG" || true
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
kill "$LOGCAT_PID" 2>/dev/null || true
|
||||
exit 0
|
||||
|
||||
@@ -13,6 +13,17 @@ on:
|
||||
# reference amounts to running whatever that repository contains tomorrow. This matters
|
||||
# more here than on pull requests: these jobs sign nothing today, but they do publish
|
||||
# the artifacts people install.
|
||||
# Declared here rather than inherited, for the reason status_check.yml gives for its own
|
||||
# block: the token's reach should be readable in the file that uses it, and a repository
|
||||
# default that widens later should not silently widen these jobs with it. The repository
|
||||
# default is `read` today, so this changes nothing about what runs -- it fixes what a
|
||||
# reader can know without leaving the file, and it is what CodeQL alert #1 asked for.
|
||||
#
|
||||
# The `release` job below overrides this with `contents: write`, which is how job-level
|
||||
# permissions work: this is a default, not a ceiling.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GRADLE_CACHE_PATHS: |
|
||||
~/.gradle/caches
|
||||
@@ -81,7 +92,11 @@ jobs:
|
||||
|
||||
- name: Verify the released artifacts
|
||||
run: |
|
||||
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
|
||||
# A glob, not `ls | head`: the glob is already here, and parsing ls is what
|
||||
# SC2012 is about. Gradle's names have no spaces today, which is exactly the
|
||||
# kind of assumption that holds until it does not.
|
||||
apks=(app/build/outputs/apk/release/*.apk)
|
||||
APK="${apks[0]}"
|
||||
# A release that shipped one ABI, or lost 16 KB alignment, would install
|
||||
# fine on a test device and fail for users or at Play submission. Both are
|
||||
# cheap to check and expensive to discover later.
|
||||
|
||||
@@ -182,6 +182,23 @@ jobs:
|
||||
docker run --rm "$SHELLCHECK" --version
|
||||
git ls-files -z '*.sh' | xargs -0 -r docker run --rm -v "$PWD:/mnt" "$SHELLCHECK"
|
||||
|
||||
# actionlint closes the half shellcheck cannot see. The step above reads .sh files;
|
||||
# a good deal of this repo's bash lives in inline `run:` blocks instead -- the release
|
||||
# verification here, the emulator setup and teardown in this file and in
|
||||
# api37-debug.yml. actionlint parses each workflow and runs shellcheck over every
|
||||
# `run:`, on top of its own checks for expression syntax, `needs:` references, matrix
|
||||
# keys and action input names.
|
||||
#
|
||||
# Pinned by digest for the same reason shellcheck is, and with a second reason of its
|
||||
# own: actionlint's documented install is `bash <(curl -s .../download-actionlint.bash)`
|
||||
# off a moving branch, which would sit badly in a repo that pins every action by SHA.
|
||||
- name: actionlint
|
||||
env:
|
||||
ACTIONLINT: rhysd/actionlint@sha256:9d36088643581e728c969f35141f88139fec77280b2be23c1f66f8e40e1025e7
|
||||
run: |
|
||||
docker run --rm "$ACTIONLINT" -version
|
||||
docker run --rm -v "$PWD:/repo" -w /repo "$ACTIONLINT" -color
|
||||
|
||||
# `!cancelled()` rather than a plain sequence: a shellcheck failure above must not
|
||||
# cost the ktlint/detekt/lint lists. Same reason this step passes --continue -- one
|
||||
# round trip should produce every list, not stop at the first.
|
||||
@@ -263,8 +280,13 @@ jobs:
|
||||
# docs/api-37-emulator-crash.md has the per-method measurements, and the
|
||||
# correction that produced them.
|
||||
#
|
||||
# api-level must be "37.0". A bare 37 is not an SDK package and fails
|
||||
# during setup, which cost a run to discover.
|
||||
# api-level must be a POINT release. A bare 37 is not an SDK package and
|
||||
# fails during setup, which cost a run to discover. `37.0` is the choice
|
||||
# here rather than the only option: `37.1` and `37.2-beta*` exist and
|
||||
# abort the same way, and api37-debug.yml's inputs document both, with
|
||||
# the wrinkle that above 37.0 they ship only as google_apis_ps16k.
|
||||
# docs/api-37-emulator-crash.md measures 37.0 rev 6 and 37.1 rev 8 side
|
||||
# by side, so pinning 37.0 is a decision, not a constraint.
|
||||
#
|
||||
# notAnnotation removes the three tests that do not pass on this image; they
|
||||
# run in the advisory job below, off the same marker so they cannot end up
|
||||
@@ -355,6 +377,7 @@ jobs:
|
||||
path: |
|
||||
${{ runner.temp }}/logcat-api${{ matrix.label }}.txt
|
||||
${{ runner.temp }}/diagnostics-api${{ matrix.label }}.txt
|
||||
${{ runner.temp }}/gradle-api${{ matrix.label }}.txt
|
||||
if-no-files-found: warn
|
||||
|
||||
# Only exists when the wrapper timeout tripped, so `ignore` keeps healthy runs quiet
|
||||
@@ -445,6 +468,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.
|
||||
@@ -477,6 +506,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
|
||||
|
||||
@@ -76,11 +76,11 @@ 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
|
||||
- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Three** of the 60 instrumented
|
||||
tests cannot pass on that image, for two unrelated reasons: two Media3 hardware transcodes fail
|
||||
inside the emulator's own `c2.goldfish.h264.decoder`, and one SAF test takes the framework down
|
||||
when it rotates the display. All three carry `@FailsOnEmulatorApi37` and run in a separate
|
||||
`continue-on-error` job; the gating leg runs the other 56.
|
||||
`continue-on-error` job; the gating leg runs the other 57.
|
||||
|
||||
That job is still called `E2E API 37 Media3 hardware transcode (advisory)`, which no longer
|
||||
describes everything in it. The name is kept deliberately — it is not a required context and
|
||||
@@ -89,6 +89,15 @@ days. Read it as the current answer, and see the git history if you need the old
|
||||
not read a green run as evidence those three tests pass.
|
||||
`docs/api-37-emulator-crash.md` has the measurements.
|
||||
|
||||
**That instruction is also why nobody looks, so the job now reports its own shape** — expected,
|
||||
received, failed, and whether the run completed — to the job summary, and compares it against
|
||||
`FAILS_ON_EMULATOR_API37_BASELINE`, committed beside the marker. A deviation is a `::notice::`;
|
||||
the job stays advisory and its conclusion is untouched. **Add or remove a `@FailsOnEmulatorApi37`
|
||||
and that number changes in the same diff**, or the next run says so. A bare failure count would
|
||||
not have worked: the run is usually truncated by an `INSTRUMENTATION_ABORTED`, and the test XML
|
||||
is written anyway and says nothing about it — `.github/scripts/e2e-report-shape.sh` is where that
|
||||
is measured and explained.
|
||||
|
||||
Still true, and the reason the advisory job is not simply deleted: **API 37 needs a manual check on
|
||||
the Pixel 10 Pro XL before each release.** Those three tests are the one thing CI cannot answer
|
||||
for.
|
||||
@@ -185,11 +194,12 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
`podman run --rm -v "$PWD:/mnt:z" docker.io/koalaman/shellcheck@sha256:61862eba... <files>`
|
||||
(the digest is in `status_check.yml`; there is no shellcheck system package on this host).
|
||||
|
||||
**It does not cover inline `run:` blocks in the workflows**, and a good deal of this repo's bash
|
||||
lives there. `actionlint` does cover them — it runs shellcheck over each `run:` — and reports one
|
||||
pre-existing `info` finding in `build.yml`. It is not wired in because every action here is
|
||||
pinned by SHA, and actionlint's usual installer is a `curl | bash` off a moving branch; doing it
|
||||
properly means pinning a container digest. Tracked separately rather than bolted on.
|
||||
**`actionlint` covers the half shellcheck cannot see** — the inline `run:` blocks, where a good
|
||||
deal of this repo's bash lives. It runs shellcheck over each `run:` plus its own checks on
|
||||
expression syntax, `needs:` references, matrix keys and action inputs. It sits in the same job,
|
||||
**pinned by digest** for the reason above and one of its own: its documented installer is a
|
||||
`curl | bash` off a moving branch, which does not belong in a repo that pins every action by SHA.
|
||||
Locally: `podman run --rm -v "$PWD:/repo:z" -w /repo docker.io/rhysd/actionlint@sha256:9d360886... -color`.
|
||||
|
||||
## Dependency versions
|
||||
|
||||
|
||||
@@ -113,7 +113,14 @@ container × codec matrix — including combinations that cannot work, which it
|
||||
offers alternatives for rather than hiding.
|
||||
|
||||
Conversions run as durable background work, so they survive leaving the app and are
|
||||
restored after a restart.
|
||||
restored when you reopen it.
|
||||
|
||||
One case is not restored, and it is worth knowing about. If Android refuses to let a job
|
||||
restart in the background, it is retried on an exponential backoff for about eight and a
|
||||
half hours and then given up on — and a job that has been given up on does not come back
|
||||
when you reopen the app. Reopening the app is what grants permission to run, so a
|
||||
conversion that has stalled this way is best started again from the app rather than waited
|
||||
on.
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.testing.jacoco.tasks.JacocoReport
|
||||
|
||||
plugins {
|
||||
@@ -206,6 +207,16 @@ detekt {
|
||||
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
|
||||
// no location for either, and the test JVM dies rather than reporting a number.
|
||||
tasks.withType<Test>().configureEach {
|
||||
// ReleasePermissionTest reads .github/workflows/build.yml, and Gradle cannot infer that a
|
||||
// test depends on a file outside the source set. Without this the task stays UP-TO-DATE
|
||||
// when the workflow changes, so the guard goes stale exactly when it matters. Measured:
|
||||
// deleting the release job's `contents: write` and re-running gave "BUILD SUCCESSFUL in
|
||||
// 614ms" with the test never executing; the same mutation under --rerun-tasks failed it.
|
||||
// A guard that does not re-run when its subject changes is not a guard.
|
||||
inputs.file(rootProject.file(".github/workflows/build.yml"))
|
||||
.withPropertyName("releaseWorkflow")
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
|
||||
extensions.configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
|
||||
@@ -18,7 +18,38 @@ 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.
|
||||
*
|
||||
* **How many tests carry it is committed below**, as [FAILS_ON_EMULATOR_API37_BASELINE], and the
|
||||
* advisory job checks the run against it. Adding or removing a marker means changing that number
|
||||
* in the same diff.
|
||||
*/
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
|
||||
annotation class FailsOnEmulatorApi37
|
||||
|
||||
/**
|
||||
* How many tests carry [FailsOnEmulatorApi37] — the advisory API 37 job's committed baseline.
|
||||
*
|
||||
* **No Kotlin reads this, and it is not stray config.** `.github/scripts/e2e-report-shape.sh`
|
||||
* parses it out of this file by name, with a line-anchored pattern, and the advisory job compares
|
||||
* the run it just did against it: this many tests should start, and all of them should fail.
|
||||
* Deleting it, renaming it, or indenting it into a class stops the comparison — the report would
|
||||
* keep printing with nothing to compare to, so it announces that it could not read the baseline
|
||||
* rather than falling quiet. If you see that notice, this line is what it means.
|
||||
*
|
||||
* **One number, both checks, and that is what the marker means.** A test carrying it cannot pass
|
||||
* on this image, so the count is simultaneously how many the advisory leg runs and how many fail.
|
||||
* A *smaller* failure count is the interesting direction: it means one of them now passes, which
|
||||
* is the trigger the KDoc above names for deleting the annotation.
|
||||
*
|
||||
* So: adding or removing a [FailsOnEmulatorApi37] means changing this number, in this file, in
|
||||
* the same diff. The report says so on the run itself if you forget — it prints the tree's own
|
||||
* `grep` count beside this one.
|
||||
*
|
||||
* Why a baseline at all (#83): that job is `continue-on-error` and red on every PR by design, so
|
||||
* a red X cannot distinguish the known failures from the known failures plus a new one. Counting
|
||||
* failures alone does not fix it either — the run is usually truncated by an
|
||||
* `INSTRUMENTATION_ABORTED`, so the count is a number taken from a partial run. The report
|
||||
* records the truncation next to the counts for that reason.
|
||||
*/
|
||||
const val FAILS_ON_EMULATOR_API37_BASELINE = 3
|
||||
|
||||
@@ -36,8 +36,20 @@ import java.io.File
|
||||
* 1. that the hardware path is worth having a second engine for at all, and
|
||||
* 2. that x264's CRF is worth the GPL licence the app carries for it.
|
||||
*
|
||||
* Skips itself when the sample files are absent, so it is harmless in CI. Populate with:
|
||||
* adb push <file>.mp4 /sdcard/Android/data/org.libremediaconverter/files/
|
||||
* Skips itself when the sample files are absent, so it is harmless in CI — every green E2E
|
||||
* leg reports two skips, and these are they.
|
||||
*
|
||||
* The two files it looks for, by exact name:
|
||||
*
|
||||
* - [H264_SAMPLE] for [hardwareVersusSoftwareOnRealVideo]
|
||||
* - [AV1_SAMPLE] for [av1InputRoutesAccordingToDeviceDecodeSupport]
|
||||
*
|
||||
* **Where they go, and how, is on [samples] — read it before staging anything.** This used to
|
||||
* carry an `adb push` line naming the external files dir, which [samples] then explains cannot
|
||||
* work: a pushed file stays owned by the shell user and the app reads EACCES, surfacing as an
|
||||
* unparseable input rather than a permission error. The instruction and its own refutation sat
|
||||
* twelve lines apart. It is named in one place now rather than restated here, because restating
|
||||
* it is what let the two drift.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
|
||||
@@ -11,15 +11,26 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.FailsOnEmulatorApi37
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.AudioPlan
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.CopyPlanner
|
||||
import org.libremediaconverter.model.InputKind
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.VideoPlan
|
||||
import java.io.File
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -170,6 +181,63 @@ class Media3EngineTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The builders that used to throw where nothing could catch them.
|
||||
*
|
||||
* `EditedMediaItem.Builder` rejects a composition with both tracks removed —
|
||||
* checkState("Audio and video cannot both be removed") — and the engine builds it on its own
|
||||
* HandlerThread. That build sat *between* two narrow `runCatching` blocks, one around
|
||||
* `buildTransformer` and one around `start`, so the exception reached the thread's uncaught
|
||||
* handler and took the process with it while the continuation was never resumed.
|
||||
*
|
||||
* `ContainerCapabilities.validate` now refuses the spec that gets here from the picker; this
|
||||
* is the other half — the engine surviving a request that arrives without being validated.
|
||||
* Deliberately not `@FailsOnEmulatorApi37`: nothing here decodes or encodes, so no emulator
|
||||
* codec is involved. The builder refuses the input before any media is touched.
|
||||
*/
|
||||
@Test
|
||||
fun aPlanThatRemovesBothTracksFailsInsteadOfKillingTheProcess() {
|
||||
val request = ConversionRequest(
|
||||
spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
probe = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
),
|
||||
)
|
||||
// Asserted rather than assumed: ConversionRequest's default probe says hasVideo = true,
|
||||
// and with it this same spec plans to (Encode, Drop) and nothing throws at all — which
|
||||
// would make the whole test vacuous without a word of warning.
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
|
||||
val failure = runCatching {
|
||||
runBlocking {
|
||||
withTimeout(BUILDER_TIMEOUT_MS) {
|
||||
engine.transcode(Uri.fromFile(input), output, request) {}
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
|
||||
// Two assertions, and the second is not pedantry. withTimeout raises
|
||||
// TimeoutCancellationException, and `java.util.concurrent.CancellationException` *extends*
|
||||
// IllegalStateException — so testing only the type below would call an unresumed
|
||||
// continuation a pass. A hang is the other half of this defect and every bit as bad as the
|
||||
// crash: the worker would sit holding a foreground service forever.
|
||||
assertFalse(
|
||||
"the continuation was never resumed — the failure escaped instead of being reported: " +
|
||||
"$failure",
|
||||
failure is CancellationException,
|
||||
)
|
||||
assertTrue(
|
||||
"the builder's refusal must surface as a failed job, not a dead process; got $failure",
|
||||
failure is IllegalStateException,
|
||||
)
|
||||
}
|
||||
|
||||
private fun durationMsOf(file: File): Long {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
@@ -211,5 +279,12 @@ class Media3EngineTest {
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 120L
|
||||
|
||||
/**
|
||||
* Short on purpose. Nothing is decoded or encoded on this path — the builder refuses the
|
||||
* input outright — so anything approaching this is a hang, which is what the test is
|
||||
* looking for.
|
||||
*/
|
||||
const val BUILDER_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.libremediaconverter.saf
|
||||
|
||||
import android.app.UiAutomation
|
||||
import androidx.compose.ui.test.ComposeTimeoutException
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
@@ -10,6 +12,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.BySelector
|
||||
import androidx.test.uiautomator.Configurator
|
||||
import androidx.test.uiautomator.StaleObjectException
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.Until
|
||||
@@ -52,12 +55,138 @@ import org.libremediaconverter.ui.TestTags
|
||||
* either ViewModel, and `StateRestorationTester` saves into an in-memory map rather than a
|
||||
* `Bundle`.
|
||||
*
|
||||
* ### #93: what actually failed was reading the screen, not the picker
|
||||
*
|
||||
* Ninety minutes after this class landed it started failing on gating legs at API 33, 34, 35 and
|
||||
* 37 — on diffs that were two KDoc comments, a MIME lookup table and a README paragraph (#93).
|
||||
* Every failure named the fixture root, so it read as a root-discovery race, and the ticket was
|
||||
* filed on that reading. It was not one, and it was not the `StaleObjectException` #80 had fixed
|
||||
* an hour earlier either.
|
||||
*
|
||||
* **DocumentsUI was fine.** On the API 34 leg of run 32806342548 its own
|
||||
* `ProvidersAccess: Matched roots` names
|
||||
* `content://org.libremediaconverter.test.fixtures/root/lmc-r38-root` five times inside the sixty
|
||||
* seconds the test spent failing, `ActivityTaskManager` logged the `PickActivity` as `Displayed`,
|
||||
* and the provider process started on cue.
|
||||
*
|
||||
* **This process could not read any window at all.** Two counts settle it. Across that whole leg
|
||||
* UiAutomator logged `Retrieving node with selector` 1095 times and `Node not found with selector`
|
||||
* 1095 times — not one selector ever matched, from the first query of the run. The green leg of
|
||||
* the same job asked 7 times and found 5. `UiDevice.getWindowRoots` builds its search set from
|
||||
* `UiAutomation.getWindows()` and, on API 21 and up, from nothing else; an empty list there makes
|
||||
* every selector unfindable and says nothing whatever about SAF. The corroborating detail is that
|
||||
* `By.desc("Show roots")` — the toolbar button, present on that screen whether the roots list is
|
||||
* stale or not — was also not found, 28 s after the picker was displayed.
|
||||
*
|
||||
* **A fresh picker is not the repair, and this was measured rather than assumed.** The same leg
|
||||
* opened a *second* `PickActivity` for the second test, in the same DocumentsUI process
|
||||
* (pid 3299), and read exactly as little from it. So whatever was broken outlived one window.
|
||||
* [requireAReadableScreen] is the part aimed at that: it asks whether this process can see the
|
||||
* app's own window *before* the picker is opened, and [rebuildUiAutomation] tears the connection
|
||||
* down and builds another if it cannot.
|
||||
*
|
||||
* **The check has since caught the real thing, in CI, and the connection rebuild did not repair
|
||||
* it.** Run 32811493607, API 35 and API 37 legs, both tests, 12 s each instead of 60:
|
||||
*
|
||||
* ```
|
||||
* java.lang.AssertionError: UiAutomator cannot see this app's own window, so it could not have
|
||||
* seen the picker's either. This is not a SAF failure.
|
||||
* at SafPickerRoundTripTest.requireAReadableScreen
|
||||
* ```
|
||||
*
|
||||
* That is the diagnosis this class could not previously give, and it moves the question off SAF
|
||||
* for good.
|
||||
*
|
||||
* ### What the window list said, and why nothing here can fix it
|
||||
*
|
||||
* [describeWindows] was added to that failure so the next occurrence would close the question
|
||||
* rather than reopen it. It did — on the API 34 leg of run 32812248131 and again, character for
|
||||
* character, on the API 33 leg of run 32812892103:
|
||||
*
|
||||
* ```
|
||||
* ... Waking the device, dismissing the keyguard and rebuilding the UiAutomation connection all
|
||||
* failed to make it readable. What it could see: com.android.systemui[type=3], android[type=3]
|
||||
* ```
|
||||
*
|
||||
* `type=3` is `AccessibilityWindowInfo.TYPE_SYSTEM`. The list is **not** empty — it holds the
|
||||
* system windows and **not one `TYPE_APPLICATION` window**, on a device where the framework had
|
||||
* already logged `Displayed org.libremediaconverter/.MainActivity`. So the application layer
|
||||
* never reaches accessibility on those boots, and every selector in this class, the picker's and
|
||||
* the app's alike, is unfindable for the whole instrumentation run.
|
||||
*
|
||||
* Three CI runs on this branch caught the fault, at API 33, 34, 35 and 37, and every one of them
|
||||
* printed that same list. It is not one level's quirk.
|
||||
*
|
||||
* ### And that list is what identified the occluder
|
||||
*
|
||||
* `android[type=3]` is `system_server`, and what it was holding is in the same logcat, minutes
|
||||
* before this class ever ran:
|
||||
*
|
||||
* ```
|
||||
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
|
||||
* Reason: Input dispatching timed out (Application does not have a focused window)
|
||||
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
|
||||
* ```
|
||||
*
|
||||
* **The launcher ANRs on a loaded runner emulator, and the dialog it leaves behind never goes
|
||||
* away.** It is opaque and fullscreen, so `AccessibilityWindowManager` drops every application
|
||||
* window beneath it — which is how the app can be `Displayed` and unreadable at once, the
|
||||
* contradiction that made #93 look like a SAF bug for six PRs. It is present on both legs
|
||||
* examined, at API 33 and 34, at the failure timestamp.
|
||||
*
|
||||
* So [dismissASystemErrorDialog] is tried first, and it is the remedy with a mechanism behind it.
|
||||
* The other two are kept behind it and are **measured as not the cause**: [unlockTheDevice] (the
|
||||
* keyguard theory, from `KeyguardViewMediator` reporting an unprovisioned device — dismissing it
|
||||
* changed nothing) and [rebuildUiAutomation]. A second `PickActivity` is not a remedy for this
|
||||
* either, and that was measured too: the first failing leg opened one and read as little from it.
|
||||
*
|
||||
* **What is honest about the dialog remedy: it has been shown to do no harm, not to work.** It
|
||||
* was forced on with no dialog present and the suite stayed green, which is the way a blind
|
||||
* `click()` could have broken a healthy run. Dismissing a real ANR dialog has not been observed,
|
||||
* because the fault has never been reproduced locally — not on six warm runs, not on cold
|
||||
* full-suite runs at API 34 and 35 on freshly created AVDs under `swangle_indirect` at two cores,
|
||||
* not under host load. If it recurs, the message now names the dialog and the window list, so the
|
||||
* next step is a measurement rather than another theory.
|
||||
*
|
||||
* ### The whole pick is retried, which is a separate and smaller claim
|
||||
*
|
||||
* [pickTheFixture] also backs out and asks for another picker when the walk comes up short. That
|
||||
* is not the answer to the paragraph above; it is the answer to a picker whose *lists* were built
|
||||
* before their data arrived, which is a real thing DocumentsUI does and which
|
||||
* [tapPickerNode]'s re-find cannot reach either — it re-acquires a handle inside the one picker.
|
||||
*
|
||||
* One API 37 run failed a step deeper than the rest: the root appeared and
|
||||
* `[TEXT='\Qlmc-r38-fixture.mp4\E']` did not. **That shape has not been reproduced or
|
||||
* diagnosed.** It is covered here only because a fresh pick re-walks from Recent, and that is
|
||||
* worth writing down rather than letting the retry read as a fix for something nobody measured.
|
||||
*
|
||||
* ### The mutations, and what they printed
|
||||
*
|
||||
* Both were run, not asserted. Narrowing the wildcard array `ConverterScreen.kt` passes to
|
||||
* `pickInput.launch` — to `arrayOf("application/x-lmc-no-such-type")` — empties the picker of the
|
||||
* fixture root entirely, and [pickingAFileThroughTheSystemPickerFillsInTheFileCard] fails on the
|
||||
* assertion that names it.
|
||||
* fixture root entirely, and both tests fail on the assertion that names it. **Re-run after the
|
||||
* #93 retry landed**, because a retry that tolerated an absent root would have made this mutation
|
||||
* vacuous, which is the one thing that must not happen here:
|
||||
*
|
||||
* ```
|
||||
* java.lang.AssertionError: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E'],
|
||||
* in 3 separate pickers (the last one left org.libremediaconverter in front)
|
||||
* at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:268)
|
||||
* ```
|
||||
*
|
||||
* The root is absent from all three pickers, so all three report it, and the cost of saying so is
|
||||
* bounded: 126 s and 127 s for the two tests, against the 1200 s wrapper timeout in
|
||||
* `.github/scripts/e2e-run.sh`. The clause about what was left in front is not decoration either
|
||||
* — it is what says the retry really did get back to the app between attempts rather than tapping
|
||||
* behind a picker that never closed.
|
||||
*
|
||||
* **That mutation only shows the retry failing correctly.** Showing it *recovering* needs a
|
||||
* failure that goes away, so one was injected: a field making the first
|
||||
* [walkThePickerToTheFixture] of each test return a selector nothing matches. Both tests then
|
||||
* passed, with `ActivityTaskManager` logging four `OPEN_DOCUMENT` starts for the two of them —
|
||||
* two pickers each. That is the run which says the reopened pick completes: that
|
||||
* `pickInput.launch` is not refused from the re-resumed Activity, and that the second test's
|
||||
* reopen, which lands in the last-accessed stack rather than on Recent, still walks to the file.
|
||||
* Making the ViewModel composition-scoped leaves the picker test alone and fails
|
||||
* [thePickedInputSurvivesARealRotation], with `:app:testDebugUnitTest` still BUILD SUCCESSFUL —
|
||||
* which is the divergence this ticket was filed to establish, and which was doubted on it. It is
|
||||
@@ -115,6 +244,10 @@ class SafPickerRoundTripTest {
|
||||
private val device: UiDevice =
|
||||
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
|
||||
/** The app under test, whose own window is what [requireAReadableScreen] asks for. */
|
||||
private val appPackage: String =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.packageName
|
||||
|
||||
/** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */
|
||||
private var rotated = false
|
||||
|
||||
@@ -208,25 +341,276 @@ class SafPickerRoundTripTest {
|
||||
* Everything between the first tap and the last belongs to `com.google.android.documentsui`,
|
||||
* which is why UiAutomator is here at all: Compose's matchers stop at this process's
|
||||
* composition and Espresso's at its view hierarchy, and the picker is neither.
|
||||
*
|
||||
* **What is retried here is the whole pick.** [tapPickerNode]'s re-find re-acquires a handle
|
||||
* to a node inside the picker that is already open, so it cannot reach a list that was built
|
||||
* before its data arrived. Backing out and tapping "Choose file" again gets a *second*
|
||||
* `PickActivity`, which rebuilds every list in it — and is what a user does when a picker
|
||||
* comes up wrong. It is **not** the answer to the unreadable-screen failure in the class
|
||||
* KDoc; [requireAReadableScreen], one line above, is the part aimed at that.
|
||||
*
|
||||
* The first attempt keeps the full [PICKER_TIMEOUT_MS]; the later ones use
|
||||
* [REOPENED_TIMEOUT_MS], because by then the picker's process, its provider and its root cache
|
||||
* are all warm and the only thing being waited on is one screen. That is what keeps the cost
|
||||
* of a genuinely absent root bounded — see the class KDoc.
|
||||
*/
|
||||
private fun pickTheFixture() {
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
|
||||
// THIS is the line the MIME filter mutation fails on. DocumentsUI matches the requested
|
||||
// types against Root.COLUMN_MIME_TYPES and drops the roots that cannot answer, so a filter
|
||||
// the fixture root does not satisfy takes the root out of the picker altogether -- along
|
||||
// with "Images", "Audio", "Videos" and "Documents", measured on API 34.
|
||||
tapPickerNode(By.text(FixtureDocumentsProvider.ROOT_TITLE)) {
|
||||
// Which screen the picker opens on is its own business: it lands on Recent, where the
|
||||
// roots are a strip at the bottom, but a device with a populated Recent may need the
|
||||
// drawer. Looking in the second place widens where the root is searched for; it does
|
||||
// not weaken what has to be found, which is still this root.
|
||||
device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click()
|
||||
var missing: BySelector? = null
|
||||
repeat(PICK_ATTEMPTS) { attempt ->
|
||||
requireAReadableScreen()
|
||||
openThePicker()
|
||||
missing = walkThePickerToTheFixture(
|
||||
if (attempt == 0) PICKER_TIMEOUT_MS else REOPENED_TIMEOUT_MS,
|
||||
)
|
||||
if (missing == null) {
|
||||
awaitNode(TestTags.Converter.FILE_CARD_NAME)
|
||||
return
|
||||
}
|
||||
dismissThePicker()
|
||||
}
|
||||
throw AssertionError(
|
||||
"the system picker never showed $missing, in $PICK_ATTEMPTS separate pickers " +
|
||||
"(the last one left ${device.currentPackageName} in front)",
|
||||
)
|
||||
}
|
||||
|
||||
tapPickerNode(By.text(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME))
|
||||
/**
|
||||
* Refuses to go near the picker until this process can read a window it already knows is there.
|
||||
*
|
||||
* **This is the check that would have answered #93 outright**, instead of leaving six PRs to
|
||||
* infer a SAF fault from a picker that was never the problem. It is here because of what the
|
||||
* failing logcat counts. Across the whole API 34 leg UiAutomator
|
||||
* asked for a node 1095 times and logged `Node not found` 1095 times — it never read anything,
|
||||
* from the first query of the run onwards. The green leg of the same job asked 7 times and
|
||||
* found 5. So the window list `UiDevice` searches, `UiAutomation.getWindows()`, was empty for
|
||||
* that entire instrumentation run; on API 21 and up that list is the *only* place
|
||||
* `getWindowRoots` looks, so an empty one makes every selector unfindable and says nothing
|
||||
* about the app, the picker or the fixture.
|
||||
*
|
||||
* The probe is deliberately the app's **own** window, asked while the app is in front and
|
||||
* before anything is tapped. It is the one window that must be readable for any of the rest to
|
||||
* mean anything, so a failure here is unambiguous — where "the picker never showed the root"
|
||||
* was not, and is what sent #93 looking at package installation and root caches.
|
||||
*
|
||||
* The repair is [rebuildUiAutomation]. It has been forced on and measured — a rebuilt
|
||||
* connection still reads windows, which is the way it could have been worse than nothing —
|
||||
* but it has **never been run against the real fault**, because the fault has never been
|
||||
* reproduced on demand. See the class KDoc. What is certain is that a fresh picker is *not*
|
||||
* the repair: the failing leg opened a second `PickActivity` for the second test, in the
|
||||
* same DocumentsUI process, and read exactly as little from it.
|
||||
*/
|
||||
private fun requireAReadableScreen() {
|
||||
val app = By.pkg(appPackage)
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||
dismissASystemErrorDialog()
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||
unlockTheDevice()
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) == true) return
|
||||
rebuildUiAutomation()
|
||||
if (device.wait(Until.hasObject(app), READABLE_TIMEOUT_MS) != true) {
|
||||
throw AssertionError(
|
||||
"UiAutomator cannot see this app's own window, so it could not have seen the " +
|
||||
"picker's either. This is not a SAF failure. Closing a system error dialog, " +
|
||||
"waking the device, dismissing the keyguard and rebuilding the UiAutomation " +
|
||||
"connection all failed to make it readable. What it could see: " +
|
||||
describeWindows(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
awaitNode(TestTags.Converter.FILE_CARD_NAME)
|
||||
/**
|
||||
* Closes a system "isn't responding" dialog, if that is what is on top of the app.
|
||||
*
|
||||
* **This is the occluder #93 turned out to have**, and it took the window list in the failure
|
||||
* message to find it. `AppNotRespondingDialog` belongs to `system_server`, so it is the
|
||||
* `android[type=3]` in `com.android.systemui[type=3], android[type=3]` — and it is opaque and
|
||||
* fullscreen, so `AccessibilityWindowManager` drops every application window beneath it. The
|
||||
* app is `Displayed` and unreadable at the same time, which is exactly the contradiction this
|
||||
* class spent #93 failing to explain. It is not even this app's dialog:
|
||||
*
|
||||
* ```
|
||||
* ANR in com.google.android.apps.nexuslauncher (com.google.android.apps.nexuslauncher/.NexusLauncherActivity)
|
||||
* Reason: Input dispatching timed out (Application does not have a focused window)
|
||||
* Window{4ed8414 u0 Application Not Responding: com.google.android.apps.nexuslauncher}
|
||||
* ```
|
||||
*
|
||||
* The launcher ANRs on a loaded runner emulator minutes before this class runs, and the dialog
|
||||
* it leaves behind never goes away on its own.
|
||||
*
|
||||
* Dismissed by resource id rather than by button text, because the text is localised and the
|
||||
* ids are not, and by id rather than by "the first button in the system window", because that
|
||||
* would click whatever system window happened to be there. `aerr_wait` first: it dismisses the
|
||||
* dialog and leaves the offending app alone, which is the polite answer when the app is not
|
||||
* ours. Back is not tried — `BaseErrorDialog` swallows key events.
|
||||
*/
|
||||
private fun dismissASystemErrorDialog() {
|
||||
for (id in ERROR_DIALOG_BUTTONS) {
|
||||
val button = device.findObject(By.res(id)) ?: continue
|
||||
button.click()
|
||||
device.waitForIdle()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wakes the display and asks the keyguard to go away.
|
||||
*
|
||||
* The cheapest explanation for "this process cannot see the app's own window" is that
|
||||
* something is in front of it, and on a runner emulator that something is the lock screen:
|
||||
* these images come up unprovisioned, and `KeyguardViewMediator` says so in as many words --
|
||||
* `we need to show the keyguard since the device isn't provisioned yet`. An occluded window is
|
||||
* not in the accessibility window list, which is the same symptom as a broken connection and
|
||||
* has a far more ordinary cause.
|
||||
*
|
||||
* `wm dismiss-keyguard` rather than a swipe, because it is a request to the window manager
|
||||
* rather than a gesture that has to land somewhere this process cannot see. It is only
|
||||
* attempted on the failure path -- a device that was readable never reaches here -- so a run
|
||||
* where the keyguard was never up pays nothing and is not altered.
|
||||
*/
|
||||
private fun unlockTheDevice() {
|
||||
device.wakeUp()
|
||||
device.executeShellCommand("wm dismiss-keyguard")
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
/** The accessibility window list, for a failure message that says what was actually there. */
|
||||
private fun describeWindows(): String {
|
||||
val windows = InstrumentationRegistry.getInstrumentation().uiAutomation.windows
|
||||
if (windows.isEmpty()) return "no windows at all (UiAutomation.getWindows() is empty)"
|
||||
return windows.joinToString(", ") { "${it.root?.packageName ?: "?"}[type=${it.type}]" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down this run's `UiAutomation` connection and establishes a new one.
|
||||
*
|
||||
* `Instrumentation.getUiAutomation` hands back the existing connection unless the flags differ
|
||||
* from the ones it was created with, in which case it destroys it and builds another — so
|
||||
* asking for different flags and then for the original ones back is how a test reaches the
|
||||
* connection at all. `UiDevice` re-reads the flags from `Configurator` on every call rather
|
||||
* than caching an instance, so the next selector goes through the new connection.
|
||||
*
|
||||
* `FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES` is toggled rather than chosen: it is only being
|
||||
* used as a value that differs from whatever is configured, and it is put back.
|
||||
*
|
||||
* **Forced on and measured, because the obvious way for this to be worse than nothing is
|
||||
* silent.** `UiDevice` puts `FLAG_RETRIEVE_INTERACTIVE_WINDOWS` on the service info during its
|
||||
* own initialisation, and `getWindows()` is empty without it — so a rebuilt connection that
|
||||
* did not get the flag back would cause exactly the emptiness this is meant to cure, on the
|
||||
* one path where it is the last hope. Run unconditionally on every attempt, on a cold API 34
|
||||
* emulator, both tests passed, and logcat shows the connection really being replaced rather
|
||||
* than handed back: `Init UiAutomation[id=2, flags=0]`, then `id=4, flags=1`, then
|
||||
* `id=6, flags=0`, with `Registering UiTestAutomationService` between each.
|
||||
*/
|
||||
private fun rebuildUiAutomation() {
|
||||
val configurator = Configurator.getInstance()
|
||||
val flags = configurator.uiAutomationFlags
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
configurator.uiAutomationFlags = flags xor UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES
|
||||
instrumentation.getUiAutomation(configurator.uiAutomationFlags)
|
||||
configurator.uiAutomationFlags = flags
|
||||
instrumentation.getUiAutomation(flags)
|
||||
}
|
||||
|
||||
/** Waits for the app to be showing its own screen again, then asks for a picker. */
|
||||
private fun openThePicker() {
|
||||
awaitNode(TestTags.Converter.CHOOSE_FILE)
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Null once the fixture URI is with the app, or the selector whose list never carried it.
|
||||
*
|
||||
* Three things have to be there, in order, and the `when` names them in that order so that a
|
||||
* failure says which one was missing rather than "the picker did not work".
|
||||
*
|
||||
* **The first branch is what tells an unreadable picker from an absent root.** In #93 neither
|
||||
* the root *nor the toolbar's "Show roots" button* could be found for sixty seconds, and a
|
||||
* stale roots list would have left the toolbar findable. Both arrived as one message. Asking
|
||||
* for the picker's package on its own separates them: `never showed BySelector [PKG=...]`
|
||||
* means the picker was not readable, and the root selector means the root was not offered.
|
||||
*
|
||||
* The second is the line the MIME filter mutation fails on: DocumentsUI matches the requested
|
||||
* types against `Root.COLUMN_MIME_TYPES` and drops the roots that cannot answer, so a filter
|
||||
* the fixture root does not satisfy takes the root out of the picker altogether — along with
|
||||
* "Images", "Audio", "Videos" and "Documents", measured on API 34.
|
||||
*
|
||||
* **The third takes no recovery action of its own, and that is deliberate rather than an
|
||||
* oversight.** [openTheRootsDrawer] exists because a root has a *second* place it can be
|
||||
* shown; a document in a directory listing has no second place, so there is nothing an
|
||||
* in-picker action could do. Its recovery is the outer loop: a fresh picker re-walks from
|
||||
* Recent into the root, which rebuilds the directory listing as well as the roots strip.
|
||||
*/
|
||||
private fun walkThePickerToTheFixture(timeoutMs: Long): BySelector? {
|
||||
val picker = By.pkg(DOCUMENTS_UI_PACKAGE)
|
||||
val root = By.text(FixtureDocumentsProvider.ROOT_TITLE)
|
||||
val fixture = By.text(FixtureDocumentsProvider.FIXTURE_DISPLAY_NAME)
|
||||
return when {
|
||||
device.wait(Until.hasObject(picker), timeoutMs) != true -> picker
|
||||
!tapPickerNode(root, timeoutMs, ifAbsent = ::openTheRootsDrawer) -> root
|
||||
!tapPickerNode(fixture, timeoutMs) -> fixture
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's own drawer, opened only when the root was not on the screen it landed on.
|
||||
*
|
||||
* **In practice it never runs, and #80 was right to say so.** A hierarchy dump taken on a
|
||||
* cold API 34 emulator while this test was passing has the fixture root on the landing
|
||||
* screen — `text="LMC R38 fixtures"` at `android:id/title`, under a `BROWSE FILES IN OTHER
|
||||
* APPS` header — with the drawer shut (`Show roots` present, `Hide roots` absent). So the
|
||||
* roots strip is the normal path and the drawer is a widening, kept because a device with a
|
||||
* populated Recent may push the strip off screen. Looking in a second place widens where the
|
||||
* root is searched for; it does not weaken what has to be found, which is still this root.
|
||||
*/
|
||||
private fun openTheRootsDrawer() {
|
||||
device.findObject(By.desc(SHOW_ROOTS_DESCRIPTION))?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs out of the picker until the app has the window focus again.
|
||||
*
|
||||
* **The focus is asked of the Activity, not of UiAutomator, and that is not a stylistic
|
||||
* choice.** The failure this retry exists for is a picker window UiAutomator cannot see, so a
|
||||
* probe that went through the same accessibility window list would cheerfully report "the
|
||||
* picker is gone" about the window that is still in front — and the reopened pick would then
|
||||
* tap "Choose file" behind it. `Activity.hasWindowFocus` comes from the framework instead, and
|
||||
* answers about the app rather than about the picker.
|
||||
*
|
||||
* It is also why this counts backs rather than pressing a fixed number of them. One back is
|
||||
* enough from Recent and two are needed from inside the root, but a third from Recent would
|
||||
* finish `MainActivity` and take the rest of the test with it.
|
||||
*/
|
||||
private fun dismissThePicker() {
|
||||
repeat(BACK_PRESSES) {
|
||||
if (awaitAppFocus()) return
|
||||
// Before the back press, not instead of it: an app-error dialog swallows key events,
|
||||
// so a back aimed at the picker lands on the dialog and nothing moves. Measured --
|
||||
// API 34 of run 32813885120 exhausted all four presses with `android` in front, which
|
||||
// is that dialog, while the launcher it belonged to went on ANRing behind everything.
|
||||
dismissASystemErrorDialog()
|
||||
device.pressBack()
|
||||
}
|
||||
// The check after the last press, and not a spare one: `repeat` presses on its final
|
||||
// iteration too, so without this a dismissal that worked on the last press would still be
|
||||
// reported as a failure to close.
|
||||
if (!awaitAppFocus()) {
|
||||
throw AssertionError(
|
||||
"the system picker would not close: after $BACK_PRESSES back presses the app " +
|
||||
"still does not have the window focus, and ${device.currentPackageName} is " +
|
||||
"in front. What could be seen: " + describeWindows(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** True once [MainActivity] has the window focus, false if it does not take it in time. */
|
||||
private fun awaitAppFocus(): Boolean = try {
|
||||
composeRule.waitUntil("the app has the window focus back", FOCUS_TIMEOUT_MS) {
|
||||
composeRule.activity.hasWindowFocus()
|
||||
}
|
||||
true
|
||||
} catch (_: ComposeTimeoutException) {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,21 +628,24 @@ class SafPickerRoundTripTest {
|
||||
* at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
|
||||
* ```
|
||||
*
|
||||
* So what is retried is *acquiring a handle to a node that has to be there anyway* — every
|
||||
* attempt still goes through [awaitPickerNode], which fails outright if the node is absent.
|
||||
* The MIME mutation's bite is untouched: a root that is not in the picker is not found on any
|
||||
* attempt, and the failure is still "the system picker never showed" rather than a stale one.
|
||||
* So what is retried is *acquiring a handle to a node that has to be there anyway*. **A node
|
||||
* that is simply not in this picker is reported rather than retried here** — it comes back as
|
||||
* `false`, and [pickTheFixture] answers it with a whole new picker, which is the only thing
|
||||
* that rebuilds a list or a window. The MIME mutation's bite is untouched either way: a root
|
||||
* that is not in the picker is not found on any attempt or in any picker, and the failure is
|
||||
* still "the system picker never showed" rather than a stale one.
|
||||
*/
|
||||
private fun tapPickerNode(selector: BySelector, ifAbsent: () -> Unit = {}) {
|
||||
private fun tapPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit = {}): Boolean {
|
||||
var stale: StaleObjectException? = null
|
||||
repeat(TAP_ATTEMPTS) { attempt ->
|
||||
// ifAbsent only on the first attempt: it navigates, and re-navigating from a screen it
|
||||
// already reached would walk away from the node.
|
||||
val node = awaitPickerNode(selector, if (attempt == 0) ifAbsent else ({}))
|
||||
val node = awaitPickerNode(selector, timeoutMs, if (attempt == 0) ifAbsent else ({}))
|
||||
?: return false
|
||||
device.waitForIdle()
|
||||
try {
|
||||
node.click()
|
||||
return
|
||||
return true
|
||||
} catch (e: StaleObjectException) {
|
||||
stale = e
|
||||
}
|
||||
@@ -267,19 +654,17 @@ class SafPickerRoundTripTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker node [selector] names, or a failure that says which one was missing.
|
||||
* The picker node [selector] names, or null if this picker never showed it.
|
||||
*
|
||||
* [ifAbsent] runs once, after the first wait comes up empty, and then the wait is repeated. A
|
||||
* null return from `findObject` is deliberately not an error there: it is the "already on the
|
||||
* right screen" case.
|
||||
*/
|
||||
private fun awaitPickerNode(selector: BySelector, ifAbsent: () -> Unit = {}) =
|
||||
device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS)
|
||||
private fun awaitPickerNode(selector: BySelector, timeoutMs: Long, ifAbsent: () -> Unit) =
|
||||
device.wait(Until.findObject(selector), timeoutMs)
|
||||
?: run {
|
||||
ifAbsent()
|
||||
requireNotNull(device.wait(Until.findObject(selector), PICKER_TIMEOUT_MS)) {
|
||||
"the system picker never showed $selector"
|
||||
}
|
||||
device.wait(Until.findObject(selector), timeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,9 +691,69 @@ class SafPickerRoundTripTest {
|
||||
const val PICKER_TIMEOUT_MS = 30_000L
|
||||
const val APP_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* The same wait once a picker has already come and gone, and shorter for a reason.
|
||||
*
|
||||
* What [PICKER_TIMEOUT_MS] is generous about is a cold start: DocumentsUI's process, the
|
||||
* fixture's provider process, the root cache. By the second attempt all three are warm and
|
||||
* the only thing left to wait on is one screen being laid out — measured at 2.7 to 3.4 s
|
||||
* from the picker starting, on cold CI emulators at API 33, 34 and 35. Ten seconds is
|
||||
* three times the worst of those, and it is what keeps a genuinely absent root — the MIME
|
||||
* mutation — from costing three full-length attempts.
|
||||
*/
|
||||
const val REOPENED_TIMEOUT_MS = 10_000L
|
||||
|
||||
/**
|
||||
* How long the app is given to take the window focus back after a back press.
|
||||
*
|
||||
* Short, because this is asked once per back press and the first one is always asked while
|
||||
* the picker is still in front, where it is *expected* to time out.
|
||||
*/
|
||||
const val FOCUS_TIMEOUT_MS = 3_000L
|
||||
|
||||
/**
|
||||
* How long this process is given to be able to read the screen at all.
|
||||
*
|
||||
* Short, and it is not waiting on anything being drawn: the app is already in front
|
||||
* when this is asked. It is waiting only on the accessibility window list existing,
|
||||
* which either does within a poll or two or -- as in #93 -- not at all.
|
||||
*/
|
||||
const val READABLE_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** `Surface.ROTATION_0`, named rather than `0` so the comparison reads. */
|
||||
const val NATURAL_ROTATION = 0
|
||||
|
||||
/**
|
||||
* How many pickers the fixture may fail to appear in before that is the finding.
|
||||
*
|
||||
* Three. Each one is a fresh `PickActivity` -- a fresh window, a fresh accessibility
|
||||
* registration, a fresh roots query and a fresh directory load -- so this bounds the thing
|
||||
* #93 measured, which is a picker that came up unreadable *once*. A root that is genuinely
|
||||
* not offered is absent from all three, which is what keeps #64's MIME mutation red.
|
||||
*/
|
||||
const val PICK_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* How many back presses may be spent getting out of a picker.
|
||||
*
|
||||
* One is enough from Recent, two from inside the fixture's own directory. Four leaves room
|
||||
* for a picker that has been navigated deeper than this test ever navigates it, and stops
|
||||
* well short of the count that would start finishing `MainActivity` instead.
|
||||
*/
|
||||
const val BACK_PRESSES = 4
|
||||
|
||||
/**
|
||||
* The package the system picker runs in.
|
||||
*
|
||||
* Named rather than resolved: `PackageManager.resolveActivity` is deprecated from API 33
|
||||
* and its replacement is a lint argument this test does not need to have. A wrong value
|
||||
* here cannot pass silently -- it is the first thing [walkThePickerToTheFixture] looks
|
||||
* for, so the failure would read `never showed BySelector [PKG='...']` on every device.
|
||||
* It is `com.google.android.documentsui` on every `google_apis` emulator image the CI
|
||||
* matrix uses and on the Pixel 10 Pro XL.
|
||||
*/
|
||||
const val DOCUMENTS_UI_PACKAGE = "com.google.android.documentsui"
|
||||
|
||||
/**
|
||||
* How many times a picker node may be re-found before its staleness is the finding.
|
||||
*
|
||||
@@ -319,6 +764,19 @@ class SafPickerRoundTripTest {
|
||||
*/
|
||||
const val TAP_ATTEMPTS = 3
|
||||
|
||||
/**
|
||||
* The buttons on the framework's app-error dialogs, by resource id.
|
||||
*
|
||||
* `aerr_wait` is first because it dismisses the dialog without killing the app under it,
|
||||
* and the app under it is usually the launcher rather than anything this suite owns.
|
||||
* `button1` catches the plainer `BaseErrorDialog` shapes that have no `aerr_` ids.
|
||||
*/
|
||||
val ERROR_DIALOG_BUTTONS = listOf(
|
||||
"android:id/aerr_wait",
|
||||
"android:id/aerr_close",
|
||||
"android:id/button1",
|
||||
)
|
||||
|
||||
/** DocumentsUI's drawer button. It carries no text, only this description. */
|
||||
const val SHOW_ROOTS_DESCRIPTION = "Show roots"
|
||||
|
||||
|
||||
@@ -101,7 +101,42 @@ sealed interface ConversionState {
|
||||
val mimeType: String = "",
|
||||
) : ConversionState
|
||||
data class Saved(val displayName: String) : ConversionState
|
||||
data class Failed(val message: String) : ConversionState
|
||||
|
||||
/**
|
||||
* The job, or the save that followed it, could not be finished.
|
||||
*
|
||||
* [retry] is non-null for exactly one cause: a [ConversionViewModel.save] whose copy to the
|
||||
* user's destination threw. That save deliberately keeps the staged file -- it can be the only
|
||||
* copy of an hour of transcoding -- and this is what lets the screen offer it again. Every
|
||||
* other failure leaves it null, because there is nothing staged to offer: a transcode that
|
||||
* died produced no output, and a save that found the file gone has nothing left to save.
|
||||
*
|
||||
* Nullable rather than a `SaveFailed` state of its own. What the screen does with the message
|
||||
* is identical either way, so a second variant would make every exhaustive `when` grow an arm
|
||||
* that duplicates this one.
|
||||
*
|
||||
* A view of the file, not a second owner of it -- see [PendingSave].
|
||||
*/
|
||||
data class Failed(val message: String, val retry: PendingSave? = null) : ConversionState
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged output a save would target from this state, or null when there is nothing to save.
|
||||
*
|
||||
* One function for two callers that have to agree. [ConversionViewModel.save] picks the file to
|
||||
* copy with it, and `ConverterScreen` registers its `CreateDocument` contract with the MIME type
|
||||
* it returns; when those two read the state separately, a retry offered after a failed save opened
|
||||
* the dialog with the *picker's* current type instead of the finished job's -- wrong for any job
|
||||
* whose spec has been edited since, and for every reattached job, whose spec was never in these
|
||||
* settings at all.
|
||||
*
|
||||
* Top-level and `internal` rather than a member of the ViewModel, so the screen can call it
|
||||
* without one -- which is also what makes the derivation testable on the JVM.
|
||||
*/
|
||||
internal fun ConversionState.pendingSave(): PendingSave? = when (this) {
|
||||
is ConversionState.Converted -> PendingSave(staged, suggestedName, mimeType)
|
||||
is ConversionState.Failed -> retry
|
||||
else -> null
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@@ -161,10 +196,12 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
/**
|
||||
* The staged output this ViewModel is responsible for deleting.
|
||||
*
|
||||
* A field rather than something read back out of [_state], because the state machine
|
||||
* cannot answer the question on the path that needs it most: a failed [save] lands on
|
||||
* [ConversionState.Failed], which carries a message and no file at all. By then the
|
||||
* only remaining reference would have been lost.
|
||||
* A field rather than something read back out of [_state], and still one now that
|
||||
* [ConversionState.Failed] carries a [PendingSave] after a failed [save]. That handle is a
|
||||
* view for the screen to offer a retry through; this one is the single reference [reset]
|
||||
* deletes through, and keeping the two apart is what stops a second owner appearing. Reading
|
||||
* the file back out of the state machine instead would mean trusting every state that has no
|
||||
* file -- `Idle`, `Saved`, a transcode failure -- to say so.
|
||||
*/
|
||||
private var pendingStaged: File? = null
|
||||
|
||||
@@ -426,35 +463,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 ?: "Could not save the file.", pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,6 +520,12 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
* cancelled with [viewModelScope] if the Activity finishes first, so it is a best
|
||||
* effort rather than a guarantee. `OutputPublisher.sweepStaging` is the backstop for
|
||||
* the times it does not run.
|
||||
*
|
||||
* **It still deletes from a [ConversionState.Failed] carrying a [PendingSave], and that is a
|
||||
* decision rather than something inherited.** Deletion is acceptable there only because the
|
||||
* alternative was offered first: the screen puts "Try saving again" directly above this
|
||||
* button, so reaching it is the user saying the work is not worth keeping. Until that button
|
||||
* existed, this delete was the only thing a failed save could lead to — which was the defect.
|
||||
*/
|
||||
fun reset() {
|
||||
observer?.cancel()
|
||||
|
||||
@@ -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) }
|
||||
@@ -325,13 +328,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]
|
||||
|
||||
@@ -23,6 +23,24 @@ const val STAGED_FILE_GONE_MESSAGE: String =
|
||||
"The finished file is no longer in the cache, so there is nothing left to save. " +
|
||||
"Start over to make it again."
|
||||
|
||||
/**
|
||||
* A staged file that is still there to be saved, and everything the save dialog needs to offer it.
|
||||
*
|
||||
* The three travel together because a save cannot be repeated without all of them: the file to
|
||||
* copy, the name to suggest, and the MIME type `CreateDocument` has to be registered with. None of
|
||||
* them can be rederived from the pickers once the job is over -- they come from the job's own
|
||||
* output `Data`, and a reattached job's spec was never in the current settings at all.
|
||||
*
|
||||
* Kept next to [STAGED_FILE_GONE_MESSAGE] for the same reason it is: both ViewModels need it and
|
||||
* staging is what it is about.
|
||||
*
|
||||
* **A view of the staged file, never an owner of it.** The delete still runs through each
|
||||
* ViewModel's own `pendingStaged` field, so a state carrying one of these can be dropped without
|
||||
* losing the only reference -- which is what keeps "a `Failed` that carries a file" from being a
|
||||
* new way to leak one.
|
||||
*/
|
||||
data class PendingSave(val staged: File, val suggestedName: String, val mimeType: String)
|
||||
|
||||
/**
|
||||
* Staging and publication of conversion output.
|
||||
*
|
||||
|
||||
@@ -46,9 +46,10 @@ 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) }
|
||||
@@ -226,13 +227,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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.convert.InputQuery
|
||||
import org.libremediaconverter.convert.PendingSave
|
||||
import org.libremediaconverter.convert.STAGED_FILE_GONE_MESSAGE
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
@@ -44,7 +45,30 @@ sealed interface JoinState {
|
||||
val mimeType: String,
|
||||
) : JoinState
|
||||
data class Saved(val displayName: String) : JoinState
|
||||
data class Failed(val message: String) : JoinState
|
||||
|
||||
/**
|
||||
* The join, or the save that followed it, could not be finished.
|
||||
*
|
||||
* [retry] is non-null for exactly one cause, and for the same reason as on
|
||||
* `ConversionState.Failed`: a [JoinViewModel.save] whose copy to the user's destination threw
|
||||
* keeps the staged file, and this is what lets the screen offer it again. Every other failure
|
||||
* leaves it null — a join that died produced no output, and a save that found the file gone
|
||||
* has nothing left to save.
|
||||
*/
|
||||
data class Failed(val message: String, val retry: PendingSave? = null) : JoinState
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged output a save would target from this state, or null when there is nothing to save.
|
||||
*
|
||||
* The join tab's half of `ConversionState.pendingSave`, and it exists for the same reason: `save`
|
||||
* and `JoinScreen`'s `CreateDocument` registration both have to answer this question, and answering
|
||||
* it twice is how a retry ends up opening the dialog with a type the finished job never chose.
|
||||
*/
|
||||
internal fun JoinState.pendingSave(): PendingSave? = when (this) {
|
||||
is JoinState.Joined -> PendingSave(staged, suggestedName, mimeType)
|
||||
is JoinState.Failed -> retry
|
||||
else -> null
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@@ -70,9 +94,10 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
* The staged output this ViewModel is responsible for deleting.
|
||||
*
|
||||
* Held here rather than read back out of [_state] for the same reason as in
|
||||
* `ConversionViewModel`: a failed [save] lands on [JoinState.Failed], which carries a
|
||||
* message and no file, so the state machine cannot answer this on the one path that
|
||||
* most needs it.
|
||||
* `ConversionViewModel`, and still held here now that [JoinState.Failed] carries a
|
||||
* [PendingSave] after a failed [save]: that handle is a view for the screen to offer a retry
|
||||
* through, this one is the single reference [reset] deletes through, and keeping the two
|
||||
* apart is what stops a second owner of the file appearing.
|
||||
*/
|
||||
private var pendingStaged: File? = null
|
||||
|
||||
@@ -229,29 +254,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 ?: "Could not save the file.", pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +295,10 @@ class JoinViewModel @JvmOverloads constructor(
|
||||
*
|
||||
* Best effort, not a guarantee: the delete is cancelled with [viewModelScope] if the
|
||||
* Activity finishes first. `OutputPublisher.sweepStaging` is the backstop.
|
||||
*
|
||||
* It deletes from a [JoinState.Failed] carrying a [PendingSave] too, deliberately and for the
|
||||
* reason `ConversionViewModel.reset` writes out: the screen offers "Try saving again" above
|
||||
* this button, so deletion is what the user chose rather than all this state could do.
|
||||
*/
|
||||
fun reset() {
|
||||
observer?.cancel()
|
||||
|
||||
@@ -129,9 +129,25 @@ object ContainerCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.videoCodec == VideoCodec.NONE && spec.audioCodec == AudioCodec.NONE) {
|
||||
// Two faces of one rule: the output would carry no tracks at all.
|
||||
//
|
||||
// The first is visible in the spec alone — NONE on both axes. The second only emerges once
|
||||
// the spec meets the probe, because [CopyPlanner] drops a video track the *input* does not
|
||||
// have no matter which codec was named for it, so "H.265 + no audio" on an MP3 plans to
|
||||
// (Drop, Drop) exactly as "None + None" does. Asking the spec alone answered the first and
|
||||
// missed the second, and the miss was not cosmetic: `EditedMediaItem.Builder` refuses that
|
||||
// composition with IllegalStateException("Audio and video cannot both be removed"), on
|
||||
// Transformer's own thread, where the user would have seen a dead app rather than a reason.
|
||||
if (spec.audioCodec == AudioCodec.NONE && (spec.videoCodec == VideoCodec.NONE || !probe.hasVideo)) {
|
||||
return Validation.Invalid(
|
||||
"This would produce an empty file — keep at least one track.",
|
||||
if (spec.videoCodec == VideoCodec.NONE) {
|
||||
"This would produce an empty file — keep at least one track."
|
||||
} else {
|
||||
// Names both halves. "No video track" alone reads as though the video setting
|
||||
// were the only thing wrong, and the user would fix that and still be stuck.
|
||||
"This file has no video track, so turning the audio off too would produce an " +
|
||||
"empty file."
|
||||
},
|
||||
suggestions(
|
||||
// Ask for both tracks back, then let repair settle what this container and
|
||||
// this input can actually give.
|
||||
@@ -284,8 +300,12 @@ object ContainerCapabilities {
|
||||
private fun repairVideo(spec: OutputSpec, probe: InputProbe): VideoCodec {
|
||||
val container = spec.container
|
||||
if (spec.videoCodec == VideoCodec.NONE || !container.canHoldVideo) return VideoCodec.NONE
|
||||
// There is no video track to make one out of, so naming a codec would be a suggestion
|
||||
// [CopyPlanner] drops on the floor. It also read as a non-sequitur: before this line, the
|
||||
// repair offered for an MP3 was "H.264", the first codec MP4 happens to encode.
|
||||
if (!probe.hasVideo) return VideoCodec.NONE
|
||||
|
||||
val source = CodecNames.videoFromName(probe.videoCodec).takeIf { probe.hasVideo }
|
||||
val source = CodecNames.videoFromName(probe.videoCodec)
|
||||
val copyable = source != null && accepts(container, source, CodecMode.COPY)
|
||||
|
||||
return when {
|
||||
|
||||
@@ -45,6 +45,17 @@ 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"
|
||||
|
||||
/** `ConverterScreen`. */
|
||||
object Converter {
|
||||
const val CHOOSE_FILE: String = "converter.chooseFile"
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -140,4 +140,34 @@ class CodecVocabularyTest {
|
||||
assertFalse("this device has no AVC decoder, and x264 is AVC", hevcOnly.canDecode("x264"))
|
||||
assertTrue("a name nobody knows keeps the permissive answer", hevcOnly.canDecode("cinepak"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the null policy, at the seam it exists for — #86.
|
||||
*
|
||||
* `mimeFor`'s `COPY, NONE -> null` arm carries its consequence in a comment: "Returning null
|
||||
* makes canEncode answer true, which is the right answer: a copied or absent track places no
|
||||
* demand on the hardware." That is a product decision, and until this test nothing held it. A
|
||||
* MIME appearing in that arm would make a device with no matching encoder refuse a stream copy
|
||||
* — a job that never encodes anything — and the router would send it to FFmpeg to re-mux what
|
||||
* Media3 could have re-muxed.
|
||||
*
|
||||
* The `H264` line is what makes the other two mean something: without it, a `canEncode` that
|
||||
* simply returned `true` would satisfy this test. `NONE` is asserted separately from `COPY`
|
||||
* because they are one arm today and two answers, and splitting the arm must not silently
|
||||
* halve the coverage.
|
||||
*/
|
||||
@Test
|
||||
fun `a device with no video encoder at all still permits a copied or absent track`() {
|
||||
val noEncoders = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/avc"))
|
||||
assertTrue(
|
||||
"a copied track is re-muxed, not encoded, so no encoder is required",
|
||||
noEncoders.canEncode(VideoCodec.COPY),
|
||||
)
|
||||
assertTrue("an absent track places no demand on the hardware", noEncoders.canEncode(VideoCodec.NONE))
|
||||
assertFalse(
|
||||
"this device has no AVC encoder, so an H.264 target has to be refused — without this, " +
|
||||
"a canEncode that always answered true would satisfy the two assertions above",
|
||||
noEncoders.canEncode(VideoCodec.H264),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package org.libremediaconverter.codec
|
||||
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.convert.Media3Engine
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
|
||||
/**
|
||||
* Bites on #86: a fifth `VideoCodec -> MIME` table, and nothing checking it agrees with the fourth.
|
||||
*
|
||||
* [AndroidDeviceCodecs.mimeFor] and [Media3Engine.videoMimeTypeFor] take the same enum and return a
|
||||
* MIME string, from opposite ends of one export. The first asks the device *"have you an encoder
|
||||
* for this?"*; the second tells Transformer *"produce this."* If they name different MIME types for
|
||||
* the same codec, the app checks for one encoder and then requests another — the check passes, the
|
||||
* export succeeds, and the user's H.265 file contains H.264. Both were `private` until #85 and #87
|
||||
* widened them, so this assertion could not be written before; each table had per-arm tests that
|
||||
* pinned its own answers and could not see the other side.
|
||||
*
|
||||
* **They do not agree everywhere, and must not be forced to.** Three buckets, all pinned below:
|
||||
*
|
||||
* - **H.264 and H.265** — both tables name a MIME, and it has to be the same one. This is the
|
||||
* bucket the defect lives in.
|
||||
* - **VP8, VP9 and AV1** — the device table names a real MIME, Transformer's returns null. That is
|
||||
* correct, not drift: `Transformer.setVideoMimeType` will not accept them, so the router sends
|
||||
* them to FFmpeg before Media3 is asked anything, while a device may still genuinely own a VP9
|
||||
* encoder and `canEncode` has to give a truthful answer about it. Flattening `mimeFor` to null
|
||||
* here to "make the tables agree" would make `canEncode(VP9)` answer true on hardware that has
|
||||
* no VP9 encoder. The routing half of that claim is proved in
|
||||
* `Media3EngineMimeTypesTest.the router sends exactly H264 and H265 video encodes to Media3`,
|
||||
* which drives the real router; it is not repeated here.
|
||||
* - **COPY and NONE** — neither names a MIME, because neither is encoded at all.
|
||||
*
|
||||
* The fourth bucket is asserted empty: a codec Transformer names and the device check cannot ask
|
||||
* about would mean `canEncode` waving through a target the app then really does encode.
|
||||
*
|
||||
* **Audio has no partner, and that is a gap rather than a decision.** [Media3Engine.audioMimeTypeFor]
|
||||
* is the same shape one enum over — `AudioCodec -> MIME` — but [AndroidDeviceCodecs] enumerates
|
||||
* `video/` MIME types only, so there is no device-side audio table to cross-check it against. An
|
||||
* audio encoder this device lacks is therefore not caught up front the way a video one is; the job
|
||||
* reaches Media3 and falls back after failing. Named here so the asymmetry reads as unfinished
|
||||
* rather than intended.
|
||||
*/
|
||||
@UnstableApi
|
||||
class VideoCodecMimeAgreementTest {
|
||||
|
||||
/** Both tables name a MIME. The pair has to match; this is the whole point of the file. */
|
||||
private val bothNameAMime = setOf(VideoCodec.H264, VideoCodec.H265)
|
||||
|
||||
/** Only the device table names one, because Transformer is never asked for these. */
|
||||
private val deviceOnly = setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1)
|
||||
|
||||
/** Neither names one: nothing is encoded, so there is no encoder to name. */
|
||||
private val neitherNamesOne = setOf(VideoCodec.COPY, VideoCodec.NONE)
|
||||
|
||||
/**
|
||||
* Sorts every [VideoCodec] by what the two tables actually answer, then compares the sorting
|
||||
* with the buckets documented above.
|
||||
*
|
||||
* This is what makes the agreement test below non-vacuous, and it is deliberately an exact
|
||||
* comparison in all four directions. A codec added to the enum lands in some bucket and fails
|
||||
* here rather than arriving unclassified. A table that starts returning null for everything —
|
||||
* the shape a filtered loop would pass on — empties two buckets and fails here. And a
|
||||
* *convergence* fails too: giving `videoMimeTypeFor(VP9)` a real MIME moves VP9 out of
|
||||
* `deviceOnly`, which is the point. The divergence should be deliberate and visible, so
|
||||
* changing it should require saying so in this file.
|
||||
*/
|
||||
@Test
|
||||
fun `each video codec is in the bucket the two tables actually put it in`() {
|
||||
assertEquals(
|
||||
"codecs both tables name a MIME for",
|
||||
bothNameAMime,
|
||||
VideoCodec.entries.filter { device(it) != null && transformer(it) != null }.toSet(),
|
||||
)
|
||||
assertEquals(
|
||||
"codecs only the device check names a MIME for, because Transformer will not encode them",
|
||||
deviceOnly,
|
||||
VideoCodec.entries.filter { device(it) != null && transformer(it) == null }.toSet(),
|
||||
)
|
||||
assertEquals(
|
||||
"codecs neither table names a MIME for, because nothing is encoded",
|
||||
neitherNamesOne,
|
||||
VideoCodec.entries.filter { device(it) == null && transformer(it) == null }.toSet(),
|
||||
)
|
||||
assertEquals(
|
||||
"codecs Transformer names a MIME for that the device check cannot ask about — canEncode " +
|
||||
"would answer true without looking, for a codec Media3 really is told to produce",
|
||||
emptySet<VideoCodec>(),
|
||||
VideoCodec.entries.filter { device(it) == null && transformer(it) != null }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The cross-check itself.
|
||||
*
|
||||
* Per-arm tests in either file cannot catch this: each pins its own table's answers, so a pair
|
||||
* changed in lockstep with its own expectations stays green on both sides while the two tables
|
||||
* describe different codecs.
|
||||
*/
|
||||
@Test
|
||||
fun `where both tables name a MIME they name the same one`() {
|
||||
bothNameAMime.forEach { codec ->
|
||||
val asked = device(codec)
|
||||
val requested = transformer(codec)
|
||||
assertNotNull("AndroidDeviceCodecs has no MIME to ask the device about for ${codec.label}", asked)
|
||||
assertNotNull("Media3Engine has no MIME to give Transformer for ${codec.label}", requested)
|
||||
assertEquals(
|
||||
"${codec.label}: the device is asked about $asked and Transformer is then told to " +
|
||||
"produce $requested, so the capability check answers about a codec that is not the output",
|
||||
asked,
|
||||
requested,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The documented divergence, asserted rather than described.
|
||||
*
|
||||
* Both halves matter. The null side is Media3's refusal; the non-null side is the device
|
||||
* check's genuine question, and it is the half a reader "tidying up" the disagreement would
|
||||
* delete.
|
||||
*/
|
||||
@Test
|
||||
fun `the codecs Transformer will not encode are still codecs this device may or may not have`() {
|
||||
deviceOnly.forEach { codec ->
|
||||
assertNotNull(
|
||||
"${codec.label} goes to FFmpeg, but canEncode still has to answer truthfully about " +
|
||||
"this device's encoder — a null here makes it answer true without looking",
|
||||
device(codec),
|
||||
)
|
||||
assertNull(
|
||||
"Transformer rejects ${codec.label}, so naming a MIME for it would request an export " +
|
||||
"Media3 cannot perform",
|
||||
transformer(codec),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards every comparison above against passing as `null == null`.
|
||||
*
|
||||
* `MediaFormat`'s MIME types are Java compile-time constants and are inlined, so the unit-test
|
||||
* classpath's stubbed `android.jar` never supplies them; `MimeTypes`' come from a real
|
||||
* `media3-common` jar. If either stopped holding, the buckets would collapse and this fails
|
||||
* first, with the reason. Same guard, and the same reason, as
|
||||
* `CodecVocabularyTest.the MIME constants are real strings rather than stubs`.
|
||||
*/
|
||||
@Test
|
||||
fun `both tables return real MIME strings rather than stubs`() {
|
||||
assertEquals("video/avc", AndroidDeviceCodecs.mimeFor(VideoCodec.H264))
|
||||
assertEquals("video/hevc", AndroidDeviceCodecs.mimeFor(VideoCodec.H265))
|
||||
assertEquals("video/x-vnd.on2.vp9", AndroidDeviceCodecs.mimeFor(VideoCodec.VP9))
|
||||
assertEquals("video/avc", Media3Engine.videoMimeTypeFor(VideoCodec.H264))
|
||||
assertEquals("video/hevc", Media3Engine.videoMimeTypeFor(VideoCodec.H265))
|
||||
}
|
||||
|
||||
private fun device(codec: VideoCodec): String? = AndroidDeviceCodecs.mimeFor(codec)
|
||||
|
||||
private fun transformer(codec: VideoCodec): String? = Media3Engine.videoMimeTypeFor(codec)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+7
-1
@@ -140,10 +140,16 @@ class ConversionViewModelProbeFailureTest {
|
||||
private fun pickedProbe(): InputProbe? {
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(INPUT)
|
||||
// The predicate is the guard, and it is the only one needed. It requires `Ready`, so a
|
||||
// pick that ended in `Failed` never satisfies it and `awaitState` fails on its timeout
|
||||
// naming what it was waiting for -- "Ready with a probe" -- which says more than a
|
||||
// separate assertion could. A `ready as? ConversionState.Failed` check used to sit here
|
||||
// and was dead: `Ready` and `Failed` are sibling subtypes of one sealed interface, so
|
||||
// the cast was always null and the assertNull could never fire. Measured, not assumed --
|
||||
// flipping it to assertNotNull failed all three callers of this helper.
|
||||
val ready = awaitState(viewModel.state, "Ready with a probe") {
|
||||
it is ConversionState.Ready && it.input.probe != null
|
||||
}
|
||||
assertNull("nothing here should reach a terminal failure", (ready as? ConversionState.Failed))
|
||||
return (ready as ConversionState.Ready).input.probe
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,11 @@ import java.io.File
|
||||
* it -- so it is unobservable from a JVM test, the same limit `FileCardTest` records for
|
||||
* `HorizontalDivider`. The message text itself is asserted; the colour would need a screenshot.
|
||||
* - **The three `assertDoesNotExist` checks on [TestTags.Converter.FILE_CARD] are compile-guarded,
|
||||
* not guarded by this file.** `Idle` is a `data object`, and `Saved` and `Failed` carry only a
|
||||
* `displayName` and a `message`; none of the three has an `input`, so `FileCard(s.input)` does not
|
||||
* compile in those arms. The lines stay because they state the intent cheaply, but they are not
|
||||
* what stops a `FileCard` appearing there and this file does not claim they are.
|
||||
* not guarded by this file.** `Idle` is a `data object`, `Saved` carries a `displayName`, and
|
||||
* `Failed` carries a message and -- after a failed save only -- the staged file it left behind;
|
||||
* none of the three has an `input`, so `FileCard(s.input)` does not compile in those arms. The
|
||||
* lines stay because they state the intent cheaply, but they are not what stops a `FileCard`
|
||||
* appearing there and this file does not claim they are.
|
||||
* - **Which constant each chip hands back** belongs to `ConverterPickerSelectionTest`, and **what
|
||||
* the file card says about an unknown size** to `FileCardTest`. This file asserts that `Ready`
|
||||
* puts those leaves on screen at all, not what they then do.
|
||||
@@ -326,6 +327,22 @@ class ConverterStateAffordancesTest {
|
||||
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* **The assertion that bounds #30's whole change**, and the one worth breaking things to keep.
|
||||
*
|
||||
* A transcode that died staged nothing, so its `Failed` carries no [PendingSave] and there is
|
||||
* nothing for a save dialog to be handed. Making the retry unconditional -- or making the
|
||||
* `WorkInfo.State.FAILED` arm of `ConversionViewModel.observe` carry a handle it has no file
|
||||
* for -- puts a button on screen that can only fail, and this is what notices.
|
||||
*/
|
||||
@Test
|
||||
fun `a transcode failure offers no way to save`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(TestTags.SAVE_FILE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping start over after a failure resets and does nothing else`() {
|
||||
setContent(ConversionState.Failed(message = "Ran out of space while writing the output."))
|
||||
@@ -335,6 +352,50 @@ class ConverterStateAffordancesTest {
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- Failed, carrying a file
|
||||
|
||||
/**
|
||||
* The defect in #30, stated as what the branch must render.
|
||||
*
|
||||
* `save()` keeps the staged file on a failure deliberately -- it can be the only copy of an
|
||||
* hour of transcoding -- and before this the only control here was "Start over", wired to
|
||||
* `reset()`, which deletes exactly that file. Both buttons, not one: the restart has to stay
|
||||
* reachable, because leaving a full-size file in cache is the outcome it exists to avoid.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed save offers the file again as well as a restart`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* The name, not just that something fired: it comes from the finished job, and a retry wired to
|
||||
* a literal or to the picker's current guess would hand the dialog a name the job never chose.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping try saving again hands back the name the job chose`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.RETRY_SAVE).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("save:holiday.mp4"), fired)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start over from here still resets, and resetting still deletes -- see `reset()`'s KDoc for
|
||||
* why that is acceptable now and was not before. What it must not do is save on the way past.
|
||||
*/
|
||||
@Test
|
||||
fun `tapping start over after a failed save resets and does not save`() {
|
||||
setContent(failedSave())
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.START_OVER).performScrollTo().performClick()
|
||||
|
||||
assertEquals(listOf("reset"), fired)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Harness
|
||||
|
||||
private fun input() = InputFile(
|
||||
@@ -348,6 +409,21 @@ class ConverterStateAffordancesTest {
|
||||
* missing file rather than throwing, so the size line reads `0 B` and no temporary folder is
|
||||
* needed to render the arm.
|
||||
*/
|
||||
/**
|
||||
* A `Failed` an earlier save left carrying its file, which is the only way [retry] is non-null.
|
||||
*
|
||||
* The same missing `staged` path as [converted], and for the same reason: this arm renders no
|
||||
* size line at all, so nothing here ever touches the filesystem.
|
||||
*/
|
||||
private fun failedSave() = ConversionState.Failed(
|
||||
message = "There was not enough room on the destination.",
|
||||
retry = PendingSave(
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
suggestedName = "holiday.mp4",
|
||||
mimeType = "video/mp4",
|
||||
),
|
||||
)
|
||||
|
||||
private fun converted(routeReason: String = "") = ConversionState.Converted(
|
||||
input = input(),
|
||||
staged = File("no-such-staged-output.mp4"),
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.join.JoinState
|
||||
import org.libremediaconverter.join.JoinViewModel
|
||||
import org.libremediaconverter.join.pendingSave
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A failed save has to leave the file *offerable*, not merely undeleted.
|
||||
*
|
||||
* The defect is #30, and both halves of it were already written down in `main`. `save()`'s
|
||||
* `onFailure` kept the staged file on purpose -- "deleting here would destroy the work to tidy up
|
||||
* a cache directory" -- and then handed the screen a `Failed` carrying a message and nothing else,
|
||||
* so the single control that branch rendered was "Start over", wired to `reset()`, which deletes
|
||||
* exactly that file. The intent and the affordance disagreed, and the affordance won.
|
||||
*
|
||||
* `ConversionViewModelCleanupTest` already pins the *keeping*: after a failed save the file is
|
||||
* still on disk and nothing has been discarded. It stays green with the state carrying nothing,
|
||||
* because it reads the filesystem rather than the state. This file asserts the other half -- that
|
||||
* the handle reaches the state a screen can read -- and the negative that bounds it: a failure
|
||||
* with nothing staged behind it must not sprout a save button.
|
||||
*
|
||||
* Both ViewModels in one class, following `MissingStagedFileTest`. They are separate state
|
||||
* machines that can each hold a staged file at once, but this defect and its fix are the same
|
||||
* shape in both, and splitting them would put the two halves of one invariant in two files.
|
||||
*
|
||||
* ### Not asserted here, so each is a decision rather than an omission
|
||||
*
|
||||
* - **That the destination received the bytes.** [RecordingPublisher.publish] is a stub, which is
|
||||
* the only way to make a save fail deterministically -- and making it fail is what every case
|
||||
* here needs. `OutputPublisherPublishTest` owns what a real publish writes.
|
||||
* - **The screen's two buttons.** `ConverterStateAffordancesTest` and `JoinStateAffordancesTest`
|
||||
* own what each state renders; this file owns what each state carries.
|
||||
* - **`ConverterScreen`'s `destinationMime` line itself.** It lives in the entry point, above the
|
||||
* `ScreenContent` seam, and reaching it needs a real ViewModel inside a composition. What it
|
||||
* reads -- `pendingSave()?.mimeType` -- is asserted directly instead, which is why that
|
||||
* derivation was moved out of the entry point in the first place.
|
||||
* - **Picking a new input while a `Failed` carries a file.** `onInputPicked` overwrites the state
|
||||
* without discarding, from `Converted` exactly as much as from a carrying `Failed`, and neither
|
||||
* branch renders a picker. It is a pre-existing path this change neither opens nor widens: the
|
||||
* carried handle is a view of `pendingStaged`, never a second owner of the file.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FailedSaveRetryTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
// MediaProbe spawns FFprobe, whose loader throws with no native library present.
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
|
||||
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Convert
|
||||
|
||||
/**
|
||||
* The bite named in #30's fix. Emitting a plain `Failed` from `save()`'s `onFailure` -- which
|
||||
* is what `main` did -- reddens this case on the null handle, and nothing else in the suite.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed save leaves the staged file offerable, not merely undeleted`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
val failed = viewModel.state.value as ConversionState.Failed
|
||||
val retry = failed.retry
|
||||
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
|
||||
assertEquals("the retry must name the file the conversion actually produced", staged, retry?.staged)
|
||||
// Both from the job's own output Data rather than from the pickers, so a retry opens the
|
||||
// same dialog the first attempt did.
|
||||
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
|
||||
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
|
||||
assertTrue("a failed save must not destroy the only copy", staged.exists())
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of carrying the handle: the second attempt is a real save, not a new job.
|
||||
*
|
||||
* `publishFailure` is cleared between the two calls, so one `RecordingPublisher` plays both a
|
||||
* full destination and an empty one -- which is exactly the user's situation.
|
||||
*/
|
||||
@Test
|
||||
fun `retrying a failed save publishes the file and leaves nothing staged`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
publisher.publishFailure = null
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val saved = awaitState(viewModel.state, "Saved") { it is ConversionState.Saved }
|
||||
assertEquals(SUGGESTED_NAME, (saved as ConversionState.Saved).displayName)
|
||||
assertFalse("a successful retry should have removed the staged file", staged.exists())
|
||||
// Nothing to collect afterwards: the retry published it, so reset() has no work left.
|
||||
viewModel.reset()
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
/**
|
||||
* The second failure must not eat the file the first one kept.
|
||||
*
|
||||
* A `Failed` built fresh from `e.message` alone would drop the handle here while every other
|
||||
* assertion in this file stayed green -- the file is still on disk, and the first failure
|
||||
* already proved the state can carry it.
|
||||
*/
|
||||
@Test
|
||||
fun `a retry that fails again still carries the file rather than dropping it`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume still full")
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
// Waited for by the *second* message rather than by `is Failed`: the state was already
|
||||
// Failed when the retry started, so the type alone would be satisfied before it ran.
|
||||
val failed = awaitState(viewModel.state, "the second failure") {
|
||||
it is ConversionState.Failed && it.message == "destination volume still full"
|
||||
} as ConversionState.Failed
|
||||
assertEquals("the second failure must offer the same file the first one did", staged, failed.retry?.staged)
|
||||
assertTrue(staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Start over" still deletes, and that is the decision `reset()`'s KDoc records: acceptable
|
||||
* only because "Try saving again" is on screen beside it. Exactly once, through the publisher.
|
||||
*/
|
||||
@Test
|
||||
fun `start over from a failed save discards the carried file exactly once`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
/**
|
||||
* A retry meets the same existence check the first attempt did, so a file collected by the
|
||||
* sweep or by the OS in between is reported as a sentence rather than as a raw ENOENT path.
|
||||
* And the state that reports it carries nothing: there is no file left to offer.
|
||||
*/
|
||||
@Test
|
||||
fun `a retry whose staged file has gone says so and offers nothing further`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
assertTrue("the fixture must start with a real staged file", staged.delete())
|
||||
|
||||
publisher.publishFailure = null
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val failed = viewModel.state.value as ConversionState.Failed
|
||||
assertEquals(STAGED_FILE_GONE_MESSAGE, failed.message)
|
||||
assertNull("a file that has gone cannot be offered again", failed.retry)
|
||||
}
|
||||
|
||||
/**
|
||||
* The negative that bounds the whole change, and the reason `retry` is nullable.
|
||||
*
|
||||
* A transcode that died staged nothing, so there is no file to hand back -- and a `Failed`
|
||||
* that carried one anyway would put a save button on a screen with nothing to save. Driven
|
||||
* through a worker that really fails rather than by constructing the state, because the line
|
||||
* under test is the `WorkInfo.State.FAILED` arm of `observe`.
|
||||
*/
|
||||
@Test
|
||||
fun `a transcode failure carries nothing to save`() {
|
||||
installFailingTestWorkManager(app, workDataOf(ConversionWorker.KEY_ERROR to "The encoder gave up."))
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mp4"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
|
||||
viewModel.convert()
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed } as ConversionState.Failed
|
||||
assertEquals("The encoder gave up.", failed.message)
|
||||
assertNull("a transcode failure has nothing staged, so it must offer no save", failed.retry)
|
||||
assertNull("and nothing for the save dialog to open with either", failed.pendingSave())
|
||||
}
|
||||
|
||||
/**
|
||||
* What the save dialog reopens with, which is the entry point's only reader of this state.
|
||||
*
|
||||
* The pickers are moved *after* the job finishes, which is what makes this bite: a retry that
|
||||
* asked the current settings would offer `audio/mpeg` for a file the job wrote as MP4. The
|
||||
* same gap is permanent for a reattached job, whose spec was never in these settings at all.
|
||||
*/
|
||||
@Test
|
||||
fun `a retry offers the type the job chose, not the one the pickers now show`() {
|
||||
val viewModel = failedSaveViewModel()
|
||||
|
||||
viewModel.setPreset(OutputFormat.MP3)
|
||||
|
||||
assertEquals(
|
||||
"the fixture needs the pickers to disagree with the job",
|
||||
"audio/mpeg",
|
||||
viewModel.settings.value.spec.mimeType,
|
||||
)
|
||||
assertEquals(JOB_MIME_TYPE, viewModel.state.value.pendingSave()?.mimeType)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- Join
|
||||
|
||||
@Test
|
||||
fun `a failed join save leaves the staged file offerable, not merely undeleted`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
val failed = viewModel.state.value as JoinState.Failed
|
||||
val retry = failed.retry
|
||||
assertNotNull("a failed save must leave the staged file offerable, not just on disk", retry)
|
||||
assertEquals(staged, retry?.staged)
|
||||
assertEquals(SUGGESTED_NAME, retry?.suggestedName)
|
||||
assertEquals(JOB_MIME_TYPE, retry?.mimeType)
|
||||
assertTrue(staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrying a failed join save publishes the file and leaves nothing staged`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
publisher.publishFailure = null
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val saved = awaitState(viewModel.state, "Saved") { it is JoinState.Saved }
|
||||
assertEquals(SUGGESTED_NAME, (saved as JoinState.Saved).displayName)
|
||||
assertFalse(staged.exists())
|
||||
viewModel.reset()
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join retry that fails again still carries the file rather than dropping it`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume still full")
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
// By the second message, not by `is Failed` -- see the converter case above.
|
||||
val failed = awaitState(viewModel.state, "the second failure") {
|
||||
it is JoinState.Failed && it.message == "destination volume still full"
|
||||
} as JoinState.Failed
|
||||
assertEquals(staged, failed.retry?.staged)
|
||||
assertTrue(staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start over from a failed join save discards the carried file exactly once`() {
|
||||
val viewModel = failedJoinSaveViewModel()
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(JoinState.Idle, viewModel.state.value)
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join failure carries nothing to save`() {
|
||||
installFailingTestWorkManager(app, workDataOf(ConcatWorker.KEY_ERROR to "The files could not be joined."))
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
|
||||
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
|
||||
viewModel.join()
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed } as JoinState.Failed
|
||||
assertEquals("The files could not be joined.", failed.message)
|
||||
assertNull("a join failure has nothing staged, so it must offer no save", failed.retry)
|
||||
assertNull(failed.pendingSave())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Harness
|
||||
|
||||
/**
|
||||
* A ViewModel driven to `Converted` and then through a save that threw.
|
||||
*
|
||||
* The WorkManager is installed here rather than in `@Before`, because two cases in this class
|
||||
* need one whose workers fail instead.
|
||||
*/
|
||||
private fun failedSaveViewModel(): ConversionViewModel {
|
||||
installTestWorkManager(app, conversionOutput())
|
||||
// Unconfined so reset()'s delete runs inline instead of on a real IO thread.
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
viewModel.convert()
|
||||
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume full")
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Failed") { it is ConversionState.Failed }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
/** The join tab's equivalent, driven to `Joined` and then through a save that threw. */
|
||||
private fun failedJoinSaveViewModel(): JoinViewModel {
|
||||
installTestWorkManager(app, joinOutput())
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
|
||||
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
viewModel.join()
|
||||
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
|
||||
|
||||
publisher.publishFailure = IllegalStateException("destination volume full")
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Failed") { it is JoinState.Failed }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
/**
|
||||
* The output `Data` a finished conversion reports.
|
||||
*
|
||||
* The name and type are set rather than left out, so the assertions above are about what the
|
||||
* *job* chose. Both ViewModels fall back to a derivation when they are missing, and a fixture
|
||||
* that omitted them would be asserting the fallback while looking like it asserted the job.
|
||||
*
|
||||
* Spelled out per worker rather than shared with [joinOutput], even though the two constants
|
||||
* hold the same strings today. A test that leaned on that would be asserting a coincidence.
|
||||
*/
|
||||
private fun conversionOutput() = workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConversionWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
/** The output `Data` a finished join reports. See [conversionOutput]. */
|
||||
private fun joinOutput() = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConcatWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConcatWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
|
||||
|
||||
const val SUGGESTED_NAME = "holiday.mp4"
|
||||
|
||||
/** What the job wrote. [OutputFormat.MP3]'s `audio/mpeg` is what the pickers move to. */
|
||||
const val JOB_MIME_TYPE = "video/mp4"
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -20,6 +20,21 @@ class ContainerCapabilitiesTest {
|
||||
container = Container.MP4,
|
||||
)
|
||||
|
||||
/**
|
||||
* An MP3, and the reason several rules below need a second probe.
|
||||
*
|
||||
* `hasVideo = false` is the load-bearing field. Every rule that reads only the spec answers the
|
||||
* same for this input as for a video file, which is exactly how a spec naming a video codec was
|
||||
* called valid for a file with no video track to put in it.
|
||||
*/
|
||||
private val mp3Source = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
container = Container.MP3,
|
||||
)
|
||||
|
||||
// --- copy and encode are different questions ----------------------------
|
||||
|
||||
/**
|
||||
@@ -85,17 +100,27 @@ class ContainerCapabilitiesTest {
|
||||
/** A suggestion that is itself invalid is worse than no suggestion. */
|
||||
@Test
|
||||
fun `every suggestion is itself valid`() {
|
||||
val broken = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC)
|
||||
val result = ContainerCapabilities.validate(broken, h264Source)
|
||||
val cases = listOf(
|
||||
OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC) to h264Source,
|
||||
// The audio-only input. Every rejection it can reach used to hand back `None + None`
|
||||
// — a spec validation refuses in the next breath — because these branches built their
|
||||
// suggestion by hand instead of going through the repair-and-filter path.
|
||||
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE) to mp3Source,
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE) to mp3Source,
|
||||
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE) to mp3Source,
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.AAC) to mp3Source,
|
||||
)
|
||||
|
||||
val invalid = result as? Validation.Invalid
|
||||
?: throw AssertionError("expected H.264 in WebM to be rejected")
|
||||
assertTrue("no alternatives offered", invalid.suggestions.isNotEmpty())
|
||||
invalid.suggestions.forEach { suggestion ->
|
||||
assertTrue(
|
||||
"suggested $suggestion is itself invalid",
|
||||
ContainerCapabilities.validate(suggestion, h264Source).isValid,
|
||||
)
|
||||
cases.forEach { (spec, probe) ->
|
||||
val invalid = ContainerCapabilities.validate(spec, probe) as? Validation.Invalid
|
||||
?: throw AssertionError("expected $spec to be rejected")
|
||||
assertTrue("no alternatives offered for $spec", invalid.suggestions.isNotEmpty())
|
||||
invalid.suggestions.forEach { suggestion ->
|
||||
assertTrue(
|
||||
"suggested $suggestion for $spec is itself invalid",
|
||||
ContainerCapabilities.validate(suggestion, probe).isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +152,117 @@ class ContainerCapabilitiesTest {
|
||||
assertTrue((result as Validation.Invalid).suggestions.isNotEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule, seen only against the probe.
|
||||
*
|
||||
* A video codec named for a file with no video track is dropped, not encoded — so
|
||||
* MP4/H.265/None on an MP3 empties the output exactly as None/None does. Reading the spec
|
||||
* alone answered "valid" because the spec names a video codec, and the job went to Media3,
|
||||
* where `EditedMediaItem.Builder` refuses a composition with both tracks removed by throwing
|
||||
* on Transformer's own HandlerThread.
|
||||
*/
|
||||
@Test
|
||||
fun `a video codec named for a file with no video track and no audio is refused`() {
|
||||
ContainerCapabilities.encodableVideo(Container.MP4).forEach { codec ->
|
||||
val spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE)
|
||||
val result = ContainerCapabilities.validate(spec, mp3Source)
|
||||
|
||||
assertFalse(
|
||||
"MP4/${codec.label}/None on an audio-only input plans to (Drop, Drop) and would " +
|
||||
"produce an empty file; it must be refused. Got $result",
|
||||
result.isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal is only worth having if it leads somewhere.
|
||||
*
|
||||
* The COPY form of this was already refused, but its one hand-built suggestion was
|
||||
* `None + None` — which validation refuses in the next breath, so the Advanced picker offered
|
||||
* a one-tap fix that fixed nothing. Every face of the rule now goes through the shared
|
||||
* suggestion path, so the offer keeps the one track the input actually has.
|
||||
*/
|
||||
@Test
|
||||
fun `refusing an empty output still offers a way to keep the audio`() {
|
||||
listOf(VideoCodec.H265, VideoCodec.H264, VideoCodec.COPY, VideoCodec.NONE).forEach { codec ->
|
||||
val spec = OutputSpec(Container.MP4, codec, AudioCodec.NONE)
|
||||
val invalid = ContainerCapabilities.validate(spec, mp3Source) as? Validation.Invalid
|
||||
?: throw AssertionError("expected MP4/${codec.label}/None to be rejected")
|
||||
|
||||
assertTrue(
|
||||
"a refusal with no way out is a dead end in the Advanced picker",
|
||||
invalid.suggestions.isNotEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
"every suggestion must keep a track, got ${invalid.suggestions}",
|
||||
invalid.suggestions.all { it.audioCodec != AudioCodec.NONE },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A repair must not name a track the input does not have.
|
||||
*
|
||||
* `repairVideo` used to fall through to "the first codec this container can encode" whenever
|
||||
* nothing else fitted, and for an MP3 that produced the non-sequitur `MP4 · H.264 · Copy`.
|
||||
* It validated, so nothing caught it — but [CopyPlanner] drops that video track anyway, which
|
||||
* makes the codec in the offer a fiction.
|
||||
*/
|
||||
@Test
|
||||
fun `a repair for a file with no video track never names a video codec`() {
|
||||
listOf(
|
||||
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE),
|
||||
OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE),
|
||||
).forEach { spec ->
|
||||
val invalid = ContainerCapabilities.validate(spec, mp3Source) as Validation.Invalid
|
||||
invalid.suggestions.forEach {
|
||||
assertEquals(
|
||||
"offering ${it.videoCodec.label} for a file with no video track is a fiction; " +
|
||||
"CopyPlanner drops it. Suggested $it for $spec",
|
||||
VideoCodec.NONE,
|
||||
it.videoCodec,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule stated as the property it is, over the whole matrix.
|
||||
*
|
||||
* A plan of (Drop, Drop) is precisely the composition `EditedMediaItem.Builder` refuses to
|
||||
* build, so no non-image spec that reaches it may be called valid. Sweeping every container ×
|
||||
* codec × codec against both probes is what stops the next container or codec from
|
||||
* reintroducing the gap on an axis nobody thought to write a case for.
|
||||
*
|
||||
* Image outputs are exempt and deliberately so: GIF and PNG frames carry no codecs at all, and
|
||||
* `None + None` is the only spec they accept — but they never reach Media3, because the router
|
||||
* sends every image output to FFmpeg.
|
||||
*/
|
||||
@Test
|
||||
fun `no valid non-image spec plans to remove both tracks`() {
|
||||
val specs = Container.entries
|
||||
.filterNot { it == Container.GIF || it == Container.IMAGE_SEQUENCE }
|
||||
.flatMap { container -> VideoCodec.entries.map { container to it } }
|
||||
.flatMap { (container, video) -> AudioCodec.entries.map { OutputSpec(container, video, it) } }
|
||||
val cases = specs.flatMap { spec -> listOf(h264Source, mp3Source).map { spec to it } }
|
||||
|
||||
val empties = cases.filter { (spec, probe) ->
|
||||
val plan = CopyPlanner.plan(spec, probe)
|
||||
plan.video == VideoPlan.Drop && plan.audio == AudioPlan.Drop
|
||||
}
|
||||
|
||||
assertTrue("the sweep found nothing to check — the filter has gone wrong", empties.isNotEmpty())
|
||||
empties.forEach { (spec, probe) ->
|
||||
assertFalse(
|
||||
"$spec on $probe plans to (Drop, Drop) — an empty file, and the composition " +
|
||||
"Media3 cannot build — so it must not validate",
|
||||
ContainerCapabilities.validate(spec, probe).isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying is offered as the fix when the codec is right but unencodable`() {
|
||||
val av1Source = InputProbe(videoCodec = "av1", audioCodec = "aac", container = Container.MKV)
|
||||
|
||||
@@ -350,6 +350,34 @@ class ConversionRouterTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `Media3Engine` still needs a guard of its own.
|
||||
*
|
||||
* `ContainerCapabilities.validate` now refuses "a video codec with the audio off" for an input
|
||||
* with no video track, so neither the picker nor `ConversionWorker` will start one. Routing is
|
||||
* a separate question and still answers MEDIA3 — nothing about a dropped track makes the job
|
||||
* un-hardware-able — so a request that skips validation, from a direct
|
||||
* `ConversionWorker.request(...)` or a job queued before the settings changed, arrives at the
|
||||
* engine with a plan Media3 cannot build. That has to fail the job, not the process.
|
||||
*/
|
||||
@Test
|
||||
fun `a plan that drops both tracks still routes to media3`() {
|
||||
val audioOnly = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
)
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE)
|
||||
|
||||
val plan = CopyPlanner.plan(spec, audioOnly)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
|
||||
assertEquals(Engine.MEDIA3, route(spec, probe = audioOnly).engine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio-only formats are flagged as such`() {
|
||||
assertEquals(true, OutputFormat.MP3.isAudioOnly)
|
||||
|
||||
@@ -146,6 +146,33 @@ class CopyPlannerTest {
|
||||
assertTrue("copying the only track is still a remux", plan.isPureRemux)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one plan `Media3Engine` cannot be handed.
|
||||
*
|
||||
* `EditedMediaItem.Builder` refuses a composition with both tracks removed —
|
||||
* checkState("Audio and video cannot both be removed") — and this is how an ordinary-looking
|
||||
* spec reaches it: a video codec named for a file that has no video, with the audio switched
|
||||
* off. Neither half is unusual on its own, which is why validation could read the spec, see a
|
||||
* video codec, and call it fine.
|
||||
*/
|
||||
@Test
|
||||
fun `an audio-only source with the audio dropped removes both tracks`() {
|
||||
val audioOnly = InputProbe(
|
||||
videoCodec = null,
|
||||
audioCodec = "mp3",
|
||||
hasVideo = false,
|
||||
container = Container.MP3,
|
||||
kind = InputKind.AUDIO_ONLY,
|
||||
)
|
||||
val plan = CopyPlanner.plan(
|
||||
OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
|
||||
audioOnly,
|
||||
)
|
||||
assertEquals(VideoPlan.Drop, plan.video)
|
||||
assertEquals(AudioPlan.Drop, plan.audio)
|
||||
assertTrue("an empty plan is not a remux", !plan.isPureRemux)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying one track and encoding the other is not a pure remux`() {
|
||||
val plan = CopyPlanner.plan(
|
||||
|
||||
@@ -32,8 +32,12 @@ reached* is not. Re-measured on 2026-08-22, seven runs, one variable at a time:
|
||||
| r06 | `android-37.1` rev 8 | `swangle_indirect` | ANGLE | **yes, 285 s** | 23 |
|
||||
| r07 | `android-37.0` rev 6 | `host` + `-feature -HostComposition` | host | **no**, wedged adb at 208 s | not readable |
|
||||
|
||||
The discriminator is exact across all seven: **a run boots if and only if the emulator log says
|
||||
something other than `gles_mode_selected:host`.**
|
||||
The discriminator is exact across the **six runs that reported**: a run boots if and only if the
|
||||
emulator log says something other than `gles_mode_selected:host`. r07 is excluded on purpose — it
|
||||
wedged adb at 208 s and is recorded below as inconclusive rather than ruled out, and a row this
|
||||
page calls inconclusive cannot also be counted as evidence. Excluding it costs nothing: r07 is a
|
||||
`host` row, so the discriminator predicts it would not boot, and confirming a prediction with the
|
||||
one run whose evidence did not come back would add no information either way.
|
||||
|
||||
One caveat about how independent those rows are, because the table flatters itself. `-gpu
|
||||
angle_indirect` (r05) and `-gpu swangle_indirect` (r03) both logged `gles_mode_selected:swangle`
|
||||
@@ -509,8 +513,9 @@ API 37", not "is that codec broken".
|
||||
|
||||
`.github/workflows/api37-debug.yml` carried "roughly every 20 s" for the kill cycle in its own
|
||||
comments. That number was the watchdog's **sampling** interval, not the cadence, and the two got
|
||||
conflated. Measured across the seven runs above, gaps between successive `hasReadColorBufferDma`
|
||||
aborts run **20 s to 90 s, median 60–70 s — three to five aborts in a four-minute window**.
|
||||
conflated. Measured across the **six runs whose crash buffer could be read** — r07 wedged adb
|
||||
before one could be taken, so it contributes no gaps — successive `hasReadColorBufferDma` aborts
|
||||
run **20 s to 90 s, median 60–70 s — three to five aborts in a four-minute window**.
|
||||
Slower than assumed, and still not slow enough: install, data-directory creation and
|
||||
instrumentation start-up do not fit inside one gap.
|
||||
|
||||
|
||||
@@ -236,10 +236,24 @@ ensure_avd() {
|
||||
else
|
||||
if [ ! -d "$img_dir" ]; then
|
||||
echo " installing $pkg"
|
||||
yes | sdkmanager --install "$pkg" > /dev/null 2>&1 || {
|
||||
# Read sdkmanager's own status, not the pipeline's. `yes` never ends, so the moment
|
||||
# sdkmanager exits and closes the pipe, `yes` dies of SIGPIPE with 141 -- and this
|
||||
# script runs under `pipefail`, which takes the rightmost NON-ZERO status. A package
|
||||
# that installed perfectly therefore reported "FAILED to install".
|
||||
#
|
||||
# Measured rather than reasoned: under `set -o pipefail`, `yes | true` exits 141 on
|
||||
# every run, and `yes | sh -c 'exit 3'` exits 3 -- so the pipeline status cannot tell
|
||||
# a clean install from a broken one, while ${PIPESTATUS[1]} reports 0 and 3.
|
||||
#
|
||||
# The `echo no | avdmanager` below is deliberately NOT changed. One line fits the pipe
|
||||
# buffer, so echo has already exited before the close and there is no signal to
|
||||
# receive; `echo no | true` measured 0 on every run. Only an unbounded producer is
|
||||
# exposed to this.
|
||||
yes | sdkmanager --install "$pkg" > /dev/null 2>&1
|
||||
if [ "${PIPESTATUS[1]}" -ne 0 ]; then
|
||||
echo " FAILED to install $pkg"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
echo " creating AVD $avd from $pkg"
|
||||
echo no | avdmanager create avd -n "$avd" -k "$pkg" -d pixel_6 --force > /dev/null 2>&1 || {
|
||||
|
||||
Reference in New Issue
Block a user