diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..60536f5
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -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 '' > /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
diff --git a/PRIVACY.md b/PRIVACY.md
new file mode 100644
index 0000000..0079d48
--- /dev/null
+++ b/PRIVACY.md
@@ -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.
diff --git a/README.md b/README.md
index 746efce..4475f24 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 34c514a..3cb0a41 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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.
diff --git a/app/src/main/keepRules/rules.keep b/app/src/main/keepRules/rules.keep
index d7e081a..d96a4ee 100644
--- a/app/src/main/keepRules/rules.keep
+++ b/app/src/main/keepRules/rules.keep
@@ -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//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 *;
-#}
\ No newline at end of file
+# --- 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 ;
+}
+
+# --- 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 (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
diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt
new file mode 100644
index 0000000..9814109
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/1.txt
@@ -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
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
new file mode 100644
index 0000000..43f6b66
--- /dev/null
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -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.
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
new file mode 100644
index 0000000..b9412cf
--- /dev/null
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+Convert video and audio on your device. No ads, no tracking, no uploads.
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
new file mode 100644
index 0000000..902a7fb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/title.txt
@@ -0,0 +1 @@
+Media Converter
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index be39fc8..bdba997 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }