Files
LibreMediaConverter/app/src/androidTest/java/org/libremediaconverter/convert/Media3EngineTest.kt
T
JMR-devandClaude Opus 5 238142d9cc Guard the whole Media3 export instead of only its two ends
transcode() posts its work to a HandlerThread, and everything on that
thread has no caller to throw back to: an escaping exception reaches the
thread's uncaught handler and takes the process down, while the
continuation is never resumed. Both halves of that are bad, and the
second is arguably worse — a worker left suspended forever holds a
foreground service.

The guarding was two narrow runCatching blocks, one around
buildTransformer and one around transformer.start, with the two Media3
builders sitting unguarded between them. That gap was not theoretical.
EditedMediaItem.Builder rejects a composition with both tracks removed,
which is exactly what a plan of (Drop, Drop) asks for, and it does so
with a plain IllegalStateException from the constructor.

Validation now refuses the spec that produces such a plan, so neither
the picker nor ConversionWorker will start one. Routing is a separate
question and still answers Media3 for it — a dropped track makes nothing
un-hardware-able — so a request that skips validation still arrives
here: a job queued before the settings changed, or one made through
ConversionWorker.request directly. CopyPlanner's own KDoc already names
that path as the reason it re-checks what validation has checked; this
is the same belt for the same braces.

One guard around the whole body costs nothing on success and turns any
such refusal into a failed job with a reason attached. The export body
moves into startExport, whose contract is the thing that makes one guard
enough: returning normally means the export is running and the listener
owns the continuation, throwing means it never started and the caller
does. Cancellation is still registered before start.

Covered twice on purpose. Robolectric runs the real HandlerThread and
the real Media3 builders, so the JVM test exercises the whole sequence
and can be run anywhere; the instrumented one repeats it against the
real framework. Neither asserts only that the failure is an
IllegalStateException, because withTimeout raises
TimeoutCancellationException and java.util.concurrent.CancellationException
extends IllegalStateException — so that assertion alone calls an
unresumed continuation a pass. Both were written that way first, and
reverting the guard is what exposed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:57:41 -05:00

291 lines
12 KiB
Kotlin

package org.libremediaconverter.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 kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.FailsOnEmulatorApi37
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.AudioPlan
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.CopyPlanner
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.VideoPlan
import java.io.File
import java.util.concurrent.CancellationException
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
@FailsOnEmulatorApi37
fun transcodesH264ToH265AndReportsProgress(): Unit = runBlocking {
val seen = mutableListOf<Int>()
engine.transcode(
input = Uri.fromFile(input),
output = output,
request = ConversionRequest(OutputFormat.MP4_H265.spec),
) { 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))
// Assert against the muxed file rather than the engine's own report: a result
// object can claim success for a file that will not play.
assertTrue("output has no duration", durationMsOf(output) > 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 audio-extraction bug.
*
* [OutputFormat.M4A_AAC] declares `VideoCodec.NONE`, and the router sends it to Media3. But
* the engine used to build a bare `EditedMediaItem` and take a video MIME type that defaulted
* to HEVC, so "extract the audio" transcoded the *video* to H.265 and wrote it to a file named
* `.m4a`. Nothing failed; the output was simply not what was asked for.
*
* This is the test the suite was missing — [Media3EngineTest] had no audio-only case at all,
* which is why the defect survived.
*/
@Test
fun audioOnlyExportDropsTheVideoTrack(): Unit = runBlocking {
val audio = File(context.cacheDir, "out_audio.m4a")
audio.delete()
try {
engine.transcode(Uri.fromFile(input), audio, ConversionRequest(OutputFormat.M4A_AAC.spec))
assertTrue("export produced no file", audio.exists() && audio.length() > 0)
val tracks = trackMimeTypesOf(audio)
assertEquals("expected exactly one track, got $tracks", 1, tracks.size)
assertEquals(MimeTypes.AUDIO_AAC, tracks.single())
assertNull("an audio-only export must carry no video track", videoMimeTypeOf(audio))
assertTrue("output has no duration", durationMsOf(audio) > 0)
} finally {
audio.delete()
}
}
/**
* 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
@FailsOnEmulatorApi37
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()
}
}
/**
* Transformer.start() failing synchronously.
*
* Previously written off as Media3-internal and unreachable. It is not: an output
* path whose parent directory does not exist makes start() fail, and the engine has
* to surface that as a rejected suspension rather than hanging forever waiting for
* a listener callback that will never come. A hang here would be far worse than an
* exception, because the worker would sit holding a foreground service.
*/
@Test
fun anUnwritableOutputPathFailsInsteadOfHanging() {
val impossible = File("/does/not/exist/nested/out.mp4")
val failure = runCatching {
runBlocking {
withTimeout(30_000) {
engine.transcode(Uri.fromFile(input), impossible, ConversionRequest(OutputFormat.MP4_H265.spec)) {}
}
}
}.exceptionOrNull()
assertTrue(
"an unwritable output must raise, not hang or silently pass; got $failure",
failure != null && failure !is kotlinx.coroutines.TimeoutCancellationException,
)
}
/**
* The builders that used to throw where nothing could catch them.
*
* `EditedMediaItem.Builder` rejects a composition with both tracks removed —
* checkState("Audio and video cannot both be removed") — and the engine builds it on its own
* HandlerThread. That build sat *between* two narrow `runCatching` blocks, one around
* `buildTransformer` and one around `start`, so the exception reached the thread's uncaught
* handler and took the process with it while the continuation was never resumed.
*
* `ContainerCapabilities.validate` now refuses the spec that gets here from the picker; this
* is the other half — the engine surviving a request that arrives without being validated.
* Deliberately not `@FailsOnEmulatorApi37`: nothing here decodes or encodes, so no emulator
* codec is involved. The builder refuses the input before any media is touched.
*/
@Test
fun aPlanThatRemovesBothTracksFailsInsteadOfKillingTheProcess() {
val request = ConversionRequest(
spec = OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.NONE),
probe = InputProbe(
videoCodec = null,
audioCodec = "mp3",
hasVideo = false,
container = Container.MP3,
kind = InputKind.AUDIO_ONLY,
),
)
// Asserted rather than assumed: ConversionRequest's default probe says hasVideo = true,
// and with it this same spec plans to (Encode, Drop) and nothing throws at all — which
// would make the whole test vacuous without a word of warning.
val plan = CopyPlanner.plan(request.spec, request.probe)
assertEquals(VideoPlan.Drop, plan.video)
assertEquals(AudioPlan.Drop, plan.audio)
val failure = runCatching {
runBlocking {
withTimeout(BUILDER_TIMEOUT_MS) {
engine.transcode(Uri.fromFile(input), output, request) {}
}
}
}.exceptionOrNull()
// Two assertions, and the second is not pedantry. withTimeout raises
// TimeoutCancellationException, and `java.util.concurrent.CancellationException` *extends*
// IllegalStateException — so testing only the type below would call an unresumed
// continuation a pass. A hang is the other half of this defect and every bit as bad as the
// crash: the worker would sit holding a foreground service forever.
assertFalse(
"the continuation was never resumed — the failure escaped instead of being reported: " +
"$failure",
failure is CancellationException,
)
assertTrue(
"the builder's refusal must surface as a failed job, not a dead process; got $failure",
failure is IllegalStateException,
)
}
private fun durationMsOf(file: File): Long {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(file.absolutePath)
(0 until extractor.trackCount)
.map { extractor.getTrackFormat(it) }
.filter { it.containsKey(MediaFormat.KEY_DURATION) }
.maxOfOrNull { it.getLong(MediaFormat.KEY_DURATION) / 1000 } ?: 0L
} finally {
extractor.release()
}
}
private fun trackMimeTypesOf(file: File): List<String> {
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()
}
}
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
/**
* Short on purpose. Nothing is decoded or encoded on this path — the builder refuses the
* input outright — so anything approaching this is a hang, which is what the test is
* looking for.
*/
const val BUILDER_TIMEOUT_MS = 30_000L
}
}