diff --git a/app/src/androidTest/assets/sample_h264.mp4 b/app/src/androidTest/assets/sample_h264.mp4 new file mode 100644 index 0000000..47814db Binary files /dev/null and b/app/src/androidTest/assets/sample_h264.mp4 differ diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/convert/Media3EngineTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/convert/Media3EngineTest.kt new file mode 100644 index 0000000..6236797 --- /dev/null +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/convert/Media3EngineTest.kt @@ -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() + + 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 { + 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 + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt b/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt new file mode 100644 index 0000000..5e684ea --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt @@ -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)) + } + } + } + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt new file mode 100644 index 0000000..0253b2e --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt @@ -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.Idle) + val state: StateFlow = _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().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() + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt new file mode 100644 index 0000000..72757ee --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt @@ -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) diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/Media3Engine.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/Media3Engine.kt new file mode 100644 index 0000000..e258930 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/Media3Engine.kt @@ -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, + ): 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, + 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 + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/OutputPublisher.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/OutputPublisher.kt new file mode 100644 index 0000000..2eb388a --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/OutputPublisher.kt @@ -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 + } +}