Enable R8 and add release, store and F-Droid infrastructure
Turning on R8 immediately surfaced a latent runtime bug: the ffmpeg-kit-next wrapper references com.arthenica.smartexception.java.Exceptions from AbstractSession.fail() in eighteen places, but a local .aar carries no transitive dependencies, so nothing was pulling it in. Debug builds tolerate this through lazy class loading -- the class is only touched on an error path -- so it would have shipped as a crash the first time an FFmpeg conversion failed. Declared explicitly now. Keep rules cover the JNI boundary. The native library resolves classes and methods by name, which R8 cannot see, so without them the FFmpeg calls fail with NoSuchMethodError in release builds only. Workers are kept too, since WorkManager reconstructs them reflectively from a class name persisted in its database, and a rename breaks jobs enqueued before the update. Verified on the produced artifacts rather than assumed: all 22 native libraries survive minification and every one is still 16 KB aligned inside the APK. Release is 82 MB against 115 MB for debug; the AAB is 40 MB and Play splits it per ABI. The privacy policy lists every permission, including the three WorkManager adds automatically (WAKE_LOCK, RECEIVE_BOOT_COMPLETED, ACCESS_NETWORK_STATE). Checking the merged manifest showed those, and a policy that omitted them would look dishonest to anyone who inspected the app. INTERNET is genuinely absent, so "files stay on the device" is enforced by the OS rather than a promise. CI runs unit tests on every push and builds the FFmpeg AAR only for release tags, since that is a full cross-compile. Releases attach the FFmpeg corresponding source next to the APK: GPL-3.0 requires it, and FFmpeg's instruction to host it "on the same webserver" cannot be satisfied by a Play listing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Unit tests and lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17' # AGP 9 requires JDK 17
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
# The FFmpeg AAR is not committed, so provide a stub for jobs that only need
|
||||
# to compile and run JVM tests. Anything that actually calls into FFmpeg is an
|
||||
# instrumented test and does not run here.
|
||||
- name: Stub the FFmpeg AAR
|
||||
run: |
|
||||
mkdir -p app/libs
|
||||
if [ ! -f app/libs/ffmpeg-kit-next-8.1.1.aar ]; then
|
||||
echo "::warning::Using an empty FFmpeg AAR stub; instrumented tests are skipped."
|
||||
mkdir -p /tmp/stub/jni && printf '<manifest package="com.arthenica.ffmpegkit"/>' > /tmp/stub/AndroidManifest.xml
|
||||
(cd /tmp/stub && zip -qr ffmpeg-kit-next-8.1.1.aar .)
|
||||
cp /tmp/stub/ffmpeg-kit-next-8.1.1.aar app/libs/
|
||||
fi
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Upload test report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unit-test-report
|
||||
path: app/build/reports/tests/
|
||||
|
||||
ffmpeg:
|
||||
name: Build FFmpeg AAR
|
||||
runs-on: ubuntu-latest
|
||||
# Expensive (a full cross-compile of FFmpeg, x264, x265 and friends), so it runs
|
||||
# only for releases rather than on every push.
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build the AAR in a container
|
||||
run: |
|
||||
cd tools/ffmpeg
|
||||
podman build -t ffmpeg-kit-builder:ci -f Containerfile . || \
|
||||
docker build -t ffmpeg-kit-builder:ci -f Containerfile .
|
||||
mkdir -p out
|
||||
(podman run --rm -v "$PWD/out":/work/out:Z ffmpeg-kit-builder:ci full || \
|
||||
docker run --rm -v "$PWD/out":/work/out ffmpeg-kit-builder:ci full)
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ffmpeg-aar
|
||||
path: tools/ffmpeg/out/ffmpeg-kit-next-*.aar
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: [test, ffmpeg]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ffmpeg-aar
|
||||
path: app/libs/
|
||||
|
||||
- name: Build release artifacts
|
||||
run: ./gradlew assembleRelease bundleRelease
|
||||
|
||||
# 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/
|
||||
{
|
||||
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 "The exact configure line used is printed in the build log and is"
|
||||
echo "reproduced by running tools/ffmpeg as documented in its README."
|
||||
} > release-source/README.txt
|
||||
tar czf ffmpeg-corresponding-source.tar.gz release-source
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
app/build/outputs/apk/release/*.apk
|
||||
app/build/outputs/bundle/release/*.aab
|
||||
ffmpeg-corresponding-source.tar.gz
|
||||
LICENSE
|
||||
LICENSES/README.md
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Media Converter 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.
|
||||
@@ -5,8 +5,9 @@ compression, audio extraction and conversion, GIF and frame export, and file mer
|
||||
|
||||
Android 13+ (API 33). Built with Jetpack Compose and Material 3.
|
||||
|
||||
> **Status: early development.** The project scaffold and UI shell exist; the conversion
|
||||
> pipeline is being built out. Not yet usable.
|
||||
> **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
|
||||
|
||||
@@ -65,16 +66,62 @@ 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+ and the Android SDK with API 37.
|
||||
Requires JDK 17+ (AGP 9 will not run on older) and the Android SDK with API 37.
|
||||
|
||||
```
|
||||
./gradlew :app:assembleDebug
|
||||
The FFmpeg AAR is **not committed** — it is a 35 MB binary, and F-Droid strips
|
||||
checked-in native libraries. Build it first:
|
||||
|
||||
```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 ../../app/libs/
|
||||
```
|
||||
|
||||
The FFmpeg native library is built separately from source; that build is not yet wired
|
||||
into this repository.
|
||||
Then:
|
||||
|
||||
```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.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -35,8 +35,10 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
optimization {
|
||||
// Left off until the JNI keep rules land with FFmpeg (see plan, Phase 6).
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +80,9 @@ dependencies {
|
||||
// archived and delisted, and ffmpeg-kit-next is source-only by design.
|
||||
// The AAR is gitignored; see app/libs/README.md to produce it.
|
||||
implementation(files("libs/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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
Media Converter
|
||||
@@ -24,6 +24,7 @@ annotation = "1.10.0"
|
||||
junit = "4.13.2"
|
||||
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" }
|
||||
@@ -63,6 +64,12 @@ androidx-room-runtime = { group = "androidx.room", name = "room-runtime", versio
|
||||
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 = "androidxJunit" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
|
||||
Reference in New Issue
Block a user