Adds the second engine and the rules that choose between them, turning a
video transcoder into a converter.
The router encodes capability boundaries, not preferences. Media3 handles
what it genuinely can and FFmpeg takes the rest:
MKV, and any container Media3 cannot mux
MP3, which Android cannot encode at any API level -- a platform gap
rather than a Media3 limitation
GIF and PNG frame sequences, which have no Media3 muxer
VP9 and AV1 targets: Transformer.setVideoMimeType accepts only
H.263/H.264/H.265/MP4V, so the WebM muxer has no encoder behind it
inputs with no platform decoder, since Transformer ignores ExoPlayer's
bundled software decoders and the dav1d extension does not rescue it
the Best quality tier, because CRF and two-pass come from x264/x265 and
no Android hardware encoder exposes either
Rule order matters and is deliberate: specific reasons are checked before
general ones because the reason is shown to the user. "Android has no
encoder for this format" is actionable for MP3; "this container needs
FFmpeg" is not. A test caught the original ordering getting this backwards.
Hardware support is vendor-declared and, per the platform's own docs,
"cannot be tested for correctness", so the static rules are backed by a
dynamic fallback: a Media3 export that fails is retried on FFmpeg rather
than surfaced as a failed conversion.
Joining files chooses between a stream copy and a re-encode by inspecting
the inputs. The concat demuxer requires matching codec, resolution and
timebase, and does not reliably fail when they differ -- it can emit a file
whose later segments are garbled. Unknown properties count as a mismatch,
because two nulls are not evidence of agreement.
The UI surfaces the routing decision rather than hiding it, so a slow job
explains itself, and offers a per-job engine override.
Navigation is adaptive: a bottom bar on phones, a side rail on wider
screens. Not cosmetic -- from targetSdk 37 Android ignores screenOrientation
and resizableActivity on displays at least 600dp wide, with no opt-out, so
the app is resized whether or not it is ready.
62 unit tests cover the routing matrix, the FFmpeg argument builder and the
concat planner on the JVM, against fabricated device profiles so branches
like "this device cannot encode HEVC" are reachable without that hardware.
Device-level tests for the FFmpeg formats are written but not yet run; the
emulator in this environment will not stay up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generated scaffold was a Views-based Material 2 shell with no activity,
no Kotlin sources, and a placeholder package. Replace it with the real base
the conversion work builds on.
Build configuration, with three AGP 9 specifics that contradict most
tutorials still in circulation:
- AGP 9 has built-in Kotlin. Applying org.jetbrains.kotlin.android now fails
the build, so the absence of that plugin is deliberate, not an oversight.
- The Compose compiler plugin is still separate and must be applied, pinned
to 2.2.10 to match the kotlin-gradle-plugin AGP 9.3.1 brings transitively.
Pinning it to the newest Kotlin release instead would mismatch.
- android.kotlinOptions {} was removed; jvm configuration moves to a
top-level kotlin { compilerOptions {} }.
Java compatibility goes 11 -> 17 (AGP 9 requires JDK 17), abiFilters
restrict packaging to arm64-v8a and x86_64, and jniLibs packaging is set
uncompressed so the APK zip-aligns native libraries on 16 KB boundaries.
Every dependency version in the catalog was checked to resolve against
Google Maven rather than copied from documentation. Note that KSP has moved
to standalone versioning (2.3.11) and no longer uses the old
<kotlin>-<ksp> scheme; it is catalogued but left unapplied until Room lands.
Set applicationId to dev.jasonmross.mediaconverter. com.example.* is
rejected by the Play Console, and the application ID is permanent once
published, so it has to be right before the first upload. The display name
is just a string resource and stays changeable.
Document the split license posture: source is MIT, but the distributed
binary will be GPL-3.0 because it bundles FFmpeg built with x264/x265.
LICENSES/README.md records why, including that libass is ISC rather than
GPL, so subtitle burn-in is not what forces the GPL choice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>