Media3 + FFmpeg conversion pipeline #1

Merged
JMR-dev merged 26 commits from feat/media3-conversion-pipeline into main 2026-08-22 02:44:12 +00:00
80 changed files with 6425 additions and 121 deletions
+130
View File
@@ -0,0 +1,130 @@
name: Build
# Pull requests are covered by status_check.yml, which runs the unit tests and the
# instrumented suite across API 33-37. This workflow keeps the post-merge and release
# duties and does not duplicate PR validation.
on:
push:
branches: [main]
tags: ['v*']
# Actions are pinned to a commit rather than a tag, with the release in a trailing
# comment. A tag is mutable -- the owner can repoint it at new code -- so a tag
# 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.
env:
GRADLE_CACHE_PATHS: |
~/.gradle/caches
~/.gradle/wrapper
jobs:
test:
name: Unit tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '17' # AGP 9 requires JDK 17
# Gradle runs through the committed wrapper rather than a setup action. The
# wrapper verifies its own distribution against distributionSha256Sum, and
# caching is a handful of lines, so the action earned little here.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.GRADLE_CACHE_PATHS }}
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Unit tests
run: ./gradlew :app:testDebugUnitTest
- name: Upload test report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: unit-test-report
path: app/build/reports/tests/
release:
name: Release
needs: [test]
runs-on: ubuntu-latest
timeout-minutes: 60
if: startsWith(github.ref, 'refs/tags/v')
permissions:
# Needed to create the release. Declared explicitly rather than relying on the
# repository default, so the token's reach is visible here.
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '17'
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.GRADLE_CACHE_PATHS }}
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
# No -PabiFilters here: released artifacts must carry every ABI. That override
# exists only so emulator jobs skip libraries they cannot execute.
- name: Build release artifacts
run: ./gradlew :app:assembleRelease :app:bundleRelease
- name: Verify the released artifacts
run: |
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
# 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.
for abi in arm64-v8a x86_64; do
n=$(unzip -l "$APK" | grep -c "lib/$abi/.*\.so$" || true)
echo " $abi: $n shared libraries"
test "$n" -gt 0 || { echo "::error::release APK is missing $abi"; exit 1; }
done
unzip -q -o "$APK" 'lib/*' -d /tmp/relcheck
bad=0
for f in /tmp/relcheck/lib/*/*.so; do
align=$(readelf -lW "$f" | awk '$1=="LOAD"{print $NF}' | sort -u)
[ "$align" = "0x4000" ] || { echo "::error::$(basename "$f") is $align"; bad=1; }
done
test "$bad" -eq 0 || exit 1
echo " all libraries are 16 KB aligned"
# GPL-3.0 requires that complete corresponding source accompany the binary.
# FFmpeg's guidance says to host it on the same server as the binary; for a Play
# listing that is impossible, so it is attached to the GitHub release next to the
# APK and linked from both the store listing and the in-app About screen.
- name: Assemble corresponding source
run: |
mkdir -p release-source
cp -r tools/ffmpeg release-source/
cp bin/README.md release-source/PREBUILT.md
{
echo "FFmpeg corresponding source for ${GITHUB_REF_NAME}"
echo
echo "Upstream: https://github.com/arthenica/ffmpeg-kit-next"
echo "Tag: v8.1.1 (FFmpeg 8.1.2)"
echo
echo "tools/ffmpeg reproduces the binary shipped in this release."
echo "PREBUILT.md records its provenance, including the SHA-256 and the"
echo "configure line read back out of the shipped libavutil."
} > release-source/README.txt
tar czf ffmpeg-corresponding-source.tar.gz release-source
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: |
app/build/outputs/apk/release/*.apk
app/build/outputs/bundle/release/*.aab
ffmpeg-corresponding-source.tar.gz
LICENSE
LICENSES/README.md
+214
View File
@@ -0,0 +1,214 @@
name: Status check
on:
pull_request:
branches: [main]
# A newer push to the same PR makes the in-flight run obsolete. An emulator matrix is
# expensive, so cancel rather than let runs pile up.
concurrency:
group: status-check-${{ github.ref }}
cancel-in-progress: true
# Third-party actions are pinned to a commit rather than a tag. A tag is mutable: the
# owner can repoint v4 at new code, so a tag reference is an open invitation to run
# whatever that repository contains tomorrow. The trailing comment records which
# release each hash corresponds to, since a bare hash is unreadable.
env:
GRADLE_CACHE_PATHS: |
~/.gradle/caches
~/.gradle/wrapper
jobs:
# ---------------------------------------------------------------------------
# Validates the committed FFmpeg archive. It does not build anything: the whole
# point of checking the binary in is that a red run means broken code rather than
# a cross-compile that hiccuped.
#
# Its own job so a bad archive reports once, clearly, instead of surfacing as five
# confusing emulator failures. It takes seconds, so gating the matrix on it costs
# almost nothing.
# ---------------------------------------------------------------------------
ffmpeg:
name: FFmpeg binary
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Verify the committed archive
run: |
AAR=bin/ffmpeg-kit-next-8.1.1.aar
test -f "$AAR" || { echo "::error::$AAR is missing"; exit 1; }
# A truncated file, or a Git LFS pointer checked out without LFS, would pass
# a file-exists check and then surface much later as a confusing linker
# error. Assert the archive actually carries native libraries for both ABIs.
for abi in arm64-v8a x86_64; do
n=$(unzip -l "$AAR" | grep -c "jni/$abi/.*\.so$" || true)
echo " $abi: $n shared libraries"
test "$n" -gt 0 || { echo "::error::AAR has no $abi libraries"; exit 1; }
done
# 16 KB alignment is a Play requirement and is easy to lose in a rebuild,
# so it is checked here rather than discovered at submission.
unzip -q -o "$AAR" 'jni/*' -d /tmp/aarcheck
bad=0
for f in /tmp/aarcheck/jni/*/*.so; do
align=$(readelf -lW "$f" | awk '$1=="LOAD"{print $NF}' | sort -u)
if [ "$align" != "0x4000" ]; then
echo "::error::$(basename "$f") is $align, not 16 KB aligned"; bad=1
fi
done
test "$bad" -eq 0 || exit 1
echo " all libraries are 16 KB aligned"
# Record what shipped, so a failing run elsewhere can be tied to a version.
echo " sha256: $(sha256sum "$AAR" | cut -d' ' -f1)"
# ---------------------------------------------------------------------------
# JVM tests: the routing matrix, the FFmpeg argument builder, the concat planner
# and the retry rule. No device needed, so this is the fastest signal on a PR.
# ---------------------------------------------------------------------------
unit:
name: Unit tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '17' # AGP 9 will not run on anything older
# Gradle is invoked through the committed wrapper rather than a setup action.
# The wrapper verifies its own distribution against distributionSha256Sum, and
# caching is a handful of lines, so the action earned little here.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.GRADLE_CACHE_PATHS }}
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Unit tests
run: ./gradlew :app:testDebugUnitTest
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: unit-test-report
path: app/build/reports/tests/
# ---------------------------------------------------------------------------
# One runner per API level, across the whole supported range.
#
# The range is the point: minSdk is 33, and the foreground service type differs
# across it -- none below 34, dataSync at 34, mediaProcessing from 35. Testing a
# single level would leave two thirds of that branch unexercised.
#
# It stops at 36 rather than targetSdk 37 because the android-37.0 emulator image
# is broken, not because 37 does not matter. See docs/api-37-emulator-crash.md.
#
# FFmpeg is not built here. The AAR is committed under bin/, so a red run means the
# code is broken rather than that a cross-compile hiccuped.
# ---------------------------------------------------------------------------
e2e:
name: E2E API ${{ matrix.label }}
runs-on: ubuntu-latest
needs: ffmpeg
timeout-minutes: 60
strategy:
# Report every API level rather than stopping at the first red one. Knowing
# whether a failure is universal or specific to one level is most of the
# diagnosis.
fail-fast: false
matrix:
include:
- label: "33"
api-level: "33"
- label: "34"
api-level: "34"
- label: "35"
api-level: "35"
- label: "36"
api-level: "36"
# No API 37 row. targetSdk is 37, but the android-37.0 emulator image
# crash-loops surfaceflinger inside its own gralloc mapper, so every test
# fails there no matter what this app does. Ruling that in took four CI
# rounds, so the evidence and the ruled-out fixes are written down rather
# than left to be rediscovered: docs/api-37-emulator-crash.md. That file
# also records what to try first when re-adding it -- note that the row
# needs api-level "37.0", since a bare 37 fails during SDK setup.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '17'
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.GRADLE_CACHE_PATHS }}
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
# Without this the emulator falls back to software rendering and takes minutes
# longer to boot, when it boots at all.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Instrumented tests
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: ${{ matrix.api-level }}
target: google_apis
arch: x86_64
profile: pixel_6
# swiftshader_indirect is correct here only because runners have no GPU to
# pass through. On a workstation the same setting routes through
# SwiftShader's JIT, which is a known crash source.
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
# The default userdata partition is not big enough for this APK once the
# FFmpeg libraries are in it. One level failed outright with "Requested
# internal only, but not enough space", and the margin was thin everywhere
# else, so give them all room.
disk-size: 8G
# Pinned because the emulator's own default is not uniform: it raises an
# undersized guest to a floor that varies by API level -- 2048M at 33, 2560M
# at 34 through 36 -- and skips levels it does not recognise entirely. 2560M
# is the highest of those floors, so no level gets less memory than it
# already had, and none of them depend on that heuristic any more.
ram-size: 2560M
# Build only the ABI the emulator can execute. FFmpeg's native libraries
# dominate the APK, so shipping arm64 to an x86_64 emulator doubles the
# install for code that can never run: 114 MB against 80 MB.
#
# The probe lines survive from diagnosing the API 37 crash and are kept
# because a red instrumented run is otherwise near-impossible to read from a
# log alone. The first reports what the guest actually got, so a wrong
# emulator configuration is visible on a green run too; the crash dump runs
# only on failure, so a green run is unchanged.
#
# Each line here is a separate `sh -c` -- the action splits the script on
# newlines -- so the failure handler has to stay on one line. The action does
# not pass ignoreReturnCode, so a non-zero line fails the job outright: the
# probe ends in `|| true` because a grep that matches nothing exits 1, and a
# diagnostic must never be the thing that turns a run red.
script: |
adb shell cat /proc/meminfo | grep -E 'MemTotal|MemAvailable|SwapTotal' || true
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64 || { echo "=== guest memory at failure ==="; adb shell cat /proc/meminfo | grep -E 'MemTotal|MemAvailable|SwapTotal'; echo "=== native crashes ==="; adb logcat -d -b crash | tail -60; exit 1; }
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-report-api${{ matrix.label }}
path: |
app/build/reports/androidTests/
app/build/outputs/androidTest-results/
+4
View File
@@ -13,3 +13,7 @@
.externalNativeBuild
.cxx
local.properties
# The FFmpeg AAR is committed under bin/ so test runs do not depend on a rebuild.
# Build outputs from tools/ffmpeg are not.
tools/ffmpeg/out/
Generated
-1
View File
@@ -1 +0,0 @@
Android(MediaConverter
+1 -1
View File
@@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-08-19T22:25:01.327233679Z">
<DropdownSelection timestamp="2026-08-20T13:53:52.273519182Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/jasonross/.android/avd/Test_Device.avd" />
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jason Ross
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+62
View File
@@ -0,0 +1,62 @@
# Licensing
This project has a **split license**, and the distinction matters.
## Source code — MIT
Everything in this repository that we wrote is MIT licensed. See [`../LICENSE`](../LICENSE).
## Distributed binary (APK / AAB) — GPL-3.0
The shipped application binary is **GPL-3.0**, not MIT.
The app bundles FFmpeg built with `--enable-gpl`, which pulls in **x264** and **x265**
(both GPL-2.0-or-later). Combining those with the application makes the *distributed
binary* a GPL work. MIT → GPL is a compatible direction, so our own source stays MIT;
the combined binary you install is GPL-3.0.
### Why GPL rather than LGPL
Building without `--enable-gpl` would keep the binary LGPL, and most features survive
that: hardware H.264/HEVC encode via MediaCodec, MP3 via libmp3lame, AV1, VP9, Opus,
FLAC, and even subtitle burn-in (libass is ISC licensed, not GPL — a common
misconception).
What GPL specifically buys is **x264/x265 software encode**, which is the only way to
get CRF and 2-pass rate control. Those are the tools that actually deliver
quality-per-bitrate for "compress this video well", and no hardware encoder on Android
exposes either. That capability was judged worth the license.
### Corresponding source
GPL-3.0 requires that complete corresponding source accompany the binary. For each
release we publish, alongside the APK:
- the exact FFmpeg source tarball or commit used,
- the full `configure` line, and
- any patches applied.
FFmpeg's own guidance says to host the source "on the same webserver" as the binary.
That is not literally possible for a Google Play listing, so the source is attached to
the corresponding **GitHub Release next to the APK**, and linked from both the Play
listing and the in-app About screen.
## Third-party components
| Component | License | Notes |
|---|---|---|
| AndroidX / Jetpack Compose | Apache-2.0 | |
| AndroidX Media3 (Transformer, Effect) | Apache-2.0 | primary hardware conversion path |
| FFmpeg | LGPL-2.1+, **GPL-2.0+ as built here** | `--enable-gpl` |
| x264 | GPL-2.0+ | the reason the binary is GPL |
| x265 | GPL-2.0+ | |
| libass | **ISC** | subtitle burn-in; not GPL |
| libmp3lame | LGPL | MP3 encode |
| libvpx, dav1d, SVT-AV1, libopus | BSD-style | |
### Explicitly excluded
- **`--enable-nonfree`** is never used. FFmpeg states it makes the resulting binary
*unredistributable*. This rules out `libfdk_aac` and `decklink`.
- **OpenH264** is not used. Its BSD-2 source license is *not* a patent grant — Cisco's
royalty payment covers only Cisco's own precompiled binary module.
+55
View File
@@ -0,0 +1,55 @@
# Privacy Policy
**LibreMediaConverter does not collect any data.**
That is the whole policy, but here is what it means concretely.
## No data leaves your device
The app has **no network access**. It does not declare the `INTERNET` permission, so it
is not merely a promise not to transmit anything — the operating system will not let it.
Your files are converted on your device and stay there.
## No analytics, advertising or tracking
There is no analytics SDK, no crash reporter, no advertising identifier, and no
third-party service of any kind.
## What the app accesses, and why
| Access | Why |
|---|---|
| Files you explicitly pick | Read as conversion input. The app uses the system file picker and can only see files you choose. It never scans your storage. |
| The destination you choose for output | Write the converted file. Again, only where you point it. |
| Notifications | Show conversion progress so you can leave the app while a long job runs. Optional; conversions work without it. |
The app does not request storage permissions. It uses the Storage Access Framework,
which grants access only to the individual files you select.
## The full permission list
Inspecting the app will show a few permissions that are not in the table above. They are
added automatically by the Jetpack WorkManager library, which runs conversions in the
background. Listing them here rather than leaving you to wonder:
| Permission | Origin | What it does here |
|---|---|---|
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MEDIA_PROCESSING`, `FOREGROUND_SERVICE_DATA_SYNC` | Ours | Keep a conversion running when the app is not in the foreground. Android requires a declared service type for this. |
| `POST_NOTIFICATIONS` | Ours | Show conversion progress. Optional. |
| `WAKE_LOCK` | WorkManager | Stop the device sleeping mid-conversion. |
| `RECEIVE_BOOT_COMPLETED` | WorkManager | Restore an unfinished job queue after a restart. |
| `ACCESS_NETWORK_STATE` | WorkManager | WorkManager can gate jobs on connectivity. **This app does not use that feature**, and the permission only allows reading whether a network exists — it does not permit any network communication. |
Notably absent is `INTERNET`. Without it the operating system will not allow the app to
open a network connection at all, so "your files stay on your device" is enforced by
Android rather than resting on our word.
## Where files are stored
Conversions are written to the app's private cache while they run, then copied to the
location you choose. The temporary copy is deleted afterwards. Uninstalling the app
removes everything in its private storage.
## Contact
Report issues at the project's repository.
+122
View File
@@ -0,0 +1,122 @@
# 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`](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: MP4/MOV in and out, H.264/HEVC, resolution and frame-rate
changes, rotation, overlays, audio to AAC, and stream-copy transmuxing. 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.
### FFmpeg — the long tail
Everything Media3 structurally cannot do:
- Containers outside MP4/WebM/Ogg/WAV/AAC — MKV, AVI, FLV, MPEG-TS
- **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
### 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."
## Features
| | Formats |
|---|---|
| Video out | MP4 (H.264/H.265), MKV (H.264/H.265), WebM (VP9) |
| Audio out | MP3, AAC/M4A, FLAC, Opus, WAV |
| Images | GIF, PNG frame sequences |
| Other | Join several files into one |
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/`](bin/README.md), 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`](bin/README.md) for its provenance and how to regenerate it.
```sh
./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`](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 FFmpeg argument builder, and the stream-copy-versus-re-encode
planner. 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.
## 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](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`.
+93 -10
View File
@@ -1,41 +1,124 @@
plugins {
alias(libs.plugins.android.application)
// Required even under AGP 9: the Compose compiler plugin is NOT built in.
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.androidmediaconverter"
namespace = "org.libremediaconverter"
compileSdk {
version = release(37)
}
defaultConfig {
applicationId = "com.example.androidmediaconverter"
applicationId = "org.libremediaconverter"
minSdk = 33
// AGP 9 defaults targetSdk to compileSdk, so always state it explicitly.
targetSdk = 37
versionCode = 1
versionName = "1.0"
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 64-bit only. The 16 KB page-size rules apply to these ABIs; 32-bit is not
// required by Play and would add ~2 more copies of the FFmpeg .so files.
//
// Overridable so a test run can build for just the ABI it will execute on.
// FFmpeg's native libraries dominate the APK, so shipping both ABIs to an
// x86_64 emulator doubles it for no benefit -- and on API 37, whose system
// image leaves less free userdata, the full APK does not fit at all:
// "Requested internal only, but not enough space".
//
// ./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64
//
// Release builds ignore this and always ship both.
ndk {
val requested = (findProperty("abiFilters") as String?)
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
abiFilters += requested ?: listOf("arm64-v8a", "x86_64")
}
}
buildFeatures {
compose = true
}
buildTypes {
release {
optimization {
enable = false
// R8 full mode. Keep rules for the JNI boundary live in
// src/main/keepRules/rules.keep -- without them the native FFmpeg
// calls break at runtime in release builds only.
enable = true
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
jniLibs {
// Uncompressed .so, so the APK zip-aligns them on 16 KB boundaries.
useLegacyPackaging = false
}
}
}
// Top-level: android.kotlinOptions {} was removed in AGP 9.
// jvmTarget is inherited from compileOptions.targetCompatibility.
kotlin {
compilerOptions {}
}
dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
implementation(libs.material)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Media3 Transformer: the hardware conversion path (MediaCodec decode -> GL surface
// -> MediaCodec encode). Apache-2.0, so no licensing exposure.
implementation(libs.media3.transformer)
implementation(libs.media3.effect)
implementation(libs.media3.common)
implementation(libs.media3.muxer)
// FFmpeg, committed under bin/. Not on any Maven repo: ffmpeg-kit was archived and
// delisted, and ffmpeg-kit-next is source-only by design.
//
// The prebuilt archive is checked in on purpose. Rebuilding it per CI run made test
// results ambiguous -- a red build could mean broken code or a cross-compile that
// hiccuped. See bin/README.md for provenance and how to regenerate it.
implementation(files(rootProject.file("bin/ffmpeg-kit-next-8.1.1.aar")))
// A local .aar carries no transitive dependencies, so the wrapper's own runtime
// dependency has to be declared here explicitly.
implementation(libs.smart.exception.java)
// Durable job queue. WorkManager survives process death, which is what makes the
// queue resumable after the foreground-service timeout fires.
implementation(libs.androidx.work.runtime.ktx)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material3.windowsize)
debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit)
}
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.work.testing)
debugImplementation(libs.compose.ui.test.manifest)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,24 +0,0 @@
package com.example.androidmediaconverter
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.androidmediaconverter", appContext.packageName)
}
}
@@ -0,0 +1,188 @@
package org.libremediaconverter.bench
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import android.util.Log
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.convert.Media3Engine
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.ffmpeg.FFmpegEngine
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.ConversionRouter
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import kotlinx.coroutines.runBlocking
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Measures the two claims that motivate the whole architecture, against real media.
*
* **This is a benchmark, not part of the automated suite.** It needs real, long-form
* media that is not committed to the repository, so it skips unless someone stages
* files deliberately. Do not read a passing run of the test suite as evidence these
* numbers still hold — correctness lives in the tests that ship their own fixtures.
*
* Not a correctness test — the assertions are deliberately loose. These exist to
* produce numbers for two decisions that were otherwise taken on faith:
*
* 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/
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class RealMediaBenchmark {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
/**
* Internal storage, not the external files dir.
*
* Files placed in the external dir by `adb push` or `adb shell cp` stay owned by
* the shell user, and the app then gets EACCES trying to read them — which
* presents as an unparseable input rather than a permission problem. Piping
* through `run-as` writes as the app's own uid, so ownership is unambiguous.
*/
private val samples: File get() = context.filesDir
private fun sample(name: String): File? =
File(samples, name).takeIf { it.exists() && it.length() > 0 }
private fun durationMs(file: File): Long {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(file.absolutePath)
(0 until extractor.trackCount)
.map { extractor.getTrackFormat(it) }
.filter { it.containsKey(MediaFormat.KEY_DURATION) }
.maxOfOrNull { it.getLong(MediaFormat.KEY_DURATION) / 1000 } ?: 0L
} finally {
extractor.release()
}
}
/** Records what this device can actually do, so the numbers below have context. */
@Test
fun reportDeviceEncoderCapabilities() {
val codecs = AndroidDeviceCodecs.get()
val summary = VideoCodec.entries
.filter { it != VideoCodec.NONE }
.joinToString(", ") { "${it.name}=${codecs.canEncode(it)}" }
Log.i(TAG, "BENCH hardware-encoders: ${codecs.hardwareEncoders()}")
Log.i(TAG, "BENCH can-encode: $summary")
}
@Test
fun hardwareVersusSoftwareOnRealVideo(): Unit = runBlocking {
val input = sample(H264_SAMPLE)
assumeTrue("$H264_SAMPLE not present; skipping", input != null)
input!!
val sourceMs = durationMs(input)
val uri = Uri.fromFile(input)
Log.i(TAG, "BENCH source: ${input.name} ${input.length() / 1_000_000}MB ${sourceMs}ms")
// --- hardware, via Media3 -------------------------------------------
// Not every real file survives this path. This source is H.264 High 4:4:4
// Predictive (avc1.F4001F), which neither the hardware decoder nor Android's
// software c2.google.avc.decoder supports, so Media3 cannot read it at all.
// Record that rather than failing: it is exactly why the FFmpeg fallback exists.
val hwOut = File(context.cacheDir, "bench_hw.mp4").apply { delete() }
val engine = Media3Engine(context)
val hwMs = try {
runCatching { timed { engine.transcode(uri, hwOut, MimeTypes.VIDEO_H265) } }
.onFailure { Log.w(TAG, "BENCH hardware: UNSUPPORTED (${it.message})") }
.getOrNull()
} finally {
engine.close()
}
// --- software, via FFmpeg + x264 CRF --------------------------------
val swOut = File(context.cacheDir, "bench_sw.mp4").apply { delete() }
val swMs = timed {
FFmpegEngine().run(
request = ConversionRequest(
format = OutputFormat.MP4_H264,
quality = QualityTier.BEST,
),
inputPath = input.absolutePath,
output = swOut,
durationMs = sourceMs,
)
}
if (hwMs != null) {
Log.i(
TAG,
"BENCH hardware: ${hwMs}ms -> ${hwOut.length() / 1_000_000}MB " +
"(${"%.1f".format(sourceMs.toDouble() / hwMs)}x realtime)",
)
}
Log.i(
TAG,
"BENCH software: ${swMs}ms -> ${swOut.length() / 1_000_000}MB " +
"(${"%.2f".format(sourceMs.toDouble() / swMs)}x realtime)",
)
if (hwMs != null) {
Log.i(TAG, "BENCH speedup: ${"%.1f".format(swMs.toDouble() / hwMs)}x")
}
hwOut.delete()
swOut.delete()
}
/**
* AV1 input is the sharpest routing case: Transformer cannot fall back to a
* software decoder, so a device without hardware AV1 decode must go to FFmpeg.
*/
@Test
fun av1InputRoutesAccordingToDeviceDecodeSupport(): Unit = runBlocking {
val input = sample(AV1_SAMPLE)
assumeTrue("$AV1_SAMPLE not present; skipping", input != null)
input!!
val probe = MediaProbe.probe(context, Uri.fromFile(input))
val decision = ConversionRouter.route(
ConversionRequest(format = OutputFormat.MP4_H265, probe = probe),
AndroidDeviceCodecs.get(),
)
Log.i(TAG, "BENCH av1 probe: codec=${probe.videoCodec} duration=${probe.durationMs}ms")
Log.i(TAG, "BENCH av1 route: ${decision.engine} (${decision.reason})")
val out = File(context.cacheDir, "bench_av1_out.mp4").apply { delete() }
val engine = Media3Engine(context)
val ms = try {
timed { engine.transcode(Uri.fromFile(input), out, MimeTypes.VIDEO_H265) }
} finally {
engine.close()
}
Log.i(
TAG,
"BENCH av1 transcode: ${ms}ms -> ${out.length() / 1_000_000}MB " +
"(${"%.1f".format(probe.durationMs.toDouble() / ms)}x realtime)",
)
out.delete()
}
private inline fun timed(block: () -> Unit): Long {
val start = System.currentTimeMillis()
block()
return System.currentTimeMillis() - start
}
private companion object {
const val TAG = "RealMediaBenchmark"
const val H264_SAMPLE = "bench_h264_720p.mp4"
const val AV1_SAMPLE = "bench_av1_1080p.mp4"
}
}
@@ -0,0 +1,169 @@
package org.libremediaconverter.convert
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
* End-to-end hardware transcode through [Media3Engine].
*
* This is the Phase 1 verification: it proves the MediaCodec pipeline actually runs on
* a device, and — more importantly — that the engine can be driven from a thread with
* no Looper of its own without tripping Transformer's single-thread requirement.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class Media3EngineTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var engine: Media3Engine
private lateinit var input: File
private lateinit var output: File
@Before
fun setUp() {
engine = Media3Engine(context)
input = File(context.cacheDir, "sample_h264.mp4")
InstrumentationRegistry.getInstrumentation().context.assets
.open("sample_h264.mp4")
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
output = File(context.cacheDir, "out_hevc.mp4")
output.delete()
}
@After
fun tearDown() {
engine.close()
input.delete()
output.delete()
}
@Test
fun transcodesH264ToH265AndReportsProgress(): Unit = runBlocking {
val seen = mutableListOf<Int>()
engine.transcode(
input = Uri.fromFile(input),
output = output,
videoMimeType = MimeTypes.VIDEO_H265,
) { percent -> seen += percent }
assertTrue("export produced no file", output.exists())
assertTrue("export produced an empty file", output.length() > 0)
// Assert against the muxed file, not just the reported result: this is what
// actually proves the output is HEVC rather than a silent fallback to H.264.
assertEquals(MimeTypes.VIDEO_H265, videoMimeTypeOf(output))
// Assert against the muxed file rather than the engine's own report: a result
// object can claim success for a file that will not play.
assertTrue("output has no duration", durationMsOf(output) > 0)
// Deliberately NOT asserting that progress fired. Polling is on a 250 ms tick,
// and a 3 s 320x240 clip can finish inside one tick on fast hardware, which
// would make the assertion fail intermittently for no real defect.
seen.forEach { assertTrue("progress out of range: $it", it in 0..100) }
}
/**
* Regression guard for the Transformer threading trap.
*
* Transformer binds to the Looper of the thread that built it, falling back to the
* main Looper when that thread has none — and then throws IllegalStateException
* when start() is called from elsewhere. A WorkManager Worker runs on exactly such
* a Looper-less thread, so this test drives the engine from one to prove the
* HandlerThread indirection holds before any of that lands in Phase 2.
*/
@Test
fun runsFromAThreadWithNoLooper() {
val pool = Executors.newSingleThreadExecutor()
try {
val task = pool.submit<Throwable?> {
check(android.os.Looper.myLooper() == null) {
"precondition failed: this thread should have no Looper"
}
runCatching {
runBlocking { engine.transcode(Uri.fromFile(input), output) }
}.exceptionOrNull()
}
val failure = task.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)
assertTrue(
"transcode from a Looper-less thread failed: $failure",
failure == null,
)
assertTrue(output.exists() && output.length() > 0)
} finally {
pool.shutdownNow()
}
}
/**
* Transformer.start() failing synchronously.
*
* Previously written off as Media3-internal and unreachable. It is not: an output
* path whose parent directory does not exist makes start() fail, and the engine has
* to surface that as a rejected suspension rather than hanging forever waiting for
* a listener callback that will never come. A hang here would be far worse than an
* exception, because the worker would sit holding a foreground service.
*/
@Test
fun anUnwritableOutputPathFailsInsteadOfHanging() {
val impossible = File("/does/not/exist/nested/out.mp4")
val failure = runCatching {
runBlocking {
withTimeout(30_000) {
engine.transcode(Uri.fromFile(input), impossible, MimeTypes.VIDEO_H265) {}
}
}
}.exceptionOrNull()
assertTrue(
"an unwritable output must raise, not hang or silently pass; got $failure",
failure != null && failure !is kotlinx.coroutines.TimeoutCancellationException,
)
}
private fun durationMsOf(file: File): Long {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(file.absolutePath)
(0 until extractor.trackCount)
.map { extractor.getTrackFormat(it) }
.filter { it.containsKey(MediaFormat.KEY_DURATION) }
.maxOfOrNull { it.getLong(MediaFormat.KEY_DURATION) / 1000 } ?: 0L
} finally {
extractor.release()
}
}
private fun videoMimeTypeOf(file: File): String? {
val extractor = MediaExtractor()
try {
extractor.setDataSource(file.absolutePath)
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith("video/")) return mime
}
return null
} finally {
extractor.release()
}
}
private companion object {
const val TIMEOUT_SECONDS = 120L
}
}
@@ -0,0 +1,74 @@
package org.libremediaconverter.fallback
import android.content.Context
import android.net.Uri
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.HardwareTranscoder
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest
import java.io.File
/**
* Test doubles that force the failure paths.
*
* These exist because error handling is otherwise the least-tested code in the app: by
* definition it only runs when something goes wrong, which is exactly what a healthy
* test run avoids. Without a way to inject failure, the branches a user meets on a bad
* day are the ones that were never executed.
*/
object FakeFailures {
class ExplodingHardware(private val message: String = "hardware exploded") : HardwareTranscoder {
var called = false
override suspend fun transcode(
input: Uri,
output: File,
videoMimeType: String,
onProgress: (Int) -> Unit,
) {
called = true
throw IllegalStateException(message)
}
override fun close() = Unit
}
class ExplodingSoftware(private val message: String = "software exploded") : SoftwareTranscoder {
var called = false
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
) {
called = true
throw IllegalStateException(message)
}
}
/** Records that it ran and writes a plausible output, without doing real work. */
class RecordingSoftware : SoftwareTranscoder {
var called = false
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
) {
called = true
output.parentFile?.mkdirs()
output.writeBytes(ByteArray(1024))
onProgress(100)
}
}
class FullDisk(context: Context) : OutputPublisher(context) {
override fun hasSpaceFor(bytes: Long): Boolean = false
}
/** Restores the real implementations. Always call this from @After. */
fun reset() = ConversionDependencies.reset()
}
@@ -0,0 +1,192 @@
package org.libremediaconverter.fallback
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Forces each failure path that real media cannot reliably trigger.
*
* Paired with [HardwareFallbackTest], which drives the same fallback with a genuinely
* undecodable file. This one covers the branches that would otherwise need a full disk,
* a broken codec, or a six-hour foreground-service budget to reach.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class ForcedFailureTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val workManager = WorkManager.getInstance(context)
private lateinit var input: File
@Before
fun setUp() {
input = File(context.cacheDir, SAMPLE)
InstrumentationRegistry.getInstrumentation().context.assets
.open(SAMPLE)
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
}
@After
fun tearDown() {
FakeFailures.reset()
input.delete()
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
private suspend fun runToCompletion(request: androidx.work.OneTimeWorkRequest): WorkInfo? {
workManager.enqueue(request).result.get()
return withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
}
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) =
ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = SAMPLE,
sizeBytes = input.length(),
format = format,
quality = QualityTier.FAST,
)
// --- the dynamic fallback, forced rather than provoked -------------------
@Test
fun hardwareFailureFallsBackToSoftware(): Unit = runBlocking {
val hardware = FakeFailures.ExplodingHardware()
val software = FakeFailures.RecordingSoftware()
ConversionDependencies.hardware = { hardware }
ConversionDependencies.software = { software }
// Pin the device profile. This test is about the fallback mechanism, not about
// routing, and most emulators expose no hardware video encoder at all -- so the
// router would legitimately send the job straight to FFmpeg and the hardware
// path would never be attempted. Without this the test passes on a Pixel and
// fails on every emulator, which says nothing about the code under test.
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
val terminal = runToCompletion(convertRequest(OutputFormat.MP4_H264))
assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
assertTrue("the hardware path should have been attempted", hardware.called)
assertTrue("the software path should have rescued it", software.called)
}
@Test
fun whenBothEnginesFailTheJobFailsWithTheReason(): Unit = runBlocking {
ConversionDependencies.hardware = { FakeFailures.ExplodingHardware() }
ConversionDependencies.software = { FakeFailures.ExplodingSoftware("no codec available") }
val terminal = runToCompletion(convertRequest(OutputFormat.MP4_H264))
assertEquals(WorkInfo.State.FAILED, terminal?.state)
assertEquals(
"the user should see why it failed",
"no codec available",
terminal?.outputData?.getString(ConversionWorker.KEY_ERROR),
)
}
@Test
fun aJobRoutedStraightToFfmpegDoesNotTouchTheHardwarePath(): Unit = runBlocking {
val hardware = FakeFailures.ExplodingHardware()
val software = FakeFailures.RecordingSoftware()
ConversionDependencies.hardware = { hardware }
ConversionDependencies.software = { software }
// MP3 has no Android encoder at all, so the router must bypass Media3 entirely.
val terminal = runToCompletion(convertRequest(OutputFormat.MP3))
assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
assertEquals(
Engine.FFMPEG.name,
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
)
assertTrue(!hardware.called, "the hardware path must not be attempted for MP3")
assertTrue("software should have run", software.called)
}
// --- the free-space precheck -------------------------------------------
@Test
fun aFullDiskFailsBeforeAnyConversionStarts(): Unit = runBlocking {
val hardware = FakeFailures.ExplodingHardware()
ConversionDependencies.publisher = { FakeFailures.FullDisk(it) }
ConversionDependencies.hardware = { hardware }
val terminal = runToCompletion(convertRequest())
assertEquals(WorkInfo.State.FAILED, terminal?.state)
assertTrue(
"the message should mention space, was: " +
terminal?.outputData?.getString(ConversionWorker.KEY_ERROR),
terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)
.orEmpty().contains("space", ignoreCase = true),
)
assertTrue(
"no conversion should be attempted when the disk is full",
!hardware.called,
)
}
@Test
fun aFullDiskFailsAJoinBeforeItStarts(): Unit = runBlocking {
ConversionDependencies.publisher = { FakeFailures.FullDisk(it) }
val request = ConcatWorker.request(
inputs = listOf(Uri.fromFile(input), Uri.fromFile(input)),
totalBytes = input.length() * 2,
)
val terminal = runToCompletion(request)
assertEquals(WorkInfo.State.FAILED, terminal?.state)
assertTrue(
terminal?.outputData?.getString(ConcatWorker.KEY_ERROR)
.orEmpty().contains("space", ignoreCase = true),
)
}
// --- malformed input ----------------------------------------------------
@Test
fun aMissingInputFailsRatherThanCrashing(): Unit = runBlocking {
val request = ConversionWorker.request(
inputUri = Uri.fromFile(File(context.cacheDir, "does_not_exist.mp4")),
displayName = "does_not_exist.mp4",
sizeBytes = 1,
)
val terminal = runToCompletion(request)
assertEquals(WorkInfo.State.FAILED, terminal?.state)
}
private fun assertTrue(message: String, condition: Boolean) =
org.junit.Assert.assertTrue(message, condition)
private fun assertTrue(condition: Boolean, message: String) =
org.junit.Assert.assertTrue(message, condition)
private companion object {
const val SAMPLE = "sample_h264.mp4"
const val TIMEOUT_MS = 300_000L
}
}
@@ -0,0 +1,100 @@
package org.libremediaconverter.fallback
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* The runtime fallback, driven by a file Media3 genuinely cannot decode.
*
* The fixture is H.264 **High 4:4:4 Predictive**. Hardware AVC decoders implement High
* 4:2:0, and Android's software `c2.google.avc.decoder` does not cover 4:4:4 either, so
* Media3 fails partway through the export on every device.
*
* The static routing rules cannot predict that: the container is MP4, the codec reports
* as "h264", and the device advertises AVC decode and encode. Everything looks viable
* until the codec is configured. This is the one case that proves the runtime fallback
* works, so it deliberately uses a **committed fixture** rather than media staged by
* hand — a regression test that silently skips is worse than no test, because the count
* still reads as coverage.
*
* The fixture was produced with x264, which the host toolchain cannot do (Fedora's
* ffmpeg ships openh264, which is Constrained Baseline only):
*
* ffmpeg -f lavfi -i testsrc=duration=3:size=320x240:rate=15 \
* -f lavfi -i sine=frequency=440:duration=3 \
* -c:v libx264 -profile:v high444 -pix_fmt yuv444p -preset ultrafast \
* -c:a aac -shortest sample_h264_444.mp4
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class HardwareFallbackTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val workManager = WorkManager.getInstance(context)
private lateinit var input: File
@Before
fun setUp() {
input = File(context.cacheDir, SAMPLE)
InstrumentationRegistry.getInstrumentation().context.assets
.open(SAMPLE)
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
}
@After
fun tearDown() {
input.delete()
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
@Test
fun aFileMedia3CannotDecodeStillConvertsViaFfmpeg(): Unit = runBlocking {
val request = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = SAMPLE,
sizeBytes = input.length(),
format = OutputFormat.MP4_H265,
// Fast deliberately: this is the tier the router sends to Media3, so it is
// the tier where the fallback has to rescue the conversion.
quality = QualityTier.FAST,
)
workManager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
val error = terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)
assertEquals(
"a file Media3 cannot decode must still convert, but failed with: $error",
WorkInfo.State.SUCCEEDED,
terminal?.state,
)
val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
assertTrue("no output produced", out.exists() && out.length() > 0)
out.delete()
}
private companion object {
const val SAMPLE = "sample_h264_444.mp4"
const val TIMEOUT_MS = 600_000L
}
}
@@ -0,0 +1,135 @@
package org.libremediaconverter.fallback
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* The branches that handle a URI the app cannot open.
*
* These were previously written off as needing a SAF grant to be revoked mid-job. That
* was wrong: a URI that was *never* valid reaches exactly the same null branch as one
* whose permission was withdrawn, and pointing at a provider that does not exist is
* trivial to arrange.
*
* The assertions deliberately check the *outcome* rather than a specific exception
* type. Whether the resolver returns null or throws is an implementation detail of the
* provider; what matters is that the job fails cleanly and tells the user something,
* instead of crashing the worker.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class UnopenableUriTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val workManager = WorkManager.getInstance(context)
private lateinit var staged: File
/** A syntactically valid content URI whose authority does not exist. */
private val bogus: Uri = Uri.parse("content://org.libremediaconverter.nonexistent.provider/media/1")
@Before
fun setUp() {
staged = File(context.cacheDir, "unopenable_src.bin").apply { writeBytes(ByteArray(2048)) }
}
@After
fun tearDown() {
FakeFailures.reset()
staged.delete()
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
private suspend fun runToCompletion(request: androidx.work.OneTimeWorkRequest): WorkInfo? {
workManager.enqueue(request).result.get()
return withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
}
// --- 1. conversion input that cannot be opened ---------------------------
@Test
fun aConversionInputThatCannotBeOpenedFailsCleanly(): Unit = runBlocking {
val request = ConversionWorker.request(
inputUri = bogus,
displayName = "gone.mp4",
sizeBytes = 1024,
)
val terminal = runToCompletion(request)
assertEquals(WorkInfo.State.FAILED, terminal?.state)
assertTrue(
"the failure should carry a message for the user",
!terminal?.outputData?.getString(ConversionWorker.KEY_ERROR).isNullOrBlank(),
)
}
// --- 2. destination that cannot be written -------------------------------
@Test
fun publishingToAnUnwritableDestinationThrowsRatherThanSilentlySucceeding() {
val publisher = OutputPublisher(context)
val failure = runCatching { publisher.publish(staged, bogus) }.exceptionOrNull()
assertTrue(
"publishing to a dead provider must not appear to succeed, got $failure",
failure != null,
)
}
// --- 3. concat input that cannot be opened -------------------------------
@Test
fun aJoinInputThatCannotBeOpenedFailsCleanly(): Unit = runBlocking {
val out = File(context.cacheDir, "joined_unopenable.mp4").also { it.delete() }
val failure = runCatching {
ConcatEngine(context).join(listOf(bogus, bogus), out)
}.exceptionOrNull()
assertTrue("joining unopenable inputs must fail, got $failure", failure != null)
out.delete()
}
// --- 4. the concat worker's missing-input-array branch -------------------
@Test
fun aJoinWithNoInputArrayFailsWithAMessage(): Unit = runBlocking {
// ConcatWorker.request() always sets the array, so this branch is unreachable
// through it -- but a worker is just input Data, and WorkManager will happily
// run one built without it. That is also what a corrupted or version-skewed
// queue entry would look like after an app update.
val request = OneTimeWorkRequestBuilder<ConcatWorker>()
.setInputData(Data.Builder().putLong(ConcatWorker.KEY_TOTAL_BYTES, 1).build())
.build()
val terminal = runToCompletion(request)
assertEquals(WorkInfo.State.FAILED, terminal?.state)
assertEquals(
"No input files.",
terminal?.outputData?.getString(ConcatWorker.KEY_ERROR),
)
}
private companion object {
const val TIMEOUT_MS = 300_000L
}
}
@@ -0,0 +1,167 @@
package org.libremediaconverter.ffmpeg
import android.media.MediaExtractor
import android.media.MediaFormat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Exercises the bundled FFmpeg on-device.
*
* These are the formats the app exists for that Media3 structurally cannot produce, so
* they are also the ones with no other coverage. Each test asserts against the produced
* file rather than the exit code, because FFmpeg will happily report success after
* writing something unplayable if the arguments are wrong.
*/
@RunWith(AndroidJUnit4::class)
class FFmpegEngineTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val engine: org.libremediaconverter.convert.SoftwareTranscoder = FFmpegEngine()
private lateinit var input: File
private val outputs = mutableListOf<File>()
@Before
fun setUp() {
input = File(context.cacheDir, "ffmpeg_sample.mp4")
InstrumentationRegistry.getInstrumentation().context.assets
.open("sample_h264.mp4")
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
}
@After
fun tearDown() {
input.delete()
outputs.forEach { it.delete() }
}
private fun outputFor(name: String) =
File(context.cacheDir, name).also { it.delete(); outputs += it }
private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File {
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
runBlocking {
engine.run(
request = ConversionRequest(format = format, quality = quality),
inputPath = input.absolutePath,
output = out,
durationMs = 3_000,
)
}
return out
}
private fun trackMimes(file: File): List<String> {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(file.absolutePath)
(0 until extractor.trackCount).map {
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty()
}
} finally {
extractor.release()
}
}
// --- the formats that justify bundling FFmpeg at all -------------------
@Test
fun encodesMp3WhichAndroidCannotDoAtAnyApiLevel() {
val out = convert(OutputFormat.MP3)
assertTrue("no MP3 produced", out.exists() && out.length() > 0)
assertTrue(
"expected an mpeg audio track, got ${trackMimes(out)}",
trackMimes(out).any { it.contains("mp") && it.startsWith("audio/") },
)
}
@Test
fun encodesGifWithAGeneratedPalette() {
val out = convert(OutputFormat.GIF)
assertTrue("no GIF produced", out.exists() && out.length() > 0)
// GIF87a / GIF89a magic. Proves a real GIF rather than a mislabelled file.
val magic = out.inputStream().use { String(it.readNBytes(6)) }
assertTrue("not a GIF: $magic", magic.startsWith("GIF"))
}
@Test
fun encodesMatroskaWhichMedia3CannotMux() {
val out = convert(OutputFormat.MKV_H264)
assertTrue("no MKV produced", out.exists() && out.length() > 0)
// EBML magic, the Matroska container header.
val magic = out.inputStream().use { it.readNBytes(4) }
assertEquals(0x1A.toByte(), magic[0])
assertEquals(0x45.toByte(), magic[1])
assertEquals(0xDF.toByte(), magic[2])
assertEquals(0xA3.toByte(), magic[3])
}
@Test
fun encodesFlacLosslessAudio() {
val out = convert(OutputFormat.FLAC)
assertTrue("no FLAC produced", out.exists() && out.length() > 0)
}
@Test
fun encodesWav() {
val out = convert(OutputFormat.WAV)
assertTrue("no WAV produced", out.exists() && out.length() > 0)
val magic = out.inputStream().use { String(it.readNBytes(4)) }
assertEquals("RIFF", magic)
}
@Test
fun encodesOpus() {
val out = convert(OutputFormat.OPUS)
assertTrue("no Opus produced", out.exists() && out.length() > 0)
}
// --- the quality tier the GPL licence was taken for --------------------
@Test
fun bestQualityProducesAPlayableH264File() {
val out = convert(OutputFormat.MP4_H264, QualityTier.BEST)
assertTrue("no MP4 produced", out.exists() && out.length() > 0)
assertTrue(
"expected an AVC track, got ${trackMimes(out)}",
trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_AVC },
)
}
@Test
fun bestQualityProducesAPlayableH265File() {
val out = convert(OutputFormat.MP4_H265, QualityTier.BEST)
assertTrue("no MP4 produced", out.exists() && out.length() > 0)
assertTrue(
"expected an HEVC track, got ${trackMimes(out)}",
trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_HEVC },
)
}
@Test
fun failureSurfacesAsAnExceptionRatherThanASilentEmptyFile() {
val out = outputFor("nope.mp4")
val failure = runCatching {
runBlocking {
engine.run(
request = ConversionRequest(format = OutputFormat.MP4_H264),
inputPath = "/does/not/exist.mp4",
output = out,
durationMs = 1_000,
)
}
}.exceptionOrNull()
assertTrue("expected an FFmpegException, got $failure", failure is FFmpegEngine.FFmpegException)
}
}
@@ -0,0 +1,177 @@
package org.libremediaconverter.join
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.ConcatStrategy
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* The join path, end to end on a device.
*
* The point of these tests is the strategy decision, not merely that a file appears.
* FFmpeg's `concat` demuxer does not reliably reject mismatched inputs -- it can emit a
* file whose later segments are garbled -- so "it produced output" is not evidence of
* correctness. Each test therefore checks which strategy ran *and* that the result is
* long enough to contain both inputs.
*/
@RunWith(AndroidJUnit4::class)
class ConcatEngineTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val engine = ConcatEngine(context)
private val staged = mutableListOf<File>()
private lateinit var clipA: File
private lateinit var clipB: File
private lateinit var clipMismatched: File
@Before
fun setUp() {
clipA = copyAsset("clip_a.mp4")
clipB = copyAsset("clip_b.mp4")
clipMismatched = copyAsset("clip_c_mismatched.mp4")
}
@After
fun tearDown() {
(staged + listOf(clipA, clipB, clipMismatched)).forEach { it.delete() }
}
private fun copyAsset(name: String): File {
val out = File(context.cacheDir, name)
InstrumentationRegistry.getInstrumentation().context.assets
.open(name)
.use { asset -> out.outputStream().use { asset.copyTo(it) } }
return out
}
private fun output(name: String) =
File(context.cacheDir, name).also { it.delete(); staged += it }
private fun durationMs(file: File): Long {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(file.absolutePath)
(0 until extractor.trackCount)
.map { extractor.getTrackFormat(it) }
.filter { it.containsKey(MediaFormat.KEY_DURATION) }
.maxOfOrNull { it.getLong(MediaFormat.KEY_DURATION) / 1000 } ?: 0L
} finally {
extractor.release()
}
}
// --- the fast path ------------------------------------------------------
@Test
fun matchingClipsAreJoinedByStreamCopy(): Unit = runBlocking {
val out = output("joined_matching.mp4")
val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out)
assertEquals(
"identical inputs should not need re-encoding",
ConcatStrategy.STREAM_COPY,
result.strategy,
)
assertTrue("no output produced", out.exists() && out.length() > 0)
// Both 2 s inputs must be present, not just the first.
assertTrue(
"joined duration ${durationMs(out)}ms is too short to hold both clips",
durationMs(out) >= 3_500,
)
}
// --- the correctness path ----------------------------------------------
@Test
fun mismatchedClipsAreReEncodedRatherThanStreamCopied(): Unit = runBlocking {
val out = output("joined_mismatched.mp4")
val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out)
// This is the case a naive implementation gets wrong: the demuxer would accept
// these and produce a corrupt second half.
assertEquals(
"differing resolution must force a re-encode",
ConcatStrategy.REENCODE,
result.strategy,
)
assertTrue("no output produced", out.exists() && out.length() > 0)
assertTrue(
"joined duration ${durationMs(out)}ms is too short to hold both clips",
durationMs(out) >= 3_500,
)
}
@Test
fun reEncodedOutputIsPlayableAndCarriesBothTracks(): Unit = runBlocking {
val out = output("joined_playable.mp4")
engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out)
val extractor = MediaExtractor()
try {
extractor.setDataSource(out.absolutePath)
val mimes = (0 until extractor.trackCount).map {
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty()
}
assertTrue("no video track in $mimes", mimes.any { it.startsWith("video/") })
assertTrue("no audio track in $mimes", mimes.any { it.startsWith("audio/") })
} finally {
extractor.release()
}
}
// --- guards -------------------------------------------------------------
@Test
fun joiningRefusesFewerThanTwoInputs() {
val out = output("joined_single.mp4")
val failure = runCatching {
runBlocking { engine.join(listOf(Uri.fromFile(clipA)), out) }
}.exceptionOrNull()
assertTrue(
"expected an IllegalArgumentException, got $failure",
failure is IllegalArgumentException,
)
}
@Test
fun theListFileIsCleanedUpAfterJoining(): Unit = runBlocking {
val out = output("joined_cleanup.mp4")
engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out)
assertTrue(
"the concat list file was left behind",
!File(out.parentFile, "concat_list.txt").exists(),
)
}
// --- the probe the planner depends on ----------------------------------
@Test
fun probeReadsThePropertiesTheStrategyDependsOn() {
val a = MediaProbe.probeForConcat(context, Uri.fromFile(clipA))
val mismatched = MediaProbe.probeForConcat(context, Uri.fromFile(clipMismatched))
assertEquals("h264", a.videoCodec)
assertEquals(320, a.width)
assertEquals(240, a.height)
assertEquals(640, mismatched.width)
assertEquals(480, mismatched.height)
assertTrue(
"the probe must actually distinguish these clips, or the planner cannot",
a.width != mismatched.width || a.height != mismatched.height,
)
}
}
@@ -0,0 +1,109 @@
package org.libremediaconverter.join
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Joining as background work.
*
* Covers what the engine test cannot: the WorkManager round trip, the foreground
* service, and the fact that the chosen strategy is reported back to the UI rather than
* lost inside the worker.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class ConcatWorkerTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val workManager = WorkManager.getInstance(context)
private lateinit var clipA: File
private lateinit var clipB: File
@Before
fun setUp() {
clipA = copyAsset("clip_a.mp4")
clipB = copyAsset("clip_b.mp4")
}
@After
fun tearDown() {
clipA.delete()
clipB.delete()
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
private fun copyAsset(name: String): File {
val out = File(context.cacheDir, name)
InstrumentationRegistry.getInstrumentation().context.assets
.open(name)
.use { asset -> out.outputStream().use { asset.copyTo(it) } }
return out
}
@Test
fun joinsTwoFilesAndReportsTheStrategyUsed(): Unit = runBlocking {
val request = ConcatWorker.request(
inputs = listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)),
totalBytes = clipA.length() + clipB.length(),
)
workManager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
assertEquals(
"join did not succeed: ${terminal?.outputData?.getString(ConcatWorker.KEY_ERROR)}",
WorkInfo.State.SUCCEEDED,
terminal?.state,
)
// The strategy has to reach the UI: it is what tells the user whether their
// files were re-encoded or copied losslessly.
assertEquals(
ConcatStrategy.STREAM_COPY.name,
terminal?.outputData?.getString(ConcatWorker.KEY_STRATEGY),
)
val out = File(terminal!!.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)!!)
assertTrue("no joined file written", out.exists() && out.length() > 0)
}
@Test
fun aSingleInputFailsWithAnActionableMessage(): Unit = runBlocking {
val request = ConcatWorker.request(
inputs = listOf(Uri.fromFile(clipA)),
totalBytes = clipA.length(),
)
workManager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
assertEquals(WorkInfo.State.FAILED, terminal?.state)
val message = terminal?.outputData?.getString(ConcatWorker.KEY_ERROR).orEmpty()
assertTrue(
"the message should tell the user what to do, was: '$message'",
message.contains("two", ignoreCase = true),
)
}
private companion object {
const val TIMEOUT_MS = 180_000L
}
}
@@ -0,0 +1,210 @@
package org.libremediaconverter.work
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Exercises the real WorkManager path, not a test double.
*
* The point is to cover what a fake worker runner would skip: `setForeground` with a
* foreground service type, on a device whose API level actually enforces the rules.
* Declaring the wrong type — or forgetting to declare it on WorkManager's
* SystemForegroundService in the manifest — fails here rather than in production.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class ConversionWorkerTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val workManager = WorkManager.getInstance(context)
private lateinit var input: File
@Before
fun setUp() {
input = File(context.cacheDir, "worker_sample.mp4")
InstrumentationRegistry.getInstrumentation().context.assets
.open("sample_h264.mp4")
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
}
@After
fun tearDown() {
input.delete()
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
/**
* Deliberately derives the expectation from the running API rather than pinning a
* value, so the same test is meaningful on an API 33, 34 or 35+ device. The three
* regimes are the whole reason ConversionForegroundType exists.
*/
@Test
fun foregroundTypeMatchesTheRunningApiLevel() {
val expected = when {
Build.VERSION.SDK_INT >= 35 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
Build.VERSION.SDK_INT >= 34 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
else -> 0
}
assertEquals(expected, ConversionForegroundType.current())
}
@Test
fun runsAConversionThroughWorkManager(): Unit = runBlocking {
// H.264 rather than the H.265 default: on a device with no hardware encoder
// this runs in software, and AVC is markedly quicker than HEVC there. The point
// of this test is the WorkManager round trip, not the codec.
val request = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = "worker_sample.mp4",
sizeBytes = input.length(),
format = OutputFormat.MP4_H264,
)
workManager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { info ->
info != null && info.state.isFinished
}
}
assertEquals(
"worker did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}",
WorkInfo.State.SUCCEEDED,
terminal?.state,
)
val path = terminal?.outputData?.getString(ConversionWorker.KEY_OUTPUT_PATH)
assertTrue("no output path in result", path != null)
val output = File(path!!)
assertTrue("output file missing", output.exists())
assertTrue("output file empty", output.length() > 0)
}
/**
* The whole point of the router, end to end.
*
* MP3 cannot be produced by Media3 on any Android version, so this asserts both
* that the job completes and that it was actually carried out by FFmpeg. Checking
* only that a file appeared would pass even if the routing were broken.
*/
@Test
fun routesAnMp3JobToFfmpegAndProducesAFile(): Unit = runBlocking {
val request = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = "worker_sample.mp4",
sizeBytes = input.length(),
format = OutputFormat.MP3,
)
workManager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
assertEquals(
"mp3 job did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}",
WorkInfo.State.SUCCEEDED,
terminal?.state,
)
assertEquals(
Engine.FFMPEG.name,
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
)
val output = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
assertTrue("no mp3 written", output.exists() && output.length() > 0)
assertTrue("wrong extension: ${output.name}", output.name.endsWith(".mp3"))
}
/**
* A Fast MP4 job takes the hardware path *when the device has one*.
*
* The expectation is derived from the device rather than assumed. Emulators
* typically expose only `c2.android.*` software codecs, which the capability probe
* correctly rejects as non-hardware, so the same job legitimately routes to FFmpeg
* there. Asserting MEDIA3 unconditionally tests the test machine, not the router.
*/
@Test
fun routesAFastMp4JobByDeviceCapability(): Unit = runBlocking {
val hasHardwareHevc = AndroidDeviceCodecs.get().canEncode(VideoCodec.H265)
val request = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = "worker_sample.mp4",
sizeBytes = input.length(),
format = OutputFormat.MP4_H265,
quality = QualityTier.FAST,
)
workManager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
assertEquals(
"job did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}",
WorkInfo.State.SUCCEEDED,
terminal?.state,
)
assertEquals(
if (hasHardwareHevc) "hardware HEVC present, expected the Media3 path"
else "no hardware HEVC encoder, expected the FFmpeg path",
if (hasHardwareHevc) Engine.MEDIA3.name else Engine.FFMPEG.name,
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
)
}
/** The quality tier is the reason the shipped binary is GPL, so verify it routes. */
@Test
fun routesABestQualityJobToFfmpeg(): Unit = runBlocking {
val request = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = "worker_sample.mp4",
sizeBytes = input.length(),
format = OutputFormat.MP4_H264,
quality = QualityTier.BEST,
)
workManager.enqueue(request).result.get()
val terminal = withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
}
assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
assertEquals(
Engine.FFMPEG.name,
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
)
}
@Test
fun outputNameTakesTheExtensionOfTheChosenFormat() {
assertEquals("clip_converted.mp3", ConversionWorker.outputNameFor("clip.mp4", OutputFormat.MP3))
assertEquals("clip_converted.gif", ConversionWorker.outputNameFor("clip.mov", OutputFormat.GIF))
assertEquals("clip_converted.mkv", ConversionWorker.outputNameFor("clip", OutputFormat.MKV_H264))
}
private companion object {
const val TIMEOUT_MS = 180_000L
}
}
+42 -2
View File
@@ -2,6 +2,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!--
mediaProcessing is API 35+. Foreground service types became mandatory at API 34,
but this type did not exist yet, so API 34 has to fall back to dataSync. minSdk is
33, where no type is required at all. See ConversionForegroundType.
-->
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING"
android:minSdkVersion="35" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
android:maxSdkVersion="34" />
<!-- Runtime permission since API 33, so it always applies here. Notifications are
off by default on new installs. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,6 +28,28 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidMediaConverter" />
android:theme="@style/Theme.LibreMediaConverter">
</manifest>
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.LibreMediaConverter">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
setForeground() runs WorkManager's own SystemForegroundService, so the type
must be declared on THAT service, not on one of ours. Both types are listed
because the manifest cannot branch on API level; the type actually passed at
runtime is chosen in ConversionForegroundType.
-->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="mediaProcessing|dataSync"
tools:node="merge" />
</application>
</manifest>
@@ -0,0 +1,112 @@
package org.libremediaconverter
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi
import org.libremediaconverter.convert.ConverterScreen
import org.libremediaconverter.join.JoinScreen
import org.libremediaconverter.ui.theme.LibreMediaConverterTheme
private enum class Destination(val label: String) {
CONVERT("Convert"),
JOIN("Join"),
}
@UnstableApi
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
// Edge-to-edge is enforced from targetSdk 35 and has no opt-out at 36+, so opt
// in explicitly rather than relying on the default.
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
LibreMediaConverterTheme {
AppRoot(widthSizeClass = calculateWindowSizeClass(this).widthSizeClass)
}
}
}
}
/**
* Adaptive shell: a bottom bar on phones, a side rail on wider screens.
*
* This is not cosmetic. From targetSdk 37, Android ignores `screenOrientation`,
* `resizableActivity` and aspect-ratio limits on any display at least 600dp wide, and
* the Android 16 opt-out no longer applies. The app will be resized and rotated
* whether or not it is ready, so it has to lay out properly at every width.
*/
@UnstableApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppRoot(widthSizeClass: WindowWidthSizeClass) {
var destination by remember { mutableStateOf(Destination.CONVERT) }
val useRail = widthSizeClass != WindowWidthSizeClass.Compact
if (useRail) {
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail {
Destination.entries.forEach { item ->
NavigationRailItem(
selected = destination == item,
onClick = { destination = item },
icon = {},
label = { Text(item.label) },
)
}
}
Scaffold(modifier = Modifier.fillMaxSize()) { padding ->
Content(destination, Modifier.padding(padding))
}
}
} else {
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
NavigationBar {
Destination.entries.forEach { item ->
NavigationBarItem(
selected = destination == item,
onClick = { destination = item },
icon = {},
label = { Text(item.label) },
)
}
}
},
) { padding ->
Content(destination, Modifier.padding(padding))
}
}
}
@UnstableApi
@Composable
private fun Content(destination: Destination, modifier: Modifier) {
when (destination) {
Destination.CONVERT -> ConverterScreen(modifier = modifier)
Destination.JOIN -> JoinScreen(modifier = modifier)
}
}
@@ -0,0 +1,107 @@
package org.libremediaconverter.codec
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaFormat
import android.util.Log
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.VideoCodec
/**
* What this device's codec hardware can actually do.
*
* Enumerated once and cached: MediaCodecList is not cheap, and the answer cannot
* change while the process is alive.
*
* Two details that are easy to get wrong:
*
* - Devices expose **aliases** for the same underlying codec, so the list must be
* deduplicated by canonical name or capabilities get counted several times.
* - `isHardwareAccelerated()` is declared by the vendor and, in the platform's own
* words, "cannot be tested for correctness". It is a hint, not a guarantee, which is
* why the router treats a failed hardware export as a signal to fall back rather
* than trusting this up front.
*/
class AndroidDeviceCodecs private constructor(
private val hardwareEncodeMimes: Set<String>,
private val decodeMimes: Set<String>,
) : DeviceCodecs {
override fun canEncode(codec: VideoCodec): Boolean =
mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
override fun canDecode(codecName: String): Boolean {
// The platform already failed to parse this input, so there is nothing to
// decode with. Send it to FFmpeg rather than letting Media3 fail later.
if (codecName == InputProbe.UNPARSEABLE) return false
return mimeForCodecName(codecName)?.let { it in decodeMimes } ?: true
}
fun hardwareEncoders(): Set<String> = hardwareEncodeMimes
companion object {
private const val TAG = "AndroidDeviceCodecs"
@Volatile
private var cached: AndroidDeviceCodecs? = null
fun get(): AndroidDeviceCodecs =
cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
private fun probe(): AndroidDeviceCodecs {
val encoders = mutableSetOf<String>()
val decoders = mutableSetOf<String>()
val seen = mutableSetOf<String>()
runCatching {
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.forEach { info ->
// Aliases point at the same underlying codec; counting both would
// double-count capabilities.
if (info.isAlias) return@forEach
if (!seen.add(info.canonicalName)) return@forEach
info.supportedTypes.forEach { mime ->
if (!mime.startsWith("video/")) return@forEach
if (info.isEncoder) {
if (info.isHardwareAccelerated && !info.isSoftwareOnly) {
encoders += mime
}
} else {
decoders += mime
}
}
}
}.onFailure { Log.w(TAG, "Codec enumeration failed; assuming permissive.", it) }
Log.i(TAG, "Hardware video encoders: $encoders")
return AndroidDeviceCodecs(encoders, decoders)
}
private fun mimeFor(codec: VideoCodec): String? = when (codec) {
VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC
VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC
VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8
VideoCodec.VP9 -> MediaFormat.MIMETYPE_VIDEO_VP9
VideoCodec.AV1 -> MediaFormat.MIMETYPE_VIDEO_AV1
VideoCodec.NONE -> null
}
/** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */
private fun mimeForCodecName(name: String): String? = when (name.lowercase()) {
"h264", "avc", "avc1" -> MediaFormat.MIMETYPE_VIDEO_AVC
"hevc", "h265", "hvc1" -> MediaFormat.MIMETYPE_VIDEO_HEVC
"vp8" -> MediaFormat.MIMETYPE_VIDEO_VP8
"vp9" -> MediaFormat.MIMETYPE_VIDEO_VP9
"av1", "av01" -> MediaFormat.MIMETYPE_VIDEO_AV1
"mpeg4" -> MediaFormat.MIMETYPE_VIDEO_MPEG4
// Unknown to us: assume the platform can handle it and let a failed export
// trigger the FFmpeg fallback, rather than pre-emptively refusing hardware.
else -> null
}
/** Test seam: lets instrumented tests build a probe from explicit sets. */
fun forTesting(encoders: Set<String>, decoders: Set<String>) =
AndroidDeviceCodecs(encoders, decoders)
}
}
@@ -0,0 +1,219 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
/** User-chosen conversion settings. */
data class ConversionSettings(
val format: OutputFormat = OutputFormat.MP4_H265,
val quality: QualityTier = QualityTier.FAST,
val enginePreference: EnginePreference = EnginePreference.AUTO,
)
data class InputFile(
val uri: Uri,
val displayName: String,
val sizeBytes: Long,
)
sealed interface ConversionState {
data object Idle : ConversionState
data class Ready(val input: InputFile) : ConversionState
data class Converting(val input: InputFile, val percent: Int) : ConversionState
/** Budget for foreground work ran out; WorkManager will retry when it can. */
data class Waiting(val input: InputFile) : ConversionState
data class Converted(
val input: InputFile,
val staged: File,
val engineUsed: String = "",
val routeReason: String = "",
) : ConversionState
data class Saved(val displayName: String) : ConversionState
data class Failed(val message: String) : ConversionState
}
@UnstableApi
class ConversionViewModel(app: Application) : AndroidViewModel(app) {
private val workManager = WorkManager.getInstance(app)
private val publisher = OutputPublisher(app)
private val _state = MutableStateFlow<ConversionState>(ConversionState.Idle)
val state: StateFlow<ConversionState> = _state.asStateFlow()
private var observer: Job? = null
private var activeWorkId: UUID? = null
/** Conversion settings, kept separate from the job state machine. */
private val _settings = MutableStateFlow(ConversionSettings())
val settings: StateFlow<ConversionSettings> = _settings.asStateFlow()
fun setFormat(format: OutputFormat) = _settings.update { it.copy(format = format) }
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
fun setEnginePreference(preference: EnginePreference) =
_settings.update { it.copy(enginePreference = preference) }
fun onInputPicked(uri: Uri) {
viewModelScope.launch {
val info = withContext(Dispatchers.IO) { queryFile(uri) }
_state.value = ConversionState.Ready(info)
}
}
/**
* Enqueues the conversion rather than running it inline.
*
* Going through WorkManager means the job outlives this ViewModel, survives the
* process being killed, and keeps running when the user leaves the app — none of
* which a viewModelScope coroutine would do.
*/
fun convert() {
val input = currentInput() ?: return
val settings = _settings.value
val request = ConversionWorker.request(
inputUri = input.uri,
displayName = input.displayName,
sizeBytes = input.sizeBytes,
format = settings.format,
quality = settings.quality,
enginePreference = settings.enginePreference,
)
activeWorkId = request.id
workManager.enqueue(request)
_state.value = ConversionState.Converting(input, 0)
observe(request.id, input)
}
private fun observe(id: UUID, input: InputFile) {
observer?.cancel()
observer = viewModelScope.launch {
workManager.getWorkInfoByIdFlow(id).collect { info ->
if (info == null) return@collect
_state.value = when (info.state) {
WorkInfo.State.RUNNING -> ConversionState.Converting(
input,
info.progress.getInt(ConversionWorker.KEY_PROGRESS, 0),
)
// ENQUEUED after a run means a retry is pending — most likely the
// six-hour foreground budget was exhausted mid-job.
WorkInfo.State.ENQUEUED ->
if (info.runAttemptCount > 0) {
ConversionState.Waiting(input)
} else {
ConversionState.Converting(input, 0)
}
WorkInfo.State.SUCCEEDED -> {
val path = info.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
if (path == null) {
ConversionState.Failed("Conversion reported success but produced no file.")
} else {
ConversionState.Converted(
input = input,
staged = File(path),
engineUsed = info.outputData
.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
routeReason = info.outputData
.getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(),
)
}
}
WorkInfo.State.FAILED -> ConversionState.Failed(
info.outputData.getString(ConversionWorker.KEY_ERROR)
?: "Conversion failed."
)
WorkInfo.State.CANCELLED -> ConversionState.Ready(input)
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
}
}
}
}
fun cancel() {
activeWorkId?.let(workManager::cancelWorkById)
}
fun save(destination: Uri) {
val converted = _state.value as? ConversionState.Converted ?: return
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
publisher.publish(converted.staged, destination)
converted.staged.delete()
}
}.onSuccess {
_state.value = ConversionState.Saved(
ConversionWorker.outputNameFor(
converted.input.displayName,
_settings.value.format,
)
)
}.onFailure { e ->
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
}
}
}
fun reset() {
observer?.cancel()
observer = null
activeWorkId = null
_state.value = ConversionState.Idle
}
fun suggestedOutputName(): String =
ConversionWorker.outputNameFor(
currentInput()?.displayName ?: "output",
_settings.value.format,
)
private fun currentInput(): InputFile? = when (val s = _state.value) {
is ConversionState.Ready -> s.input
is ConversionState.Converting -> s.input
is ConversionState.Waiting -> s.input
is ConversionState.Converted -> s.input
else -> null
}
private fun queryFile(uri: Uri): InputFile {
var name = "input"
var size = 0L
getApplication<Application>().contentResolver
.query(uri, null, null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
.takeIf { it >= 0 }
?.let { name = cursor.getString(it) ?: name }
cursor.getColumnIndex(OpenableColumns.SIZE)
.takeIf { it >= 0 }
?.let { size = cursor.getLong(it) }
}
}
return InputFile(uri, name, size)
}
}
@@ -0,0 +1,268 @@
package org.libremediaconverter.convert
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import java.util.Locale
@UnstableApi
@Composable
fun ConverterScreen(
modifier: Modifier = Modifier,
viewModel: ConversionViewModel = viewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
// ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is images and video
// only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm.
// SAF needs no runtime permission.
val pickInput = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let(viewModel::onInputPicked) }
val chooseDestination = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(settings.format.mimeType)
) { uri -> uri?.let(viewModel::save) }
// Requested at the point of use rather than on first launch, so the ask carries its
// own justification. The conversion starts either way: without the permission the
// foreground service still runs, but its progress notification is confined to the
// Task Manager instead of the shade.
val requestNotifications = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { viewModel.convert() }
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
) {
Text(
"LibreMediaConverter",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp),
)
// The empty state is centred in whatever space is left. The working states
// scroll instead, since their content can exceed the screen.
val body = Modifier.fillMaxWidth().weight(1f)
when (val s = state) {
is ConversionState.Idle -> Column(
modifier = body,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Pick a file to convert.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp),
)
Button(
onClick = { pickInput.launch(arrayOf("*/*")) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
) { Text("Choose file") }
}
else -> Column(
modifier = body.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (s) {
is ConversionState.Idle -> Unit
is ConversionState.Ready -> {
FileCard(s.input)
FormatPicker(settings.format, viewModel::setFormat)
QualityPicker(settings.quality, viewModel::setQuality)
EnginePicker(settings.enginePreference, viewModel::setEnginePreference)
Button(
onClick = {
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
},
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Convert") }
OutlinedButton(
onClick = { pickInput.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth(),
) { Text("Choose a different file") }
}
is ConversionState.Converting -> {
FileCard(s.input)
Text("Converting… ${s.percent}%")
LinearProgressIndicator(
progress = { s.percent / 100f },
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
) { Text("Cancel") }
}
is ConversionState.Waiting -> {
FileCard(s.input)
Text(
"Paused. The system limits background media processing to " +
"six hours a day, so this will resume automatically.",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
) { Text("Cancel") }
}
is ConversionState.Converted -> {
FileCard(s.input)
Text(
"Done — ${formatBytes(s.staged.length())} output.",
style = MaterialTheme.typography.bodyMedium,
)
if (s.routeReason.isNotBlank()) {
// Surfacing the routing decision rather than hiding it: it
// explains why a job was slow, and makes the software
// fallback visible.
AssistChip(onClick = {}, label = { Text(s.routeReason) })
}
Button(
onClick = { chooseDestination.launch(viewModel.suggestedOutputName()) },
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Save file") }
OutlinedButton(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth(),
) { Text("Start over") }
}
is ConversionState.Saved -> {
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Convert another") }
}
is ConversionState.Failed -> {
Text(
s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Start over") }
}
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FormatPicker(selected: OutputFormat, onSelect: (OutputFormat) -> Unit) {
Text("Output format", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutputFormat.entries.forEach { format ->
FilterChip(
selected = format == selected,
onClick = { onSelect(format) },
label = { Text(format.label) },
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
Text("Quality", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
QualityTier.entries.forEach { tier ->
FilterChip(
selected = tier == selected,
onClick = { onSelect(tier) },
label = { Text(tier.label) },
)
}
}
Text(selected.description, style = MaterialTheme.typography.bodySmall)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
Text("Engine", style = MaterialTheme.typography.titleSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
EnginePreference.entries.forEach { preference ->
FilterChip(
selected = preference == selected,
onClick = { onSelect(preference) },
label = { Text(preference.label()) },
)
}
}
}
private fun EnginePreference.label(): String = when (this) {
EnginePreference.AUTO -> "Automatic"
EnginePreference.PREFER_HARDWARE -> "Prefer hardware"
EnginePreference.FORCE_SOFTWARE -> "Force software"
}
@Composable
private fun FileCard(input: InputFile) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(input.displayName, style = MaterialTheme.typography.titleMedium)
Text(formatBytes(input.sizeBytes), style = MaterialTheme.typography.bodySmall)
}
}
}
private fun formatBytes(bytes: Long): String = when {
bytes >= 1_000_000_000 -> String.format(Locale.US, "%.1f GB", bytes / 1e9)
bytes >= 1_000_000 -> String.format(Locale.US, "%.1f MB", bytes / 1e6)
bytes >= 1_000 -> String.format(Locale.US, "%.0f kB", bytes / 1e3)
else -> "$bytes B"
}
@@ -0,0 +1,131 @@
package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.transformer.Composition
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.ProgressHolder
import androidx.media3.transformer.Transformer
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Hardware conversion via Media3 Transformer.
*
* ## Why the HandlerThread
*
* Transformer documents that instances "must be accessed from a single application
* thread", and both [Transformer.start] and [Transformer.cancel] throw
* [IllegalStateException] when called from anywhere else. The thread it binds to is
* whichever one had a Looper when the Builder was constructed — falling back to the
* *main* Looper if the constructing thread has none.
*
* That default is a trap for background execution. A WorkManager `Worker` runs on an
* executor thread with no Looper, so a Transformer built there silently binds to the
* main thread, and the subsequent `start()` from the worker thread throws.
*
* This class therefore owns a dedicated [HandlerThread], passes its Looper explicitly
* via `setLooper`, and marshals every Transformer call onto it. Callers get a plain
* suspending function and never have to think about it.
*/
@UnstableApi
class Media3Engine(private val context: Context) : HardwareTranscoder {
private val thread = HandlerThread("media3-transformer").apply { start() }
private val handler = Handler(thread.looper)
/**
* Transcodes [input] to [output], reporting progress 0..100.
*
* [output] must be a real filesystem path, not a SAF document. Writing through a
* SAF file descriptor breaks MP4 muxing, because faststart needs to seek back and
* rewrite the moov atom, and a SAF fd is not reliably seekable. Callers stage into
* app-private storage and publish afterwards.
*/
override suspend fun transcode(
input: Uri,
output: File,
videoMimeType: String,
onProgress: (Int) -> Unit,
): Unit = suspendCancellableCoroutine { cont ->
handler.post {
val transformer = buildTransformer(videoMimeType, cont)
val item = EditedMediaItem.Builder(MediaItem.fromUri(input)).build()
cont.invokeOnCancellation {
// cancel() has the same single-thread requirement as start().
handler.post { runCatching { transformer.cancel() } }
}
runCatching { transformer.start(item, output.absolutePath) }
.onFailure { cont.resumeWithException(it); return@post }
pollProgress(transformer, cont, onProgress)
}
}
private fun buildTransformer(
videoMimeType: String,
cont: CancellableContinuation<Unit>,
): Transformer = Transformer.Builder(context)
.setLooper(thread.looper)
.setVideoMimeType(videoMimeType)
.addListener(object : Transformer.Listener {
override fun onCompleted(composition: Composition, result: ExportResult) {
if (cont.isActive) cont.resume(Unit)
}
override fun onError(
composition: Composition,
result: ExportResult,
exception: ExportException,
) {
if (cont.isActive) cont.resumeWithException(exception)
}
})
.build()
/**
* Polls export progress on the Transformer's own thread.
*
* Deliberately ~4x/second: the underlying value updates far more often than a UI
* or a notification can usefully consume, and over-frequent notification updates
* will jank the system UI.
*/
private fun pollProgress(
transformer: Transformer,
cont: CancellableContinuation<Unit>,
onProgress: (Int) -> Unit,
) {
val holder = ProgressHolder()
val tick = object : Runnable {
override fun run() {
if (!cont.isActive) return
if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) {
onProgress(holder.progress)
}
handler.postDelayed(this, PROGRESS_INTERVAL_MS)
}
}
handler.postDelayed(tick, PROGRESS_INTERVAL_MS)
}
override fun close() {
thread.quitSafely()
}
private companion object {
const val PROGRESS_INTERVAL_MS = 250L
}
}
@@ -0,0 +1,112 @@
package org.libremediaconverter.convert
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import android.util.Log
import org.libremediaconverter.model.ConcatInput
import org.libremediaconverter.model.InputProbe
/**
* Reads just enough about an input to route it.
*
* Uses the platform extractor rather than FFprobe: it needs no native library, works
* directly on a content:// URI, and the router only needs the codec and duration. If
* the platform cannot parse the file at all, that is itself the answer — a file
* MediaExtractor cannot open is one Media3 cannot convert, so it routes to FFmpeg.
*/
object MediaProbe {
fun probe(context: Context, uri: Uri): InputProbe {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(context, uri, null)
var video: String? = null
var audio: String? = null
var durationUs = 0L
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (format.containsKey(MediaFormat.KEY_DURATION)) {
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
}
when {
mime.startsWith("video/") && video == null -> video = shortName(mime)
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
}
}
InputProbe(
videoCodec = video,
audioCodec = audio,
hasVideo = video != null,
durationMs = durationUs / 1000,
)
} catch (e: Exception) {
// Not a failure: an unparseable input is a strong signal that this job
// belongs on FFmpeg. Reporting an unknown codec makes the router say so.
Log.i(TAG, "Platform extractor could not read $uri; routing to FFmpeg.", e)
InputProbe(videoCodec = InputProbe.UNPARSEABLE, hasVideo = true, durationMs = 0)
} finally {
runCatching { extractor.release() }
}
}
/**
* Reads the properties that decide whether inputs can be joined without
* re-encoding. Unknown values stay null, which [ConcatPlanner] treats as "cannot
* prove a match" rather than as agreement.
*/
fun probeForConcat(context: Context, uri: Uri): ConcatInput {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(context, uri, null)
var video: String? = null
var audio: String? = null
var width = 0
var height = 0
var fps = 0
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith("video/") && video == null) {
video = shortName(mime)
width = format.intOr(MediaFormat.KEY_WIDTH)
height = format.intOr(MediaFormat.KEY_HEIGHT)
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
} else if (mime.startsWith("audio/") && audio == null) {
audio = shortName(mime)
}
}
ConcatInput(video, audio, width, height, fps)
} catch (e: Exception) {
Log.i(TAG, "Could not probe $uri for concat; will re-encode.", e)
ConcatInput(null, null, 0, 0, 0)
} finally {
runCatching { extractor.release() }
}
}
private fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
if (containsKey(key)) runCatching { getInteger(key) }.getOrDefault(fallback) else fallback
/** MediaFormat MIME -> the short codec names the router and FFmpeg both speak. */
private fun shortName(mime: String): String = when (mime) {
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
MediaFormat.MIMETYPE_VIDEO_HEVC -> "hevc"
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9"
MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1"
MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4"
MediaFormat.MIMETYPE_AUDIO_AAC -> "aac"
MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus"
MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac"
MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis"
else -> mime.substringAfter('/')
}
private const val TAG = "MediaProbe"
}
@@ -0,0 +1,52 @@
package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import java.io.File
/**
* Staging and publication of conversion output.
*
* Conversions never write directly to the destination the user picked. FFmpeg and the
* MP4 muxer both need to seek backwards to finalise a file — faststart rewrites the
* moov atom at the end — and a SAF file descriptor is not reliably seekable. Writing
* through one produces a truncated or unplayable file.
*
* So every job writes to app-private cache, which is a real POSIX path with no
* permissions and no scoped-storage rules, and the finished file is copied out to the
* user's chosen destination afterwards.
*
* The cost is one extra copy and transient double disk usage, which is why
* [hasSpaceFor] exists.
*/
open class OutputPublisher(private val context: Context) {
private val stagingDir: File
get() = File(context.cacheDir, "conversions").apply { mkdirs() }
open fun createStagingFile(name: String): File = File(stagingDir, name)
/**
* True if there is room for a further [bytes], including headroom.
*
* Staging means peak usage is roughly input + output at once, so a job that would
* just barely fit is rejected rather than failing partway through.
*/
open fun hasSpaceFor(bytes: Long): Boolean =
stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
/** Copies a finished staging file into a user-chosen SAF destination. */
open fun publish(staged: File, destination: Uri) {
context.contentResolver.openOutputStream(destination)?.use { out ->
staged.inputStream().use { it.copyTo(out) }
} ?: error("Could not open destination for writing: $destination")
}
fun clearStaging() {
stagingDir.listFiles()?.forEach { it.delete() }
}
private companion object {
const val SPACE_HEADROOM_BYTES = 128L * 1024 * 1024
}
}
@@ -0,0 +1,72 @@
package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import org.libremediaconverter.ffmpeg.FFmpegEngine
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.codec.AndroidDeviceCodecs
import java.io.File
/** The hardware conversion path. Implemented by [Media3Engine]. */
interface HardwareTranscoder : AutoCloseable {
suspend fun transcode(
input: Uri,
output: File,
videoMimeType: String = androidx.media3.common.MimeTypes.VIDEO_H265,
onProgress: (Int) -> Unit = {},
)
}
/** The software conversion path. Implemented by [FFmpegEngine]. */
interface SoftwareTranscoder {
suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit = {},
)
}
/**
* The seam that lets tests force failure paths.
*
* Workers are constructed by WorkManager, so they cannot take constructor arguments,
* and the app deliberately carries no DI framework. This holds the few collaborators a
* conversion needs, defaulting to the real implementations.
*
* Its reason for existing is coverage of the branches that only run when something goes
* wrong. Those branches are, by definition, the ones that never execute in a healthy
* test run — and they are also the ones a user meets on a bad day, so leaving them
* unexercised means the error handling is the least-tested code in the app.
*
* Tests must call [reset] afterwards; `FakeFailures` in the androidTest source set
* does that for them.
*
* Every failure branch in the conversion and join paths is now forced by a test. The
* three that needed a seam are covered here; the rest are reachable directly, either
* with a content URI pointing at a provider that does not exist, or by constructing a
* worker's input Data by hand rather than through its request() helper.
*/
object ConversionDependencies {
@Volatile
var hardware: (Context) -> HardwareTranscoder = { Media3Engine(it) }
@Volatile
var software: () -> SoftwareTranscoder = { FFmpegEngine() }
@Volatile
var publisher: (Context) -> OutputPublisher = { OutputPublisher(it) }
@Volatile
var deviceCodecs: () -> DeviceCodecs = { AndroidDeviceCodecs.get() }
fun reset() {
hardware = { Media3Engine(it) }
software = { FFmpegEngine() }
publisher = { OutputPublisher(it) }
deviceCodecs = { AndroidDeviceCodecs.get() }
}
}
@@ -0,0 +1,85 @@
package org.libremediaconverter.ffmpeg
import android.content.Context
import android.net.Uri
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.ReturnCode
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.model.ConcatPlanner
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Joins several inputs into one file.
*
* Picks between a stream copy and a full re-encode by inspecting the inputs, because
* 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. See [ConcatPlanner].
*/
class ConcatEngine(private val context: Context) {
data class Result(val strategy: ConcatStrategy, val output: File)
suspend fun join(
inputs: List<Uri>,
output: File,
format: OutputFormat = OutputFormat.MP4_H264,
): Result {
require(inputs.size >= 2) { "Joining needs at least two files." }
val paths = inputs.map { uri ->
if (uri.scheme == "content") {
FFmpegKitConfig.getSafParameterForRead(context, uri)
} else {
uri.path
} ?: error("Could not open one of the input files.")
}
val strategy = ConcatPlanner.plan(inputs.map { MediaProbe.probeForConcat(context, it) })
Log.i(TAG, "Joining ${inputs.size} files using $strategy")
// The demuxer reads its input list from a file, which must live somewhere
// FFmpeg can read; app cache is a real path, so it just works.
val listFile = File(output.parentFile, "concat_list.txt").apply {
writeText(FFmpegConcatCommand.listFileContents(paths))
}
val args = FFmpegConcatCommand.build(strategy, paths, listFile, output, format)
try {
execute(args)
} finally {
listFile.delete()
}
return Result(strategy, output)
}
private suspend fun execute(args: List<String>) = suspendCancellableCoroutine { cont ->
Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
val session = FFmpegKit.executeWithArgumentsAsync(args.toTypedArray()) { completed ->
val rc = completed.getReturnCode()
when {
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
ReturnCode.isCancel(rc) -> cont.cancel()
else -> cont.resumeWithException(
FFmpegEngine.FFmpegException(
"Joining failed (${rc?.value}): " +
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
)
)
}
}
cont.invokeOnCancellation { FFmpegKit.cancel(session.getSessionId()) }
}
private companion object {
const val TAG = "ConcatEngine"
const val LOG_TAIL_LIMIT = 40
}
}
@@ -0,0 +1,155 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
/**
* Builds FFmpeg argument lists.
*
* Kept free of Android types so the whole matrix can be unit tested on the JVM. A
* wrong flag here produces a corrupt file or a silent quality regression, which is
* exactly the kind of thing that should not need a device to catch.
*
* Arguments are produced as a list rather than a shell string: paths routinely contain
* spaces, and a list has no quoting rules to get wrong.
*/
object FFmpegCommandBuilder {
/**
* CRF values, chosen per codec rather than shared.
*
* x265 is roughly one CRF step "stronger" than x264 at the same number, so a
* shared constant would silently make HEVC output larger than intended.
*/
private const val CRF_H264 = 20
private const val CRF_H265 = 24
/**
* Force 4:2:0 chroma on every video encode.
*
* Sources are not always 4:2:0. Real footage encoded as H.264 High 4:4:4 Predictive
* exists, and FFmpeg will happily decode it to yuv444p and then hand those frames to
* an encoder that cannot take them. The MediaCodec wrappers fail hard in that case —
* "Invalid to call at Released state" partway through the export — and hardware
* players reject 4:4:4 output anyway. Naming the pixel format makes FFmpeg insert
* the conversion instead of failing.
*/
private val PIX_FMT = listOf("-pix_fmt", "yuv420p")
fun build(
request: ConversionRequest,
inputPath: String,
outputPath: String,
): List<String> = buildList {
add("-hide_banner")
// Overwrite: the output path is one we just created in our own cache.
add("-y")
add("-i"); add(inputPath)
addAll(streamSelection(request.format))
addAll(videoArgs(request))
addAll(audioArgs(request.format))
addAll(containerArgs(request.format))
add(outputPath)
}
private fun streamSelection(format: OutputFormat): List<String> = when {
// -vn drops video entirely. Without it FFmpeg will happily try to carry a video
// stream into an audio container and fail at the muxer.
format.isAudioOnly -> listOf("-vn")
format == OutputFormat.GIF || format == OutputFormat.FRAMES_PNG -> listOf("-an")
else -> emptyList()
}
private fun videoArgs(request: ConversionRequest): List<String> {
val format = request.format
if (format.isAudioOnly) return emptyList()
return when (format) {
OutputFormat.GIF -> listOf(
// One pass with a generated palette. GIF is limited to 256 colours, and
// the default palette produces visibly banded output; split+palettegen
// and paletteuse in a single graph avoids a temporary palette file.
"-vf",
"fps=12,scale=480:-1:flags=lanczos,split[a][b];" +
"[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer",
"-loop", "0",
)
OutputFormat.FRAMES_PNG -> listOf("-vf", "fps=1", "-vsync", "0")
else -> when (request.quality) {
// Software encoding: this is what the GPL licence buys. CRF targets a
// quality level and lets the bitrate fall where it may, which is what
// "compress this well" actually needs. No hardware encoder on Android
// exposes it.
QualityTier.BEST -> when (format.videoCodec) {
org.libremediaconverter.model.VideoCodec.H265 -> listOf(
"-c:v", "libx265", "-crf", "$CRF_H265", "-preset", "medium",
// Without this, many players and Apple devices refuse HEVC in MP4.
"-tag:v", "hvc1",
) + PIX_FMT
org.libremediaconverter.model.VideoCodec.VP9 -> listOf(
"-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0",
) + PIX_FMT
else -> listOf(
"-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "medium",
) + PIX_FMT
}
// Software encoding, always.
//
// FFmpeg's *_mediacodec encoders used to be selected here, on the theory
// that a job routed to FFmpeg for container reasons could still encode in
// hardware. In practice they are undocumented, per-device flaky, and were
// observed failing twice on real footage on a Pixel 10 Pro XL -- once
// binding to a software codec while claiming to be the fast path, and once
// dying mid-export with "Error submitting video frame to the encoder" even
// after the pixel format was pinned.
//
// They also duplicate, badly, something Media3 already does properly. A
// job only reaches FFmpeg because Media3 could not handle it, which is
// itself evidence that hardware encoding is unlikely to work for that
// input. Fast therefore means a fast *preset*, not a different encoder.
QualityTier.FAST -> when (format.videoCodec) {
org.libremediaconverter.model.VideoCodec.H265 -> listOf(
"-c:v", "libx265", "-crf", "$CRF_H265", "-preset", "veryfast",
"-tag:v", "hvc1",
) + PIX_FMT
org.libremediaconverter.model.VideoCodec.VP9 -> listOf(
"-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0",
"-deadline", "realtime",
) + PIX_FMT
else -> listOf(
"-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "veryfast",
) + PIX_FMT
}
}
}
}
private fun audioArgs(format: OutputFormat): List<String> = when (format) {
OutputFormat.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
OutputFormat.FLAC -> listOf("-c:a", "flac")
OutputFormat.WAV -> listOf("-c:a", "pcm_s16le")
OutputFormat.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
OutputFormat.M4A_AAC -> listOf("-c:a", "aac", "-b:a", "192k")
OutputFormat.GIF, OutputFormat.FRAMES_PNG -> emptyList()
OutputFormat.WEBM_VP9 -> listOf("-c:a", "libopus", "-b:a", "128k")
else -> listOf("-c:a", "aac", "-b:a", "192k")
}
private fun containerArgs(format: OutputFormat): List<String> = when (format.container) {
// Move the moov atom to the front so the file starts playing before it is
// fully downloaded. This is also the reason output never goes through a SAF
// file descriptor: faststart has to seek backwards to rewrite the header.
org.libremediaconverter.model.Container.MP4 -> listOf("-movflags", "+faststart")
else -> emptyList()
}
/** Output filename pattern for formats that emit many files. */
fun outputPattern(format: OutputFormat, baseName: String): String =
if (format == OutputFormat.FRAMES_PNG) "${baseName}_%04d.png" else baseName
}
@@ -0,0 +1,69 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import java.io.File
/**
* Builds the two different FFmpeg invocations for joining files.
*
* Pure so the argument shapes can be unit tested; the caller supplies paths.
*/
object FFmpegConcatCommand {
/**
* Content of the list file the `concat` demuxer reads.
*
* Single quotes are escaped the way the demuxer expects, because file names
* routinely contain apostrophes and an unescaped one silently truncates the entry.
*/
fun listFileContents(paths: List<String>): String =
paths.joinToString("\n") { "file '${it.replace("'", "'\\''")}'" } + "\n"
fun build(
strategy: ConcatStrategy,
inputPaths: List<String>,
listFile: File,
output: File,
format: OutputFormat,
): List<String> = when (strategy) {
ConcatStrategy.STREAM_COPY -> buildList {
add("-hide_banner"); add("-y")
// -safe 0 permits absolute paths in the list file, which ours are.
add("-f"); add("concat")
add("-safe"); add("0")
add("-i"); add(listFile.absolutePath)
add("-c"); add("copy")
if (format.container == org.libremediaconverter.model.Container.MP4) {
add("-movflags"); add("+faststart")
}
add(output.absolutePath)
}
ConcatStrategy.REENCODE -> buildList {
add("-hide_banner"); add("-y")
inputPaths.forEach { add("-i"); add(it) }
// Normalise every input to a common size and frame rate before joining,
// otherwise the concat filter refuses mismatched inputs.
val filter = buildString {
inputPaths.indices.forEach { i ->
append("[$i:v]scale=1280:720:force_original_aspect_ratio=decrease,")
append("pad=1280:720:-1:-1,setsar=1,fps=30[v$i];")
}
inputPaths.indices.forEach { i -> append("[v$i][$i:a]") }
append("concat=n=${inputPaths.size}:v=1:a=1[v][a]")
}
add("-filter_complex"); add(filter)
add("-map"); add("[v]")
add("-map"); add("[a]")
add("-c:v"); add("libx264")
add("-crf"); add("20")
add("-pix_fmt"); add("yuv420p")
add("-c:a"); add("aac")
if (format.container == org.libremediaconverter.model.Container.MP4) {
add("-movflags"); add("+faststart")
}
add(output.absolutePath)
}
}
}
@@ -0,0 +1,90 @@
package org.libremediaconverter.ffmpeg
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.Level
import com.arthenica.ffmpegkit.ReturnCode
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Software conversion via the bundled FFmpeg.
*
* Handles everything Media3 structurally cannot: Matroska, MP3, GIF, frame sequences,
* inputs with no platform decoder, and the CRF quality tier.
*
* Like [org.libremediaconverter.convert.Media3Engine], input arrives as a real
* filesystem path and output is written to app-private cache. FFmpeg is perfectly able
* to write through a SAF descriptor via its ffkitsaf protocol, but MP4 faststart has
* to seek backwards to rewrite the moov atom, which a SAF descriptor does not reliably
* support — so staging is the safe default for every format rather than a special case.
*/
class FFmpegEngine : SoftwareTranscoder {
init {
FFmpegKitConfig.setLogLevel(Level.AV_LOG_WARNING)
}
/**
* Runs a conversion, reporting progress 0..100.
*
* Progress is derived from the statistics callback's timestamp against the known
* input duration. FFmpeg has no native notion of percentage complete, so a
* [durationMs] of zero means progress simply cannot be reported — the caller gets
* an indeterminate job rather than a fabricated number.
*/
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
): Unit = suspendCancellableCoroutine { cont ->
val args = FFmpegCommandBuilder.build(request, inputPath, output.absolutePath)
Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
val session = FFmpegKit.executeWithArgumentsAsync(
args.toTypedArray(),
{ completed ->
val rc = completed.getReturnCode()
when {
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
ReturnCode.isCancel(rc) ->
cont.cancel()
else -> cont.resumeWithException(
FFmpegException(
"FFmpeg failed (${rc?.value}): " +
completed.getFailStackTrace().orEmpty().ifBlank {
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
}
)
)
}
},
{ log -> Log.d(TAG, log.message.trimEnd()) },
{ stats ->
if (durationMs > 0) {
val percent = (stats.time / durationMs * 100).toInt().coerceIn(0, 100)
onProgress(percent)
}
},
)
cont.invokeOnCancellation {
FFmpegKit.cancel(session.getSessionId())
output.delete()
}
}
class FFmpegException(message: String) : RuntimeException(message)
private companion object {
const val TAG = "FFmpegEngine"
const val LOG_TAIL_LIMIT = 40
}
}
@@ -0,0 +1,183 @@
package org.libremediaconverter.join
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
@UnstableApi
@Composable
fun JoinScreen(
modifier: Modifier = Modifier,
viewModel: JoinViewModel = viewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val pickInputs = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
val chooseDestination = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("video/mp4")
) { uri -> uri?.let(viewModel::save) }
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
) {
Text(
"Join files",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp),
)
// Same split as the converter screen: the empty state is centred, the working
// states scroll because their content can exceed the screen.
val body = Modifier.fillMaxWidth().weight(1f)
when (val s = state) {
is JoinState.Idle -> Column(
modifier = body,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Pick two or more files to join, in the order you want them.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp),
)
Button(
onClick = { pickInputs.launch(arrayOf("video/*")) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
) { Text("Choose files") }
}
else -> Column(
modifier = body.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (s) {
is JoinState.Idle -> Unit
is JoinState.Ready -> {
s.inputs.forEach { FileRow(it) }
Button(
onClick = viewModel::join,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Join ${s.inputs.size} files") }
OutlinedButton(
onClick = { pickInputs.launch(arrayOf("video/*")) },
modifier = Modifier.fillMaxWidth(),
) { Text("Choose different files") }
}
is JoinState.Joining -> {
Text("Joining ${s.inputs.size} files…")
// Indeterminate on purpose: FFmpeg reports progress against a
// single input's duration, which means nothing across a
// concatenation. A fabricated percentage would be worse than none.
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
) { Text("Cancel") }
}
is JoinState.Waiting -> {
Text(
"Paused. The system limits background media processing to " +
"six hours a day, so this will resume automatically.",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
) { Text("Cancel") }
}
is JoinState.Joined -> {
Text("Joined — ${s.staged.length() / 1_000_000} MB.")
Text(
when (s.strategy) {
ConcatStrategy.STREAM_COPY ->
"Files matched, so they were joined without " +
"re-encoding — no quality loss."
ConcatStrategy.REENCODE ->
"Files differed in format, so they were re-encoded " +
"to match."
},
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { chooseDestination.launch("joined.mp4") },
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Save file") }
OutlinedButton(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth(),
) { Text("Start over") }
}
is JoinState.Saved -> {
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Join more") }
}
is JoinState.Failed -> {
Text(
s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Start over") }
}
}
}
}
}
}
@Composable
private fun FileRow(input: InputFile) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Text(input.displayName, style = MaterialTheme.typography.bodyMedium)
}
}
}
@@ -0,0 +1,141 @@
package org.libremediaconverter.join
import android.app.Application
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
sealed interface JoinState {
data object Idle : JoinState
data class Ready(val inputs: List<InputFile>) : JoinState
data class Joining(val inputs: List<InputFile>) : JoinState
data class Waiting(val inputs: List<InputFile>) : JoinState
data class Joined(val staged: File, val strategy: ConcatStrategy) : JoinState
data class Saved(val displayName: String) : JoinState
data class Failed(val message: String) : JoinState
}
@UnstableApi
class JoinViewModel(app: Application) : AndroidViewModel(app) {
private val workManager = WorkManager.getInstance(app)
private val publisher = OutputPublisher(app)
private val _state = MutableStateFlow<JoinState>(JoinState.Idle)
val state: StateFlow<JoinState> = _state.asStateFlow()
private var observer: Job? = null
private var activeWorkId: UUID? = null
fun onInputsPicked(uris: List<Uri>) {
if (uris.size < 2) {
_state.value = JoinState.Failed("Pick at least two files to join.")
return
}
viewModelScope.launch {
val files = withContext(Dispatchers.IO) { uris.map(::queryFile) }
_state.value = JoinState.Ready(files)
}
}
fun join() {
val inputs = (_state.value as? JoinState.Ready)?.inputs ?: return
val request = ConcatWorker.request(
inputs = inputs.map { it.uri },
totalBytes = inputs.sumOf { it.sizeBytes },
)
activeWorkId = request.id
workManager.enqueue(request)
_state.value = JoinState.Joining(inputs)
observer?.cancel()
observer = viewModelScope.launch {
workManager.getWorkInfoByIdFlow(request.id).collect { info ->
if (info == null) return@collect
_state.value = when (info.state) {
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
WorkInfo.State.ENQUEUED ->
if (info.runAttemptCount > 0) JoinState.Waiting(inputs)
else JoinState.Joining(inputs)
WorkInfo.State.SUCCEEDED -> {
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
val strategy = info.outputData.getString(ConcatWorker.KEY_STRATEGY)
?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
if (path == null) {
JoinState.Failed("Joining reported success but produced no file.")
} else {
JoinState.Joined(File(path), strategy)
}
}
WorkInfo.State.FAILED -> JoinState.Failed(
info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed."
)
WorkInfo.State.CANCELLED -> JoinState.Ready(inputs)
}
}
}
}
fun cancel() {
activeWorkId?.let(workManager::cancelWorkById)
}
fun save(destination: Uri) {
val joined = _state.value as? JoinState.Joined ?: return
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
publisher.publish(joined.staged, destination)
joined.staged.delete()
}
}.onSuccess {
_state.value = JoinState.Saved("joined.mp4")
}.onFailure { e ->
_state.value = JoinState.Failed(e.message ?: "Could not save the file.")
}
}
}
fun reset() {
observer?.cancel()
observer = null
activeWorkId = null
_state.value = JoinState.Idle
}
private fun queryFile(uri: Uri): InputFile {
var name = "input"
var size = 0L
getApplication<Application>().contentResolver
.query(uri, null, null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }
?.let { name = cursor.getString(it) ?: name }
cursor.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }
?.let { size = cursor.getLong(it) }
}
}
return InputFile(uri, name, size)
}
}
@@ -0,0 +1,65 @@
package org.libremediaconverter.model
/**
* How to join several inputs into one file.
*
* The naive answer — FFmpeg's `concat` demuxer — only works when every input shares a
* codec, resolution and timebase. That is true for clips from one camera in one
* session, and false for the case users actually hit: two clips from different phones,
* or a screen recording joined to a camera clip. The demuxer does not fail loudly on
* mismatch; it can produce a file whose second half is garbled.
*
* So the strategy is chosen from the inputs rather than assumed.
*/
enum class ConcatStrategy {
/**
* Stream copy via the `concat` demuxer. No re-encode, near-instant, lossless.
* Requires every input to agree on codec, resolution, frame rate and timebase.
*/
STREAM_COPY,
/**
* Re-encode through the `concat` filter, normalising to a common format.
* Slower and lossy, but it is the only correct answer for mismatched inputs.
*/
REENCODE,
}
/** The properties that decide whether inputs can be joined without re-encoding. */
data class ConcatInput(
val videoCodec: String?,
val audioCodec: String?,
val width: Int,
val height: Int,
val frameRate: Int,
)
object ConcatPlanner {
/**
* Picks a strategy for [inputs].
*
* Errs towards [ConcatStrategy.REENCODE]: a needless re-encode costs time, while a
* wrong stream copy costs the user a corrupt file they may not notice until later.
*/
fun plan(inputs: List<ConcatInput>): ConcatStrategy {
if (inputs.size < 2) return ConcatStrategy.STREAM_COPY
val first = inputs.first()
// A null codec means we could not determine it. That is not evidence of a
// match, so it must not be treated as one.
if (inputs.any { it.videoCodec == null || it.videoCodec != first.videoCodec }) {
return ConcatStrategy.REENCODE
}
if (inputs.any { it.audioCodec != first.audioCodec }) {
return ConcatStrategy.REENCODE
}
if (inputs.any { it.width != first.width || it.height != first.height }) {
return ConcatStrategy.REENCODE
}
if (inputs.any { it.frameRate != first.frameRate }) {
return ConcatStrategy.REENCODE
}
return ConcatStrategy.STREAM_COPY
}
}
@@ -0,0 +1,140 @@
package org.libremediaconverter.model
/**
* Chooses the engine for a conversion.
*
* Media3 Transformer is preferred wherever it is capable, because it is hardware
* accelerated end to end (MediaCodec decode -> GL surface -> MediaCodec encode) and
* runs several times faster than software encoding at a fraction of the battery cost.
* FFmpeg handles everything Media3 structurally cannot.
*
* The rules below are capability boundaries, not preferences. Each one exists because
* Media3 would fail or silently produce the wrong thing.
*/
object ConversionRouter {
/** Containers Media3 can mux. Anything else has to go to FFmpeg. */
private val MEDIA3_CONTAINERS = setOf(
Container.MP4,
Container.WEBM,
Container.OGG,
Container.WAV,
Container.AAC_ADTS,
)
/** WebM is codec-restricted: Media3's WebmMuxer writes only these. */
private val WEBM_AUDIO = setOf(AudioCodec.OPUS, AudioCodec.VORBIS)
private val WEBM_VIDEO = setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.NONE)
/** Video codecs Media3 can be asked to encode (`Transformer.setVideoMimeType`). */
private val MEDIA3_VIDEO = setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.NONE)
/** Audio codecs Media3 can encode. Notably absent: MP3 and FLAC. */
private val MEDIA3_AUDIO = setOf(AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.PCM, AudioCodec.NONE)
fun route(request: ConversionRequest, device: DeviceCodecs): Decision {
when (request.enginePreference) {
EnginePreference.FORCE_SOFTWARE ->
return Decision(Engine.FFMPEG, Reason.USER_FORCED_SOFTWARE)
else -> Unit
}
// Order matters below: the specific reasons are checked before the general
// ones, because the reason string is shown to the user. "Android has no
// encoder for this format" tells them something actionable about MP3;
// "this container needs FFmpeg" does not.
// MP3 has no encoder anywhere on Android, at any API level. That is a platform
// gap rather than a Media3 limitation, and libmp3lame is the only way the app
// can produce MP3 at all.
if (request.format.audioCodec !in MEDIA3_AUDIO) {
return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_ENCODER)
}
// GIF and frame sequences are image outputs; Media3 has no muxer for them.
if (request.format.isImageOutput) {
return Decision(Engine.FFMPEG, Reason.IMAGE_OUTPUT)
}
// Containers Media3 cannot mux at all, chiefly Matroska.
if (request.format.container !in MEDIA3_CONTAINERS) {
return Decision(Engine.FFMPEG, Reason.CONTAINER_UNSUPPORTED)
}
// WebM is codec-restricted even though the container itself is supported.
if (request.format.container == Container.WEBM &&
(request.format.audioCodec !in WEBM_AUDIO || request.format.videoCodec !in WEBM_VIDEO)
) {
return Decision(Engine.FFMPEG, Reason.WEBM_CODEC_UNSUPPORTED)
}
// Media3 does not bundle ExoPlayer's software decoders, so an input codec with
// no platform decoder cannot be read at all. The dav1d extension does not
// rescue this: Transformer ignores bundled software decoder modules.
val inputCodec = request.probe.videoCodec
if (inputCodec != null && !device.canDecode(inputCodec)) {
return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_DECODER)
}
// CRF and two-pass are the whole point of the quality tier, and MediaCodec
// exposes neither, so BEST always means software encoding.
if (request.quality == QualityTier.BEST) {
return Decision(Engine.FFMPEG, Reason.QUALITY_TIER_REQUIRES_CRF)
}
// Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V, so VP9 and
// AV1 targets cannot be encoded by Media3 regardless of what the device can do.
if (request.format.videoCodec !in MEDIA3_VIDEO) {
return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_ENCODER)
}
// Finally, the target codec has to be hardware-encodable on this specific
// device. HEVC is near-universal; AV1 encode is rare.
if (request.format.videoCodec != VideoCodec.NONE &&
!device.canEncode(request.format.videoCodec)
) {
return Decision(Engine.FFMPEG, Reason.NO_HARDWARE_ENCODER)
}
return Decision(Engine.MEDIA3, Reason.HARDWARE_CAPABLE)
}
data class Decision(val engine: Engine, val reason: Reason)
enum class Reason(val explanation: String) {
HARDWARE_CAPABLE("Hardware accelerated"),
CONTAINER_UNSUPPORTED("This container needs FFmpeg"),
WEBM_CODEC_UNSUPPORTED("WebM only supports VP8/VP9 with Opus or Vorbis"),
NO_PLATFORM_ENCODER("Android has no encoder for this format"),
NO_PLATFORM_DECODER("This device cannot decode the input in hardware"),
NO_HARDWARE_ENCODER("This device has no hardware encoder for that codec"),
IMAGE_OUTPUT("Image output needs FFmpeg"),
QUALITY_TIER_REQUIRES_CRF("Best quality uses software encoding"),
USER_FORCED_SOFTWARE("Software encoding was requested"),
MEDIA3_FAILED("Hardware conversion failed; retrying in software"),
}
}
/**
* What this device's codecs can actually do.
*
* An interface so the routing rules can be unit tested against fabricated device
* profiles rather than whatever hardware the test happens to run on.
*/
interface DeviceCodecs {
fun canEncode(codec: VideoCodec): Boolean
fun canDecode(codecName: String): Boolean
companion object {
/**
* Assumes everything works, except an input the platform could not parse.
*
* That exception matters: a device double that claims it can decode an
* unparseable file would let the router send a doomed job to Media3.
*/
val PERMISSIVE = object : DeviceCodecs {
override fun canEncode(codec: VideoCodec) = true
override fun canDecode(codecName: String) = codecName != InputProbe.UNPARSEABLE
}
}
}
@@ -0,0 +1,96 @@
package org.libremediaconverter.model
/** Container families, used by the router to decide which engine can mux the result. */
enum class Container { MP4, WEBM, MKV, OGG, WAV, AAC_ADTS, MP3, GIF, IMAGE_SEQUENCE }
/** Codecs this app can be asked to produce. */
enum class VideoCodec { H264, H265, VP9, VP8, AV1, NONE }
enum class AudioCodec { AAC, OPUS, VORBIS, MP3, FLAC, PCM, NONE }
/**
* A user-selectable output format.
*
* Deliberately a closed set rather than a free-form codec/container matrix: most
* combinations are either invalid or pointless, and the closed set is what makes the
* routing rules decidable.
*/
enum class OutputFormat(
val label: String,
val container: Container,
val videoCodec: VideoCodec,
val audioCodec: AudioCodec,
val extension: String,
val mimeType: String,
) {
MP4_H264("MP4 (H.264)", Container.MP4, VideoCodec.H264, AudioCodec.AAC, "mp4", "video/mp4"),
MP4_H265("MP4 (H.265)", Container.MP4, VideoCodec.H265, AudioCodec.AAC, "mp4", "video/mp4"),
WEBM_VP9("WebM (VP9)", Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS, "webm", "video/webm"),
MKV_H264("MKV (H.264)", Container.MKV, VideoCodec.H264, AudioCodec.AAC, "mkv", "video/x-matroska"),
MKV_H265("MKV (H.265)", Container.MKV, VideoCodec.H265, AudioCodec.AAC, "mkv", "video/x-matroska"),
MP3("MP3", Container.MP3, VideoCodec.NONE, AudioCodec.MP3, "mp3", "audio/mpeg"),
M4A_AAC("M4A (AAC)", Container.MP4, VideoCodec.NONE, AudioCodec.AAC, "m4a", "audio/mp4"),
OPUS("Opus", Container.OGG, VideoCodec.NONE, AudioCodec.OPUS, "opus", "audio/opus"),
FLAC("FLAC", Container.MKV, VideoCodec.NONE, AudioCodec.FLAC, "flac", "audio/flac"),
WAV("WAV", Container.WAV, VideoCodec.NONE, AudioCodec.PCM, "wav", "audio/wav"),
GIF("GIF", Container.GIF, VideoCodec.NONE, AudioCodec.NONE, "gif", "image/gif"),
FRAMES_PNG("PNG frames", Container.IMAGE_SEQUENCE, VideoCodec.NONE, AudioCodec.NONE, "png", "image/png");
val isAudioOnly: Boolean get() = videoCodec == VideoCodec.NONE && audioCodec != AudioCodec.NONE
val isImageOutput: Boolean
get() = container == Container.GIF || container == Container.IMAGE_SEQUENCE
}
/**
* How hard to work for quality.
*
* This is the user-facing form of the engine split. [BEST] is the entire reason the
* shipped binary is GPL: CRF and two-pass rate control come from x264/x265 and are not
* exposed by any Android hardware encoder.
*/
enum class QualityTier(val label: String, val description: String) {
FAST("Fast", "Hardware accelerated. Best for sharing and batches."),
BEST("Best quality", "Software encode with CRF. Slower, smaller files."),
}
/** Escape hatch, mostly for debugging and for devices with broken encoders. */
enum class EnginePreference { AUTO, PREFER_HARDWARE, FORCE_SOFTWARE }
enum class Engine { MEDIA3, FFMPEG }
/** What we know about the input, as far as routing is concerned. */
data class InputProbe(
val videoCodec: String? = null,
val audioCodec: String? = null,
val hasVideo: Boolean = true,
val durationMs: Long = 0,
) {
companion object {
/**
* Codec name used when the platform could not parse the input at all.
*
* Distinct from `null` (nothing known, assume the platform copes) and from a
* real codec name. [DeviceCodecs] treats it as undecodable, which routes the
* job to FFmpeg — the correct answer when the platform extractor has already
* failed to open the file.
*/
const val UNPARSEABLE = "\u0000unparseable"
}
}
data class ConversionRequest(
val format: OutputFormat,
val quality: QualityTier = QualityTier.FAST,
val enginePreference: EnginePreference = EnginePreference.AUTO,
val probe: InputProbe = InputProbe(),
/**
* Whether this device has a real hardware encoder for the target codec.
*
* When it does not, FFmpeg's `*_mediacodec` wrappers still appear to work: they
* bind to the platform's *software* codec (`c2.android.*`) and encode at a crawl
* while presenting as the fast path. Knowing this lets the Fast tier choose a
* genuinely fast software preset instead of a mislabelled slow one.
*/
val hardwareEncodeAvailable: Boolean = true,
)
@@ -0,0 +1,24 @@
package org.libremediaconverter.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Layout constants shared by the top-level screens.
*
* Shared rather than duplicated so the two tabs cannot drift apart: an empty state that
* looks different depending on which tab you are on reads as a bug.
*/
/** Primary actions are taller than the Material default so they read as the main affordance. */
val PrimaryButtonHeight: Dp = 56.dp
/**
* Horizontal screen inset.
*
* Deliberately tighter than the vertical inset so a full-width primary button reaches
* close to both edges of the display.
*/
val ScreenPaddingHorizontal: Dp = 16.dp
val ScreenPaddingVertical: Dp = 24.dp
@@ -0,0 +1,12 @@
package org.libremediaconverter.ui.theme
import androidx.compose.ui.graphics.Color
// Static fallback palette, used when dynamic color is unavailable or disabled.
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,51 @@
package org.libremediaconverter.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
)
/**
* Material 3 theme.
*
* Dynamic color (Material You) needs API 31+; minSdk is 33, so it is available
* unconditionally and no version guard is required. It stays switchable so users can
* opt back to the brand palette.
*/
@Composable
fun LibreMediaConverterTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && darkTheme -> dynamicDarkColorScheme(context)
dynamicColor -> dynamicLightColorScheme(context)
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,5 @@
package org.libremediaconverter.ui.theme
import androidx.compose.material3.Typography
val Typography = Typography()
@@ -0,0 +1,116 @@
package org.libremediaconverter.work
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.OutputFormat
/**
* Joins several files into one, as durable foreground work.
*
* Separate from [ConversionWorker] rather than folded into it: joining takes a list of
* inputs, has its own stream-copy-versus-re-encode decision, and reports a different
* result. Overloading one worker with both would make the input contract ambiguous.
*
* Progress is not reported. FFmpeg's statistics callback gives a timestamp against a
* single input's duration, which is meaningless once several files are being
* concatenated; showing a fabricated percentage would be worse than showing none.
*/
@UnstableApi
class ConcatWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
private val notifications = ConversionNotifications(applicationContext)
private val publisher = ConversionDependencies.publisher(applicationContext)
override suspend fun doWork(): Result {
val uris = inputData.getStringArray(KEY_INPUT_URIS)?.map(Uri::parse)
?: return Result.failure(workDataOf(KEY_ERROR to "No input files."))
if (uris.size < 2) {
return Result.failure(workDataOf(KEY_ERROR to "Pick at least two files to join."))
}
val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L)
val format = OutputFormat.valueOf(
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name
)
if (!publisher.hasSpaceFor(totalBytes)) {
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to join these files."))
}
setForeground(
ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true),
ConversionForegroundType.current(),
)
)
val staged = publisher.createStagingFile("joined.${format.extension}")
return try {
val result = ConcatEngine(applicationContext).join(uris, staged, format)
Result.success(
workDataOf(
KEY_OUTPUT_PATH to staged.absolutePath,
KEY_STRATEGY to result.strategy.name,
)
)
} catch (e: Throwable) {
staged.delete()
when (FailureOutcome.forStopReason(stopReason)) {
FailureOutcome.RETRY -> {
Log.w(TAG, "Foreground budget exhausted while joining; will retry.", e)
Result.retry()
}
FailureOutcome.FAIL -> {
Log.e(TAG, "Joining failed.", e)
Result.failure(workDataOf(KEY_ERROR to (e.message ?: "Joining failed.")))
}
}
}
}
override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, "Joining files", 0, indeterminate = true),
ConversionForegroundType.current(),
)
companion object {
const val KEY_INPUT_URIS = "input_uris"
const val KEY_TOTAL_BYTES = "total_bytes"
const val KEY_FORMAT = "format"
const val KEY_OUTPUT_PATH = "output_path"
const val KEY_STRATEGY = "strategy"
const val KEY_ERROR = "error"
private const val NOTIFICATION_ID = 1002
private const val TAG = "ConcatWorker"
fun request(
inputs: List<Uri>,
totalBytes: Long,
format: OutputFormat = OutputFormat.MP4_H264,
) = OneTimeWorkRequestBuilder<ConcatWorker>()
.setInputData(
Data.Builder()
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
.putLong(KEY_TOTAL_BYTES, totalBytes)
.putString(KEY_FORMAT, format.name)
.build()
)
.build()
}
}
@@ -0,0 +1,37 @@
package org.libremediaconverter.work
import android.content.pm.ServiceInfo
import android.os.Build
/**
* Picks the foreground service type for a conversion job.
*
* There are three regimes across the supported range, which is why this is not a
* single constant:
*
* | API | Regime |
* |------------|-----------------------------------------------------------------|
* | 33 | Foreground service types are not required at all. |
* | 34 | A type is mandatory, but `mediaProcessing` does not exist yet, |
* | | so `dataSync` is the only sensible fit. |
* | 35+ | `mediaProcessing` exists and is the correct type. Its own docs |
* | | describe it as "converting media to different formats". |
*
* Both types carry the same budget: **six hours out of every twenty-four**, shared
* across all of the app's foreground services. On expiry the system calls
* `Service.onTimeout` and the app has seconds to stop before taking an ANR.
*/
object ConversionForegroundType {
/**
* The `foregroundServiceType` to pass to `ForegroundInfo`.
*
* Returns 0 on API 33, where passing a type is unnecessary — and where the
* `mediaProcessing` constant does not exist to pass in the first place.
*/
fun current(): Int = when {
Build.VERSION.SDK_INT >= 35 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
Build.VERSION.SDK_INT >= 34 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
else -> 0
}
}
@@ -0,0 +1,69 @@
package org.libremediaconverter.work
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.work.WorkManager
import org.libremediaconverter.R
import java.util.UUID
/** Progress notification for a running conversion. */
class ConversionNotifications(private val context: Context) {
init {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.channel_conversions),
// Low importance: a long-running progress bar should not make noise or
// push a heads-up card on every update.
NotificationManager.IMPORTANCE_LOW,
).apply {
description = context.getString(R.string.channel_conversions_description)
setShowBadge(false)
}
context.getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
fun build(id: UUID, title: String, percent: Int, indeterminate: Boolean = false) =
NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(
if (indeterminate) {
context.getString(R.string.notification_preparing)
} else {
context.getString(R.string.notification_progress, percent)
}
)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
// Progress updates far outpace what the UI can use; alerting once keeps
// the system UI from being hammered.
.setOnlyAlertOnce(true)
.setProgress(100, percent, indeterminate)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
context.getString(R.string.action_cancel),
WorkManager.getInstance(context).createCancelPendingIntent(id),
)
.build()
/**
* True when notifications can actually be shown.
*
* A foreground service still starts without POST_NOTIFICATIONS, but its
* notification appears only in the Task Manager rather than the shade — so
* progress silently vanishes from the user's point of view.
*/
fun areEnabled(): Boolean =
context.getSystemService(NotificationManager::class.java)
.areNotificationsEnabled()
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
companion object {
const val CHANNEL_ID = "conversions"
private const val TAG = "ConversionNotifications"
}
}
@@ -0,0 +1,252 @@
package org.libremediaconverter.work
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.arthenica.ffmpegkit.FFmpegKitConfig
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.ConversionRouter
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import java.io.File
/**
* Runs one conversion as durable, cancellable background work.
*
* WorkManager rather than a bare foreground service: the queue survives process death,
* cancellation and progress are already modelled, and `WorkInfo` gives the UI a Flow to
* observe. That durability is what makes the six-hour foreground-service timeout
* recoverable instead of fatal.
*
* Expedited work is deliberately *not* used. It maps to JobScheduler expedited jobs
* with a short quota, which is the wrong shape for a multi-minute transcode.
*/
@UnstableApi
class ConversionWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
private val notifications = ConversionNotifications(applicationContext)
// Resolved through ConversionDependencies so tests can force the failure paths.
private val publisher = ConversionDependencies.publisher(applicationContext)
override suspend fun doWork(): Result {
val inputUri = inputData.getString(KEY_INPUT_URI)?.let(Uri::parse)
?: return Result.failure(workDataOf(KEY_ERROR to "No input file."))
val displayName = inputData.getString(KEY_DISPLAY_NAME) ?: "input"
val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L)
val format = OutputFormat.valueOf(
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H265.name
)
val quality = QualityTier.valueOf(
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name
)
val preference = EnginePreference.valueOf(
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name
)
if (!publisher.hasSpaceFor(sizeBytes)) {
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to convert."))
}
setForeground(foregroundInfo(displayName, percent = 0, indeterminate = true))
val probe = MediaProbe.probe(applicationContext, inputUri)
val devices = ConversionDependencies.deviceCodecs()
val request = ConversionRequest(
format = format,
quality = quality,
enginePreference = preference,
probe = probe,
hardwareEncodeAvailable = devices.canEncode(format.videoCodec),
)
val decision = ConversionRouter.route(request, devices)
Log.i(TAG, "Routing $displayName -> ${format.name} via ${decision.engine} (${decision.reason})")
val staged = publisher.createStagingFile(outputNameFor(displayName, format))
return try {
when (decision.engine) {
Engine.MEDIA3 -> runMedia3OrFallBack(request, inputUri, staged, displayName)
Engine.FFMPEG -> runFFmpeg(request, inputUri, staged, displayName)
}
Result.success(
workDataOf(
KEY_OUTPUT_PATH to staged.absolutePath,
KEY_ENGINE_USED to decision.engine.name,
KEY_ROUTE_REASON to decision.reason.explanation,
)
)
} catch (e: Throwable) {
staged.delete()
handleTimeoutIfNeeded(e)
}
}
/**
* The dynamic half of the routing rules.
*
* The static predicates catch what is knowably unsupported, but hardware encoders
* are vendor-declared and, per the platform's own documentation, "cannot be tested
* for correctness". A device that claims HEVC support and then fails mid-export is
* a real and common failure. Rather than surface that to the user as a failed
* conversion, retry the same job in software — slower, but it produces the file.
*/
private suspend fun runMedia3OrFallBack(
request: ConversionRequest,
inputUri: Uri,
staged: File,
displayName: String,
) {
val engine = ConversionDependencies.hardware(applicationContext)
try {
engine.transcode(inputUri, staged, media3MimeType(request.format)) { percent ->
publishProgress(displayName, percent)
}
return
} catch (e: Throwable) {
if (isCancellation(e)) throw e
Log.w(TAG, "Hardware conversion failed; retrying in software.", e)
} finally {
engine.close()
}
staged.delete()
runFFmpeg(request, inputUri, staged, displayName)
}
private suspend fun runFFmpeg(
request: ConversionRequest,
inputUri: Uri,
staged: File,
displayName: String,
) {
// FFmpeg needs a path. ffkitsaf bridges a content:// URI for reading; the read
// side is seekable for local providers, which is all the demuxer needs. Output
// still goes to a real cache path — see OutputPublisher.
val inputPath = if (inputUri.scheme == "content") {
FFmpegKitConfig.getSafParameterForRead(applicationContext, inputUri)
} else {
inputUri.path
} ?: error("Could not open the input file.")
ConversionDependencies.software()
.run(request, inputPath, staged, request.probe.durationMs) { percent ->
publishProgress(displayName, percent)
}
}
private var lastNotified = 0L
private fun publishProgress(displayName: String, percent: Int) {
setProgressAsync(workDataOf(KEY_PROGRESS to percent))
// Throttle to ~1/sec: progress arrives several times a second and pushing every
// update janks the system UI.
val now = System.currentTimeMillis()
if (now - lastNotified >= NOTIFICATION_INTERVAL_MS) {
lastNotified = now
applicationContext.getSystemService(android.app.NotificationManager::class.java)
.notify(NOTIFICATION_ID, notifications.build(id, displayName, percent))
}
}
private fun isCancellation(e: Throwable): Boolean =
e is kotlinx.coroutines.CancellationException || isStopped
/**
* Distinguishes a genuine failure from the foreground-service budget expiring.
*
* `mediaProcessing` allows six hours out of every twenty-four, shared across the
* app. When that runs out WorkManager reports
* `STOP_REASON_FOREGROUND_SERVICE_TIMEOUT`, and the right response is to retry
* later rather than tell the user the conversion failed — the work is still valid,
* there is simply no budget right now.
*/
private fun handleTimeoutIfNeeded(cause: Throwable): Result =
when (FailureOutcome.forStopReason(stopReason)) {
FailureOutcome.RETRY -> {
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
Result.retry()
}
FailureOutcome.FAIL -> {
Log.e(TAG, "Conversion failed.", cause)
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
}
}
private fun media3MimeType(format: OutputFormat): String = when (format.videoCodec) {
VideoCodec.H264 -> MimeTypes.VIDEO_H264
else -> MimeTypes.VIDEO_H265
}
override suspend fun getForegroundInfo(): ForegroundInfo =
foregroundInfo(
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
percent = 0,
indeterminate = true,
)
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) =
ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, title, percent, indeterminate),
ConversionForegroundType.current(),
)
companion object {
const val KEY_INPUT_URI = "input_uri"
const val KEY_DISPLAY_NAME = "display_name"
const val KEY_SIZE_BYTES = "size_bytes"
const val KEY_FORMAT = "format"
const val KEY_QUALITY = "quality"
const val KEY_ENGINE_PREFERENCE = "engine_preference"
const val KEY_PROGRESS = "progress"
const val KEY_OUTPUT_PATH = "output_path"
const val KEY_ENGINE_USED = "engine_used"
const val KEY_ROUTE_REASON = "route_reason"
const val KEY_ERROR = "error"
private const val NOTIFICATION_ID = 1001
private const val NOTIFICATION_INTERVAL_MS = 1_000L
private const val TAG = "ConversionWorker"
fun outputNameFor(inputName: String, format: OutputFormat): String =
inputName.substringBeforeLast('.', inputName) +
"_converted.${format.extension}"
fun request(
inputUri: Uri,
displayName: String,
sizeBytes: Long,
format: OutputFormat = OutputFormat.MP4_H265,
quality: QualityTier = QualityTier.FAST,
enginePreference: EnginePreference = EnginePreference.AUTO,
) = OneTimeWorkRequestBuilder<ConversionWorker>()
.setInputData(
Data.Builder()
.putString(KEY_INPUT_URI, inputUri.toString())
.putString(KEY_DISPLAY_NAME, displayName)
.putLong(KEY_SIZE_BYTES, sizeBytes)
.putString(KEY_FORMAT, format.name)
.putString(KEY_QUALITY, quality.name)
.putString(KEY_ENGINE_PREFERENCE, enginePreference.name)
.build()
)
.build()
}
}
@@ -0,0 +1,25 @@
package org.libremediaconverter.work
import androidx.work.WorkInfo
/**
* Decides whether a failed job should be retried or reported as failed.
*
* A pure function rather than a branch inside the worker, because the case that matters
* cannot be provoked in a test: the foreground-service budget is six hours per
* twenty-four, and no test is going to exhaust it. Isolating the decision means the
* rule itself can still be verified on the JVM, even though the condition that triggers
* it in production cannot be reproduced.
*/
enum class FailureOutcome {
/** Budget exhausted, not a real failure — the work is still valid, so try later. */
RETRY,
/** A genuine failure; report it to the user. */
FAIL;
companion object {
fun forStopReason(stopReason: Int): FailureOutcome =
if (stopReason == WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT) RETRY else FAIL
}
}
+38 -11
View File
@@ -1,12 +1,39 @@
# Add project specific R8 rules here.
# AGP will combine all keep rule files in src/main/keepRules to pass to R8
#
# For more details, see
# https://d.android.com/r/tools/r8/keep-rules
# R8 keep rules.
# AGP combines every file under src/<variant>/keepRules and passes them to R8.
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# --- FFmpegKit JNI boundary -------------------------------------------------
# The native library looks these classes and members up by name through JNI.
# R8 cannot see those references, so without explicit keeps it will rename or
# remove them and the native calls fail at runtime with NoSuchMethodError --
# only in release builds, and only once a conversion is actually attempted.
-keep class com.arthenica.ffmpegkit.** { *; }
-keep class com.arthenica.smartexception.** { *; }
# Callback types the native layer instantiates and invokes.
-keep interface com.arthenica.ffmpegkit.FFmpegSessionCompleteCallback { *; }
-keep interface com.arthenica.ffmpegkit.FFprobeSessionCompleteCallback { *; }
-keep interface com.arthenica.ffmpegkit.MediaInformationSessionCompleteCallback { *; }
-keep interface com.arthenica.ffmpegkit.LogCallback { *; }
-keep interface com.arthenica.ffmpegkit.StatisticsCallback { *; }
# Any method the native side calls back into.
-keepclasseswithmembernames class * {
native <methods>;
}
# --- WorkManager ------------------------------------------------------------
# Workers are constructed reflectively from a class name stored in the WorkManager
# database, so a renamed worker breaks jobs that were enqueued before the update.
-keep class * extends androidx.work.ListenableWorker {
public <init>(android.content.Context, androidx.work.WorkerParameters);
}
# --- Media3 -----------------------------------------------------------------
# Transformer selects codecs and muxers reflectively in places.
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
# Keep the source file and line numbers so release crash reports stay readable,
# then hide the original file name.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
+3 -16
View File
@@ -1,16 +1,3 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.AndroidMediaConverter" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
<resources>
<style name="Theme.LibreMediaConverter" parent="android:Theme.Material.NoActionBar" />
</resources>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+8 -2
View File
@@ -1,3 +1,9 @@
<resources>
<string name="app_name">Android MediaConverter</string>
</resources>
<string name="app_name">LibreMediaConverter</string>
<string name="channel_conversions">Conversions</string>
<string name="channel_conversions_description">Progress for running conversions</string>
<string name="notification_progress">Converting… %1$d%%</string>
<string name="notification_preparing">Preparing…</string>
<string name="action_cancel">Cancel</string>
</resources>
+3 -16
View File
@@ -1,16 +1,3 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.AndroidMediaConverter" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
<resources>
<style name="Theme.LibreMediaConverter" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -1,17 +0,0 @@
package com.example.androidmediaconverter
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
@@ -0,0 +1,231 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FFmpegCommandBuilderTest {
private fun cmd(
format: OutputFormat,
quality: QualityTier = QualityTier.BEST,
hardwareEncodeAvailable: Boolean = true,
): List<String> = FFmpegCommandBuilder.build(
ConversionRequest(
format = format,
quality = quality,
hardwareEncodeAvailable = hardwareEncodeAvailable,
),
inputPath = "/cache/in.mp4",
outputPath = "/cache/out.${format.extension}",
)
/** Asserts `flag` is present and immediately followed by `value`. */
private fun assertPair(args: List<String>, flag: String, value: String) {
val i = args.indexOf(flag)
assertTrue("missing $flag in $args", i >= 0)
assertEquals("wrong value for $flag in $args", value, args[i + 1])
}
// --- structure ---------------------------------------------------------
@Test
fun `input precedes output and both are present`() {
val args = cmd(OutputFormat.MP4_H264)
assertPair(args, "-i", "/cache/in.mp4")
assertEquals("/cache/out.mp4", args.last())
}
@Test
fun `overwrite is enabled because the output path is our own cache file`() {
assertTrue(cmd(OutputFormat.MP4_H264).contains("-y"))
}
// --- the GPL quality path ---------------------------------------------
@Test
fun `best quality h264 uses libx264 with crf, not a bitrate target`() {
val args = cmd(OutputFormat.MP4_H264, QualityTier.BEST)
assertPair(args, "-c:v", "libx264")
assertPair(args, "-crf", "20")
assertFalse("CRF and -b:v are mutually exclusive", args.contains("-b:v"))
}
@Test
fun `best quality h265 uses libx265 with a higher crf than h264`() {
val args = cmd(OutputFormat.MP4_H265, QualityTier.BEST)
assertPair(args, "-c:v", "libx265")
// x265 is roughly a step stronger at the same number, so sharing a constant
// with x264 would silently change the size target.
assertPair(args, "-crf", "24")
}
@Test
fun `hevc in mp4 is tagged hvc1 for player compatibility`() {
assertPair(cmd(OutputFormat.MP4_H265, QualityTier.BEST), "-tag:v", "hvc1")
}
/**
* Regression test for a failure found with real footage on a Pixel 10 Pro XL.
*
* The source was H.264 High 4:4:4 Predictive. FFmpeg decodes that to yuv444p and,
* without an explicit pixel format, hands those frames straight to an encoder that
* cannot accept them — hevc_mediacodec died with "Invalid to call at Released
* state" partway through. Every video encode path has to name the format so FFmpeg
* inserts the conversion, not just the one path that happened to have it.
*/
@Test
fun `every video encode path forces yuv420p`() {
val videoFormats = listOf(
OutputFormat.MP4_H264, OutputFormat.MP4_H265,
OutputFormat.MKV_H264, OutputFormat.MKV_H265,
OutputFormat.WEBM_VP9,
)
videoFormats.forEach { format ->
listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality ->
listOf(true, false).forEach { hw ->
val args = cmd(format, quality, hardwareEncodeAvailable = hw)
assertPair(args, "-pix_fmt", "yuv420p")
}
}
}
}
@Test
fun `audio only outputs do not set a pixel format`() {
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV).forEach {
assertFalse("${it.name} should not set -pix_fmt", cmd(it).contains("-pix_fmt"))
}
}
// --- the hardware fallback path ---------------------------------------
/**
* Regression test for a defect found on a device with no hardware HEVC encoder.
*
* FFmpeg's *_mediacodec wrappers do not fail on such a device — they quietly bind
* to the platform software codec (c2.android.*) and encode far slower than
* libx264/libx265 would, while still presenting as the fast path. When no hardware
* encoder exists, a real software encoder on a fast preset is both quicker and
* honest about what it is doing.
*/
@Test
fun `the encoder choice no longer depends on hardware availability`() {
// Once FFmpeg stopped selecting MediaCodec encoders, this flag only affects
// whether the router sends the job to Media3 at all -- not what FFmpeg does.
listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265).forEach { format ->
assertEquals(
cmd(format, QualityTier.FAST, hardwareEncodeAvailable = true),
cmd(format, QualityTier.FAST, hardwareEncodeAvailable = false),
)
}
}
@Test
fun `fast software preset is faster than the best quality preset`() {
// Both use libx264; the distinction between the tiers has to survive the
// fallback, otherwise Fast and Best become the same slow thing.
val fast = cmd(OutputFormat.MP4_H264, QualityTier.FAST, hardwareEncodeAvailable = false)
val best = cmd(OutputFormat.MP4_H264, QualityTier.BEST)
assertEquals("veryfast", fast[fast.indexOf("-preset") + 1])
assertEquals("medium", best[best.indexOf("-preset") + 1])
}
// --- audio -------------------------------------------------------------
@Test
fun `mp3 uses libmp3lame and drops video`() {
val args = cmd(OutputFormat.MP3)
assertPair(args, "-c:a", "libmp3lame")
assertTrue("audio-only output must drop the video stream", args.contains("-vn"))
}
@Test
fun `flac wav and opus select the right encoders`() {
assertPair(cmd(OutputFormat.FLAC), "-c:a", "flac")
assertPair(cmd(OutputFormat.WAV), "-c:a", "pcm_s16le")
assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
}
@Test
fun `audio only formats never carry a video encoder`() {
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
.forEach { format ->
val args = cmd(format)
assertFalse("$format should not set -c:v", args.contains("-c:v"))
assertTrue("$format should set -vn", args.contains("-vn"))
}
}
// --- image outputs -----------------------------------------------------
@Test
fun `gif generates a palette to avoid banding and drops audio`() {
val args = cmd(OutputFormat.GIF)
val filter = args[args.indexOf("-vf") + 1]
assertTrue("gif needs palettegen: $filter", filter.contains("palettegen"))
assertTrue("gif needs paletteuse: $filter", filter.contains("paletteuse"))
assertTrue(args.contains("-an"))
}
@Test
fun `gif loops forever`() {
assertPair(cmd(OutputFormat.GIF), "-loop", "0")
}
@Test
fun `frame export sets an fps filter and numbered output`() {
val args = cmd(OutputFormat.FRAMES_PNG)
assertPair(args, "-vf", "fps=1")
assertEquals(
"shot_%04d.png",
FFmpegCommandBuilder.outputPattern(OutputFormat.FRAMES_PNG, "shot"),
)
}
@Test
fun `single file formats keep their plain name`() {
assertEquals(
"clip.mp4",
FFmpegCommandBuilder.outputPattern(OutputFormat.MP4_H264, "clip.mp4"),
)
}
// --- containers --------------------------------------------------------
@Test
fun `mp4 output enables faststart`() {
assertPair(cmd(OutputFormat.MP4_H264), "-movflags", "+faststart")
}
@Test
fun `non mp4 containers do not get faststart`() {
assertFalse(cmd(OutputFormat.MKV_H264).contains("-movflags"))
assertFalse(cmd(OutputFormat.WAV).contains("-movflags"))
}
@Test
fun `mkv accepts h264 and h265 without faststart`() {
assertPair(cmd(OutputFormat.MKV_H264, QualityTier.BEST), "-c:v", "libx264")
assertPair(cmd(OutputFormat.MKV_H265, QualityTier.BEST), "-c:v", "libx265")
}
// --- exhaustiveness ----------------------------------------------------
@Test
fun `every format builds a well formed command`() {
OutputFormat.entries.forEach { format ->
listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality ->
val args = cmd(format, quality)
assertTrue("$format/$quality has no input", args.contains("-i"))
assertTrue("$format/$quality has too few args", args.size >= 5)
// Every flag that takes a value must actually have one.
assertFalse("$format/$quality ends on a dangling flag", args.last().startsWith("-"))
}
}
}
}
@@ -0,0 +1,96 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class FFmpegConcatCommandTest {
private val listFile = File("/cache/list.txt")
private val output = File("/cache/joined.mp4")
private val inputs = listOf("/cache/a.mp4", "/cache/b.mp4")
@Test
fun `list file quotes each entry for the concat demuxer`() {
val text = FFmpegConcatCommand.listFileContents(inputs)
assertEquals("file '/cache/a.mp4'\nfile '/cache/b.mp4'\n", text)
}
@Test
fun `apostrophes in filenames are escaped`() {
// An unescaped apostrophe silently truncates the entry, so the file would be
// skipped rather than the command failing.
val text = FFmpegConcatCommand.listFileContents(listOf("/cache/jason's clip.mp4"))
assertTrue("apostrophe not escaped: $text", text.contains("""jason'\''s clip.mp4"""))
}
@Test
fun `stream copy uses the concat demuxer and copies codecs`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
)
assertTrue(args.contains("concat"))
assertEquals("copy", args[args.indexOf("-c") + 1])
assertEquals(listFile.absolutePath, args[args.indexOf("-i") + 1])
assertFalse("stream copy must not re-encode", args.contains("libx264"))
}
@Test
fun `stream copy allows absolute paths in the list file`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
)
// Without -safe 0 the demuxer rejects the absolute paths we generate.
assertEquals("0", args[args.indexOf("-safe") + 1])
}
@Test
fun `re-encode passes every input separately and builds a filter graph`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
)
assertEquals(2, args.count { it == "-i" })
val filter = args[args.indexOf("-filter_complex") + 1]
assertTrue("missing concat filter: $filter", filter.contains("concat=n=2:v=1:a=1"))
assertTrue("inputs must be normalised before joining", filter.contains("scale="))
assertTrue("frame rates must be normalised", filter.contains("fps=30"))
assertTrue(args.contains("libx264"))
}
@Test
fun `re-encode maps the filter outputs rather than raw streams`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
)
assertTrue(args.contains("[v]"))
assertTrue(args.contains("[a]"))
}
@Test
fun `mp4 output gets faststart on both strategies`() {
listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
assertTrue("$strategy missing faststart", args.contains("+faststart"))
}
}
@Test
fun `mkv output does not get faststart`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264,
)
assertFalse(args.contains("+faststart"))
}
@Test
fun `output path is always last`() {
listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
assertEquals(output.absolutePath, args.last())
}
}
}
@@ -0,0 +1,83 @@
package org.libremediaconverter.model
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The stream-copy-vs-re-encode decision.
*
* This matters more than it looks: the `concat` demuxer does not reject mismatched
* inputs loudly, it can emit a file whose later segments are garbled. So the default
* on any doubt has to be re-encoding.
*/
class ConcatPlannerTest {
private fun clip(
video: String? = "h264",
audio: String? = "aac",
width: Int = 1920,
height: Int = 1080,
fps: Int = 30,
) = ConcatInput(video, audio, width, height, fps)
@Test
fun `identical clips can be stream copied`() {
assertEquals(
ConcatStrategy.STREAM_COPY,
ConcatPlanner.plan(listOf(clip(), clip())),
)
}
@Test
fun `a single input needs no re-encode`() {
assertEquals(ConcatStrategy.STREAM_COPY, ConcatPlanner.plan(listOf(clip())))
}
@Test
fun `different video codecs force a re-encode`() {
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(video = "h264"), clip(video = "hevc"))),
)
}
@Test
fun `different audio codecs force a re-encode`() {
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(audio = "aac"), clip(audio = "opus"))),
)
}
@Test
fun `different resolutions force a re-encode`() {
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(width = 1920, height = 1080), clip(width = 1280, height = 720))),
)
}
@Test
fun `different frame rates force a re-encode`() {
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(fps = 30), clip(fps = 60))),
)
}
@Test
fun `an unknown codec is not treated as a match`() {
// Two nulls are not evidence of agreement. Assuming they match is exactly how
// a silent corrupt concat happens.
assertEquals(
ConcatStrategy.REENCODE,
ConcatPlanner.plan(listOf(clip(video = null), clip(video = null))),
)
}
@Test
fun `a mismatch anywhere in a longer list is caught`() {
val clips = listOf(clip(), clip(), clip(), clip(width = 640, height = 480), clip())
assertEquals(ConcatStrategy.REENCODE, ConcatPlanner.plan(clips))
}
}
@@ -0,0 +1,197 @@
package org.libremediaconverter.model
import org.libremediaconverter.model.ConversionRouter.Reason
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* One test per routing predicate.
*
* These run on the JVM against fabricated device profiles rather than on hardware, so
* every branch is reachable — including "this device cannot encode AV1", which would
* otherwise depend on which phone the suite happened to run on.
*/
class ConversionRouterTest {
private fun route(
format: OutputFormat,
quality: QualityTier = QualityTier.FAST,
preference: EnginePreference = EnginePreference.AUTO,
probe: InputProbe = InputProbe(videoCodec = "h264"),
device: DeviceCodecs = DeviceCodecs.PERMISSIVE,
) = ConversionRouter.route(
ConversionRequest(format, quality, preference, probe),
device,
)
// --- the happy path -----------------------------------------------------
@Test
fun `mp4 h264 on a capable device uses hardware`() {
val d = route(OutputFormat.MP4_H264)
assertEquals(Engine.MEDIA3, d.engine)
assertEquals(Reason.HARDWARE_CAPABLE, d.reason)
}
@Test
fun `mp4 h265 uses hardware`() {
assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H265).engine)
}
@Test
fun `webm vp9 routes to ffmpeg because media3 cannot encode vp9`() {
// Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V. The WebM
// muxer exists, but there is no VP9 *encoder* behind it, so producing WebM
// video is FFmpeg's job even though the container is nominally supported.
val d = route(OutputFormat.WEBM_VP9)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
}
@Test
fun `aac audio extraction stays on hardware`() {
assertEquals(Engine.MEDIA3, route(OutputFormat.M4A_AAC).engine)
}
// --- 1. container ------------------------------------------------------
@Test
fun `mkv routes to ffmpeg because media3 has no matroska muxer`() {
val d = route(OutputFormat.MKV_H264)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
}
// --- 3. no platform encoder -------------------------------------------
@Test
fun `mp3 routes to ffmpeg because android has no mp3 encoder at any api level`() {
val d = route(OutputFormat.MP3)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
}
@Test
fun `flac routes to ffmpeg`() {
assertEquals(Engine.FFMPEG, route(OutputFormat.FLAC).engine)
}
// --- 4. image output ---------------------------------------------------
@Test
fun `gif routes to ffmpeg as an image output`() {
val d = route(OutputFormat.GIF)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.IMAGE_OUTPUT, d.reason)
}
@Test
fun `png frame export routes to ffmpeg`() {
assertEquals(Engine.FFMPEG, route(OutputFormat.FRAMES_PNG).engine)
}
// --- 5. no platform decoder -------------------------------------------
@Test
fun `av1 input on a device without av1 decode routes to ffmpeg`() {
val noAv1 = object : DeviceCodecs {
override fun canEncode(codec: VideoCodec) = true
override fun canDecode(codecName: String) = codecName != "av1"
}
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"), device = noAv1)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
}
@Test
fun `av1 input on a device with av1 decode stays on hardware`() {
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"))
assertEquals(Engine.MEDIA3, d.engine)
}
@Test
fun `an input the platform cannot parse routes to ffmpeg`() {
// MediaProbe reports this when the platform extractor fails to open the file.
// Media3 cannot convert what the platform cannot read, so the job must not be
// sent down the hardware path only to fail there.
val d = route(
OutputFormat.MP4_H264,
probe = InputProbe(videoCodec = InputProbe.UNPARSEABLE),
)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
}
@Test
fun `a null input codec is treated as no information, not as a failure`() {
// Nothing known about the input is different from "known to be unreadable":
// the former should still take the fast path.
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = null))
assertEquals(Engine.MEDIA3, d.engine)
}
// --- 6. quality tier ---------------------------------------------------
@Test
fun `best quality routes to ffmpeg because mediacodec exposes no crf`() {
val d = route(OutputFormat.MP4_H264, quality = QualityTier.BEST)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.QUALITY_TIER_REQUIRES_CRF, d.reason)
}
@Test
fun `fast quality is the hardware path`() {
assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H264, quality = QualityTier.FAST).engine)
}
// --- 7. hardware encoder availability ---------------------------------
@Test
fun `h265 target on a device without hevc encode falls back to ffmpeg`() {
val noHevc = object : DeviceCodecs {
override fun canEncode(codec: VideoCodec) = codec != VideoCodec.H265
override fun canDecode(codecName: String) = true
}
val d = route(OutputFormat.MP4_H265, device = noHevc)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.NO_HARDWARE_ENCODER, d.reason)
}
// --- user override -----------------------------------------------------
@Test
fun `forcing software overrides an otherwise hardware-capable job`() {
val d = route(OutputFormat.MP4_H264, preference = EnginePreference.FORCE_SOFTWARE)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
}
@Test
fun `forcing software wins even for formats ffmpeg would take anyway`() {
val d = route(OutputFormat.MP3, preference = EnginePreference.FORCE_SOFTWARE)
assertEquals(Engine.FFMPEG, d.engine)
assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
}
// --- exhaustiveness ----------------------------------------------------
@Test
fun `every output format routes somewhere without throwing`() {
OutputFormat.entries.forEach { format ->
val decision = route(format)
assertEquals(
"format $format produced no engine",
true,
decision.engine == Engine.MEDIA3 || decision.engine == Engine.FFMPEG,
)
}
}
@Test
fun `audio-only formats are flagged as such`() {
assertEquals(true, OutputFormat.MP3.isAudioOnly)
assertEquals(true, OutputFormat.FLAC.isAudioOnly)
assertEquals(false, OutputFormat.MP4_H264.isAudioOnly)
assertEquals(false, OutputFormat.GIF.isAudioOnly)
}
}
@@ -0,0 +1,64 @@
package org.libremediaconverter.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Guards the format table itself.
*
* The router, the FFmpeg command builder and the output filename all read from these
* entries, so a wrong extension or a mislabelled codec propagates everywhere.
*/
class OutputFormatTest {
@Test
fun `every format has a non-empty label and extension`() {
OutputFormat.entries.forEach {
assertTrue("${it.name} has no label", it.label.isNotBlank())
assertTrue("${it.name} has no extension", it.extension.isNotBlank())
assertFalse("${it.name} extension should not include a dot", it.extension.startsWith("."))
assertTrue("${it.name} has no mime type", it.mimeType.contains('/'))
}
}
@Test
fun `audio only formats carry no video codec`() {
OutputFormat.entries.filter { it.isAudioOnly }.forEach {
assertEquals("${it.name} should have no video codec", VideoCodec.NONE, it.videoCodec)
}
}
@Test
fun `image outputs carry neither video nor audio codecs`() {
OutputFormat.entries.filter { it.isImageOutput }.forEach {
assertEquals(VideoCodec.NONE, it.videoCodec)
assertEquals(AudioCodec.NONE, it.audioCodec)
assertFalse("${it.name} is not audio-only", it.isAudioOnly)
}
}
@Test
fun `extensions match their containers`() {
assertEquals("mp4", OutputFormat.MP4_H264.extension)
assertEquals("mkv", OutputFormat.MKV_H264.extension)
assertEquals("mp3", OutputFormat.MP3.extension)
assertEquals("gif", OutputFormat.GIF.extension)
assertEquals("wav", OutputFormat.WAV.extension)
}
@Test
fun `video formats are not misreported as audio only`() {
listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265, OutputFormat.MKV_H265, OutputFormat.WEBM_VP9)
.forEach { assertFalse("${it.name} should not be audio-only", it.isAudioOnly) }
}
@Test
fun `quality tiers describe themselves for the UI`() {
QualityTier.entries.forEach {
assertTrue(it.label.isNotBlank())
assertTrue(it.description.isNotBlank())
}
}
}
@@ -0,0 +1,58 @@
package org.libremediaconverter.work
import androidx.work.WorkInfo
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The retry-versus-fail rule.
*
* Isolated from the worker precisely so it can be tested: the condition that triggers
* a retry in production is the foreground-service budget running out, six hours per
* twenty-four, which no test can reach. Extracting the decision means the rule is still
* verified even though its trigger cannot be reproduced.
*/
class FailureOutcomeTest {
@Test
fun `a foreground service timeout is a retry, not a failure`() {
// The work is still valid; there is simply no budget right now. Telling the
// user their conversion failed would be wrong.
assertEquals(
FailureOutcome.RETRY,
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT),
)
}
@Test
fun `an ordinary failure is reported as a failure`() {
assertEquals(
FailureOutcome.FAIL,
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_NOT_STOPPED),
)
}
@Test
fun `every other stop reason fails rather than retrying forever`() {
// Retrying on, say, a user cancellation or a battery constraint would either
// ignore the user or spin. Only the timeout earns a retry.
val others = listOf(
WorkInfo.STOP_REASON_CANCELLED_BY_APP,
WorkInfo.STOP_REASON_USER,
WorkInfo.STOP_REASON_CONSTRAINT_BATTERY_NOT_LOW,
WorkInfo.STOP_REASON_CONSTRAINT_CHARGING,
WorkInfo.STOP_REASON_CONSTRAINT_CONNECTIVITY,
WorkInfo.STOP_REASON_CONSTRAINT_DEVICE_IDLE,
WorkInfo.STOP_REASON_CONSTRAINT_STORAGE_NOT_LOW,
WorkInfo.STOP_REASON_DEVICE_STATE,
WorkInfo.STOP_REASON_QUOTA,
WorkInfo.STOP_REASON_BACKGROUND_RESTRICTION,
WorkInfo.STOP_REASON_APP_STANDBY,
WorkInfo.STOP_REASON_TIMEOUT,
WorkInfo.STOP_REASON_UNKNOWN,
)
others.forEach {
assertEquals("stop reason $it should fail", FailureOutcome.FAIL, FailureOutcome.forStopReason(it))
}
}
}
+80
View File
@@ -0,0 +1,80 @@
# Prebuilt FFmpeg
`ffmpeg-kit-next-8.1.1.aar` is committed here deliberately, and this file records what
it is so the binary is auditable rather than opaque.
## Why it is committed
Tests must be deterministic. When CI rebuilt FFmpeg on every run, a red build could mean
"the code is broken" or "a 40-minute cross-compile hiccuped", and those are not the same
signal. Committing the artifact removes the second possibility entirely: a test run
either passes or points at real code.
It also removes roughly forty minutes from every cold CI run.
## Provenance
| | |
|---|---|
| Upstream | [arthenica/ffmpeg-kit-next](https://github.com/arthenica/ffmpeg-kit-next) v8.1.1 |
| FFmpeg | 8.1.2 |
| NDK | r27d (27.3.13750724), pinned by the upstream flake |
| API level | 33, matching the app's minSdk |
| ABIs | arm64-v8a, x86_64 |
| Shared libraries | 20 (10 per ABI) |
| SHA-256 | `ae188c9aec3c89a1c87a169589253c85438d57cfdcc3ce8b40fb3e87de368ff2` |
Configure line, read back out of the shipped `libavutil.so`:
```
--enable-asm --enable-cross-compile --enable-gpl --enable-iconv
--enable-inline-asm --enable-jni --enable-libass --enable-libdav1d
--enable-libfontconfig --enable-libfreetype --enable-libfribidi
--enable-libharfbuzz --enable-libjxl --enable-libmp3lame --enable-libopus
--enable-libsvtav1 --enable-libvpx --enable-libx264 --enable-libx265
--enable-lto --enable-mediacodec --enable-neon --enable-optimizations
--enable-pic --enable-pthreads --enable-shared --enable-small
--enable-swscale --enable-v4l2-m2m --enable-version3 --enable-zlib
```
Every `.so` reports `LOAD align 0x4000`, so the archive satisfies the 16 KB page-size
requirement. Verify with:
```sh
unzip -o bin/ffmpeg-kit-next-8.1.1.aar 'jni/*' -d /tmp/aarcheck
for f in /tmp/aarcheck/jni/*/*.so; do
readelf -lW "$f" | awk -v f="$f" '$1=="LOAD"{print f, $NF}'
done | sort -u -k2
```
## Licensing
Built with `--enable-gpl` and `--enable-version3`, so this binary is **GPL-3.0** and the
distributed APK is GPL-3.0 with it. That is deliberate: x264 and x265 are the only route
to CRF and two-pass rate control, which no Android hardware encoder exposes. See
[`../LICENSES/README.md`](../LICENSES/README.md).
GPL-3.0 obliges us to ship corresponding source with the binary. The recipe in
[`../tools/ffmpeg`](../tools/ffmpeg) is that source, and it remains the authority: this
archive is its output, not a substitute for it.
## Rebuilding
```sh
cd tools/ffmpeg
podman build -t ffmpeg-kit-builder:local -f Containerfile .
mkdir -p out
podman run --name ffmpeg-build -v "$PWD/out":/work/out:Z localhost/ffmpeg-kit-builder:local full
cp out/ffmpeg-kit-next-*.aar ../../bin/
```
Update the SHA-256 above when you do. Note that replacing this file adds another ~34 MB
blob to git history permanently, so rebuild only when the FFmpeg version or the configure
flags actually change.
## F-Droid
F-Droid's scanner flags checked-in prebuilt native libraries. If the app is submitted
there, the metadata needs a `scandelete` entry for `bin/` so their build uses the recipe
in `tools/ffmpeg` rather than this archive. Nothing here prevents a from-source build;
the recipe is complete on its own.
Binary file not shown.
+214
View File
@@ -0,0 +1,214 @@
# API 37 is not tested in CI: a crash in Google's `android-37.0` emulator image
**Status:** open upstream, worked around by removing API 37 from the E2E matrix.
The app itself is verified good on real API 37 hardware — this is an emulator bug only.
**Last verified:** 2026-08-21, against emulator `37.1.11.0` and system image revision 6
`minSdk` is 33 and `targetSdk` is 37, and the E2E matrix in
[`status_check.yml`](../.github/workflows/status_check.yml) runs API 33 through 36.
API 37 is deliberately absent. This is why.
## Summary
The `android-37.0` emulator system image crashes `surfaceflinger` in a loop. The app
under test never gets a working framework, so every instrumented test fails regardless
of what the app does. The bug is in the emulator image, not in this project.
The crash is an assertion inside the emulator's own gralloc implementation:
```
Executable: /system/bin/surfaceflinger
signal 6 (SIGABRT), code -1 (SI_QUEUE), tid: RegionSampling
Abort message: 'Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma'
#03 mapper.ranchu.so GoldfishMapper::readFromHost(cb_handle_t const&) const
#04 mapper.ranchu.so GoldfishMapper::GoldfishMapper()::'lambda'(...)::__invoke
#05 libui.so android::Gralloc5Mapper::lock(...)
#06 libui.so android::GraphicBufferMapper::lock(...)
#07 libui.so android::GraphicBuffer::lockAsync(...)
#08 libui.so android::GraphicBuffer::lock(...)
#09 surfaceflinger android::RegionSamplingThread::threadMain()
```
`RegionSamplingThread` is SystemUI's navigation-bar luma sampling. It calls
`GraphicBuffer::lock`, which routes into `GoldfishMapper::readFromHost`, which asserts
that the host has *not* negotiated the `ReadColorBufferDma` capability. On this image
the host has, so the assertion fails and `surfaceflinger` aborts. It restarts and
aborts again.
## Impact
The failure surfaces in two different ways depending on how far the job gets, which is
why it took several rounds to identify:
| Guest RAM | Where it dies | What CI reports |
|---|---|---|
| 1536 MB | during APK install | `Unknown failure: cmd: Can't find service: package` |
| 2560 MB | during the test run | `There were failing tests` — all of them |
At 2560 MB the install succeeds and the tests actually execute, then fail wholesale.
The first failure in the report is misleading:
```
kotlin.UninitializedPropertyAccessException: lateinit property output has not
been initialized
at Media3EngineTest.tearDown(Media3EngineTest.kt:53)
java.lang.IllegalStateException: WorkManager is not initialized properly.
You have explicitly disabled WorkManagerInitializer in your manifest, ...
```
Neither is a real defect in this project. `tearDown` throws because `setUp` never got
far enough to assign `output`, and WorkManager's `InitializationProvider` never runs
because content-provider installation fails on a framework whose `surfaceflinger` is
crash-looping. The same tests pass at API 33, 34, 35, and 36 in the same CI run, and
the first `surfaceflinger` abort is timestamped *before* the test results are reported.
This is not inference. The full suite was run against a physical API 37 device and
passed — see [Verified on real API 37 hardware](#verified-on-real-api-37-hardware)
below. `ConversionWorkerTest` and `ConcatWorkerTest`, which drive a real WorkManager
round trip and are among the tests that failed this way in CI, both pass there.
## Environment
Reproduced identically in two unrelated environments, so it is not specific to a host
GPU, driver, or CI runner.
| | GitHub Actions | Local workstation |
|---|---|---|
| Host | `ubuntu-latest`, no GPU | Fedora, Intel Iris Xe (TGL GT2) |
| Emulator | `37.1.11.0` (build 15917651) | `37.1.11.0` (build 15917651) |
| GPU mode | `swiftshader_indirect` | `host` |
| Result | boots, aborts during tests | aborts before boot completes |
System image: `system-images;android-37.0;google_apis;x86_64`, `Pkg.Revision=6`,
`AndroidVersion.ApiLevel=37.0`, `AndroidVersion.ExtensionLevel=22`
```
Build fingerprint: google/sdk_gphone64_x86_64/emu64xa:17/CE2A.260420.019/15611780:userdebug/dev-keys
Kernel Release: 6.12.58-android16-6-gccafb60de224-ab14828483
```
## What was ruled out, and how
Each of these was tested rather than reasoned about, because the first three attempts
at this bug were plausible fixes that turned out to address earlier, unrelated failures.
**Guest memory.** The emulator raises an undersized guest to a minimum on its own, but
only for API levels it recognises, and it does not recognise `"37.0"`. API 33 bumps to
2048 MB and 34/35/36 to 2560 MB, while API 37 logged no bump at all and ran at the
`pixel_6` default of 1536 MB. Setting `ram-size: 2560M` explicitly fixed that asymmetry
and did change the outcome — the job got past install and into the test run — but it is
not the underlying bug. At the moment of failure the guest reported `MemTotal 2527392
kB` with `MemAvailable 1507104 kB`: 1.5 GB free, and no OOM kills.
**GPU mode.** Both `swiftshader_indirect` and `host` crash, with the same assertion and
the same frames. The crash is in the gralloc mapper, below the renderer.
**Disabling the DMA feature.** `GLDMA` is the host feature that most plausibly backs the
guest's `hasReadColorBufferDma`. Launching with `-feature -GLDMA` was accepted by the
emulator — the log confirms `Feature 'GLDMA' (51) is overridden to 'disabled'` — and
`surfaceflinger` still aborted 13 times and the device never finished booting. Whatever
sets that guest capability, it is not this flag.
**An ATD image.** `google_atd` / `aosp_atd` images are built for automated testing and
ship without the SystemUI package set, which is what drives `RegionSamplingThread` in
the first place. That would likely sidestep the bug class entirely, but **no ATD image
exists for `android-37.0`** — only `google_apis`, `google_apis_playstore`, the `ps16k`
16 KB-page variants, and Wear OS. Check again when revisiting; if an ATD image appears,
try it before anything else here.
## Verified on real API 37 hardware
The bug is confined to the emulator image. On 2026-08-21 the whole instrumented suite
was run against a physical device and passed:
```
Device: Pixel 10 Pro XL (mustang), arm64-v8a
Build: google/mustang/mustang:17/CP2A.260805.005/15828068:user/release-keys
API: 37 (Android 17, codename REL -- a release build, not a preview)
./gradlew :app:connectedDebugAndroidTest -PabiFilters=arm64-v8a
40 tests, 0 failures, 0 errors, 2 skipped BUILD SUCCESSFUL
```
The two skips are `RealMediaBenchmark.hardwareVersusSoftwareOnRealVideo` and
`av1InputRoutesAccordingToDeviceDecodeSupport`, which `assumeTrue` their sample files
are present and skip when they are not. That is by design and unrelated to API level.
One harmless warning appears during the run and can be ignored:
`No UID for androidx.test.services in user 0`, from an `appops` call the test services
package makes before it is fully registered.
So the app is correct on Android 17. What is missing is only *automated* coverage in
CI. Until the image is fixed, run the suite on a physical API 37 device before release;
that is the substitute for the missing matrix row.
## Reproducing it
Locally, with `-gpu host` so the emulator itself does not segfault on Intel graphics:
```bash
sdkmanager --install "system-images;android-37.0;google_apis;x86_64"
echo no | avdmanager create avd -n api37_repro \
-k "system-images;android-37.0;google_apis;x86_64" -d pixel_6 --force
$ANDROID_HOME/emulator/emulator -avd api37_repro \
-no-window -gpu host -noaudio -no-boot-anim -camera-back none -no-snapshot &
# Boot never completes. Count the aborts:
adb logcat -d -b crash | grep -c hasReadColorBufferDma
```
`sys.boot_completed` never reaches `1`, `pgrep -f system_server` stays empty, and
`keystore2`'s watchdog logs `await_boot_completed ... Overdue` indefinitely.
To see the CI-side form instead, restore the API 37 row in the E2E matrix of
`status_check.yml` (`api-level: "37.0"` — a bare `37` fails earlier still, during SDK
setup, because there is no `platforms;android-37`).
## Filing this upstream
Not yet filed. To file it:
1. Go to <https://issuetracker.google.com/> and sign in with a Google account.
2. Choose **Report an issue**, then pick the component for the Android emulator — search
the component picker for "Emulator"; it sits under the Android Studio component tree.
If the picker is unclear, Android Studio's **Help → Submit Feedback** opens the same
tracker with the component preselected, and the emulator's own **Extended controls →
Help → File a bug** does likewise.
3. Title it for the mechanism, not the symptom, so it is searchable — for example:
`surfaceflinger aborts in GoldfishMapper::readFromHost (hasReadColorBufferDma) on
android-37.0 google_apis x86_64`.
4. Paste the assertion and backtrace from the top of this document, the environment
table, and the reproduction steps above. State explicitly that it reproduces on two
unrelated hosts under both GPU modes — that is the detail that stops it being closed
as a local graphics problem.
5. List what was ruled out. Bugs that arrive with `-feature -GLDMA` already eliminated
tend not to bounce back asking for it.
6. Attach:
- the guest tombstone, via `adb pull /data/tombstones` (or the `pbtombstone` output
the crash log names)
- `adb logcat -d -b crash > crash.txt`
- the emulator's own stdout log, captured by redirecting the launch command
- the AVD's `config.ini`
- a link to a failing CI job, which shows it on hardware you do not control:
<https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32545625459/job/96963461184>
Record the issue number here once filed.
## When to revisit
Re-add the API 37 row when any of these happens:
- a new `android-37.0` system image revision ships (this was revision 6)
- an ATD image appears for API 37
- the upstream issue is marked fixed
Until then the gap is narrower than the missing row suggests. `targetSdk` is 37, so the
app is compiled and unit-tested against it; the API-dependent behaviour this matrix
exists to exercise — the foreground service type, absent below 34, `dataSync` at 34,
`mediaProcessing` from 35 — is covered at 35 and 36; and the full instrumented suite has
been run green on real API 37 hardware. What is missing is *automated* API 37 coverage,
so a regression there would not be caught by a pull request. Run the suite on a physical
API 37 device before each release for as long as this row is absent.
@@ -0,0 +1,7 @@
First release.
* Video conversion between MP4, MKV and WebM
* Audio extraction to MP3, AAC, FLAC, Opus and WAV
* GIF and PNG frame export
* Joining several files into one
* Hardware acceleration where the device supports it, with a visible fallback
@@ -0,0 +1,18 @@
A media converter that runs entirely on your device. Nothing is uploaded anywhere.
Convert video between MP4, MKV and WebM. Extract and convert audio to MP3, AAC,
FLAC, Opus or WAV. Export GIFs and PNG frame sequences. Join several clips into one
file.
Two conversion engines work together. Common video jobs run on your device's video
hardware, which is several times faster than software encoding and much easier on the
battery. Everything the hardware cannot do — Matroska, MP3, GIF, frame export, and the
highest quality settings — runs through a bundled FFmpeg build. The app tells you which
one it used and why, and lets you force software encoding if you prefer.
Quality settings:
* Fast — hardware accelerated, ideal for sharing and batches
* Best quality — software encoding with CRF rate control, for archiving
Free and open source. No advertising, no analytics, no network access.
@@ -0,0 +1 @@
Convert video and audio on your device. No ads, no tracking, no uploads.
@@ -0,0 +1 @@
LibreMediaConverter
+70 -9
View File
@@ -1,20 +1,81 @@
[versions]
# Build tooling.
# NOTE: AGP 9 has BUILT-IN Kotlin support. Applying org.jetbrains.kotlin.android
# FAILS the build. AGP 9.3.1 brings kotlin-gradle-plugin 2.2.10 transitively, so the
# Compose compiler plugin below must match that version, not the newest Kotlin release.
agp = "9.3.1"
coreKtx = "1.10.1"
kotlin = "2.2.10"
ksp = "2.3.11"
# AndroidX / Compose
composeBom = "2026.08.00"
coreKtx = "1.19.0"
activityCompose = "1.13.0"
lifecycle = "2.11.0"
navigation = "2.9.8"
work = "2.11.2"
datastore = "1.2.1"
media3 = "1.11.0"
room = "2.8.4"
documentfile = "1.1.0"
annotation = "1.10.0"
# Test
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
appcompat = "1.6.1"
material = "1.10.0"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
smartException = "0.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
# Compose — versions come from the BOM, so no version.ref here.
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material3-windowsize = { group = "androidx.compose.material3", name = "material3-window-size-class" }
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" }
# Media3 — the hardware fast path (Apache-2.0).
media3-transformer = { group = "androidx.media3", name = "media3-transformer", version.ref = "media3" }
media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "media3" }
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-muxer = { group = "androidx.media3", name = "media3-muxer", version.ref = "media3" }
# Room — job history. Added in a later phase; KSP plugin stays unapplied until then.
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Required at runtime by the ffmpeg-kit-next wrapper: AbstractSession.fail()
# references smartexception.java.Exceptions. Debug builds tolerate its absence through
# lazy class loading, so this only surfaces as a crash on the first FFmpeg error --
# or, as it did here, as an R8 missing-class error.
smart-exception-java = { group = "com.arthenica", name = "smart-exception-java", version.ref = "smartException" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
# DO NOT add org.jetbrains.kotlin.android — AGP 9 built-in Kotlin makes it a build failure.
+1 -1
View File
@@ -22,5 +22,5 @@ dependencyResolutionManagement {
}
}
rootProject.name = "Android(MediaConverter"
rootProject.name = "LibreMediaConverter"
include(":app")
+33
View File
@@ -0,0 +1,33 @@
# Reproducible FFmpeg build environment for ffmpeg-kit-next.
#
# ffmpeg-kit-next is Nix-only: the repository ships nix-android.sh and a flake, and
# there is no plain android.sh. Rather than install Nix on a developer machine, the
# whole toolchain lives in this image. It also serves as the reproducibility artifact
# F-Droid expects.
#
# The flake pins Android NDK 27.3.13750724 (r27d) itself and applies
# -Wl,-z,max-page-size=16384 for arm64-v8a and x86_64 in android/jni/Android.mk, so
# the output is 16 KB page-size compliant without needing NDK r28+ on the host.
#
# The base image already provides git, bash, curl and tar. Compilers and build tools
# (make, cmake, autotools, pkg-config) are supplied by the flake's devShell at build
# time, so nothing further is installed here — `nix profile install` would in fact
# collide with the base image's existing profile entries.
FROM docker.io/nixos/nix:2.35.2
# nix-android.sh points NIX_USER_CONF_FILES at the repo's own nix.conf, which enables
# the flakes and nix-command experimental features. Set them here too so that plain
# `nix` invocations behave the same way.
RUN mkdir -p /etc/nix && \
printf 'experimental-features = nix-command flakes\naccept-flake-config = true\nwarn-dirty = false\nmax-jobs = auto\n' \
>> /etc/nix/nix.conf
# Upstream's scripts/*.sh use `#!/bin/bash`, but this image provides only /bin/sh.
RUN ln -sf /bin/sh /bin/bash
WORKDIR /work
COPY build-ffmpeg.sh /usr/local/bin/build-ffmpeg.sh
RUN chmod +x /usr/local/bin/build-ffmpeg.sh
ENTRYPOINT ["/usr/local/bin/build-ffmpeg.sh"]
+124
View File
@@ -0,0 +1,124 @@
# FFmpeg build
Builds [`ffmpeg-kit-next`](https://github.com/arthenica/ffmpeg-kit-next) into an Android
AAR that the app consumes.
## Why this exists
`arthenica/ffmpeg-kit` — the library nearly every Android FFmpeg tutorial still points at
— was **archived**, and its binaries were **deleted from Maven Central**. Every
`com.arthenica:ffmpeg-kit-*` coordinate now returns 404, and all of its GitHub release
tags have zero attached assets. Maven Central's search index still *lists* the old
versions, which is misleading; the files behind those entries are gone.
Its successor, `ffmpeg-kit-next`, is **source-only by design** and publishes no
prebuilt packages. So building FFmpeg ourselves is not a preference, it is the only
remaining option.
Community forks publishing prebuilt 16 KB-aligned AARs do exist, but each fails on
license, ABI coverage, or publisher credibility — and at least one redistributes a
non-free FDK-AAC build, which FFmpeg states is *unredistributable*.
## Why a container
`ffmpeg-kit-next` is **Nix-only**. There is no plain `android.sh`; the repository ships
`nix-android.sh` plus a flake, and the flake pins the entire toolchain including
**Android NDK 27.3.13750724 (r27d)**.
Rather than install Nix on a developer machine, the toolchain lives in a container
image. That keeps the host clean and doubles as the reproducibility artifact F-Droid
wants.
Note the NDK version: **do not** "helpfully" upgrade to r28+. The flake pins r27d and
`android/jni/Android.mk` applies `-Wl,-z,max-page-size=16384` manually for `arm64-v8a`
and `x86_64` precisely because r27 predates automatic 16 KB alignment. The output is
16 KB compliant as-is.
## Usage
```sh
podman build -t ffmpeg-kit-builder:local -f Containerfile .
mkdir -p out
podman run --name ffmpeg-build -v "$PWD/out":/work/out:Z \
localhost/ffmpeg-kit-builder:local full
```
Two modes:
| Mode | Libraries | Purpose |
|---|---|---|
| `spike` | minimal | Validates the toolchain end to end without waiting on x264/x265/SVT-AV1 |
| `full` | shipping set | The GPL configuration that ships |
The run deliberately omits `--rm`: the container's writable layer retains the several
gigabytes of Nix store contents (Android SDK and NDK), so subsequent builds skip the
download. Reuse it with `podman start -a ffmpeg-build`.
Only `arm64-v8a` and `x86_64` are built, matching the app's `abiFilters`. Dropping the
32-bit ABIs roughly halves both build time and APK size, and Play does not require them.
## Library selection
Flag names come from `get_library_name()` in the upstream `scripts/function.sh`. Two
that are easy to get wrong:
- It is **`--enable-lame`**, not `--enable-libmp3lame`.
- It is **`--enable-libsvtav1`** for SVT-AV1.
MP3 deserves a note: **Android has no MP3 encoder at any API level**. That is a platform
gap, not a Media3 limitation, so `--enable-lame` is the only way the app can output MP3.
`--enable-android-media-codec` gives FFmpeg the `h264_mediacodec` / `hevc_mediacodec`
wrappers (added in FFmpeg 6.0). These act as a fallback-within-the-fallback: hardware
encode from the FFmpeg side when a job was routed away from Media3 for container reasons
but still wants hardware speed.
## Licensing
The `full` build passes `--enable-gpl` with **x264** and **x265**, which makes the
distributed binary **GPL-3.0**. This is deliberate — see [`../../LICENSES/README.md`](../../LICENSES/README.md).
Worth recording, because it is widely misunderstood: **libass is ISC licensed, not GPL**,
so subtitle burn-in does not require the GPL flag. The only things GPL genuinely buys are
x264/x265 software encode (and with them CRF and 2-pass rate control), vidstab, and the
GPL filter set.
Never build with `--enable-nonfree`. FFmpeg states it renders the binary
*unredistributable*.
## Verified build output (2026-08-19, ffmpeg-kit-next v8.1.1 / FFmpeg 8.1.2)
The `full` build produced a 35 MB AAR with 10 shared libraries per ABI for `arm64-v8a`
and `x86_64`. Confirmed against the artifact rather than assumed:
- **16 KB page alignment**: every `.so` on both ABIs reports `LOAD align 0x4000`
(`readelf -lW`). This is the hard Play gate.
- **Separate shared libraries**, not a static monolith — which is what the LGPL/GPL
relinking obligation requires.
- **Embedded configure line** (from `strings libavutil.so`):
`--enable-gpl --enable-version3 --enable-libx264 --enable-libx265 --enable-libsvtav1
--enable-libvpx --enable-libmp3lame --enable-libopus --enable-libdav1d --enable-libass
--enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libharfbuzz
--enable-mediacodec --enable-jni --enable-shared --enable-small --enable-lto`
- Present and verified: `libx264` (with an x264 core banner, so genuinely linked),
`libx265`, `libsvtav1`, `libmp3lame`, `h264_mediacodec`, `hevc_mediacodec`, `libopus`,
`libdav1d`, the GIF encoder and muxer, libass internals (`ass_shaper_new`), and the
`subtitles`, `scale`, `palettegen`, `paletteuse` and `concat` filters.
Note `--enable-version3`: combined with `--enable-gpl` this makes the binary **GPL-3.0**,
which is what `LICENSES/README.md` states.
A caution on verifying this yourself: the build uses `--enable-small` and `--enable-lto`,
so internal symbols like `ff_libx264_encoder` do **not** appear in `strings` output.
Their absence proves nothing. Check the configure line and the registered codec *names*
instead.
## Release checklist
GPL-3.0 requires corresponding source alongside the binary. For each release, attach to
the GitHub Release next to the APK:
- the exact `ffmpeg-kit-next` tag and FFmpeg version used,
- the full configure line (printed in this script's build log), and
- any patches applied.
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
#
# Builds ffmpeg-kit-next into an Android AAR. Runs INSIDE the container image
# defined by the sibling Containerfile.
#
# Usage: build-ffmpeg.sh [spike|full]
#
# spike - minimal library set. Validates that the toolchain works and produces an
# AAR, without waiting on x264/x265/SVT-AV1. Use this first.
# full - the shipping configuration (GPL: x264 + x265).
#
set -euo pipefail
# The upstream scripts use `#!/bin/bash`, but the nixos/nix image ships only /bin/sh
# (itself bash, via the Nix store). Without this, start-android.sh dies with
# "cannot execute: required file not found" AFTER the whole toolchain has been built,
# which is an expensive way to discover a missing symlink.
if [[ ! -e /bin/bash ]]; then
ln -sf "$(command -v bash)" /bin/bash
fi
MODE="${1:-spike}"
TAG="${FFMPEG_KIT_TAG:-v8.1.1}"
SRC=/work/ffmpeg-kit-next
OUT=/work/out
# ---------------------------------------------------------------------------
# Library selection
# ---------------------------------------------------------------------------
# Flag names come from get_library_name() in scripts/function.sh — note it is
# --enable-lame, NOT --enable-libmp3lame.
#
# android-media-codec gives FFmpeg the h264_mediacodec / hevc_mediacodec wrappers.
# Those are the fallback-within-the-fallback: hardware encode from the FFmpeg side
# when a job has been routed away from Media3 for container reasons but still wants
# hardware encode.
COMMON_LIBS=(
--enable-android-media-codec
--enable-android-zlib
--enable-lame # MP3 encode. Android has NO MP3 encoder at any API level,
# so this is the only way the app can output MP3 at all.
--enable-opus
--enable-dav1d # fast AV1 decode
)
SUBTITLE_LIBS=(
--enable-libass # ISC licensed, NOT GPL - subtitle burn-in is LGPL-safe
--enable-fontconfig
--enable-freetype
--enable-fribidi
--enable-harfbuzz
)
# GPL. These are the reason the shipped binary is GPL-3.0 rather than LGPL: they are
# the only route to CRF and 2-pass rate control, which no Android hardware encoder
# exposes. See LICENSES/README.md.
GPL_LIBS=(
--enable-gpl
--enable-x264
--enable-x265
)
EXTRA_LIBS=(
--enable-libvpx # VP8/VP9
--enable-libsvtav1 # fast AV1 encode
)
case "$MODE" in
spike) LIBS=("${COMMON_LIBS[@]}") ;;
full) LIBS=("${COMMON_LIBS[@]}" "${SUBTITLE_LIBS[@]}" "${GPL_LIBS[@]}" "${EXTRA_LIBS[@]}") ;;
*) echo "unknown mode: $MODE (expected 'spike' or 'full')" >&2; exit 2 ;;
esac
echo "=============================================="
echo " ffmpeg-kit-next build"
echo " mode : $MODE"
echo " tag : $TAG"
echo " libraries : ${LIBS[*]}"
echo " started : $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "=============================================="
if [[ ! -d "$SRC" ]]; then
git clone --branch "$TAG" --depth 1 \
https://github.com/arthenica/ffmpeg-kit-next.git "$SRC"
fi
cd "$SRC"
# ---------------------------------------------------------------------------
# AAPT2 override
# ---------------------------------------------------------------------------
# The final step packages the .so files into an AAR with Gradle. Gradle's default
# AAPT2 comes from Maven as a prebuilt binary dynamically linked against normal FHS
# paths (/lib64/ld-linux-x86-64.so.2). Those do not exist in a Nix image, so it dies
# with "AAPT2 ... Daemon startup failed" AFTER the entire native build has succeeded.
#
# The Android SDK that Nix provides has an aapt2 that nixpkgs has already patchelf'd,
# so point Gradle at that one instead.
AAPT2="$(find /nix/store -maxdepth 6 -name aapt2 -type f 2>/dev/null | head -1)"
if [[ -n "$AAPT2" ]]; then
echo "using nix-provided aapt2: $AAPT2"
grep -v 'aapt2FromMavenOverride' android/gradle.properties > /tmp/gradle.properties.new || true
mv /tmp/gradle.properties.new android/gradle.properties
echo "android.aapt2FromMavenOverride=$AAPT2" >> android/gradle.properties
else
echo "WARNING: no nix aapt2 found; the AAR packaging step will probably fail." >&2
fi
# 64-bit only, matching the app's abiFilters. Dropping the 32-bit ABIs roughly halves
# build time and APK size, and Play does not require them.
# --api-level matches the app's minSdk 33 (default is 24), so the native code may use
# the newer NDK media APIs. ffmpeg-kit protocols (ffkitsaf, ffkitmem, ffkitstream) are
# left enabled: ffkitsaf is the SAF bridge that replaces the old getSafParameter trick.
# Output still stages through a real cache path rather than a SAF fd, because MP4
# faststart needs to seek back to rewrite the moov atom.
./nix-android.sh -p android-r27d \
--api-level=33 \
--disable-arm-v7a \
--disable-arm-v7a-neon \
--disable-x86 \
"${LIBS[@]}"
mkdir -p "$OUT"
# Only the ffmpeg-kit AAR. A bare '*.aar' find also sweeps up every AAR that Gradle
# happens to have unpacked into its own caches (junit, espresso, tracing...), which
# is confusing noise in the output directory.
find "$SRC/android/ffmpeg-kit-next-android-lib/build/outputs/aar" \
"$SRC/prebuilt" \
-name 'ffmpeg-kit-next*.aar' -exec cp -v {} "$OUT/" \; 2>/dev/null
echo "=============================================="
echo " finished : $(date -u +%Y-%m-%dT%H:%M:%SZ)"
ls -la "$OUT" || true