Make the 4:4:4 fallback regression run without manual setup

The test covering the runtime fallback read its input from the app's internal
storage, which only ever contained a file because it had been piped in by hand
with run-as. On a fresh checkout it hit assumeTrue and skipped -- silently,
while still counting toward the suite total. A regression test that skips is
worse than no test, because the number reads as coverage.

It now ships its own fixture: three seconds of H.264 High 4:4:4 Predictive,
76 KB. Producing it needed x264, which the host toolchain cannot supply --
Fedora's ffmpeg carries openh264, which is Constrained Baseline only and
cannot even decode 4:4:4 -- so it was generated with ffmpeg-full inside the
existing FFmpeg build container. The command is recorded in the test's own
documentation so the fixture can be regenerated rather than trusted blindly.

Verified by deleting the hand-staged files first and running the suite clean:
the fallback test executes, Media3 fails to decode as expected, and the worker
completes the conversion through FFmpeg. It is no longer among the skips.

The benchmark stays opt-in and is now documented as such. It needs real
long-form media that does not belong in the repository, and its numbers should
not be mistaken for something the suite verifies.

29 instrumented tests on a Pixel 10 Pro XL: 0 failures, 2 skipped, and both
skips are the benchmark by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:45:48 -05:00
co-authored by Claude Opus 5
parent ec6e37ad08
commit 356c04d137
4 changed files with 105 additions and 87 deletions
Binary file not shown.
@@ -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
}
}
@@ -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:
*
@@ -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
}
}