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" }