JMR-devandClaude Opus 5 cfd705af05 Delete staged output the user never saved, instead of waiting for the OS
Every conversion writes a full-size file into <cacheDir>/conversions/. save()
published it and deleted it, but reset() -- what the "Start over" button on the
Converted and Joined states calls -- dropped the File reference and left the file
behind. Converting something and deciding not to save it is an ordinary path
through the UI, so it leaked a full-size copy every time. cacheDir is evictable,
so this was never unbounded growth; it was the app relying on the OS to clean up
after it, and on a device under no storage pressure "eventually" means never.

OutputPublisher.clearStaging() was written for exactly this and called from
nowhere. It is NOT wired up here -- it is deleted. It emptied the directory
unconditionally, and the convert tab, the join tab and ConcatEngine's
concat_list.txt all share that directory with no per-job namespacing (D8), so a
blanket delete could take a file out from under a running job. Two narrower
methods replace it:

  discardStaged(file)  one file, guarded. The handle reaches the ViewModel as a
                       path string in WorkInfo.outputData and becomes a File with
                       nothing checking where it points, so this compares the
                       CANONICAL parent against the staging dir -- the naive
                       string comparison accepts conversions/../elsewhere.

  sweepStaging(now)    age-based, for orphans no ViewModel is left to clean up.

The cleanup handle is a ViewModel field, not something read back out of the state
machine, because the state machine cannot answer it on the path that needs it
most: a failed save lands on Failed(message), which carries no file reference at
all. On that path the file is deliberately kept -- it may be the only copy of an
hour of transcoding and the destination did not receive it, so deleting to tidy a
cache directory would destroy the work. It stays collectable by a later reset()
or by the sweep.

The sweep runs once per process from a new Application subclass, off the main
thread. The reason it cannot race a live job is the grace period, not ordering:
WorkManager initialises through androidx.startup's InitializationProvider, a
ContentProvider, so it is already up before onCreate() and can be resuming a
worker in this same process while the sweep runs. StagingSweep only collects a
file nothing has written to for 24 hours. Outputs are written continuously and
keep their own mtime fresh; concat_list.txt is the one file written once and then
only read, and a WorkManager attempt is capped by the six-hour foreground-service
budget with retries restarting doWork() from the top, so no attempt can hold a
file still for a day. sweepStaging() also re-reads each timestamp immediately
before deleting, closing the window between listing the directory and acting on
the list -- unlinking an inode a running job still holds open would end with the
job reporting success for a path that no longer exists.

The rule itself is a pure function over (name, lastModifiedMs) pairs and a clock.
Timestamps are values rather than Files so the tests measure the arithmetic --
the grace boundary, and a clock that moved backwards -- rather than the
filesystem's mtime granularity.

Tests cover the tool AND the wiring, because the wiring is where the defect was.
A pure rule test and a Robolectric test of OutputPublisher both stay green when
the discardStaged call is deleted from reset(), which would have made the number
read as coverage of a bug that was still there. So both ViewModels are driven --
through a real WorkManager, to Converted/Joined -- and then asserted on the
filesystem: Start over deletes the staged file; a successful save leaves nothing
to delete twice; a failed save keeps the file and a later reset collects it, which
pins the argued decision above rather than leaving it as a comment. Verified by
deleting the discardStaged line from both reset() methods: 4 of the 6 fail with
"reset() should have discarded exactly the staged file expected:<[...]> but
was:<[]>", and the two save-path tests correctly stay green.

Three things made that reachable, all reusing what was already here:

  - Both ViewModels now resolve their publisher through ConversionDependencies,
    like the workers already did. They were the only place bypassing the seam.
  - MediaProbe joins that seam too. It spawns FFprobe, and FFmpegKit's loader
    throws a bare java.lang.Error when the native library is absent -- which its
    own `catch (e: Exception)` cannot catch, so every JVM test died on the file
    pick. Instrumented tests are unaffected and still get the real probe.

    That error path is a LATENT PRODUCTION HAZARD, recorded in the KDoc and
    deliberately not fixed here: onInputPicked does not catch it either, so a
    missing .so would surface as an uncaught error rather than the "could not read
    this file" the code was written to give. It cannot fire on a device that ships
    the libraries, so widening MediaProbe's catch to Throwable would change the
    pick path on the strength of a condition no user meets. Its own commit.
  - reset()'s cleanup dispatcher is a constructor parameter defaulting to
    Dispatchers.IO, which makes the delete assertable and states the ordering --
    Idle is published synchronously, the delete is dispatched -- as a decision
    rather than an accident. @JvmOverloads keeps the single-argument constructor
    that viewModel()'s AndroidViewModelFactory looks up reflectively.

androidx-work-testing was already in the catalog and already inside the prerelease
guard via its androidx. group, so it needed no new pinning argument.

Robolectric is added for the one assertion no pure function can make: that the
file is really gone from a real cacheDir. It is PINNED at 4.16.1 and belongs with
ktlint/detekt/jacoco rather than the floating libraries. The prerelease guard in
app/build.gradle.kts only covers androidx., junit and com.arthenica, so
org.robolectric is unguarded and a "4.+" would resolve to 4.17-beta-3; beyond
that, a bump changes which android-all jar the tests execute against, which is the
same "a tool moved under a diff that cannot explain it" failure the linters are
pinned for.

Two things Robolectric needed. testOptions did not exist in this module at all;
it now sets isIncludeAndroidResources so the merged manifest and resource table
reach the JVM tests, and grants --enable-native-access, which Java 25 otherwise
warns about four times per run when Robolectric's native runtime calls
System.load(). And robolectric.properties pins sdk=36: Robolectric defaults to the
manifest's targetSdk of 37, there is no android-all jar for 37, and the class
fails to initialise before any test body runs. 36 is where CI's emulator matrix
already stops, so this does not widen the gap -- API 37 was already a manual check
on the Pixel 10 Pro XL before each release.

Known and left alone: a conversion that fails inside the worker never reaches
Converted, so pendingStaged is never set and any partial output relies on the
sweep alone. reset()'s delete is also fire-and-forget on viewModelScope, so it is
cancelled if the Activity finishes first. The sweep is the backstop for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:40:29 -05:00
…

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:

  1. Fixed-function video codec silicon — reached through MediaCodec. This is what "hardware accelerated" means for encode and decode. It is not the GPU.
  2. GPU shader cores — genuinely used, but only for filters, scaling, and color effects on already-decoded frames, via OpenGL ES. Never for entropy coding.
  3. 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.

S
Description
On-device FOSS media converter for mobile. Android first, iOS aspirational. Android flavor uses Media3 and FFMPEG.
Readme MIT
70 MiB
Languages
Kotlin 91.1%
Shell 7.4%
Java 1.4%
Dockerfile 0.1%