Add Media3 hardware conversion path with end-to-end tests

First working conversion: SAF input -> hardware transcode -> staged cache
file -> SAF export, driven from a Compose screen.

Media3 Transformer is the engine for this path rather than FFmpeg. It is
Apache-2.0, needs no native build, consumes content:// URIs directly, and
runs MediaCodec decode -> GL surface -> MediaCodec encode without frames
round-tripping through the CPU. FFmpeg remains necessary for the long tail
(MP3, GIF, MKV, exotic containers) but is not the right tool here.

Two hazards are designed against rather than discovered later:

Transformer must be driven from a single thread that has a Looper, and
start()/cancel() throw IllegalStateException from anywhere else. The Looper
it binds to is whichever the Builder saw, silently falling back to the main
one. A WorkManager Worker runs on a Looper-less executor thread, so the
naive arrangement builds against the main Looper and then throws on start.
Media3Engine owns a dedicated HandlerThread, passes its Looper explicitly,
and marshals every call onto it, so callers get a plain suspending function
and cannot reintroduce the bug. Media3EngineTest covers this directly by
driving a conversion from a Looper-less thread.

Output never goes through a SAF file descriptor. MP4 faststart rewrites the
moov atom at the end and needs to seek backwards, which a SAF fd does not
reliably support. OutputPublisher stages to app-private cache, a real POSIX
path, and copies out afterwards. That costs transient double disk usage, so
it checks free space before starting.

Input uses ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is
images and video only, offers no audio at all, and does not reliably
surface .mkv/.flac/.webm. SAF needs no runtime permission.

Tests run on an API 37 emulator and assert the output codec by reading the
muxed file with MediaExtractor, so a silent fallback to H.264 fails rather
than passing. Progress reporting is deliberately not asserted as non-empty:
a 3 s fixture can finish inside one 250 ms poll tick, which would be an
intermittent failure rather than a real defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 21:18:44 -05:00
co-authored by Claude Opus 5
parent 48f65f0941
commit e4efa8a74a
7 changed files with 623 additions and 0 deletions
Binary file not shown.
@@ -0,0 +1,128 @@
package dev.jasonmross.mediaconverter.convert
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
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
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
* End-to-end hardware transcode through [Media3Engine].
*
* This is the Phase 1 verification: it proves the MediaCodec pipeline actually runs on
* a device, and — more importantly — that the engine can be driven from a thread with
* no Looper of its own without tripping Transformer's single-thread requirement.
*/
@UnstableApi
@RunWith(AndroidJUnit4::class)
class Media3EngineTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var engine: Media3Engine
private lateinit var input: File
private lateinit var output: File
@Before
fun setUp() {
engine = Media3Engine(context)
input = File(context.cacheDir, "sample_h264.mp4")
InstrumentationRegistry.getInstrumentation().context.assets
.open("sample_h264.mp4")
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
output = File(context.cacheDir, "out_hevc.mp4")
output.delete()
}
@After
fun tearDown() {
engine.close()
input.delete()
output.delete()
}
@Test
fun transcodesH264ToH265AndReportsProgress() = runBlocking {
val seen = mutableListOf<Int>()
val result = engine.transcode(
input = Uri.fromFile(input),
output = output,
videoMimeType = MimeTypes.VIDEO_H265,
) { percent -> seen += percent }
assertTrue("export produced no file", output.exists())
assertTrue("export produced an empty file", output.length() > 0)
// Assert against the muxed file, not just the reported result: this is what
// actually proves the output is HEVC rather than a silent fallback to H.264.
assertEquals(MimeTypes.VIDEO_H265, videoMimeTypeOf(output))
assertTrue("no duration reported", result.approximateDurationMs > 0)
assertTrue("no frames encoded", result.videoFrameCount > 0)
// Deliberately NOT asserting that progress fired. Polling is on a 250 ms tick,
// and a 3 s 320x240 clip can finish inside one tick on fast hardware, which
// would make the assertion fail intermittently for no real defect.
seen.forEach { assertTrue("progress out of range: $it", it in 0..100) }
}
/**
* Regression guard for the Transformer threading trap.
*
* Transformer binds to the Looper of the thread that built it, falling back to the
* main Looper when that thread has none — and then throws IllegalStateException
* when start() is called from elsewhere. A WorkManager Worker runs on exactly such
* a Looper-less thread, so this test drives the engine from one to prove the
* HandlerThread indirection holds before any of that lands in Phase 2.
*/
@Test
fun runsFromAThreadWithNoLooper() {
val pool = Executors.newSingleThreadExecutor()
try {
val task = pool.submit<Throwable?> {
check(android.os.Looper.myLooper() == null) {
"precondition failed: this thread should have no Looper"
}
runCatching {
runBlocking { engine.transcode(Uri.fromFile(input), output) }
}.exceptionOrNull()
}
val failure = task.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)
assertTrue(
"transcode from a Looper-less thread failed: $failure",
failure == null,
)
assertTrue(output.exists() && output.length() > 0)
} finally {
pool.shutdownNow()
}
}
private fun videoMimeTypeOf(file: File): String? {
val extractor = MediaExtractor()
try {
extractor.setDataSource(file.absolutePath)
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith("video/")) return mime
}
return null
} finally {
extractor.release()
}
}
private companion object {
const val TIMEOUT_SECONDS = 120L
}
}
@@ -0,0 +1,30 @@
package dev.jasonmross.mediaconverter
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi
import dev.jasonmross.mediaconverter.convert.ConverterScreen
import dev.jasonmross.mediaconverter.ui.theme.MediaConverterTheme
@UnstableApi
class MainActivity : ComponentActivity() {
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.
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
MediaConverterTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
ConverterScreen(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
@@ -0,0 +1,154 @@
package dev.jasonmross.mediaconverter.convert
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 kotlinx.coroutines.Dispatchers
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
data class InputFile(
val uri: Uri,
val displayName: String,
val sizeBytes: Long,
)
sealed interface ConversionState {
data object Idle : ConversionState
data class Ready(val input: InputFile) : ConversionState
data class Converting(val input: InputFile, val percent: Int) : ConversionState
data class Converted(
val input: InputFile,
val staged: File,
val elapsedMs: Long,
) : ConversionState
data class Saved(val displayName: String) : ConversionState
data class Failed(val message: String) : ConversionState
}
@UnstableApi
class ConversionViewModel(app: Application) : AndroidViewModel(app) {
private val engine = Media3Engine(app)
private val publisher = OutputPublisher(app)
private val _state = MutableStateFlow<ConversionState>(ConversionState.Idle)
val state: StateFlow<ConversionState> = _state.asStateFlow()
fun onInputPicked(uri: Uri) {
viewModelScope.launch {
val info = withContext(Dispatchers.IO) { queryFile(uri) }
_state.value = ConversionState.Ready(info)
}
}
fun convert() {
val input = when (val s = _state.value) {
is ConversionState.Ready -> s.input
is ConversionState.Converted -> s.input
else -> return
}
viewModelScope.launch {
// Staging plus the source means peak usage is roughly both at once.
if (!publisher.hasSpaceFor(input.sizeBytes)) {
_state.value = ConversionState.Failed(
"Not enough free space to convert this file."
)
return@launch
}
_state.value = ConversionState.Converting(input, 0)
val staged = publisher.createStagingFile(outputNameFor(input.displayName))
val startedAt = System.currentTimeMillis()
runCatching {
engine.transcode(input.uri, staged) { percent ->
_state.update { current ->
if (current is ConversionState.Converting) {
current.copy(percent = percent)
} else {
current
}
}
}
}.onSuccess {
_state.value = ConversionState.Converted(
input = input,
staged = staged,
elapsedMs = System.currentTimeMillis() - startedAt,
)
}.onFailure { e ->
staged.delete()
_state.value = ConversionState.Failed(e.message ?: "Conversion failed.")
}
}
}
/** Copies the staged result out to the destination the user chose. */
fun save(destination: Uri) {
val converted = _state.value as? ConversionState.Converted ?: return
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
publisher.publish(converted.staged, destination)
converted.staged.delete()
}
}.onSuccess {
_state.value = ConversionState.Saved(
outputNameFor(converted.input.displayName)
)
}.onFailure { e ->
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
}
}
}
fun reset() {
_state.value = ConversionState.Idle
}
fun suggestedOutputName(): String {
val s = _state.value
val base = when (s) {
is ConversionState.Converted -> s.input.displayName
is ConversionState.Ready -> s.input.displayName
else -> "output"
}
return outputNameFor(base)
}
private fun queryFile(uri: Uri): InputFile {
var name = "input"
var size = 0L
getApplication<Application>().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)
}
private fun outputNameFor(inputName: String): String =
inputName.substringBeforeLast('.', inputName) + "_converted.mp4"
override fun onCleared() {
engine.close()
super.onCleared()
}
}
@@ -0,0 +1,129 @@
package dev.jasonmross.mediaconverter.convert
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.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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import java.util.Locale
@UnstableApi
@Composable
fun ConverterScreen(
modifier: Modifier = Modifier,
viewModel: ConversionViewModel = viewModel(),
) {
val state by viewModel.state.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.
// SAF needs no runtime permission.
val pickInput = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let(viewModel::onInputPicked) }
val chooseDestination = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("video/mp4")
) { uri -> uri?.let(viewModel::save) }
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Media Converter", style = MaterialTheme.typography.headlineMedium)
when (val s = state) {
is ConversionState.Idle -> {
Text(
"Pick a video to transcode to H.265.",
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = { pickInput.launch(arrayOf("video/*")) }) {
Text("Choose file")
}
}
is ConversionState.Ready -> {
FileCard(s.input)
Button(onClick = viewModel::convert) { Text("Convert") }
OutlinedButton(onClick = { pickInput.launch(arrayOf("video/*")) }) {
Text("Choose a different file")
}
}
is ConversionState.Converting -> {
FileCard(s.input)
Text("Converting… ${s.percent}%")
LinearProgressIndicator(
progress = { s.percent / 100f },
modifier = Modifier.fillMaxWidth(),
)
}
is ConversionState.Converted -> {
FileCard(s.input)
Text(
"Done in ${formatSeconds(s.elapsedMs)} — " +
"${formatBytes(s.staged.length())} output.",
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = { chooseDestination.launch(viewModel.suggestedOutputName()) }) {
Text("Save file")
}
}
is ConversionState.Saved -> {
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(onClick = viewModel::reset) { Text("Convert another") }
}
is ConversionState.Failed -> {
Text(
s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = viewModel::reset) { Text("Start over") }
}
}
}
}
@Composable
private fun FileCard(input: InputFile) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(input.displayName, style = MaterialTheme.typography.titleMedium)
Text(formatBytes(input.sizeBytes), style = MaterialTheme.typography.bodySmall)
}
}
}
private fun formatBytes(bytes: Long): String = when {
bytes >= 1_000_000_000 -> String.format(Locale.US, "%.1f GB", bytes / 1e9)
bytes >= 1_000_000 -> String.format(Locale.US, "%.1f MB", bytes / 1e6)
bytes >= 1_000 -> String.format(Locale.US, "%.0f kB", bytes / 1e3)
else -> "$bytes B"
}
private fun formatSeconds(ms: Long): String =
String.format(Locale.US, "%.1f s", ms / 1000.0)
@@ -0,0 +1,130 @@
package dev.jasonmross.mediaconverter.convert
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.transformer.Composition
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.ProgressHolder
import androidx.media3.transformer.Transformer
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Hardware conversion via Media3 Transformer.
*
* ## Why the HandlerThread
*
* Transformer documents that instances "must be accessed from a single application
* thread", and both [Transformer.start] and [Transformer.cancel] throw
* [IllegalStateException] when called from anywhere else. The thread it binds to is
* whichever one had a Looper when the Builder was constructed — falling back to the
* *main* Looper if the constructing thread has none.
*
* That default is a trap for background execution. A WorkManager `Worker` runs on an
* executor thread with no Looper, so a Transformer built there silently binds to the
* main thread, and the subsequent `start()` from the worker thread throws.
*
* This class therefore owns a dedicated [HandlerThread], passes its Looper explicitly
* via `setLooper`, and marshals every Transformer call onto it. Callers get a plain
* suspending function and never have to think about it.
*/
@UnstableApi
class Media3Engine(private val context: Context) : AutoCloseable {
private val thread = HandlerThread("media3-transformer").apply { start() }
private val handler = Handler(thread.looper)
/**
* Transcodes [input] to [output], reporting progress 0..100.
*
* [output] must be a real filesystem path, not a SAF document. Writing through a
* SAF file descriptor breaks MP4 muxing, because faststart needs to seek back and
* rewrite the moov atom, and a SAF fd is not reliably seekable. Callers stage into
* app-private storage and publish afterwards.
*/
suspend fun transcode(
input: Uri,
output: File,
videoMimeType: String = MimeTypes.VIDEO_H265,
onProgress: (Int) -> Unit = {},
): ExportResult = suspendCancellableCoroutine { cont ->
handler.post {
val transformer = buildTransformer(videoMimeType, cont)
val item = EditedMediaItem.Builder(MediaItem.fromUri(input)).build()
cont.invokeOnCancellation {
// cancel() has the same single-thread requirement as start().
handler.post { runCatching { transformer.cancel() } }
}
runCatching { transformer.start(item, output.absolutePath) }
.onFailure { cont.resumeWithException(it); return@post }
pollProgress(transformer, cont, onProgress)
}
}
private fun buildTransformer(
videoMimeType: String,
cont: CancellableContinuation<ExportResult>,
): Transformer = Transformer.Builder(context)
.setLooper(thread.looper)
.setVideoMimeType(videoMimeType)
.addListener(object : Transformer.Listener {
override fun onCompleted(composition: Composition, result: ExportResult) {
if (cont.isActive) cont.resume(result)
}
override fun onError(
composition: Composition,
result: ExportResult,
exception: ExportException,
) {
if (cont.isActive) cont.resumeWithException(exception)
}
})
.build()
/**
* Polls export progress on the Transformer's own thread.
*
* Deliberately ~4x/second: the underlying value updates far more often than a UI
* or a notification can usefully consume, and over-frequent notification updates
* will jank the system UI.
*/
private fun pollProgress(
transformer: Transformer,
cont: CancellableContinuation<ExportResult>,
onProgress: (Int) -> Unit,
) {
val holder = ProgressHolder()
val tick = object : Runnable {
override fun run() {
if (!cont.isActive) return
if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) {
onProgress(holder.progress)
}
handler.postDelayed(this, PROGRESS_INTERVAL_MS)
}
}
handler.postDelayed(tick, PROGRESS_INTERVAL_MS)
}
override fun close() {
thread.quitSafely()
}
private companion object {
const val PROGRESS_INTERVAL_MS = 250L
}
}
@@ -0,0 +1,52 @@
package dev.jasonmross.mediaconverter.convert
import android.content.Context
import android.net.Uri
import java.io.File
/**
* Staging and publication of conversion output.
*
* Conversions never write directly to the destination the user picked. FFmpeg and the
* MP4 muxer both need to seek backwards to finalise a file — faststart rewrites the
* moov atom at the end — and a SAF file descriptor is not reliably seekable. Writing
* through one produces a truncated or unplayable file.
*
* So every job writes to app-private cache, which is a real POSIX path with no
* permissions and no scoped-storage rules, and the finished file is copied out to the
* user's chosen destination afterwards.
*
* The cost is one extra copy and transient double disk usage, which is why
* [hasSpaceFor] exists.
*/
class OutputPublisher(private val context: Context) {
private val stagingDir: File
get() = File(context.cacheDir, "conversions").apply { mkdirs() }
fun createStagingFile(name: String): File = File(stagingDir, name)
/**
* True if there is room for a further [bytes], including headroom.
*
* Staging means peak usage is roughly input + output at once, so a job that would
* just barely fit is rejected rather than failing partway through.
*/
fun hasSpaceFor(bytes: Long): Boolean =
stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
/** Copies a finished staging file into a user-chosen SAF destination. */
fun publish(staged: File, destination: Uri) {
context.contentResolver.openOutputStream(destination)?.use { out ->
staged.inputStream().use { it.copyTo(out) }
} ?: error("Could not open destination for writing: $destination")
}
fun clearStaging() {
stagingDir.listFiles()?.forEach { it.delete() }
}
private companion object {
const val SPACE_HEADROOM_BYTES = 128L * 1024 * 1024
}
}