The test covering the runtime fallback read its input from the app's internal
storage, which only ever contained a file because it had been piped in by hand
with run-as. On a fresh checkout it hit assumeTrue and skipped -- silently,
while still counting toward the suite total. A regression test that skips is
worse than no test, because the number reads as coverage.
It now ships its own fixture: three seconds of H.264 High 4:4:4 Predictive,
76 KB. Producing it needed x264, which the host toolchain cannot supply --
Fedora's ffmpeg carries openh264, which is Constrained Baseline only and
cannot even decode 4:4:4 -- so it was generated with ffmpeg-full inside the
existing FFmpeg build container. The command is recorded in the test's own
documentation so the fixture can be regenerated rather than trusted blindly.
Verified by deleting the hand-staged files first and running the suite clean:
the fallback test executes, Media3 fails to decode as expected, and the worker
completes the conversion through FFmpeg. It is no longer among the skips.
The benchmark stays opt-in and is now documented as such. It needs real
long-form media that does not belong in the repository, and its numbers should
not be mistaken for something the suite verifies.
29 instrumented tests on a Pixel 10 Pro XL: 0 failures, 2 skipped, and both
skips are the benchmark by design.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The join flow was implemented but had never run end to end: unit tests covered
the planner and the argument shapes, neither of which can tell you what FFmpeg
actually does with real files.
Three fixtures make the strategy decision testable. clip_a and clip_b match in
codec, resolution and frame rate; clip_c deliberately differs in both
resolution and frame rate. Without a genuinely mismatched input there is no way
to prove the re-encode branch is ever taken.
The tests assert which strategy ran, not merely that output appeared. That
distinction is the whole point here: the concat demuxer does not reliably
reject mismatched inputs, so a naive implementation produces a file whose later
segments are garbled while still exiting successfully. Each test also checks
the output is long enough to contain both inputs, since a truncated join is
exactly what a wrong stream copy looks like.
Also covered: the list file is cleaned up, fewer than two inputs is refused,
the probe distinguishes the clips the planner depends on, and the chosen
strategy reaches the UI through WorkManager -- it is what tells the user
whether their files were copied losslessly or re-encoded.
Measured on an API 37 emulator, the two paths differ by roughly thirty times
on the same pair of clips: 0.026s to stream copy against 0.829s to re-encode.
That gap is itself evidence the planner is not quietly re-encoding everything.
25 instrumented tests now pass, up from 17. 65 unit tests unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First working conversion: SAF input -> hardware transcode -> staged cache
file -> SAF export, driven from a Compose screen.
Media3 Transformer is the engine for this path rather than FFmpeg. It is
Apache-2.0, needs no native build, consumes content:// URIs directly, and
runs MediaCodec decode -> GL surface -> MediaCodec encode without frames
round-tripping through the CPU. FFmpeg remains necessary for the long tail
(MP3, GIF, MKV, exotic containers) but is not the right tool here.
Two hazards are designed against rather than discovered later:
Transformer must be driven from a single thread that has a Looper, and
start()/cancel() throw IllegalStateException from anywhere else. The Looper
it binds to is whichever the Builder saw, silently falling back to the main
one. A WorkManager Worker runs on a Looper-less executor thread, so the
naive arrangement builds against the main Looper and then throws on start.
Media3Engine owns a dedicated HandlerThread, passes its Looper explicitly,
and marshals every call onto it, so callers get a plain suspending function
and cannot reintroduce the bug. Media3EngineTest covers this directly by
driving a conversion from a Looper-less thread.
Output never goes through a SAF file descriptor. MP4 faststart rewrites the
moov atom at the end and needs to seek backwards, which a SAF fd does not
reliably support. OutputPublisher stages to app-private cache, a real POSIX
path, and copies out afterwards. That costs transient double disk usage, so
it checks free space before starting.
Input uses ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is
images and video only, offers no audio at all, and does not reliably
surface .mkv/.flac/.webm. SAF needs no runtime permission.
Tests run on an API 37 emulator and assert the output codec by reading the
muxed file with MediaExtractor, so a silent fallback to H.264 fails rather
than passing. Progress reporting is deliberately not asserted as non-empty:
a 3 s fixture can finish inside one 250 ms poll tick, which would be an
intermittent failure rather than a real defect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>