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
<kotlin>-<ksp> 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 21:18:28 -05:00
co-authored by Claude Opus 5
parent 18ae2cff80
commit 48f65f0941
17 changed files with 382 additions and 108 deletions
+3
View File
@@ -13,3 +13,6 @@
.externalNativeBuild
.cxx
local.properties
# Built FFmpeg AAR - see tools/ffmpeg/ for the build recipe
app/libs/*.aar
+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.
+82
View File
@@ -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`.
+60 -9
View File
@@ -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)
}
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
}
@@ -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)
}
}
+14 -4
View File
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
@@ -10,6 +9,17 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidMediaConverter" />
android:theme="@style/Theme.MediaConverter">
</manifest>
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.MediaConverter">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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)
@@ -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,
)
}
@@ -0,0 +1,5 @@
package dev.jasonmross.mediaconverter.ui.theme
import androidx.compose.material3.Typography
val Typography = Typography()
+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.MediaConverter" 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>
+2 -2
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">Android MediaConverter</string>
</resources>
<string name="app_name">Media Converter</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.MediaConverter" 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)
}
}
+63 -9
View File
@@ -1,20 +1,74 @@
[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"
[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" }
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 = "AndroidMediaConverter"
include(":app")