diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 65b69c3..34c514a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -74,6 +74,11 @@ dependencies {
implementation(libs.media3.common)
implementation(libs.media3.muxer)
+ // FFmpeg, built from source by tools/ffmpeg. Not on any Maven repo: ffmpeg-kit was
+ // 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"))
+
// Durable job queue. WorkManager survives process death, which is what makes the
// queue resumable after the foreground-service timeout fires.
implementation(libs.androidx.work.runtime.ktx)
diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngineTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngineTest.kt
new file mode 100644
index 0000000..698a094
--- /dev/null
+++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngineTest.kt
@@ -0,0 +1,167 @@
+package dev.jasonmross.mediaconverter.ffmpeg
+
+import android.media.MediaExtractor
+import android.media.MediaFormat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import dev.jasonmross.mediaconverter.model.ConversionRequest
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import dev.jasonmross.mediaconverter.model.QualityTier
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+
+/**
+ * Exercises the bundled FFmpeg on-device.
+ *
+ * These are the formats the app exists for that Media3 structurally cannot produce, so
+ * they are also the ones with no other coverage. Each test asserts against the produced
+ * file rather than the exit code, because FFmpeg will happily report success after
+ * writing something unplayable if the arguments are wrong.
+ */
+@RunWith(AndroidJUnit4::class)
+class FFmpegEngineTest {
+
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+ private val engine = FFmpegEngine()
+ private lateinit var input: File
+ private val outputs = mutableListOf()
+
+ @Before
+ fun setUp() {
+ input = File(context.cacheDir, "ffmpeg_sample.mp4")
+ InstrumentationRegistry.getInstrumentation().context.assets
+ .open("sample_h264.mp4")
+ .use { asset -> input.outputStream().use { asset.copyTo(it) } }
+ }
+
+ @After
+ fun tearDown() {
+ input.delete()
+ outputs.forEach { it.delete() }
+ }
+
+ private fun outputFor(name: String) =
+ File(context.cacheDir, name).also { it.delete(); outputs += it }
+
+ private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File {
+ val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
+ runBlocking {
+ engine.run(
+ request = ConversionRequest(format = format, quality = quality),
+ inputPath = input.absolutePath,
+ output = out,
+ durationMs = 3_000,
+ )
+ }
+ return out
+ }
+
+ private fun trackMimes(file: File): List {
+ val extractor = MediaExtractor()
+ return try {
+ extractor.setDataSource(file.absolutePath)
+ (0 until extractor.trackCount).map {
+ extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty()
+ }
+ } finally {
+ extractor.release()
+ }
+ }
+
+ // --- the formats that justify bundling FFmpeg at all -------------------
+
+ @Test
+ fun encodesMp3WhichAndroidCannotDoAtAnyApiLevel() {
+ val out = convert(OutputFormat.MP3)
+ assertTrue("no MP3 produced", out.exists() && out.length() > 0)
+ assertTrue(
+ "expected an mpeg audio track, got ${trackMimes(out)}",
+ trackMimes(out).any { it.contains("mp") && it.startsWith("audio/") },
+ )
+ }
+
+ @Test
+ fun encodesGifWithAGeneratedPalette() {
+ val out = convert(OutputFormat.GIF)
+ assertTrue("no GIF produced", out.exists() && out.length() > 0)
+ // GIF87a / GIF89a magic. Proves a real GIF rather than a mislabelled file.
+ val magic = out.inputStream().use { String(it.readNBytes(6)) }
+ assertTrue("not a GIF: $magic", magic.startsWith("GIF"))
+ }
+
+ @Test
+ fun encodesMatroskaWhichMedia3CannotMux() {
+ val out = convert(OutputFormat.MKV_H264)
+ assertTrue("no MKV produced", out.exists() && out.length() > 0)
+ // EBML magic, the Matroska container header.
+ val magic = out.inputStream().use { it.readNBytes(4) }
+ assertEquals(0x1A.toByte(), magic[0])
+ assertEquals(0x45.toByte(), magic[1])
+ assertEquals(0xDF.toByte(), magic[2])
+ assertEquals(0xA3.toByte(), magic[3])
+ }
+
+ @Test
+ fun encodesFlacLosslessAudio() {
+ val out = convert(OutputFormat.FLAC)
+ assertTrue("no FLAC produced", out.exists() && out.length() > 0)
+ }
+
+ @Test
+ fun encodesWav() {
+ val out = convert(OutputFormat.WAV)
+ assertTrue("no WAV produced", out.exists() && out.length() > 0)
+ val magic = out.inputStream().use { String(it.readNBytes(4)) }
+ assertEquals("RIFF", magic)
+ }
+
+ @Test
+ fun encodesOpus() {
+ val out = convert(OutputFormat.OPUS)
+ assertTrue("no Opus produced", out.exists() && out.length() > 0)
+ }
+
+ // --- the quality tier the GPL licence was taken for --------------------
+
+ @Test
+ fun bestQualityProducesAPlayableH264File() {
+ val out = convert(OutputFormat.MP4_H264, QualityTier.BEST)
+ assertTrue("no MP4 produced", out.exists() && out.length() > 0)
+ assertTrue(
+ "expected an AVC track, got ${trackMimes(out)}",
+ trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_AVC },
+ )
+ }
+
+ @Test
+ fun bestQualityProducesAPlayableH265File() {
+ val out = convert(OutputFormat.MP4_H265, QualityTier.BEST)
+ assertTrue("no MP4 produced", out.exists() && out.length() > 0)
+ assertTrue(
+ "expected an HEVC track, got ${trackMimes(out)}",
+ trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_HEVC },
+ )
+ }
+
+ @Test
+ fun failureSurfacesAsAnExceptionRatherThanASilentEmptyFile() {
+ val out = outputFor("nope.mp4")
+ val failure = runCatching {
+ runBlocking {
+ engine.run(
+ request = ConversionRequest(format = OutputFormat.MP4_H264),
+ inputPath = "/does/not/exist.mp4",
+ output = out,
+ durationMs = 1_000,
+ )
+ }
+ }.exceptionOrNull()
+ assertTrue("expected an FFmpegException, got $failure", failure is FFmpegEngine.FFmpegException)
+ }
+}
diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt
index 2660305..caf72e9 100644
--- a/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt
+++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt
@@ -8,6 +8,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
+import dev.jasonmross.mediaconverter.model.Engine
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import dev.jasonmross.mediaconverter.model.QualityTier
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -87,6 +90,93 @@ class ConversionWorkerTest {
assertTrue("output file empty", output.length() > 0)
}
+ /**
+ * The whole point of the router, end to end.
+ *
+ * MP3 cannot be produced by Media3 on any Android version, so this asserts both
+ * that the job completes and that it was actually carried out by FFmpeg. Checking
+ * only that a file appeared would pass even if the routing were broken.
+ */
+ @Test
+ fun routesAnMp3JobToFfmpegAndProducesAFile() = runBlocking {
+ val request = ConversionWorker.request(
+ inputUri = Uri.fromFile(input),
+ displayName = "worker_sample.mp4",
+ sizeBytes = input.length(),
+ format = OutputFormat.MP3,
+ )
+ workManager.enqueue(request).result.get()
+
+ val terminal = withTimeout(TIMEOUT_MS) {
+ workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
+ }
+
+ assertEquals(
+ "mp3 job did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}",
+ WorkInfo.State.SUCCEEDED,
+ terminal?.state,
+ )
+ assertEquals(
+ Engine.FFMPEG.name,
+ terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
+ )
+ val output = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
+ assertTrue("no mp3 written", output.exists() && output.length() > 0)
+ assertTrue("wrong extension: ${output.name}", output.name.endsWith(".mp3"))
+ }
+
+ @Test
+ fun routesAFastMp4JobToMedia3() = runBlocking {
+ val request = ConversionWorker.request(
+ inputUri = Uri.fromFile(input),
+ displayName = "worker_sample.mp4",
+ sizeBytes = input.length(),
+ format = OutputFormat.MP4_H265,
+ quality = QualityTier.FAST,
+ )
+ workManager.enqueue(request).result.get()
+
+ val terminal = withTimeout(TIMEOUT_MS) {
+ workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
+ }
+
+ assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
+ assertEquals(
+ Engine.MEDIA3.name,
+ terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
+ )
+ }
+
+ /** The quality tier is the reason the shipped binary is GPL, so verify it routes. */
+ @Test
+ fun routesABestQualityJobToFfmpeg() = runBlocking {
+ val request = ConversionWorker.request(
+ inputUri = Uri.fromFile(input),
+ displayName = "worker_sample.mp4",
+ sizeBytes = input.length(),
+ format = OutputFormat.MP4_H264,
+ quality = QualityTier.BEST,
+ )
+ workManager.enqueue(request).result.get()
+
+ val terminal = withTimeout(TIMEOUT_MS) {
+ workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
+ }
+
+ assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
+ assertEquals(
+ Engine.FFMPEG.name,
+ terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
+ )
+ }
+
+ @Test
+ fun outputNameTakesTheExtensionOfTheChosenFormat() {
+ assertEquals("clip_converted.mp3", ConversionWorker.outputNameFor("clip.mp4", OutputFormat.MP3))
+ assertEquals("clip_converted.gif", ConversionWorker.outputNameFor("clip.mov", OutputFormat.GIF))
+ assertEquals("clip_converted.mkv", ConversionWorker.outputNameFor("clip", OutputFormat.MKV_H264))
+ }
+
private companion object {
const val TIMEOUT_MS = 180_000L
}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt b/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt
index 5e684ea..ddb35ba 100644
--- a/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt
@@ -4,27 +4,109 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
+import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
+import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi
import dev.jasonmross.mediaconverter.convert.ConverterScreen
+import dev.jasonmross.mediaconverter.join.JoinScreen
import dev.jasonmross.mediaconverter.ui.theme.MediaConverterTheme
+private enum class Destination(val label: String) {
+ CONVERT("Convert"),
+ JOIN("Join"),
+}
+
@UnstableApi
class MainActivity : ComponentActivity() {
+
+ @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
- // Edge-to-edge is enforced from targetSdk 35 and has no opt-out at 36+,
- // so opt in explicitly rather than relying on the default.
+ // Edge-to-edge is enforced from targetSdk 35 and has no opt-out at 36+, so opt
+ // in explicitly rather than relying on the default.
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
MediaConverterTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- ConverterScreen(modifier = Modifier.padding(innerPadding))
- }
+ AppRoot(widthSizeClass = calculateWindowSizeClass(this).widthSizeClass)
}
}
}
}
+
+/**
+ * Adaptive shell: a bottom bar on phones, a side rail on wider screens.
+ *
+ * This is not cosmetic. From targetSdk 37, Android ignores `screenOrientation`,
+ * `resizableActivity` and aspect-ratio limits on any display at least 600dp wide, and
+ * the Android 16 opt-out no longer applies. The app will be resized and rotated
+ * whether or not it is ready, so it has to lay out properly at every width.
+ */
+@UnstableApi
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AppRoot(widthSizeClass: WindowWidthSizeClass) {
+ var destination by remember { mutableStateOf(Destination.CONVERT) }
+ val useRail = widthSizeClass != WindowWidthSizeClass.Compact
+
+ if (useRail) {
+ Row(modifier = Modifier.fillMaxSize()) {
+ NavigationRail {
+ Destination.entries.forEach { item ->
+ NavigationRailItem(
+ selected = destination == item,
+ onClick = { destination = item },
+ icon = {},
+ label = { Text(item.label) },
+ )
+ }
+ }
+ Scaffold(modifier = Modifier.fillMaxSize()) { padding ->
+ Content(destination, Modifier.padding(padding))
+ }
+ }
+ } else {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ bottomBar = {
+ NavigationBar {
+ Destination.entries.forEach { item ->
+ NavigationBarItem(
+ selected = destination == item,
+ onClick = { destination = item },
+ icon = {},
+ label = { Text(item.label) },
+ )
+ }
+ }
+ },
+ ) { padding ->
+ Content(destination, Modifier.padding(padding))
+ }
+ }
+}
+
+@UnstableApi
+@Composable
+private fun Content(destination: Destination, modifier: Modifier) {
+ when (destination) {
+ Destination.CONVERT -> ConverterScreen(modifier = modifier)
+ Destination.JOIN -> JoinScreen(modifier = modifier)
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/codec/AndroidDeviceCodecs.kt b/app/src/main/java/dev/jasonmross/mediaconverter/codec/AndroidDeviceCodecs.kt
new file mode 100644
index 0000000..8295826
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/codec/AndroidDeviceCodecs.kt
@@ -0,0 +1,107 @@
+package dev.jasonmross.mediaconverter.codec
+
+import android.media.MediaCodecInfo
+import android.media.MediaCodecList
+import android.media.MediaFormat
+import android.util.Log
+import dev.jasonmross.mediaconverter.model.DeviceCodecs
+import dev.jasonmross.mediaconverter.model.InputProbe
+import dev.jasonmross.mediaconverter.model.VideoCodec
+
+/**
+ * What this device's codec hardware can actually do.
+ *
+ * Enumerated once and cached: MediaCodecList is not cheap, and the answer cannot
+ * change while the process is alive.
+ *
+ * Two details that are easy to get wrong:
+ *
+ * - Devices expose **aliases** for the same underlying codec, so the list must be
+ * deduplicated by canonical name or capabilities get counted several times.
+ * - `isHardwareAccelerated()` is declared by the vendor and, in the platform's own
+ * words, "cannot be tested for correctness". It is a hint, not a guarantee, which is
+ * why the router treats a failed hardware export as a signal to fall back rather
+ * than trusting this up front.
+ */
+class AndroidDeviceCodecs private constructor(
+ private val hardwareEncodeMimes: Set,
+ private val decodeMimes: Set,
+) : DeviceCodecs {
+
+ override fun canEncode(codec: VideoCodec): Boolean =
+ mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
+
+ override fun canDecode(codecName: String): Boolean {
+ // The platform already failed to parse this input, so there is nothing to
+ // decode with. Send it to FFmpeg rather than letting Media3 fail later.
+ if (codecName == InputProbe.UNPARSEABLE) return false
+ return mimeForCodecName(codecName)?.let { it in decodeMimes } ?: true
+ }
+
+ fun hardwareEncoders(): Set = hardwareEncodeMimes
+
+ companion object {
+ private const val TAG = "AndroidDeviceCodecs"
+
+ @Volatile
+ private var cached: AndroidDeviceCodecs? = null
+
+ fun get(): AndroidDeviceCodecs =
+ cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
+
+ private fun probe(): AndroidDeviceCodecs {
+ val encoders = mutableSetOf()
+ val decoders = mutableSetOf()
+ val seen = mutableSetOf()
+
+ runCatching {
+ MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.forEach { info ->
+ // Aliases point at the same underlying codec; counting both would
+ // double-count capabilities.
+ if (info.isAlias) return@forEach
+ if (!seen.add(info.canonicalName)) return@forEach
+
+ info.supportedTypes.forEach { mime ->
+ if (!mime.startsWith("video/")) return@forEach
+ if (info.isEncoder) {
+ if (info.isHardwareAccelerated && !info.isSoftwareOnly) {
+ encoders += mime
+ }
+ } else {
+ decoders += mime
+ }
+ }
+ }
+ }.onFailure { Log.w(TAG, "Codec enumeration failed; assuming permissive.", it) }
+
+ Log.i(TAG, "Hardware video encoders: $encoders")
+ return AndroidDeviceCodecs(encoders, decoders)
+ }
+
+ private fun mimeFor(codec: VideoCodec): String? = when (codec) {
+ VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC
+ VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC
+ VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8
+ VideoCodec.VP9 -> MediaFormat.MIMETYPE_VIDEO_VP9
+ VideoCodec.AV1 -> MediaFormat.MIMETYPE_VIDEO_AV1
+ VideoCodec.NONE -> null
+ }
+
+ /** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */
+ private fun mimeForCodecName(name: String): String? = when (name.lowercase()) {
+ "h264", "avc", "avc1" -> MediaFormat.MIMETYPE_VIDEO_AVC
+ "hevc", "h265", "hvc1" -> MediaFormat.MIMETYPE_VIDEO_HEVC
+ "vp8" -> MediaFormat.MIMETYPE_VIDEO_VP8
+ "vp9" -> MediaFormat.MIMETYPE_VIDEO_VP9
+ "av1", "av01" -> MediaFormat.MIMETYPE_VIDEO_AV1
+ "mpeg4" -> MediaFormat.MIMETYPE_VIDEO_MPEG4
+ // Unknown to us: assume the platform can handle it and let a failed export
+ // trigger the FFmpeg fallback, rather than pre-emptively refusing hardware.
+ else -> null
+ }
+
+ /** Test seam: lets instrumented tests build a probe from explicit sets. */
+ fun forTesting(encoders: Set, decoders: Set) =
+ AndroidDeviceCodecs(encoders, decoders)
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt
index 821707a..aeba52a 100644
--- a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt
@@ -8,17 +8,28 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkInfo
import androidx.work.WorkManager
+import dev.jasonmross.mediaconverter.model.EnginePreference
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import dev.jasonmross.mediaconverter.model.QualityTier
import dev.jasonmross.mediaconverter.work.ConversionWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
+/** User-chosen conversion settings. */
+data class ConversionSettings(
+ val format: OutputFormat = OutputFormat.MP4_H265,
+ val quality: QualityTier = QualityTier.FAST,
+ val enginePreference: EnginePreference = EnginePreference.AUTO,
+)
+
data class InputFile(
val uri: Uri,
val displayName: String,
@@ -31,7 +42,12 @@ sealed interface ConversionState {
data class Converting(val input: InputFile, val percent: Int) : ConversionState
/** Budget for foreground work ran out; WorkManager will retry when it can. */
data class Waiting(val input: InputFile) : ConversionState
- data class Converted(val input: InputFile, val staged: File) : ConversionState
+ data class Converted(
+ val input: InputFile,
+ val staged: File,
+ val engineUsed: String = "",
+ val routeReason: String = "",
+ ) : ConversionState
data class Saved(val displayName: String) : ConversionState
data class Failed(val message: String) : ConversionState
}
@@ -48,6 +64,15 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
private var observer: Job? = null
private var activeWorkId: UUID? = null
+ /** Conversion settings, kept separate from the job state machine. */
+ private val _settings = MutableStateFlow(ConversionSettings())
+ val settings: StateFlow = _settings.asStateFlow()
+
+ fun setFormat(format: OutputFormat) = _settings.update { it.copy(format = format) }
+ fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
+ fun setEnginePreference(preference: EnginePreference) =
+ _settings.update { it.copy(enginePreference = preference) }
+
fun onInputPicked(uri: Uri) {
viewModelScope.launch {
val info = withContext(Dispatchers.IO) { queryFile(uri) }
@@ -65,10 +90,14 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
fun convert() {
val input = currentInput() ?: return
+ val settings = _settings.value
val request = ConversionWorker.request(
inputUri = input.uri,
displayName = input.displayName,
sizeBytes = input.sizeBytes,
+ format = settings.format,
+ quality = settings.quality,
+ enginePreference = settings.enginePreference,
)
activeWorkId = request.id
workManager.enqueue(request)
@@ -101,7 +130,14 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
if (path == null) {
ConversionState.Failed("Conversion reported success but produced no file.")
} else {
- ConversionState.Converted(input, File(path))
+ ConversionState.Converted(
+ input = input,
+ staged = File(path),
+ engineUsed = info.outputData
+ .getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
+ routeReason = info.outputData
+ .getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(),
+ )
}
}
@@ -131,7 +167,10 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
}
}.onSuccess {
_state.value = ConversionState.Saved(
- ConversionWorker.outputNameFor(converted.input.displayName)
+ ConversionWorker.outputNameFor(
+ converted.input.displayName,
+ _settings.value.format,
+ )
)
}.onFailure { e ->
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
@@ -147,7 +186,10 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
}
fun suggestedOutputName(): String =
- ConversionWorker.outputNameFor(currentInput()?.displayName ?: "output")
+ ConversionWorker.outputNameFor(
+ currentInput()?.displayName ?: "output",
+ _settings.value.format,
+ )
private fun currentInput(): InputFile? = when (val s = _state.value) {
is ConversionState.Ready -> s.input
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt
index b85d2ea..8126d47 100644
--- a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt
@@ -5,26 +5,31 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
+import androidx.compose.material3.FilterChip
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
+import dev.jasonmross.mediaconverter.model.EnginePreference
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import dev.jasonmross.mediaconverter.model.QualityTier
import java.util.Locale
@UnstableApi
@@ -34,6 +39,7 @@ fun ConverterScreen(
viewModel: ConversionViewModel = viewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
+ val settings by viewModel.settings.collectAsStateWithLifecycle()
// ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is images and video
// only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm.
@@ -43,23 +49,22 @@ fun ConverterScreen(
) { uri -> uri?.let(viewModel::onInputPicked) }
val chooseDestination = rememberLauncherForActivityResult(
- ActivityResultContracts.CreateDocument("video/mp4")
+ ActivityResultContracts.CreateDocument(settings.format.mimeType)
) { uri -> uri?.let(viewModel::save) }
- // Requested at the point of use rather than on first launch, so the ask carries
- // its own justification. The conversion starts either way: without the permission
- // the foreground service still runs, but its progress notification is confined to
- // the Task Manager instead of the shade.
+ // Requested at the point of use rather than on first launch, so the ask carries its
+ // own justification. The conversion starts either way: without the permission the
+ // foreground service still runs, but its progress notification is confined to the
+ // Task Manager instead of the shade.
val requestNotifications = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { viewModel.convert() }
- fun startConversion() {
- requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
- }
-
Column(
- modifier = modifier.fillMaxSize().padding(24.dp),
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Media Converter", style = MaterialTheme.typography.headlineMedium)
@@ -67,22 +72,32 @@ fun ConverterScreen(
when (val s = state) {
is ConversionState.Idle -> {
Text(
- "Pick a video to transcode to H.265.",
+ "Pick a file to convert.",
style = MaterialTheme.typography.bodyMedium,
)
- Button(onClick = { pickInput.launch(arrayOf("video/*")) }) {
- Text("Choose file")
- }
+ Button(onClick = { pickInput.launch(arrayOf("*/*")) }) { Text("Choose file") }
}
is ConversionState.Ready -> {
FileCard(s.input)
- Text(
- "We'll show progress in a notification so you can leave the app.",
- style = MaterialTheme.typography.bodySmall,
+ FormatPicker(
+ selected = settings.format,
+ onSelect = viewModel::setFormat,
)
- Button(onClick = ::startConversion) { Text("Convert") }
- OutlinedButton(onClick = { pickInput.launch(arrayOf("video/*")) }) {
+ QualityPicker(
+ selected = settings.quality,
+ onSelect = viewModel::setQuality,
+ )
+ EnginePicker(
+ selected = settings.enginePreference,
+ onSelect = viewModel::setEnginePreference,
+ )
+ Button(
+ onClick = {
+ requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ ) { Text("Convert") }
+ OutlinedButton(onClick = { pickInput.launch(arrayOf("*/*")) }) {
Text("Choose a different file")
}
}
@@ -113,9 +128,15 @@ fun ConverterScreen(
"Done — ${formatBytes(s.staged.length())} output.",
style = MaterialTheme.typography.bodyMedium,
)
+ if (s.routeReason.isNotBlank()) {
+ // Surfacing the routing decision rather than hiding it: it explains
+ // why a job was slow, and makes the software fallback visible.
+ AssistChip(onClick = {}, label = { Text(s.routeReason) })
+ }
Button(onClick = { chooseDestination.launch(viewModel.suggestedOutputName()) }) {
Text("Save file")
}
+ OutlinedButton(onClick = viewModel::reset) { Text("Start over") }
}
is ConversionState.Saved -> {
@@ -135,6 +156,58 @@ fun ConverterScreen(
}
}
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun FormatPicker(selected: OutputFormat, onSelect: (OutputFormat) -> Unit) {
+ Text("Output format", style = MaterialTheme.typography.titleSmall)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutputFormat.entries.forEach { format ->
+ FilterChip(
+ selected = format == selected,
+ onClick = { onSelect(format) },
+ label = { Text(format.label) },
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
+ Text("Quality", style = MaterialTheme.typography.titleSmall)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ QualityTier.entries.forEach { tier ->
+ FilterChip(
+ selected = tier == selected,
+ onClick = { onSelect(tier) },
+ label = { Text(tier.label) },
+ )
+ }
+ }
+ Text(selected.description, style = MaterialTheme.typography.bodySmall)
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
+ Text("Engine", style = MaterialTheme.typography.titleSmall)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ EnginePreference.entries.forEach { preference ->
+ FilterChip(
+ selected = preference == selected,
+ onClick = { onSelect(preference) },
+ label = { Text(preference.label()) },
+ )
+ }
+ }
+}
+
+private fun EnginePreference.label(): String = when (this) {
+ EnginePreference.AUTO -> "Automatic"
+ EnginePreference.PREFER_HARDWARE -> "Prefer hardware"
+ EnginePreference.FORCE_SOFTWARE -> "Force software"
+}
+
@Composable
private fun FileCard(input: InputFile) {
Card(modifier = Modifier.fillMaxWidth()) {
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/MediaProbe.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/MediaProbe.kt
new file mode 100644
index 0000000..d0fba4e
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/MediaProbe.kt
@@ -0,0 +1,112 @@
+package dev.jasonmross.mediaconverter.convert
+
+import android.content.Context
+import android.media.MediaExtractor
+import android.media.MediaFormat
+import android.net.Uri
+import android.util.Log
+import dev.jasonmross.mediaconverter.model.ConcatInput
+import dev.jasonmross.mediaconverter.model.InputProbe
+
+/**
+ * Reads just enough about an input to route it.
+ *
+ * Uses the platform extractor rather than FFprobe: it needs no native library, works
+ * directly on a content:// URI, and the router only needs the codec and duration. If
+ * the platform cannot parse the file at all, that is itself the answer — a file
+ * MediaExtractor cannot open is one Media3 cannot convert, so it routes to FFmpeg.
+ */
+object MediaProbe {
+
+ fun probe(context: Context, uri: Uri): InputProbe {
+ val extractor = MediaExtractor()
+ return try {
+ extractor.setDataSource(context, uri, null)
+ var video: String? = null
+ var audio: String? = null
+ var durationUs = 0L
+
+ for (i in 0 until extractor.trackCount) {
+ val format = extractor.getTrackFormat(i)
+ val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
+ if (format.containsKey(MediaFormat.KEY_DURATION)) {
+ durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
+ }
+ when {
+ mime.startsWith("video/") && video == null -> video = shortName(mime)
+ mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
+ }
+ }
+
+ InputProbe(
+ videoCodec = video,
+ audioCodec = audio,
+ hasVideo = video != null,
+ durationMs = durationUs / 1000,
+ )
+ } catch (e: Exception) {
+ // Not a failure: an unparseable input is a strong signal that this job
+ // belongs on FFmpeg. Reporting an unknown codec makes the router say so.
+ Log.i(TAG, "Platform extractor could not read $uri; routing to FFmpeg.", e)
+ InputProbe(videoCodec = InputProbe.UNPARSEABLE, hasVideo = true, durationMs = 0)
+ } finally {
+ runCatching { extractor.release() }
+ }
+ }
+
+ /**
+ * Reads the properties that decide whether inputs can be joined without
+ * re-encoding. Unknown values stay null, which [ConcatPlanner] treats as "cannot
+ * prove a match" rather than as agreement.
+ */
+ fun probeForConcat(context: Context, uri: Uri): ConcatInput {
+ val extractor = MediaExtractor()
+ return try {
+ extractor.setDataSource(context, uri, null)
+ var video: String? = null
+ var audio: String? = null
+ var width = 0
+ var height = 0
+ var fps = 0
+
+ for (i in 0 until extractor.trackCount) {
+ val format = extractor.getTrackFormat(i)
+ val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
+ if (mime.startsWith("video/") && video == null) {
+ video = shortName(mime)
+ width = format.intOr(MediaFormat.KEY_WIDTH)
+ height = format.intOr(MediaFormat.KEY_HEIGHT)
+ fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
+ } else if (mime.startsWith("audio/") && audio == null) {
+ audio = shortName(mime)
+ }
+ }
+ ConcatInput(video, audio, width, height, fps)
+ } catch (e: Exception) {
+ Log.i(TAG, "Could not probe $uri for concat; will re-encode.", e)
+ ConcatInput(null, null, 0, 0, 0)
+ } finally {
+ runCatching { extractor.release() }
+ }
+ }
+
+ private fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
+ if (containsKey(key)) runCatching { getInteger(key) }.getOrDefault(fallback) else fallback
+
+ /** MediaFormat MIME -> the short codec names the router and FFmpeg both speak. */
+ private fun shortName(mime: String): String = when (mime) {
+ MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
+ MediaFormat.MIMETYPE_VIDEO_HEVC -> "hevc"
+ MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
+ MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9"
+ MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1"
+ MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4"
+ MediaFormat.MIMETYPE_AUDIO_AAC -> "aac"
+ MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus"
+ MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac"
+ MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis"
+ else -> mime.substringAfter('/')
+ }
+
+ private const val TAG = "MediaProbe"
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/ConcatEngine.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/ConcatEngine.kt
new file mode 100644
index 0000000..0bb4277
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/ConcatEngine.kt
@@ -0,0 +1,85 @@
+package dev.jasonmross.mediaconverter.ffmpeg
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import com.arthenica.ffmpegkit.FFmpegKit
+import com.arthenica.ffmpegkit.FFmpegKitConfig
+import com.arthenica.ffmpegkit.ReturnCode
+import dev.jasonmross.mediaconverter.convert.MediaProbe
+import dev.jasonmross.mediaconverter.model.ConcatPlanner
+import dev.jasonmross.mediaconverter.model.ConcatStrategy
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.io.File
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+/**
+ * Joins several inputs into one file.
+ *
+ * Picks between a stream copy and a full re-encode by inspecting the inputs, because
+ * the `concat` demuxer requires matching codec, resolution and timebase and does not
+ * reliably fail when they differ — it can emit a file whose later segments are
+ * garbled. See [ConcatPlanner].
+ */
+class ConcatEngine(private val context: Context) {
+
+ data class Result(val strategy: ConcatStrategy, val output: File)
+
+ suspend fun join(
+ inputs: List,
+ output: File,
+ format: OutputFormat = OutputFormat.MP4_H264,
+ ): Result {
+ require(inputs.size >= 2) { "Joining needs at least two files." }
+
+ val paths = inputs.map { uri ->
+ if (uri.scheme == "content") {
+ FFmpegKitConfig.getSafParameterForRead(context, uri)
+ } else {
+ uri.path
+ } ?: error("Could not open one of the input files.")
+ }
+
+ val strategy = ConcatPlanner.plan(inputs.map { MediaProbe.probeForConcat(context, it) })
+ Log.i(TAG, "Joining ${inputs.size} files using $strategy")
+
+ // The demuxer reads its input list from a file, which must live somewhere
+ // FFmpeg can read; app cache is a real path, so it just works.
+ val listFile = File(output.parentFile, "concat_list.txt").apply {
+ writeText(FFmpegConcatCommand.listFileContents(paths))
+ }
+
+ val args = FFmpegConcatCommand.build(strategy, paths, listFile, output, format)
+ try {
+ execute(args)
+ } finally {
+ listFile.delete()
+ }
+ return Result(strategy, output)
+ }
+
+ private suspend fun execute(args: List) = suspendCancellableCoroutine { cont ->
+ Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
+ val session = FFmpegKit.executeWithArgumentsAsync(args.toTypedArray()) { completed ->
+ val rc = completed.getReturnCode()
+ when {
+ ReturnCode.isSuccess(rc) -> cont.resume(Unit)
+ ReturnCode.isCancel(rc) -> cont.cancel()
+ else -> cont.resumeWithException(
+ FFmpegEngine.FFmpegException(
+ "Joining failed (${rc?.value}): " +
+ completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
+ )
+ )
+ }
+ }
+ cont.invokeOnCancellation { FFmpegKit.cancel(session.getSessionId()) }
+ }
+
+ private companion object {
+ const val TAG = "ConcatEngine"
+ const val LOG_TAIL_LIMIT = 40
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt
new file mode 100644
index 0000000..3765b81
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt
@@ -0,0 +1,129 @@
+package dev.jasonmross.mediaconverter.ffmpeg
+
+import dev.jasonmross.mediaconverter.model.ConversionRequest
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import dev.jasonmross.mediaconverter.model.QualityTier
+
+/**
+ * Builds FFmpeg argument lists.
+ *
+ * Kept free of Android types so the whole matrix can be unit tested on the JVM. A
+ * wrong flag here produces a corrupt file or a silent quality regression, which is
+ * exactly the kind of thing that should not need a device to catch.
+ *
+ * Arguments are produced as a list rather than a shell string: paths routinely contain
+ * spaces, and a list has no quoting rules to get wrong.
+ */
+object FFmpegCommandBuilder {
+
+ /**
+ * CRF values, chosen per codec rather than shared.
+ *
+ * x265 is roughly one CRF step "stronger" than x264 at the same number, so a
+ * shared constant would silently make HEVC output larger than intended.
+ */
+ private const val CRF_H264 = 20
+ private const val CRF_H265 = 24
+
+ fun build(
+ request: ConversionRequest,
+ inputPath: String,
+ outputPath: String,
+ ): List = buildList {
+ add("-hide_banner")
+ // Overwrite: the output path is one we just created in our own cache.
+ add("-y")
+ add("-i"); add(inputPath)
+
+ addAll(streamSelection(request.format))
+ addAll(videoArgs(request))
+ addAll(audioArgs(request.format))
+ addAll(containerArgs(request.format))
+
+ add(outputPath)
+ }
+
+ private fun streamSelection(format: OutputFormat): List = when {
+ // -vn drops video entirely. Without it FFmpeg will happily try to carry a video
+ // stream into an audio container and fail at the muxer.
+ format.isAudioOnly -> listOf("-vn")
+ format == OutputFormat.GIF || format == OutputFormat.FRAMES_PNG -> listOf("-an")
+ else -> emptyList()
+ }
+
+ private fun videoArgs(request: ConversionRequest): List {
+ val format = request.format
+ if (format.isAudioOnly) return emptyList()
+
+ return when (format) {
+ OutputFormat.GIF -> listOf(
+ // One pass with a generated palette. GIF is limited to 256 colours, and
+ // the default palette produces visibly banded output; split+palettegen
+ // and paletteuse in a single graph avoids a temporary palette file.
+ "-vf",
+ "fps=12,scale=480:-1:flags=lanczos,split[a][b];" +
+ "[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer",
+ "-loop", "0",
+ )
+
+ OutputFormat.FRAMES_PNG -> listOf("-vf", "fps=1", "-vsync", "0")
+
+ else -> when (request.quality) {
+ // Software encoding: this is what the GPL licence buys. CRF targets a
+ // quality level and lets the bitrate fall where it may, which is what
+ // "compress this well" actually needs. No hardware encoder on Android
+ // exposes it.
+ QualityTier.BEST -> when (format.videoCodec) {
+ dev.jasonmross.mediaconverter.model.VideoCodec.H265 -> listOf(
+ "-c:v", "libx265", "-crf", "$CRF_H265", "-preset", "medium",
+ // Without this, many players and Apple devices refuse HEVC in MP4.
+ "-tag:v", "hvc1",
+ )
+ dev.jasonmross.mediaconverter.model.VideoCodec.VP9 -> listOf(
+ "-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0",
+ )
+ else -> listOf(
+ "-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "medium",
+ // 4:2:0 for compatibility; some sources decode to 4:4:4, which
+ // most hardware players cannot handle.
+ "-pix_fmt", "yuv420p",
+ )
+ }
+
+ // FFmpeg's MediaCodec wrappers. Used when a job landed on FFmpeg for
+ // container reasons but the user still asked for speed.
+ QualityTier.FAST -> when (format.videoCodec) {
+ dev.jasonmross.mediaconverter.model.VideoCodec.H265 ->
+ listOf("-c:v", "hevc_mediacodec", "-b:v", "5M", "-tag:v", "hvc1")
+ dev.jasonmross.mediaconverter.model.VideoCodec.VP9 ->
+ listOf("-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0")
+ else ->
+ listOf("-c:v", "h264_mediacodec", "-b:v", "5M")
+ }
+ }
+ }
+ }
+
+ private fun audioArgs(format: OutputFormat): List = when (format) {
+ OutputFormat.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
+ OutputFormat.FLAC -> listOf("-c:a", "flac")
+ OutputFormat.WAV -> listOf("-c:a", "pcm_s16le")
+ OutputFormat.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
+ OutputFormat.M4A_AAC -> listOf("-c:a", "aac", "-b:a", "192k")
+ OutputFormat.GIF, OutputFormat.FRAMES_PNG -> emptyList()
+ OutputFormat.WEBM_VP9 -> listOf("-c:a", "libopus", "-b:a", "128k")
+ else -> listOf("-c:a", "aac", "-b:a", "192k")
+ }
+
+ private fun containerArgs(format: OutputFormat): List = when (format.container) {
+ // Move the moov atom to the front so the file starts playing before it is
+ // fully downloaded. This is also the reason output never goes through a SAF
+ // file descriptor: faststart has to seek backwards to rewrite the header.
+ dev.jasonmross.mediaconverter.model.Container.MP4 -> listOf("-movflags", "+faststart")
+ else -> emptyList()
+ }
+
+ /** Output filename pattern for formats that emit many files. */
+ fun outputPattern(format: OutputFormat, baseName: String): String =
+ if (format == OutputFormat.FRAMES_PNG) "${baseName}_%04d.png" else baseName
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommand.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommand.kt
new file mode 100644
index 0000000..52c82af
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommand.kt
@@ -0,0 +1,69 @@
+package dev.jasonmross.mediaconverter.ffmpeg
+
+import dev.jasonmross.mediaconverter.model.ConcatStrategy
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import java.io.File
+
+/**
+ * Builds the two different FFmpeg invocations for joining files.
+ *
+ * Pure so the argument shapes can be unit tested; the caller supplies paths.
+ */
+object FFmpegConcatCommand {
+
+ /**
+ * Content of the list file the `concat` demuxer reads.
+ *
+ * Single quotes are escaped the way the demuxer expects, because file names
+ * routinely contain apostrophes and an unescaped one silently truncates the entry.
+ */
+ fun listFileContents(paths: List): String =
+ paths.joinToString("\n") { "file '${it.replace("'", "'\\''")}'" } + "\n"
+
+ fun build(
+ strategy: ConcatStrategy,
+ inputPaths: List,
+ listFile: File,
+ output: File,
+ format: OutputFormat,
+ ): List = when (strategy) {
+ ConcatStrategy.STREAM_COPY -> buildList {
+ add("-hide_banner"); add("-y")
+ // -safe 0 permits absolute paths in the list file, which ours are.
+ add("-f"); add("concat")
+ add("-safe"); add("0")
+ add("-i"); add(listFile.absolutePath)
+ add("-c"); add("copy")
+ if (format.container == dev.jasonmross.mediaconverter.model.Container.MP4) {
+ add("-movflags"); add("+faststart")
+ }
+ add(output.absolutePath)
+ }
+
+ ConcatStrategy.REENCODE -> buildList {
+ add("-hide_banner"); add("-y")
+ inputPaths.forEach { add("-i"); add(it) }
+ // Normalise every input to a common size and frame rate before joining,
+ // otherwise the concat filter refuses mismatched inputs.
+ val filter = buildString {
+ inputPaths.indices.forEach { i ->
+ append("[$i:v]scale=1280:720:force_original_aspect_ratio=decrease,")
+ append("pad=1280:720:-1:-1,setsar=1,fps=30[v$i];")
+ }
+ inputPaths.indices.forEach { i -> append("[v$i][$i:a]") }
+ append("concat=n=${inputPaths.size}:v=1:a=1[v][a]")
+ }
+ add("-filter_complex"); add(filter)
+ add("-map"); add("[v]")
+ add("-map"); add("[a]")
+ add("-c:v"); add("libx264")
+ add("-crf"); add("20")
+ add("-pix_fmt"); add("yuv420p")
+ add("-c:a"); add("aac")
+ if (format.container == dev.jasonmross.mediaconverter.model.Container.MP4) {
+ add("-movflags"); add("+faststart")
+ }
+ add(output.absolutePath)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngine.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngine.kt
new file mode 100644
index 0000000..f575068
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngine.kt
@@ -0,0 +1,89 @@
+package dev.jasonmross.mediaconverter.ffmpeg
+
+import android.util.Log
+import com.arthenica.ffmpegkit.FFmpegKit
+import com.arthenica.ffmpegkit.FFmpegKitConfig
+import com.arthenica.ffmpegkit.Level
+import com.arthenica.ffmpegkit.ReturnCode
+import dev.jasonmross.mediaconverter.model.ConversionRequest
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.io.File
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+/**
+ * Software conversion via the bundled FFmpeg.
+ *
+ * Handles everything Media3 structurally cannot: Matroska, MP3, GIF, frame sequences,
+ * inputs with no platform decoder, and the CRF quality tier.
+ *
+ * Like [dev.jasonmross.mediaconverter.convert.Media3Engine], input arrives as a real
+ * filesystem path and output is written to app-private cache. FFmpeg is perfectly able
+ * to write through a SAF descriptor via its ffkitsaf protocol, but MP4 faststart has
+ * to seek backwards to rewrite the moov atom, which a SAF descriptor does not reliably
+ * support — so staging is the safe default for every format rather than a special case.
+ */
+class FFmpegEngine {
+
+ init {
+ FFmpegKitConfig.setLogLevel(Level.AV_LOG_WARNING)
+ }
+
+ /**
+ * Runs a conversion, reporting progress 0..100.
+ *
+ * Progress is derived from the statistics callback's timestamp against the known
+ * input duration. FFmpeg has no native notion of percentage complete, so a
+ * [durationMs] of zero means progress simply cannot be reported — the caller gets
+ * an indeterminate job rather than a fabricated number.
+ */
+ suspend fun run(
+ request: ConversionRequest,
+ inputPath: String,
+ output: File,
+ durationMs: Long,
+ onProgress: (Int) -> Unit = {},
+ ): Unit = suspendCancellableCoroutine { cont ->
+ val args = FFmpegCommandBuilder.build(request, inputPath, output.absolutePath)
+ Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
+
+ val session = FFmpegKit.executeWithArgumentsAsync(
+ args.toTypedArray(),
+ { completed ->
+ val rc = completed.getReturnCode()
+ when {
+ ReturnCode.isSuccess(rc) -> cont.resume(Unit)
+ ReturnCode.isCancel(rc) ->
+ cont.cancel()
+ else -> cont.resumeWithException(
+ FFmpegException(
+ "FFmpeg failed (${rc?.value}): " +
+ completed.getFailStackTrace().orEmpty().ifBlank {
+ completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
+ }
+ )
+ )
+ }
+ },
+ { log -> Log.d(TAG, log.message.trimEnd()) },
+ { stats ->
+ if (durationMs > 0) {
+ val percent = (stats.time / durationMs * 100).toInt().coerceIn(0, 100)
+ onProgress(percent)
+ }
+ },
+ )
+
+ cont.invokeOnCancellation {
+ FFmpegKit.cancel(session.getSessionId())
+ output.delete()
+ }
+ }
+
+ class FFmpegException(message: String) : RuntimeException(message)
+
+ private companion object {
+ const val TAG = "FFmpegEngine"
+ const val LOG_TAIL_LIMIT = 40
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinScreen.kt b/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinScreen.kt
new file mode 100644
index 0000000..18471e6
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinScreen.kt
@@ -0,0 +1,129 @@
+package dev.jasonmross.mediaconverter.join
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.media3.common.util.UnstableApi
+import dev.jasonmross.mediaconverter.convert.InputFile
+import dev.jasonmross.mediaconverter.model.ConcatStrategy
+
+@UnstableApi
+@Composable
+fun JoinScreen(
+ modifier: Modifier = Modifier,
+ viewModel: JoinViewModel = viewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ val pickInputs = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenMultipleDocuments()
+ ) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
+
+ val chooseDestination = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument("video/mp4")
+ ) { uri -> uri?.let(viewModel::save) }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text("Join files", style = MaterialTheme.typography.headlineMedium)
+
+ when (val s = state) {
+ is JoinState.Idle -> {
+ Text(
+ "Pick two or more files to join, in the order you want them.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Button(onClick = { pickInputs.launch(arrayOf("video/*")) }) {
+ Text("Choose files")
+ }
+ }
+
+ is JoinState.Ready -> {
+ s.inputs.forEach { FileRow(it) }
+ Button(onClick = viewModel::join) { Text("Join ${s.inputs.size} files") }
+ OutlinedButton(onClick = { pickInputs.launch(arrayOf("video/*")) }) {
+ Text("Choose different files")
+ }
+ }
+
+ is JoinState.Joining -> {
+ Text("Joining ${s.inputs.size} files…")
+ // Indeterminate on purpose: FFmpeg reports progress against a single
+ // input's duration, which means nothing across a concatenation. A
+ // fabricated percentage would be worse than none.
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") }
+ }
+
+ is JoinState.Waiting -> {
+ Text(
+ "Paused. The system limits background media processing to six " +
+ "hours a day, so this will resume automatically.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") }
+ }
+
+ is JoinState.Joined -> {
+ Text("Joined — ${s.staged.length() / 1_000_000} MB.")
+ Text(
+ when (s.strategy) {
+ ConcatStrategy.STREAM_COPY ->
+ "Files matched, so they were joined without re-encoding — no quality loss."
+ ConcatStrategy.REENCODE ->
+ "Files differed in format, so they were re-encoded to match."
+ },
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Button(onClick = { chooseDestination.launch("joined.mp4") }) { Text("Save file") }
+ OutlinedButton(onClick = viewModel::reset) { Text("Start over") }
+ }
+
+ is JoinState.Saved -> {
+ Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
+ Button(onClick = viewModel::reset) { Text("Join more") }
+ }
+
+ is JoinState.Failed -> {
+ Text(
+ s.message,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Button(onClick = viewModel::reset) { Text("Start over") }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FileRow(input: InputFile) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Text(input.displayName, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinViewModel.kt b/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinViewModel.kt
new file mode 100644
index 0000000..2d27b37
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinViewModel.kt
@@ -0,0 +1,141 @@
+package dev.jasonmross.mediaconverter.join
+
+import android.app.Application
+import android.net.Uri
+import android.provider.OpenableColumns
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.media3.common.util.UnstableApi
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import dev.jasonmross.mediaconverter.convert.InputFile
+import dev.jasonmross.mediaconverter.convert.OutputPublisher
+import dev.jasonmross.mediaconverter.model.ConcatStrategy
+import dev.jasonmross.mediaconverter.work.ConcatWorker
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.util.UUID
+
+sealed interface JoinState {
+ data object Idle : JoinState
+ data class Ready(val inputs: List) : JoinState
+ data class Joining(val inputs: List) : JoinState
+ data class Waiting(val inputs: List) : JoinState
+ data class Joined(val staged: File, val strategy: ConcatStrategy) : JoinState
+ data class Saved(val displayName: String) : JoinState
+ data class Failed(val message: String) : JoinState
+}
+
+@UnstableApi
+class JoinViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val workManager = WorkManager.getInstance(app)
+ private val publisher = OutputPublisher(app)
+
+ private val _state = MutableStateFlow(JoinState.Idle)
+ val state: StateFlow = _state.asStateFlow()
+
+ private var observer: Job? = null
+ private var activeWorkId: UUID? = null
+
+ fun onInputsPicked(uris: List) {
+ if (uris.size < 2) {
+ _state.value = JoinState.Failed("Pick at least two files to join.")
+ return
+ }
+ viewModelScope.launch {
+ val files = withContext(Dispatchers.IO) { uris.map(::queryFile) }
+ _state.value = JoinState.Ready(files)
+ }
+ }
+
+ fun join() {
+ val inputs = (_state.value as? JoinState.Ready)?.inputs ?: return
+ val request = ConcatWorker.request(
+ inputs = inputs.map { it.uri },
+ totalBytes = inputs.sumOf { it.sizeBytes },
+ )
+ activeWorkId = request.id
+ workManager.enqueue(request)
+ _state.value = JoinState.Joining(inputs)
+
+ observer?.cancel()
+ observer = viewModelScope.launch {
+ workManager.getWorkInfoByIdFlow(request.id).collect { info ->
+ if (info == null) return@collect
+ _state.value = when (info.state) {
+ WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
+ WorkInfo.State.ENQUEUED ->
+ if (info.runAttemptCount > 0) JoinState.Waiting(inputs)
+ else JoinState.Joining(inputs)
+
+ WorkInfo.State.SUCCEEDED -> {
+ val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
+ val strategy = info.outputData.getString(ConcatWorker.KEY_STRATEGY)
+ ?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
+ if (path == null) {
+ JoinState.Failed("Joining reported success but produced no file.")
+ } else {
+ JoinState.Joined(File(path), strategy)
+ }
+ }
+
+ WorkInfo.State.FAILED -> JoinState.Failed(
+ info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed."
+ )
+
+ WorkInfo.State.CANCELLED -> JoinState.Ready(inputs)
+ }
+ }
+ }
+ }
+
+ fun cancel() {
+ activeWorkId?.let(workManager::cancelWorkById)
+ }
+
+ fun save(destination: Uri) {
+ val joined = _state.value as? JoinState.Joined ?: return
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) {
+ publisher.publish(joined.staged, destination)
+ joined.staged.delete()
+ }
+ }.onSuccess {
+ _state.value = JoinState.Saved("joined.mp4")
+ }.onFailure { e ->
+ _state.value = JoinState.Failed(e.message ?: "Could not save the file.")
+ }
+ }
+ }
+
+ fun reset() {
+ observer?.cancel()
+ observer = null
+ activeWorkId = null
+ _state.value = JoinState.Idle
+ }
+
+ private fun queryFile(uri: Uri): InputFile {
+ var name = "input"
+ var size = 0L
+ getApplication().contentResolver
+ .query(uri, null, null, null, null)
+ ?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }
+ ?.let { name = cursor.getString(it) ?: name }
+ cursor.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }
+ ?.let { size = cursor.getLong(it) }
+ }
+ }
+ return InputFile(uri, name, size)
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/model/ConcatStrategy.kt b/app/src/main/java/dev/jasonmross/mediaconverter/model/ConcatStrategy.kt
new file mode 100644
index 0000000..81ee1f4
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/model/ConcatStrategy.kt
@@ -0,0 +1,65 @@
+package dev.jasonmross.mediaconverter.model
+
+/**
+ * How to join several inputs into one file.
+ *
+ * The naive answer — FFmpeg's `concat` demuxer — only works when every input shares a
+ * codec, resolution and timebase. That is true for clips from one camera in one
+ * session, and false for the case users actually hit: two clips from different phones,
+ * or a screen recording joined to a camera clip. The demuxer does not fail loudly on
+ * mismatch; it can produce a file whose second half is garbled.
+ *
+ * So the strategy is chosen from the inputs rather than assumed.
+ */
+enum class ConcatStrategy {
+ /**
+ * Stream copy via the `concat` demuxer. No re-encode, near-instant, lossless.
+ * Requires every input to agree on codec, resolution, frame rate and timebase.
+ */
+ STREAM_COPY,
+
+ /**
+ * Re-encode through the `concat` filter, normalising to a common format.
+ * Slower and lossy, but it is the only correct answer for mismatched inputs.
+ */
+ REENCODE,
+}
+
+/** The properties that decide whether inputs can be joined without re-encoding. */
+data class ConcatInput(
+ val videoCodec: String?,
+ val audioCodec: String?,
+ val width: Int,
+ val height: Int,
+ val frameRate: Int,
+)
+
+object ConcatPlanner {
+
+ /**
+ * Picks a strategy for [inputs].
+ *
+ * Errs towards [ConcatStrategy.REENCODE]: a needless re-encode costs time, while a
+ * wrong stream copy costs the user a corrupt file they may not notice until later.
+ */
+ fun plan(inputs: List): ConcatStrategy {
+ if (inputs.size < 2) return ConcatStrategy.STREAM_COPY
+
+ val first = inputs.first()
+ // A null codec means we could not determine it. That is not evidence of a
+ // match, so it must not be treated as one.
+ if (inputs.any { it.videoCodec == null || it.videoCodec != first.videoCodec }) {
+ return ConcatStrategy.REENCODE
+ }
+ if (inputs.any { it.audioCodec != first.audioCodec }) {
+ return ConcatStrategy.REENCODE
+ }
+ if (inputs.any { it.width != first.width || it.height != first.height }) {
+ return ConcatStrategy.REENCODE
+ }
+ if (inputs.any { it.frameRate != first.frameRate }) {
+ return ConcatStrategy.REENCODE
+ }
+ return ConcatStrategy.STREAM_COPY
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/model/ConversionRouter.kt b/app/src/main/java/dev/jasonmross/mediaconverter/model/ConversionRouter.kt
new file mode 100644
index 0000000..ea26256
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/model/ConversionRouter.kt
@@ -0,0 +1,140 @@
+package dev.jasonmross.mediaconverter.model
+
+/**
+ * Chooses the engine for a conversion.
+ *
+ * Media3 Transformer is preferred wherever it is capable, because it is hardware
+ * accelerated end to end (MediaCodec decode -> GL surface -> MediaCodec encode) and
+ * runs several times faster than software encoding at a fraction of the battery cost.
+ * FFmpeg handles everything Media3 structurally cannot.
+ *
+ * The rules below are capability boundaries, not preferences. Each one exists because
+ * Media3 would fail or silently produce the wrong thing.
+ */
+object ConversionRouter {
+
+ /** Containers Media3 can mux. Anything else has to go to FFmpeg. */
+ private val MEDIA3_CONTAINERS = setOf(
+ Container.MP4,
+ Container.WEBM,
+ Container.OGG,
+ Container.WAV,
+ Container.AAC_ADTS,
+ )
+
+ /** WebM is codec-restricted: Media3's WebmMuxer writes only these. */
+ private val WEBM_AUDIO = setOf(AudioCodec.OPUS, AudioCodec.VORBIS)
+ private val WEBM_VIDEO = setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.NONE)
+
+ /** Video codecs Media3 can be asked to encode (`Transformer.setVideoMimeType`). */
+ private val MEDIA3_VIDEO = setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.NONE)
+
+ /** Audio codecs Media3 can encode. Notably absent: MP3 and FLAC. */
+ private val MEDIA3_AUDIO = setOf(AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.PCM, AudioCodec.NONE)
+
+ fun route(request: ConversionRequest, device: DeviceCodecs): Decision {
+ when (request.enginePreference) {
+ EnginePreference.FORCE_SOFTWARE ->
+ return Decision(Engine.FFMPEG, Reason.USER_FORCED_SOFTWARE)
+ else -> Unit
+ }
+
+ // Order matters below: the specific reasons are checked before the general
+ // ones, because the reason string is shown to the user. "Android has no
+ // encoder for this format" tells them something actionable about MP3;
+ // "this container needs FFmpeg" does not.
+
+ // MP3 has no encoder anywhere on Android, at any API level. That is a platform
+ // gap rather than a Media3 limitation, and libmp3lame is the only way the app
+ // can produce MP3 at all.
+ if (request.format.audioCodec !in MEDIA3_AUDIO) {
+ return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_ENCODER)
+ }
+
+ // GIF and frame sequences are image outputs; Media3 has no muxer for them.
+ if (request.format.isImageOutput) {
+ return Decision(Engine.FFMPEG, Reason.IMAGE_OUTPUT)
+ }
+
+ // Containers Media3 cannot mux at all, chiefly Matroska.
+ if (request.format.container !in MEDIA3_CONTAINERS) {
+ return Decision(Engine.FFMPEG, Reason.CONTAINER_UNSUPPORTED)
+ }
+
+ // WebM is codec-restricted even though the container itself is supported.
+ if (request.format.container == Container.WEBM &&
+ (request.format.audioCodec !in WEBM_AUDIO || request.format.videoCodec !in WEBM_VIDEO)
+ ) {
+ return Decision(Engine.FFMPEG, Reason.WEBM_CODEC_UNSUPPORTED)
+ }
+
+ // Media3 does not bundle ExoPlayer's software decoders, so an input codec with
+ // no platform decoder cannot be read at all. The dav1d extension does not
+ // rescue this: Transformer ignores bundled software decoder modules.
+ val inputCodec = request.probe.videoCodec
+ if (inputCodec != null && !device.canDecode(inputCodec)) {
+ return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_DECODER)
+ }
+
+ // CRF and two-pass are the whole point of the quality tier, and MediaCodec
+ // exposes neither, so BEST always means software encoding.
+ if (request.quality == QualityTier.BEST) {
+ return Decision(Engine.FFMPEG, Reason.QUALITY_TIER_REQUIRES_CRF)
+ }
+
+ // Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V, so VP9 and
+ // AV1 targets cannot be encoded by Media3 regardless of what the device can do.
+ if (request.format.videoCodec !in MEDIA3_VIDEO) {
+ return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_ENCODER)
+ }
+
+ // Finally, the target codec has to be hardware-encodable on this specific
+ // device. HEVC is near-universal; AV1 encode is rare.
+ if (request.format.videoCodec != VideoCodec.NONE &&
+ !device.canEncode(request.format.videoCodec)
+ ) {
+ return Decision(Engine.FFMPEG, Reason.NO_HARDWARE_ENCODER)
+ }
+
+ return Decision(Engine.MEDIA3, Reason.HARDWARE_CAPABLE)
+ }
+
+ data class Decision(val engine: Engine, val reason: Reason)
+
+ enum class Reason(val explanation: String) {
+ HARDWARE_CAPABLE("Hardware accelerated"),
+ CONTAINER_UNSUPPORTED("This container needs FFmpeg"),
+ WEBM_CODEC_UNSUPPORTED("WebM only supports VP8/VP9 with Opus or Vorbis"),
+ NO_PLATFORM_ENCODER("Android has no encoder for this format"),
+ NO_PLATFORM_DECODER("This device cannot decode the input in hardware"),
+ NO_HARDWARE_ENCODER("This device has no hardware encoder for that codec"),
+ IMAGE_OUTPUT("Image output needs FFmpeg"),
+ QUALITY_TIER_REQUIRES_CRF("Best quality uses software encoding"),
+ USER_FORCED_SOFTWARE("Software encoding was requested"),
+ MEDIA3_FAILED("Hardware conversion failed; retrying in software"),
+ }
+}
+
+/**
+ * What this device's codecs can actually do.
+ *
+ * An interface so the routing rules can be unit tested against fabricated device
+ * profiles rather than whatever hardware the test happens to run on.
+ */
+interface DeviceCodecs {
+ fun canEncode(codec: VideoCodec): Boolean
+ fun canDecode(codecName: String): Boolean
+
+ companion object {
+ /**
+ * Assumes everything works, except an input the platform could not parse.
+ *
+ * That exception matters: a device double that claims it can decode an
+ * unparseable file would let the router send a doomed job to Media3.
+ */
+ val PERMISSIVE = object : DeviceCodecs {
+ override fun canEncode(codec: VideoCodec) = true
+ override fun canDecode(codecName: String) = codecName != InputProbe.UNPARSEABLE
+ }
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt b/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt
new file mode 100644
index 0000000..e62ab36
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt
@@ -0,0 +1,87 @@
+package dev.jasonmross.mediaconverter.model
+
+/** Container families, used by the router to decide which engine can mux the result. */
+enum class Container { MP4, WEBM, MKV, OGG, WAV, AAC_ADTS, MP3, GIF, IMAGE_SEQUENCE }
+
+/** Codecs this app can be asked to produce. */
+enum class VideoCodec { H264, H265, VP9, VP8, AV1, NONE }
+enum class AudioCodec { AAC, OPUS, VORBIS, MP3, FLAC, PCM, NONE }
+
+/**
+ * A user-selectable output format.
+ *
+ * Deliberately a closed set rather than a free-form codec/container matrix: most
+ * combinations are either invalid or pointless, and the closed set is what makes the
+ * routing rules decidable.
+ */
+enum class OutputFormat(
+ val label: String,
+ val container: Container,
+ val videoCodec: VideoCodec,
+ val audioCodec: AudioCodec,
+ val extension: String,
+ val mimeType: String,
+) {
+ MP4_H264("MP4 (H.264)", Container.MP4, VideoCodec.H264, AudioCodec.AAC, "mp4", "video/mp4"),
+ MP4_H265("MP4 (H.265)", Container.MP4, VideoCodec.H265, AudioCodec.AAC, "mp4", "video/mp4"),
+ WEBM_VP9("WebM (VP9)", Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS, "webm", "video/webm"),
+ MKV_H264("MKV (H.264)", Container.MKV, VideoCodec.H264, AudioCodec.AAC, "mkv", "video/x-matroska"),
+ MKV_H265("MKV (H.265)", Container.MKV, VideoCodec.H265, AudioCodec.AAC, "mkv", "video/x-matroska"),
+
+ MP3("MP3", Container.MP3, VideoCodec.NONE, AudioCodec.MP3, "mp3", "audio/mpeg"),
+ M4A_AAC("M4A (AAC)", Container.MP4, VideoCodec.NONE, AudioCodec.AAC, "m4a", "audio/mp4"),
+ OPUS("Opus", Container.OGG, VideoCodec.NONE, AudioCodec.OPUS, "opus", "audio/opus"),
+ FLAC("FLAC", Container.MKV, VideoCodec.NONE, AudioCodec.FLAC, "flac", "audio/flac"),
+ WAV("WAV", Container.WAV, VideoCodec.NONE, AudioCodec.PCM, "wav", "audio/wav"),
+
+ GIF("GIF", Container.GIF, VideoCodec.NONE, AudioCodec.NONE, "gif", "image/gif"),
+ FRAMES_PNG("PNG frames", Container.IMAGE_SEQUENCE, VideoCodec.NONE, AudioCodec.NONE, "png", "image/png");
+
+ val isAudioOnly: Boolean get() = videoCodec == VideoCodec.NONE && audioCodec != AudioCodec.NONE
+ val isImageOutput: Boolean
+ get() = container == Container.GIF || container == Container.IMAGE_SEQUENCE
+}
+
+/**
+ * How hard to work for quality.
+ *
+ * This is the user-facing form of the engine split. [BEST] is the entire reason the
+ * shipped binary is GPL: CRF and two-pass rate control come from x264/x265 and are not
+ * exposed by any Android hardware encoder.
+ */
+enum class QualityTier(val label: String, val description: String) {
+ FAST("Fast", "Hardware accelerated. Best for sharing and batches."),
+ BEST("Best quality", "Software encode with CRF. Slower, smaller files."),
+}
+
+/** Escape hatch, mostly for debugging and for devices with broken encoders. */
+enum class EnginePreference { AUTO, PREFER_HARDWARE, FORCE_SOFTWARE }
+
+enum class Engine { MEDIA3, FFMPEG }
+
+/** What we know about the input, as far as routing is concerned. */
+data class InputProbe(
+ val videoCodec: String? = null,
+ val audioCodec: String? = null,
+ val hasVideo: Boolean = true,
+ val durationMs: Long = 0,
+) {
+ companion object {
+ /**
+ * Codec name used when the platform could not parse the input at all.
+ *
+ * Distinct from `null` (nothing known, assume the platform copes) and from a
+ * real codec name. [DeviceCodecs] treats it as undecodable, which routes the
+ * job to FFmpeg — the correct answer when the platform extractor has already
+ * failed to open the file.
+ */
+ const val UNPARSEABLE = "\u0000unparseable"
+ }
+}
+
+data class ConversionRequest(
+ val format: OutputFormat,
+ val quality: QualityTier = QualityTier.FAST,
+ val enginePreference: EnginePreference = EnginePreference.AUTO,
+ val probe: InputProbe = InputProbe(),
+)
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConcatWorker.kt b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConcatWorker.kt
new file mode 100644
index 0000000..e34c7e4
--- /dev/null
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConcatWorker.kt
@@ -0,0 +1,113 @@
+package dev.jasonmross.mediaconverter.work
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.media3.common.util.UnstableApi
+import androidx.work.CoroutineWorker
+import androidx.work.Data
+import androidx.work.ForegroundInfo
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import dev.jasonmross.mediaconverter.convert.OutputPublisher
+import dev.jasonmross.mediaconverter.ffmpeg.ConcatEngine
+import dev.jasonmross.mediaconverter.model.OutputFormat
+
+/**
+ * Joins several files into one, as durable foreground work.
+ *
+ * Separate from [ConversionWorker] rather than folded into it: joining takes a list of
+ * inputs, has its own stream-copy-versus-re-encode decision, and reports a different
+ * result. Overloading one worker with both would make the input contract ambiguous.
+ *
+ * Progress is not reported. FFmpeg's statistics callback gives a timestamp against a
+ * single input's duration, which is meaningless once several files are being
+ * concatenated; showing a fabricated percentage would be worse than showing none.
+ */
+@UnstableApi
+class ConcatWorker(
+ context: Context,
+ params: WorkerParameters,
+) : CoroutineWorker(context, params) {
+
+ private val notifications = ConversionNotifications(applicationContext)
+ private val publisher = OutputPublisher(applicationContext)
+
+ override suspend fun doWork(): Result {
+ val uris = inputData.getStringArray(KEY_INPUT_URIS)?.map(Uri::parse)
+ ?: return Result.failure(workDataOf(KEY_ERROR to "No input files."))
+ if (uris.size < 2) {
+ return Result.failure(workDataOf(KEY_ERROR to "Pick at least two files to join."))
+ }
+ val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L)
+ val format = OutputFormat.valueOf(
+ inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name
+ )
+
+ if (!publisher.hasSpaceFor(totalBytes)) {
+ return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to join these files."))
+ }
+
+ setForeground(
+ ForegroundInfo(
+ NOTIFICATION_ID,
+ notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true),
+ ConversionForegroundType.current(),
+ )
+ )
+
+ val staged = publisher.createStagingFile("joined.${format.extension}")
+ return try {
+ val result = ConcatEngine(applicationContext).join(uris, staged, format)
+ Result.success(
+ workDataOf(
+ KEY_OUTPUT_PATH to staged.absolutePath,
+ KEY_STRATEGY to result.strategy.name,
+ )
+ )
+ } catch (e: Throwable) {
+ staged.delete()
+ if (stopReason == WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT) {
+ Log.w(TAG, "Foreground budget exhausted while joining; will retry.", e)
+ Result.retry()
+ } else {
+ Log.e(TAG, "Joining failed.", e)
+ Result.failure(workDataOf(KEY_ERROR to (e.message ?: "Joining failed.")))
+ }
+ }
+ }
+
+ override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo(
+ NOTIFICATION_ID,
+ notifications.build(id, "Joining files", 0, indeterminate = true),
+ ConversionForegroundType.current(),
+ )
+
+ companion object {
+ const val KEY_INPUT_URIS = "input_uris"
+ const val KEY_TOTAL_BYTES = "total_bytes"
+ const val KEY_FORMAT = "format"
+ const val KEY_OUTPUT_PATH = "output_path"
+ const val KEY_STRATEGY = "strategy"
+ const val KEY_ERROR = "error"
+
+ private const val NOTIFICATION_ID = 1002
+ private const val TAG = "ConcatWorker"
+
+ fun request(
+ inputs: List,
+ totalBytes: Long,
+ format: OutputFormat = OutputFormat.MP4_H264,
+ ) = OneTimeWorkRequestBuilder()
+ .setInputData(
+ Data.Builder()
+ .putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
+ .putLong(KEY_TOTAL_BYTES, totalBytes)
+ .putString(KEY_FORMAT, format.name)
+ .build()
+ )
+ .build()
+ }
+}
diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt
index 0d43207..c9375cb 100644
--- a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt
+++ b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt
@@ -12,16 +12,28 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
+import com.arthenica.ffmpegkit.FFmpegKitConfig
+import dev.jasonmross.mediaconverter.codec.AndroidDeviceCodecs
import dev.jasonmross.mediaconverter.convert.Media3Engine
+import dev.jasonmross.mediaconverter.convert.MediaProbe
import dev.jasonmross.mediaconverter.convert.OutputPublisher
+import dev.jasonmross.mediaconverter.ffmpeg.FFmpegEngine
+import dev.jasonmross.mediaconverter.model.ConversionRequest
+import dev.jasonmross.mediaconverter.model.ConversionRouter
+import dev.jasonmross.mediaconverter.model.Engine
+import dev.jasonmross.mediaconverter.model.EnginePreference
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import dev.jasonmross.mediaconverter.model.QualityTier
+import dev.jasonmross.mediaconverter.model.VideoCodec
+import java.io.File
/**
* Runs one conversion as durable, cancellable background work.
*
- * WorkManager rather than a bare foreground service: the queue survives process
- * death, cancellation and progress are already modelled, and `WorkInfo` gives the UI
- * a Flow to observe. That durability is what makes the six-hour foreground-service
- * timeout recoverable instead of fatal — see [handleTimeoutIfNeeded].
+ * WorkManager rather than a bare foreground service: the queue survives process death,
+ * cancellation and progress are already modelled, and `WorkInfo` gives the UI a Flow to
+ * observe. That durability is what makes the six-hour foreground-service timeout
+ * recoverable instead of fatal.
*
* Expedited work is deliberately *not* used. It maps to JobScheduler expedited jobs
* with a short quota, which is the wrong shape for a multi-minute transcode.
@@ -36,10 +48,19 @@ class ConversionWorker(
private val publisher = OutputPublisher(applicationContext)
override suspend fun doWork(): Result {
- val inputUri = inputData.getString(KEY_INPUT_URI)?.toUri() ?: return Result.failure()
+ val inputUri = inputData.getString(KEY_INPUT_URI)?.let(Uri::parse)
+ ?: return Result.failure(workDataOf(KEY_ERROR to "No input file."))
val displayName = inputData.getString(KEY_DISPLAY_NAME) ?: "input"
val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L)
- val mimeType = inputData.getString(KEY_VIDEO_MIME) ?: MimeTypes.VIDEO_H265
+ val format = OutputFormat.valueOf(
+ inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H265.name
+ )
+ val quality = QualityTier.valueOf(
+ inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name
+ )
+ val preference = EnginePreference.valueOf(
+ inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name
+ )
if (!publisher.hasSpaceFor(sizeBytes)) {
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to convert."))
@@ -47,52 +68,121 @@ class ConversionWorker(
setForeground(foregroundInfo(displayName, percent = 0, indeterminate = true))
- val staged = publisher.createStagingFile(outputNameFor(displayName))
- // The engine owns its own Looper thread, so it is safe to call from this
- // Looper-less worker thread. See Media3Engine.
- val engine = Media3Engine(applicationContext)
+ val probe = MediaProbe.probe(applicationContext, inputUri)
+ val request = ConversionRequest(format, quality, preference, probe)
+ val decision = ConversionRouter.route(request, AndroidDeviceCodecs.get())
+ Log.i(TAG, "Routing $displayName -> ${format.name} via ${decision.engine} (${decision.reason})")
+
+ val staged = publisher.createStagingFile(outputNameFor(displayName, format))
return try {
- var lastPublished = 0L
- engine.transcode(inputUri, staged, mimeType) { percent ->
- setProgressAsync(workDataOf(KEY_PROGRESS to percent))
- // Throttle the notification to ~1/sec. The underlying progress updates
- // several times a second, and pushing every one janks the system UI.
- val now = System.currentTimeMillis()
- if (now - lastPublished >= NOTIFICATION_INTERVAL_MS) {
- lastPublished = now
- notifications
- .build(id, displayName, percent)
- .let { notificationManager().notify(NOTIFICATION_ID, it) }
- }
+ when (decision.engine) {
+ Engine.MEDIA3 -> runMedia3OrFallBack(request, inputUri, staged, displayName)
+ Engine.FFMPEG -> runFFmpeg(request, inputUri, staged, displayName)
}
- Result.success(workDataOf(KEY_OUTPUT_PATH to staged.absolutePath))
+ Result.success(
+ workDataOf(
+ KEY_OUTPUT_PATH to staged.absolutePath,
+ KEY_ENGINE_USED to decision.engine.name,
+ KEY_ROUTE_REASON to decision.reason.explanation,
+ )
+ )
} catch (e: Throwable) {
staged.delete()
handleTimeoutIfNeeded(e)
+ }
+ }
+
+ /**
+ * The dynamic half of the routing rules.
+ *
+ * The static predicates catch what is knowably unsupported, but hardware encoders
+ * are vendor-declared and, per the platform's own documentation, "cannot be tested
+ * for correctness". A device that claims HEVC support and then fails mid-export is
+ * a real and common failure. Rather than surface that to the user as a failed
+ * conversion, retry the same job in software — slower, but it produces the file.
+ */
+ private suspend fun runMedia3OrFallBack(
+ request: ConversionRequest,
+ inputUri: Uri,
+ staged: File,
+ displayName: String,
+ ) {
+ val engine = Media3Engine(applicationContext)
+ try {
+ engine.transcode(inputUri, staged, media3MimeType(request.format)) { percent ->
+ publishProgress(displayName, percent)
+ }
+ return
+ } catch (e: Throwable) {
+ if (isCancellation(e)) throw e
+ Log.w(TAG, "Hardware conversion failed; retrying in software.", e)
} finally {
engine.close()
}
+
+ staged.delete()
+ runFFmpeg(request, inputUri, staged, displayName)
}
+ private suspend fun runFFmpeg(
+ request: ConversionRequest,
+ inputUri: Uri,
+ staged: File,
+ displayName: String,
+ ) {
+ // FFmpeg needs a path. ffkitsaf bridges a content:// URI for reading; the read
+ // side is seekable for local providers, which is all the demuxer needs. Output
+ // still goes to a real cache path — see OutputPublisher.
+ val inputPath = if (inputUri.scheme == "content") {
+ FFmpegKitConfig.getSafParameterForRead(applicationContext, inputUri)
+ } else {
+ inputUri.path
+ } ?: error("Could not open the input file.")
+
+ FFmpegEngine().run(request, inputPath, staged, request.probe.durationMs) { percent ->
+ publishProgress(displayName, percent)
+ }
+ }
+
+ private var lastNotified = 0L
+
+ private fun publishProgress(displayName: String, percent: Int) {
+ setProgressAsync(workDataOf(KEY_PROGRESS to percent))
+ // Throttle to ~1/sec: progress arrives several times a second and pushing every
+ // update janks the system UI.
+ val now = System.currentTimeMillis()
+ if (now - lastNotified >= NOTIFICATION_INTERVAL_MS) {
+ lastNotified = now
+ applicationContext.getSystemService(android.app.NotificationManager::class.java)
+ .notify(NOTIFICATION_ID, notifications.build(id, displayName, percent))
+ }
+ }
+
+ private fun isCancellation(e: Throwable): Boolean =
+ e is kotlinx.coroutines.CancellationException || isStopped
+
/**
* Distinguishes a genuine failure from the foreground-service budget expiring.
*
* `mediaProcessing` allows six hours out of every twenty-four, shared across the
* app. When that runs out WorkManager reports
- * `STOP_REASON_FOREGROUND_SERVICE_TIMEOUT`, and the correct response is to retry
+ * `STOP_REASON_FOREGROUND_SERVICE_TIMEOUT`, and the right response is to retry
* later rather than tell the user the conversion failed — the work is still valid,
* there is simply no budget right now.
*/
private fun handleTimeoutIfNeeded(cause: Throwable): Result {
- val timedOut = stopReason == WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT
- return if (timedOut) {
+ if (stopReason == WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT) {
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
- Result.retry()
- } else {
- Log.e(TAG, "Conversion failed.", cause)
- Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
+ return Result.retry()
}
+ Log.e(TAG, "Conversion failed.", cause)
+ return Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
+ }
+
+ private fun media3MimeType(format: OutputFormat): String = when (format.videoCodec) {
+ VideoCodec.H264 -> MimeTypes.VIDEO_H264
+ else -> MimeTypes.VIDEO_H265
}
override suspend fun getForegroundInfo(): ForegroundInfo =
@@ -109,39 +199,43 @@ class ConversionWorker(
ConversionForegroundType.current(),
)
- private fun notificationManager() =
- applicationContext.getSystemService(android.app.NotificationManager::class.java)
-
- private fun String.toUri(): Uri? = runCatching { Uri.parse(this) }.getOrNull()
-
companion object {
const val KEY_INPUT_URI = "input_uri"
const val KEY_DISPLAY_NAME = "display_name"
const val KEY_SIZE_BYTES = "size_bytes"
- const val KEY_VIDEO_MIME = "video_mime"
+ const val KEY_FORMAT = "format"
+ const val KEY_QUALITY = "quality"
+ const val KEY_ENGINE_PREFERENCE = "engine_preference"
const val KEY_PROGRESS = "progress"
const val KEY_OUTPUT_PATH = "output_path"
+ const val KEY_ENGINE_USED = "engine_used"
+ const val KEY_ROUTE_REASON = "route_reason"
const val KEY_ERROR = "error"
private const val NOTIFICATION_ID = 1001
private const val NOTIFICATION_INTERVAL_MS = 1_000L
private const val TAG = "ConversionWorker"
- fun outputNameFor(inputName: String): String =
- inputName.substringBeforeLast('.', inputName) + "_converted.mp4"
+ fun outputNameFor(inputName: String, format: OutputFormat): String =
+ inputName.substringBeforeLast('.', inputName) +
+ "_converted.${format.extension}"
fun request(
inputUri: Uri,
displayName: String,
sizeBytes: Long,
- videoMimeType: String = MimeTypes.VIDEO_H265,
+ format: OutputFormat = OutputFormat.MP4_H265,
+ quality: QualityTier = QualityTier.FAST,
+ enginePreference: EnginePreference = EnginePreference.AUTO,
) = OneTimeWorkRequestBuilder()
.setInputData(
Data.Builder()
.putString(KEY_INPUT_URI, inputUri.toString())
.putString(KEY_DISPLAY_NAME, displayName)
.putLong(KEY_SIZE_BYTES, sizeBytes)
- .putString(KEY_VIDEO_MIME, videoMimeType)
+ .putString(KEY_FORMAT, format.name)
+ .putString(KEY_QUALITY, quality.name)
+ .putString(KEY_ENGINE_PREFERENCE, enginePreference.name)
.build()
)
.build()
diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt
new file mode 100644
index 0000000..067739e
--- /dev/null
+++ b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt
@@ -0,0 +1,180 @@
+package dev.jasonmross.mediaconverter.ffmpeg
+
+import dev.jasonmross.mediaconverter.model.ConversionRequest
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import dev.jasonmross.mediaconverter.model.QualityTier
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FFmpegCommandBuilderTest {
+
+ private fun cmd(
+ format: OutputFormat,
+ quality: QualityTier = QualityTier.BEST,
+ ): List = FFmpegCommandBuilder.build(
+ ConversionRequest(format = format, quality = quality),
+ inputPath = "/cache/in.mp4",
+ outputPath = "/cache/out.${format.extension}",
+ )
+
+ /** Asserts `flag` is present and immediately followed by `value`. */
+ private fun assertPair(args: List, flag: String, value: String) {
+ val i = args.indexOf(flag)
+ assertTrue("missing $flag in $args", i >= 0)
+ assertEquals("wrong value for $flag in $args", value, args[i + 1])
+ }
+
+ // --- structure ---------------------------------------------------------
+
+ @Test
+ fun `input precedes output and both are present`() {
+ val args = cmd(OutputFormat.MP4_H264)
+ assertPair(args, "-i", "/cache/in.mp4")
+ assertEquals("/cache/out.mp4", args.last())
+ }
+
+ @Test
+ fun `overwrite is enabled because the output path is our own cache file`() {
+ assertTrue(cmd(OutputFormat.MP4_H264).contains("-y"))
+ }
+
+ // --- the GPL quality path ---------------------------------------------
+
+ @Test
+ fun `best quality h264 uses libx264 with crf, not a bitrate target`() {
+ val args = cmd(OutputFormat.MP4_H264, QualityTier.BEST)
+ assertPair(args, "-c:v", "libx264")
+ assertPair(args, "-crf", "20")
+ assertFalse("CRF and -b:v are mutually exclusive", args.contains("-b:v"))
+ }
+
+ @Test
+ fun `best quality h265 uses libx265 with a higher crf than h264`() {
+ val args = cmd(OutputFormat.MP4_H265, QualityTier.BEST)
+ assertPair(args, "-c:v", "libx265")
+ // x265 is roughly a step stronger at the same number, so sharing a constant
+ // with x264 would silently change the size target.
+ assertPair(args, "-crf", "24")
+ }
+
+ @Test
+ fun `hevc in mp4 is tagged hvc1 for player compatibility`() {
+ assertPair(cmd(OutputFormat.MP4_H265, QualityTier.BEST), "-tag:v", "hvc1")
+ }
+
+ @Test
+ fun `h264 output is forced to yuv420p for hardware player compatibility`() {
+ assertPair(cmd(OutputFormat.MP4_H264, QualityTier.BEST), "-pix_fmt", "yuv420p")
+ }
+
+ // --- the hardware fallback path ---------------------------------------
+
+ @Test
+ fun `fast quality uses the mediacodec wrappers`() {
+ assertPair(cmd(OutputFormat.MP4_H264, QualityTier.FAST), "-c:v", "h264_mediacodec")
+ assertPair(cmd(OutputFormat.MP4_H265, QualityTier.FAST), "-c:v", "hevc_mediacodec")
+ }
+
+ @Test
+ fun `fast quality targets a bitrate because mediacodec has no crf`() {
+ val args = cmd(OutputFormat.MP4_H264, QualityTier.FAST)
+ assertTrue(args.contains("-b:v"))
+ assertFalse(args.contains("-crf"))
+ }
+
+ // --- audio -------------------------------------------------------------
+
+ @Test
+ fun `mp3 uses libmp3lame and drops video`() {
+ val args = cmd(OutputFormat.MP3)
+ assertPair(args, "-c:a", "libmp3lame")
+ assertTrue("audio-only output must drop the video stream", args.contains("-vn"))
+ }
+
+ @Test
+ fun `flac wav and opus select the right encoders`() {
+ assertPair(cmd(OutputFormat.FLAC), "-c:a", "flac")
+ assertPair(cmd(OutputFormat.WAV), "-c:a", "pcm_s16le")
+ assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
+ }
+
+ @Test
+ fun `audio only formats never carry a video encoder`() {
+ listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
+ .forEach { format ->
+ val args = cmd(format)
+ assertFalse("$format should not set -c:v", args.contains("-c:v"))
+ assertTrue("$format should set -vn", args.contains("-vn"))
+ }
+ }
+
+ // --- image outputs -----------------------------------------------------
+
+ @Test
+ fun `gif generates a palette to avoid banding and drops audio`() {
+ val args = cmd(OutputFormat.GIF)
+ val filter = args[args.indexOf("-vf") + 1]
+ assertTrue("gif needs palettegen: $filter", filter.contains("palettegen"))
+ assertTrue("gif needs paletteuse: $filter", filter.contains("paletteuse"))
+ assertTrue(args.contains("-an"))
+ }
+
+ @Test
+ fun `gif loops forever`() {
+ assertPair(cmd(OutputFormat.GIF), "-loop", "0")
+ }
+
+ @Test
+ fun `frame export sets an fps filter and numbered output`() {
+ val args = cmd(OutputFormat.FRAMES_PNG)
+ assertPair(args, "-vf", "fps=1")
+ assertEquals(
+ "shot_%04d.png",
+ FFmpegCommandBuilder.outputPattern(OutputFormat.FRAMES_PNG, "shot"),
+ )
+ }
+
+ @Test
+ fun `single file formats keep their plain name`() {
+ assertEquals(
+ "clip.mp4",
+ FFmpegCommandBuilder.outputPattern(OutputFormat.MP4_H264, "clip.mp4"),
+ )
+ }
+
+ // --- containers --------------------------------------------------------
+
+ @Test
+ fun `mp4 output enables faststart`() {
+ assertPair(cmd(OutputFormat.MP4_H264), "-movflags", "+faststart")
+ }
+
+ @Test
+ fun `non mp4 containers do not get faststart`() {
+ assertFalse(cmd(OutputFormat.MKV_H264).contains("-movflags"))
+ assertFalse(cmd(OutputFormat.WAV).contains("-movflags"))
+ }
+
+ @Test
+ fun `mkv accepts h264 and h265 without faststart`() {
+ assertPair(cmd(OutputFormat.MKV_H264, QualityTier.BEST), "-c:v", "libx264")
+ assertPair(cmd(OutputFormat.MKV_H265, QualityTier.BEST), "-c:v", "libx265")
+ }
+
+ // --- exhaustiveness ----------------------------------------------------
+
+ @Test
+ fun `every format builds a well formed command`() {
+ OutputFormat.entries.forEach { format ->
+ listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality ->
+ val args = cmd(format, quality)
+ assertTrue("$format/$quality has no input", args.contains("-i"))
+ assertTrue("$format/$quality has too few args", args.size >= 5)
+ // Every flag that takes a value must actually have one.
+ assertFalse("$format/$quality ends on a dangling flag", args.last().startsWith("-"))
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommandTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommandTest.kt
new file mode 100644
index 0000000..da1a71b
--- /dev/null
+++ b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommandTest.kt
@@ -0,0 +1,96 @@
+package dev.jasonmross.mediaconverter.ffmpeg
+
+import dev.jasonmross.mediaconverter.model.ConcatStrategy
+import dev.jasonmross.mediaconverter.model.OutputFormat
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class FFmpegConcatCommandTest {
+
+ private val listFile = File("/cache/list.txt")
+ private val output = File("/cache/joined.mp4")
+ private val inputs = listOf("/cache/a.mp4", "/cache/b.mp4")
+
+ @Test
+ fun `list file quotes each entry for the concat demuxer`() {
+ val text = FFmpegConcatCommand.listFileContents(inputs)
+ assertEquals("file '/cache/a.mp4'\nfile '/cache/b.mp4'\n", text)
+ }
+
+ @Test
+ fun `apostrophes in filenames are escaped`() {
+ // An unescaped apostrophe silently truncates the entry, so the file would be
+ // skipped rather than the command failing.
+ val text = FFmpegConcatCommand.listFileContents(listOf("/cache/jason's clip.mp4"))
+ assertTrue("apostrophe not escaped: $text", text.contains("""jason'\''s clip.mp4"""))
+ }
+
+ @Test
+ fun `stream copy uses the concat demuxer and copies codecs`() {
+ val args = FFmpegConcatCommand.build(
+ ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
+ )
+ assertTrue(args.contains("concat"))
+ assertEquals("copy", args[args.indexOf("-c") + 1])
+ assertEquals(listFile.absolutePath, args[args.indexOf("-i") + 1])
+ assertFalse("stream copy must not re-encode", args.contains("libx264"))
+ }
+
+ @Test
+ fun `stream copy allows absolute paths in the list file`() {
+ val args = FFmpegConcatCommand.build(
+ ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
+ )
+ // Without -safe 0 the demuxer rejects the absolute paths we generate.
+ assertEquals("0", args[args.indexOf("-safe") + 1])
+ }
+
+ @Test
+ fun `re-encode passes every input separately and builds a filter graph`() {
+ val args = FFmpegConcatCommand.build(
+ ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
+ )
+ assertEquals(2, args.count { it == "-i" })
+ val filter = args[args.indexOf("-filter_complex") + 1]
+ assertTrue("missing concat filter: $filter", filter.contains("concat=n=2:v=1:a=1"))
+ assertTrue("inputs must be normalised before joining", filter.contains("scale="))
+ assertTrue("frame rates must be normalised", filter.contains("fps=30"))
+ assertTrue(args.contains("libx264"))
+ }
+
+ @Test
+ fun `re-encode maps the filter outputs rather than raw streams`() {
+ val args = FFmpegConcatCommand.build(
+ ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
+ )
+ assertTrue(args.contains("[v]"))
+ assertTrue(args.contains("[a]"))
+ }
+
+ @Test
+ fun `mp4 output gets faststart on both strategies`() {
+ listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
+ val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
+ assertTrue("$strategy missing faststart", args.contains("+faststart"))
+ }
+ }
+
+ @Test
+ fun `mkv output does not get faststart`() {
+ val args = FFmpegConcatCommand.build(
+ ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264,
+ )
+ assertFalse(args.contains("+faststart"))
+ }
+
+ @Test
+ fun `output path is always last`() {
+ listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
+ val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
+ assertEquals(output.absolutePath, args.last())
+ }
+ }
+}
diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/model/ConcatPlannerTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/model/ConcatPlannerTest.kt
new file mode 100644
index 0000000..eedad46
--- /dev/null
+++ b/app/src/test/java/dev/jasonmross/mediaconverter/model/ConcatPlannerTest.kt
@@ -0,0 +1,83 @@
+package dev.jasonmross.mediaconverter.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * The stream-copy-vs-re-encode decision.
+ *
+ * This matters more than it looks: the `concat` demuxer does not reject mismatched
+ * inputs loudly, it can emit a file whose later segments are garbled. So the default
+ * on any doubt has to be re-encoding.
+ */
+class ConcatPlannerTest {
+
+ private fun clip(
+ video: String? = "h264",
+ audio: String? = "aac",
+ width: Int = 1920,
+ height: Int = 1080,
+ fps: Int = 30,
+ ) = ConcatInput(video, audio, width, height, fps)
+
+ @Test
+ fun `identical clips can be stream copied`() {
+ assertEquals(
+ ConcatStrategy.STREAM_COPY,
+ ConcatPlanner.plan(listOf(clip(), clip())),
+ )
+ }
+
+ @Test
+ fun `a single input needs no re-encode`() {
+ assertEquals(ConcatStrategy.STREAM_COPY, ConcatPlanner.plan(listOf(clip())))
+ }
+
+ @Test
+ fun `different video codecs force a re-encode`() {
+ assertEquals(
+ ConcatStrategy.REENCODE,
+ ConcatPlanner.plan(listOf(clip(video = "h264"), clip(video = "hevc"))),
+ )
+ }
+
+ @Test
+ fun `different audio codecs force a re-encode`() {
+ assertEquals(
+ ConcatStrategy.REENCODE,
+ ConcatPlanner.plan(listOf(clip(audio = "aac"), clip(audio = "opus"))),
+ )
+ }
+
+ @Test
+ fun `different resolutions force a re-encode`() {
+ assertEquals(
+ ConcatStrategy.REENCODE,
+ ConcatPlanner.plan(listOf(clip(width = 1920, height = 1080), clip(width = 1280, height = 720))),
+ )
+ }
+
+ @Test
+ fun `different frame rates force a re-encode`() {
+ assertEquals(
+ ConcatStrategy.REENCODE,
+ ConcatPlanner.plan(listOf(clip(fps = 30), clip(fps = 60))),
+ )
+ }
+
+ @Test
+ fun `an unknown codec is not treated as a match`() {
+ // Two nulls are not evidence of agreement. Assuming they match is exactly how
+ // a silent corrupt concat happens.
+ assertEquals(
+ ConcatStrategy.REENCODE,
+ ConcatPlanner.plan(listOf(clip(video = null), clip(video = null))),
+ )
+ }
+
+ @Test
+ fun `a mismatch anywhere in a longer list is caught`() {
+ val clips = listOf(clip(), clip(), clip(), clip(width = 640, height = 480), clip())
+ assertEquals(ConcatStrategy.REENCODE, ConcatPlanner.plan(clips))
+ }
+}
diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/model/ConversionRouterTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/model/ConversionRouterTest.kt
new file mode 100644
index 0000000..5a51ba1
--- /dev/null
+++ b/app/src/test/java/dev/jasonmross/mediaconverter/model/ConversionRouterTest.kt
@@ -0,0 +1,197 @@
+package dev.jasonmross.mediaconverter.model
+
+import dev.jasonmross.mediaconverter.model.ConversionRouter.Reason
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * One test per routing predicate.
+ *
+ * These run on the JVM against fabricated device profiles rather than on hardware, so
+ * every branch is reachable — including "this device cannot encode AV1", which would
+ * otherwise depend on which phone the suite happened to run on.
+ */
+class ConversionRouterTest {
+
+ private fun route(
+ format: OutputFormat,
+ quality: QualityTier = QualityTier.FAST,
+ preference: EnginePreference = EnginePreference.AUTO,
+ probe: InputProbe = InputProbe(videoCodec = "h264"),
+ device: DeviceCodecs = DeviceCodecs.PERMISSIVE,
+ ) = ConversionRouter.route(
+ ConversionRequest(format, quality, preference, probe),
+ device,
+ )
+
+ // --- the happy path -----------------------------------------------------
+
+ @Test
+ fun `mp4 h264 on a capable device uses hardware`() {
+ val d = route(OutputFormat.MP4_H264)
+ assertEquals(Engine.MEDIA3, d.engine)
+ assertEquals(Reason.HARDWARE_CAPABLE, d.reason)
+ }
+
+ @Test
+ fun `mp4 h265 uses hardware`() {
+ assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H265).engine)
+ }
+
+ @Test
+ fun `webm vp9 routes to ffmpeg because media3 cannot encode vp9`() {
+ // Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V. The WebM
+ // muxer exists, but there is no VP9 *encoder* behind it, so producing WebM
+ // video is FFmpeg's job even though the container is nominally supported.
+ val d = route(OutputFormat.WEBM_VP9)
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
+ }
+
+ @Test
+ fun `aac audio extraction stays on hardware`() {
+ assertEquals(Engine.MEDIA3, route(OutputFormat.M4A_AAC).engine)
+ }
+
+ // --- 1. container ------------------------------------------------------
+
+ @Test
+ fun `mkv routes to ffmpeg because media3 has no matroska muxer`() {
+ val d = route(OutputFormat.MKV_H264)
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
+ }
+
+ // --- 3. no platform encoder -------------------------------------------
+
+ @Test
+ fun `mp3 routes to ffmpeg because android has no mp3 encoder at any api level`() {
+ val d = route(OutputFormat.MP3)
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
+ }
+
+ @Test
+ fun `flac routes to ffmpeg`() {
+ assertEquals(Engine.FFMPEG, route(OutputFormat.FLAC).engine)
+ }
+
+ // --- 4. image output ---------------------------------------------------
+
+ @Test
+ fun `gif routes to ffmpeg as an image output`() {
+ val d = route(OutputFormat.GIF)
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.IMAGE_OUTPUT, d.reason)
+ }
+
+ @Test
+ fun `png frame export routes to ffmpeg`() {
+ assertEquals(Engine.FFMPEG, route(OutputFormat.FRAMES_PNG).engine)
+ }
+
+ // --- 5. no platform decoder -------------------------------------------
+
+ @Test
+ fun `av1 input on a device without av1 decode routes to ffmpeg`() {
+ val noAv1 = object : DeviceCodecs {
+ override fun canEncode(codec: VideoCodec) = true
+ override fun canDecode(codecName: String) = codecName != "av1"
+ }
+ val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"), device = noAv1)
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
+ }
+
+ @Test
+ fun `av1 input on a device with av1 decode stays on hardware`() {
+ val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"))
+ assertEquals(Engine.MEDIA3, d.engine)
+ }
+
+ @Test
+ fun `an input the platform cannot parse routes to ffmpeg`() {
+ // MediaProbe reports this when the platform extractor fails to open the file.
+ // Media3 cannot convert what the platform cannot read, so the job must not be
+ // sent down the hardware path only to fail there.
+ val d = route(
+ OutputFormat.MP4_H264,
+ probe = InputProbe(videoCodec = InputProbe.UNPARSEABLE),
+ )
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
+ }
+
+ @Test
+ fun `a null input codec is treated as no information, not as a failure`() {
+ // Nothing known about the input is different from "known to be unreadable":
+ // the former should still take the fast path.
+ val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = null))
+ assertEquals(Engine.MEDIA3, d.engine)
+ }
+
+ // --- 6. quality tier ---------------------------------------------------
+
+ @Test
+ fun `best quality routes to ffmpeg because mediacodec exposes no crf`() {
+ val d = route(OutputFormat.MP4_H264, quality = QualityTier.BEST)
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.QUALITY_TIER_REQUIRES_CRF, d.reason)
+ }
+
+ @Test
+ fun `fast quality is the hardware path`() {
+ assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H264, quality = QualityTier.FAST).engine)
+ }
+
+ // --- 7. hardware encoder availability ---------------------------------
+
+ @Test
+ fun `h265 target on a device without hevc encode falls back to ffmpeg`() {
+ val noHevc = object : DeviceCodecs {
+ override fun canEncode(codec: VideoCodec) = codec != VideoCodec.H265
+ override fun canDecode(codecName: String) = true
+ }
+ val d = route(OutputFormat.MP4_H265, device = noHevc)
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.NO_HARDWARE_ENCODER, d.reason)
+ }
+
+ // --- user override -----------------------------------------------------
+
+ @Test
+ fun `forcing software overrides an otherwise hardware-capable job`() {
+ val d = route(OutputFormat.MP4_H264, preference = EnginePreference.FORCE_SOFTWARE)
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
+ }
+
+ @Test
+ fun `forcing software wins even for formats ffmpeg would take anyway`() {
+ val d = route(OutputFormat.MP3, preference = EnginePreference.FORCE_SOFTWARE)
+ assertEquals(Engine.FFMPEG, d.engine)
+ assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
+ }
+
+ // --- exhaustiveness ----------------------------------------------------
+
+ @Test
+ fun `every output format routes somewhere without throwing`() {
+ OutputFormat.entries.forEach { format ->
+ val decision = route(format)
+ assertEquals(
+ "format $format produced no engine",
+ true,
+ decision.engine == Engine.MEDIA3 || decision.engine == Engine.FFMPEG,
+ )
+ }
+ }
+
+ @Test
+ fun `audio-only formats are flagged as such`() {
+ assertEquals(true, OutputFormat.MP3.isAudioOnly)
+ assertEquals(true, OutputFormat.FLAC.isAudioOnly)
+ assertEquals(false, OutputFormat.MP4_H264.isAudioOnly)
+ assertEquals(false, OutputFormat.GIF.isAudioOnly)
+ }
+}
diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/model/OutputFormatTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/model/OutputFormatTest.kt
new file mode 100644
index 0000000..d184a89
--- /dev/null
+++ b/app/src/test/java/dev/jasonmross/mediaconverter/model/OutputFormatTest.kt
@@ -0,0 +1,64 @@
+package dev.jasonmross.mediaconverter.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Guards the format table itself.
+ *
+ * The router, the FFmpeg command builder and the output filename all read from these
+ * entries, so a wrong extension or a mislabelled codec propagates everywhere.
+ */
+class OutputFormatTest {
+
+ @Test
+ fun `every format has a non-empty label and extension`() {
+ OutputFormat.entries.forEach {
+ assertTrue("${it.name} has no label", it.label.isNotBlank())
+ assertTrue("${it.name} has no extension", it.extension.isNotBlank())
+ assertFalse("${it.name} extension should not include a dot", it.extension.startsWith("."))
+ assertTrue("${it.name} has no mime type", it.mimeType.contains('/'))
+ }
+ }
+
+ @Test
+ fun `audio only formats carry no video codec`() {
+ OutputFormat.entries.filter { it.isAudioOnly }.forEach {
+ assertEquals("${it.name} should have no video codec", VideoCodec.NONE, it.videoCodec)
+ }
+ }
+
+ @Test
+ fun `image outputs carry neither video nor audio codecs`() {
+ OutputFormat.entries.filter { it.isImageOutput }.forEach {
+ assertEquals(VideoCodec.NONE, it.videoCodec)
+ assertEquals(AudioCodec.NONE, it.audioCodec)
+ assertFalse("${it.name} is not audio-only", it.isAudioOnly)
+ }
+ }
+
+ @Test
+ fun `extensions match their containers`() {
+ assertEquals("mp4", OutputFormat.MP4_H264.extension)
+ assertEquals("mkv", OutputFormat.MKV_H264.extension)
+ assertEquals("mp3", OutputFormat.MP3.extension)
+ assertEquals("gif", OutputFormat.GIF.extension)
+ assertEquals("wav", OutputFormat.WAV.extension)
+ }
+
+ @Test
+ fun `video formats are not misreported as audio only`() {
+ listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265, OutputFormat.MKV_H265, OutputFormat.WEBM_VP9)
+ .forEach { assertFalse("${it.name} should not be audio-only", it.isAudioOnly) }
+ }
+
+ @Test
+ fun `quality tiers describe themselves for the UI`() {
+ QualityTier.entries.forEach {
+ assertTrue(it.label.isNotBlank())
+ assertTrue(it.description.isNotBlank())
+ }
+ }
+}