diff --git a/app/src/androidTest/assets/sample_h264_444.mp4 b/app/src/androidTest/assets/sample_h264_444.mp4 new file mode 100644 index 0000000..36b1b72 Binary files /dev/null and b/app/src/androidTest/assets/sample_h264_444.mp4 differ diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/HardwareFallbackTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/HardwareFallbackTest.kt deleted file mode 100644 index 3854663..0000000 --- a/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/HardwareFallbackTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package dev.jasonmross.mediaconverter.bench - -import android.net.Uri -import androidx.media3.common.util.UnstableApi -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 dev.jasonmross.mediaconverter.work.ConversionWorker -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import org.junit.Assume.assumeTrue -import org.junit.Test -import org.junit.runner.RunWith -import java.io.File - -/** - * The dynamic fallback, exercised by a real file that genuinely breaks the fast path. - * - * The sample is H.264 **High 4:4:4 Predictive** (`avc1.F4001F`). Hardware AVC decoders - * implement High 4:2:0, and Android's software `c2.google.avc.decoder` does not cover - * 4:4:4 either, so Media3 cannot decode it on any device — it fails with a codec - * exception partway through the export. - * - * The static routing rules cannot predict this: the container is MP4, the codec is - * "h264", and the device reports both AVC decode and encode. Everything looks fine - * until the codec is actually configured. This is the case the runtime fallback exists - * for, and until a file like this was tried on hardware it had never actually fired. - */ -@UnstableApi -@RunWith(AndroidJUnit4::class) -class HardwareFallbackTest { - - private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val workManager = WorkManager.getInstance(context) - - @Test - fun aFileMedia3CannotDecodeStillConvertsViaFfmpeg(): Unit = runBlocking { - val input = File(context.filesDir, SAMPLE).takeIf { it.exists() && it.length() > 0 } - assumeTrue("$SAMPLE not present; skipping", input != null) - - val request = ConversionWorker.request( - inputUri = Uri.fromFile(input!!), - displayName = SAMPLE, - sizeBytes = input.length(), - format = OutputFormat.MP4_H265, - // Fast deliberately: this is the tier that would be routed to Media3, so it - // is the tier where the fallback has to save the conversion. - quality = QualityTier.FAST, - ) - workManager.enqueue(request).result.get() - - val terminal = withTimeout(TIMEOUT_MS) { - workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished } - } - - val error = terminal?.outputData?.getString(ConversionWorker.KEY_ERROR) - org.junit.Assert.assertEquals( - "a file Media3 cannot decode must still convert, but failed with: $error", - WorkInfo.State.SUCCEEDED, - terminal?.state, - ) - - val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!) - org.junit.Assert.assertTrue("no output produced", out.exists() && out.length() > 0) - - // The routing decision still reads MEDIA3 -- that is the *static* decision, which - // was reasonable on the information available. The recovery happened underneath - // it at runtime, which is the point. - android.util.Log.i( - TAG, - "BENCH fallback: routed=${terminal.outputData.getString(ConversionWorker.KEY_ENGINE_USED)} " + - "output=${out.length() / 1_000_000}MB", - ) - out.delete() - } - - private companion object { - const val TAG = "HardwareFallbackTest" - const val SAMPLE = "bench_h264_720p.mp4" - const val TIMEOUT_MS = 600_000L - } -} diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/RealMediaBenchmark.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/RealMediaBenchmark.kt index 7769e7e..66a80ef 100644 --- a/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/RealMediaBenchmark.kt +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/bench/RealMediaBenchmark.kt @@ -26,6 +26,11 @@ import java.io.File /** * Measures the two claims that motivate the whole architecture, against real media. * + * **This is a benchmark, not part of the automated suite.** It needs real, long-form + * media that is not committed to the repository, so it skips unless someone stages + * files deliberately. Do not read a passing run of the test suite as evidence these + * numbers still hold — correctness lives in the tests that ship their own fixtures. + * * Not a correctness test — the assertions are deliberately loose. These exist to * produce numbers for two decisions that were otherwise taken on faith: * diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/fallback/HardwareFallbackTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/fallback/HardwareFallbackTest.kt new file mode 100644 index 0000000..0f148db --- /dev/null +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/fallback/HardwareFallbackTest.kt @@ -0,0 +1,100 @@ +package dev.jasonmross.mediaconverter.fallback + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +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.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier +import dev.jasonmross.mediaconverter.work.ConversionWorker +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +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 + +/** + * The runtime fallback, driven by a file Media3 genuinely cannot decode. + * + * The fixture is H.264 **High 4:4:4 Predictive**. Hardware AVC decoders implement High + * 4:2:0, and Android's software `c2.google.avc.decoder` does not cover 4:4:4 either, so + * Media3 fails partway through the export on every device. + * + * The static routing rules cannot predict that: the container is MP4, the codec reports + * as "h264", and the device advertises AVC decode and encode. Everything looks viable + * until the codec is configured. This is the one case that proves the runtime fallback + * works, so it deliberately uses a **committed fixture** rather than media staged by + * hand — a regression test that silently skips is worse than no test, because the count + * still reads as coverage. + * + * The fixture was produced with x264, which the host toolchain cannot do (Fedora's + * ffmpeg ships openh264, which is Constrained Baseline only): + * + * ffmpeg -f lavfi -i testsrc=duration=3:size=320x240:rate=15 \ + * -f lavfi -i sine=frequency=440:duration=3 \ + * -c:v libx264 -profile:v high444 -pix_fmt yuv444p -preset ultrafast \ + * -c:a aac -shortest sample_h264_444.mp4 + */ +@UnstableApi +@RunWith(AndroidJUnit4::class) +class HardwareFallbackTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val workManager = WorkManager.getInstance(context) + private lateinit var input: File + + @Before + fun setUp() { + input = File(context.cacheDir, SAMPLE) + InstrumentationRegistry.getInstrumentation().context.assets + .open(SAMPLE) + .use { asset -> input.outputStream().use { asset.copyTo(it) } } + } + + @After + fun tearDown() { + input.delete() + File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() } + } + + @Test + fun aFileMedia3CannotDecodeStillConvertsViaFfmpeg(): Unit = runBlocking { + val request = ConversionWorker.request( + inputUri = Uri.fromFile(input), + displayName = SAMPLE, + sizeBytes = input.length(), + format = OutputFormat.MP4_H265, + // Fast deliberately: this is the tier the router sends to Media3, so it is + // the tier where the fallback has to rescue the conversion. + quality = QualityTier.FAST, + ) + workManager.enqueue(request).result.get() + + val terminal = withTimeout(TIMEOUT_MS) { + workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished } + } + + val error = terminal?.outputData?.getString(ConversionWorker.KEY_ERROR) + assertEquals( + "a file Media3 cannot decode must still convert, but failed with: $error", + WorkInfo.State.SUCCEEDED, + terminal?.state, + ) + + val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!) + assertTrue("no output produced", out.exists() && out.length() > 0) + out.delete() + } + + private companion object { + const val SAMPLE = "sample_h264_444.mp4" + const val TIMEOUT_MS = 600_000L + } +}