From 48f65f0941495d0ecc49776f32ca98da0208c116 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 19 Aug 2026 21:18:28 -0500 Subject: [PATCH] Replace scaffold with Compose/Material 3 base and project identity The generated scaffold was a Views-based Material 2 shell with no activity, no Kotlin sources, and a placeholder package. Replace it with the real base the conversion work builds on. Build configuration, with three AGP 9 specifics that contradict most tutorials still in circulation: - AGP 9 has built-in Kotlin. Applying org.jetbrains.kotlin.android now fails the build, so the absence of that plugin is deliberate, not an oversight. - The Compose compiler plugin is still separate and must be applied, pinned to 2.2.10 to match the kotlin-gradle-plugin AGP 9.3.1 brings transitively. Pinning it to the newest Kotlin release instead would mismatch. - android.kotlinOptions {} was removed; jvm configuration moves to a top-level kotlin { compilerOptions {} }. Java compatibility goes 11 -> 17 (AGP 9 requires JDK 17), abiFilters restrict packaging to arm64-v8a and x86_64, and jniLibs packaging is set uncompressed so the APK zip-aligns native libraries on 16 KB boundaries. Every dependency version in the catalog was checked to resolve against Google Maven rather than copied from documentation. Note that KSP has moved to standalone versioning (2.3.11) and no longer uses the old - scheme; it is catalogued but left unapplied until Room lands. Set applicationId to dev.jasonmross.mediaconverter. com.example.* is rejected by the Play Console, and the application ID is permanent once published, so it has to be right before the first upload. The display name is just a string resource and stays changeable. Document the split license posture: source is MIT, but the distributed binary will be GPL-3.0 because it bundles FFmpeg built with x264/x265. LICENSES/README.md records why, including that libass is ISC rather than GPL, so subtitle burn-in is not what forces the GPL choice. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + LICENSE | 21 +++++ LICENSES/README.md | 62 ++++++++++++++ README.md | 82 +++++++++++++++++++ app/build.gradle.kts | 69 ++++++++++++++-- .../ExampleInstrumentedTest.kt | 24 ------ app/src/main/AndroidManifest.xml | 18 +++- .../mediaconverter/ui/theme/Color.kt | 12 +++ .../mediaconverter/ui/theme/Theme.kt | 51 ++++++++++++ .../mediaconverter/ui/theme/Type.kt | 5 ++ app/src/main/res/values-night/themes.xml | 19 +---- app/src/main/res/values/colors.xml | 10 --- app/src/main/res/values/strings.xml | 4 +- app/src/main/res/values/themes.xml | 19 +---- .../androidmediaconverter/ExampleUnitTest.kt | 17 ---- gradle/libs.versions.toml | 72 ++++++++++++++-- settings.gradle.kts | 2 +- 17 files changed, 382 insertions(+), 108 deletions(-) create mode 100644 LICENSE create mode 100644 LICENSES/README.md create mode 100644 README.md delete mode 100644 app/src/androidTest/java/com/example/androidmediaconverter/ExampleInstrumentedTest.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Color.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Theme.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Type.kt delete mode 100644 app/src/main/res/values/colors.xml delete mode 100644 app/src/test/java/com/example/androidmediaconverter/ExampleUnitTest.kt diff --git a/.gitignore b/.gitignore index aa724b7..9184f80 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ .externalNativeBuild .cxx local.properties + +# Built FFmpeg AAR - see tools/ffmpeg/ for the build recipe +app/libs/*.aar diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c824f74 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/LICENSES/README.md b/LICENSES/README.md new file mode 100644 index 0000000..f0ac010 --- /dev/null +++ b/LICENSES/README.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..746efce --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Media Converter + +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: early development.** The project scaffold and UI shell exist; the conversion +> pipeline is being built out. Not yet usable. + +## 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." + +## Building + +Requires JDK 17+ and the Android SDK with API 37. + +``` +./gradlew :app:assembleDebug +``` + +The FFmpeg native library is built separately from source; that build is not yet wired +into this repository. + +## 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`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f9a698d..5646484 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,41 +1,92 @@ 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 = "dev.jasonmross.mediaconverter" + compileSdk { version = release(37) } defaultConfig { - applicationId = "com.example.androidmediaconverter" + applicationId = "dev.jasonmross.mediaconverter" 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. + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + buildFeatures { + compose = true } buildTypes { release { optimization { + // Left off until the JNI keep rules land with FFmpeg (see plan, Phase 6). enable = false } } } + 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) + + 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) -} \ No newline at end of file + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.compose.ui.test.junit4) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/app/src/androidTest/java/com/example/androidmediaconverter/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/androidmediaconverter/ExampleInstrumentedTest.kt deleted file mode 100644 index d83a5b4..0000000 --- a/app/src/androidTest/java/com/example/androidmediaconverter/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0d7c056..2117410 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + + android:theme="@style/Theme.MediaConverter"> - \ No newline at end of file + + + + + + + + + diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Color.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Color.kt new file mode 100644 index 0000000..8734722 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Color.kt @@ -0,0 +1,12 @@ +package dev.jasonmross.mediaconverter.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) diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Theme.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Theme.kt new file mode 100644 index 0000000..74e4d36 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Theme.kt @@ -0,0 +1,51 @@ +package dev.jasonmross.mediaconverter.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 MediaConverterTheme( + 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, + ) +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Type.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Type.kt new file mode 100644 index 0000000..1f69a0f --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package dev.jasonmross.mediaconverter.ui.theme + +import androidx.compose.material3.Typography + +val Typography = Typography() diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index ad32eb5..03efa9d 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,16 +1,3 @@ - - - - \ No newline at end of file + + - \ No newline at end of file + +