AppRoot held the selected tab in `remember`, which survives recomposition and nothing
else. MainActivity declares no configChanges, so every rotation and every resize
destroys and recreates the Activity, and the tab went back to Convert each time.
The KDoc directly above that line is the argument for why it matters: from targetSdk 37
Android ignores screenOrientation, resizableActivity and the aspect-ratio limits on any
display at least 600dp wide, and the Android 16 opt-out is gone, so the app is resized
and rotated whether or not it is ready. The shell was written for that case and then
lost its own state to it. Both ViewModels are Activity-scoped and come back intact, so
a conversion in flight was never at risk -- only the tab, which is what makes this
visibly wrong rather than merely stale.
rememberSaveable, with a Saver that writes the constant's NAME. Three ways to make an
enum saveable and the reasons for this one:
- autoSaver already accepts it. An enum is Serializable, so plain
`rememberSaveable { mutableStateOf(Destination.CONVERT) }` compiles, works, and
passes the restoration test below unchanged. That is a reason to be explicit, not a
reason not to be: nothing in the declaration says Destination has to stay
Serializable, so the implicit route keeps working right up until someone makes it a
value class or a sealed interface -- and then stops, silently, on a path only a
rotation reaches.
- The ordinal is a position, not an identity. Inserting a tab between Convert and Join
would redefine every value already written down. A name only changes when someone
renames a constant, which is an edit that shows up in a diff. It also reads as
itself in a Bundle dump.
- An unknown name restores to null, which rememberSaveable treats as "nothing saved"
and falls back to Convert. That is exactly what a downgrade or a renamed constant
leaves behind, and Convert is the right answer for it.
The test runs on the JVM, which took two changes to reach.
AppRoot and Destination are `internal` rather than `private` -- the unit test source set
is a friend of main, so this stays invisible outside the module -- and AppRoot takes its
`content` as a defaulted parameter instead of calling Content() directly. Nothing in the
app passes it. It is there because both screens resolve a ViewModel, which builds a
WorkManager and a media probe, and none of that has anything to do with which tab is
selected; the test hands in a tagged Box and drives the shell alone. Content() stays
private and is still what the app gets.
compose-ui-test-junit4 joins the JVM test source set. It was already in the catalog for
androidTest, it is inside the prerelease guard via its androidx. group, and its version
comes from the BOM, so this adds no new pinning argument. It is there because
createComposeRule() runs under Robolectric: a red test in androidTest is one nobody on
this host can execute (CLAUDE.md), which is not a loop anyone can work in.
ui-test-manifest is NOT repeated on that source set. It supplies the ComponentActivity
the rule launches, and the existing debugImplementation entry already puts it in the
merged manifest the unit tests build against -- checked by removing the line and
watching AppRootRestorationTest stay green, rather than assumed.
What the test does and does not prove. StateRestorationTester's
emulateSavedInstanceStateRestore() disposes the composition and rebuilds it, so anything
held only by `remember` is gone -- that is what makes it bite. It saves into an in-memory
map rather than parcelling through a Bundle, so it cannot tell a name from an ordinal
from autoSaver. The saved representation is pinned separately by three pure-JVM tests
over the Saver itself, which is where the choice above is actually held down.
Verified by writing it red first, against the restructured AppRoot with `remember` still
in place: all three restoration tests failed at the post-restore assertion with
"Expected exactly '1' node but could not find any node that satisfies:
(TestTag = 'content:JOIN')", while every assertion before the restore -- including the
bar's own selected state -- passed. Six new tests, 186 green in all.
Not addressed here, and deliberately: MainActivity still declares no configChanges, and
should not. Handling the configuration change is not the same as keeping one enum, and
Compose's saved-state machinery is the mechanism the platform intends for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LibreMediaConverter
A free and open-source media converter for Android — batch video transcoding and compression, audio extraction and conversion, GIF and frame export, and file merging.
Android 13+ (API 33). Built with Jetpack Compose and Material 3.
Status: working, unreleased. Both conversion engines, the router, the background job queue and the join flow are implemented and building. The FFmpeg format tests have been written but not yet executed on a device.
Licensing at a glance
- Source code: MIT
- Distributed APK: GPL-3.0 — because it bundles FFmpeg built with x264/x265
That split is deliberate, not an oversight. See LICENSES/README.md
for the reasoning and the corresponding-source obligations.
Architecture
Two conversion engines behind an explicit router, because neither one covers the job alone.
AndroidX Media3 Transformer — the hardware path
Handles the common cases: H.264/HEVC, resolution and frame-rate changes, rotation, overlays, and audio to AAC. Fully hardware accelerated end to end — MediaCodec decodes to a GL surface and MediaCodec re-encodes, so frames never round-trip through the CPU. Roughly 7–8× realtime on 720p.
It writes MP4 and nothing else. media3-muxer ships WebM, Ogg, WAV and AAC muxers too,
but none can be driven by Transformer — they throw from addMetadataEntry, which the muxer
wrapper calls for every metadata entry a real recording carries. It reads far more than it
writes, Matroska included, which is what makes MKV → MP4 a hardware remux.
FFmpeg — the long tail
Everything Media3 structurally cannot do:
- Containers outside MP4/WebM/Ogg/WAV/AAC — MKV, MOV, AVI, FLV, MPEG-TS, WMV/ASF
- MP3 output — Android has no MP3 encoder at any version; this is a platform gap
- GIF and image sequences
- Input codecs with no platform decoder on the device
- CRF and 2-pass rate control, for the quality tier
- Codecs Media3's muxers decline even on a stream copy — its MP4 muxer carries AAC, Opus, Vorbis and PCM, but neither MP3 nor FLAC
Quality tiers
The router is surfaced to users as a quality choice rather than hidden:
| Tier | Engine | Rate control | Trade-off |
|---|---|---|---|
| Fast (default) | Media3 / MediaCodec | bitrate-targeted | ~7–8× realtime, low battery cost |
| Best quality | FFmpeg + x264/x265 | CRF or 2-pass | ~realtime or slower, better quality per byte |
A note on "GPU acceleration"
Android has no GPU video codec path. There are three distinct tiers, and conflating them causes a lot of confusion:
- Fixed-function video codec silicon — reached through
MediaCodec. This is what "hardware accelerated" means for encode and decode. It is not the GPU. - GPU shader cores — genuinely used, but only for filters, scaling, and color effects on already-decoded frames, via OpenGL ES. Never for entropy coding.
- CPU — x264, x265, and software decoders.
FFmpeg's -hwaccel is meaningful on Android only as mediacodec, and even then it
targets direct-to-Surface playback rather than file-to-file transcoding. Vulkan Video
exists in FFmpeg 8.0+ but no shipping Android GPU driver exposes it — no VK_KHR_video_*
extension appears in any Android Vulkan Profile tier.
So this app is hardware accelerated via MediaCodec, and GPU accelerated for effects via GL shaders. Both are real; neither is "the GPU decoding video."
Remuxing
Changing the container without touching the streams. Copying an H.264 track from MKV into MP4 moves the same samples into a different wrapper: it finishes in seconds instead of minutes, costs no quality, and needs no encoder — which is why it stays on the hardware path even on a device that cannot encode the codec in question.
Copy is a codec choice like any other, so it can be mixed: copy the video and re-encode
only the audio, or the reverse. Picking a codec the source already uses is upgraded to a
copy automatically when the container is changing — if container and codec both already
match, the only reason to run the job is to re-encode it, so it does.
A copy is never attempted on a stream whose codec could not be identified. A needless re-encode costs time; a wrong stream copy costs a file that will not play.
Features
| Formats | |
|---|---|
| Video out | MP4, MOV, MKV, WebM, MPEG-TS, AVI, FLV, WMV/ASF |
| Video codecs | H.264, H.265, VP9, or copy the source stream |
| Audio out | MP3, AAC/M4A, FLAC, Opus, WAV, MKA |
| Audio codecs | AAC, Opus, MP3, FLAC, PCM, or copy the source stream |
| Images | GIF, PNG frame sequences |
| Other | Remux without re-encoding; join several files into one |
Presets cover the common combinations in one tap. The Advanced picker exposes the full container × codec matrix — including combinations that cannot work, which it explains and offers alternatives for rather than hiding.
Conversions run as durable background work, so they survive leaving the app and are restored after a restart.
Building
Requires JDK 17+ (AGP 9 will not run on older) and the Android SDK with API 37.
FFmpeg is committed as a prebuilt archive under bin/, so a clone
builds without a cross-compile. That is deliberate: rebuilding it per CI run made test
results ambiguous, because a red build could mean broken code or a build that hiccuped.
See bin/README.md for its provenance and how to regenerate it.
./gradlew :app:assembleDebug # debug APK
./gradlew :app:testDebugUnitTest # JVM tests
./gradlew :app:connectedDebugAndroidTest # device tests, needs a running device
./gradlew :app:assembleRelease # R8-minified release
See tools/ffmpeg/README.md for why the build is
containerised and which flags matter. That recipe remains the authority — the committed
archive is its output, and is also what satisfies the GPL corresponding-source
obligation.
Testing
Unit tests cover the parts that decide correctness without needing hardware: the routing matrix, the container × codec capability matrix, the FFmpeg argument builder, and both stream-copy-versus-re-encode planners. They run against fabricated device profiles, so branches like "this device cannot encode HEVC" are reachable regardless of what the test machine is.
Instrumented tests cover the parts that only a device can prove: real hardware
transcoding, the foreground service type, and each FFmpeg output format asserted against
the produced file rather than the exit code. The remux tests additionally assert which
engine ran — a stream copy produces an identical file either way, so an output-only
assertion cannot tell a hardware transmux from FFmpeg's -c copy.
Privacy
The app has no INTERNET permission, so it cannot open a network connection at all.
Nothing is uploaded, and there is no analytics or advertising. See PRIVACY.md,
which also explains the permissions WorkManager adds automatically.
Contributing
Contributions are welcome. Note that contributions to the source are under MIT, while
the distributed binary remains GPL-3.0 for the reasons described in LICENSES/README.md.