diff --git a/app/src/androidTest/assets/clip_a.mp4 b/app/src/androidTest/assets/clip_a.mp4 new file mode 100644 index 0000000..47f4a04 Binary files /dev/null and b/app/src/androidTest/assets/clip_a.mp4 differ diff --git a/app/src/androidTest/assets/clip_b.mp4 b/app/src/androidTest/assets/clip_b.mp4 new file mode 100644 index 0000000..3003b1c Binary files /dev/null and b/app/src/androidTest/assets/clip_b.mp4 differ diff --git a/app/src/androidTest/assets/clip_c_mismatched.mp4 b/app/src/androidTest/assets/clip_c_mismatched.mp4 new file mode 100644 index 0000000..bc8a83d Binary files /dev/null and b/app/src/androidTest/assets/clip_c_mismatched.mp4 differ diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatEngineTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatEngineTest.kt new file mode 100644 index 0000000..a2e7af5 --- /dev/null +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatEngineTest.kt @@ -0,0 +1,177 @@ +package dev.jasonmross.mediaconverter.join + +import android.media.MediaExtractor +import android.media.MediaFormat +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.jasonmross.mediaconverter.convert.MediaProbe +import dev.jasonmross.mediaconverter.ffmpeg.ConcatEngine +import dev.jasonmross.mediaconverter.model.ConcatStrategy +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 + +/** + * The join path, end to end on a device. + * + * The point of these tests is the strategy decision, not merely that a file appears. + * FFmpeg's `concat` demuxer does not reliably reject mismatched inputs -- it can emit a + * file whose later segments are garbled -- so "it produced output" is not evidence of + * correctness. Each test therefore checks which strategy ran *and* that the result is + * long enough to contain both inputs. + */ +@RunWith(AndroidJUnit4::class) +class ConcatEngineTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val engine = ConcatEngine(context) + private val staged = mutableListOf() + + private lateinit var clipA: File + private lateinit var clipB: File + private lateinit var clipMismatched: File + + @Before + fun setUp() { + clipA = copyAsset("clip_a.mp4") + clipB = copyAsset("clip_b.mp4") + clipMismatched = copyAsset("clip_c_mismatched.mp4") + } + + @After + fun tearDown() { + (staged + listOf(clipA, clipB, clipMismatched)).forEach { it.delete() } + } + + private fun copyAsset(name: String): File { + val out = File(context.cacheDir, name) + InstrumentationRegistry.getInstrumentation().context.assets + .open(name) + .use { asset -> out.outputStream().use { asset.copyTo(it) } } + return out + } + + private fun output(name: String) = + File(context.cacheDir, name).also { it.delete(); staged += it } + + private fun durationMs(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() + } + } + + // --- the fast path ------------------------------------------------------ + + @Test + fun matchingClipsAreJoinedByStreamCopy() = runBlocking { + val out = output("joined_matching.mp4") + val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out) + + assertEquals( + "identical inputs should not need re-encoding", + ConcatStrategy.STREAM_COPY, + result.strategy, + ) + assertTrue("no output produced", out.exists() && out.length() > 0) + // Both 2 s inputs must be present, not just the first. + assertTrue( + "joined duration ${durationMs(out)}ms is too short to hold both clips", + durationMs(out) >= 3_500, + ) + } + + // --- the correctness path ---------------------------------------------- + + @Test + fun mismatchedClipsAreReEncodedRatherThanStreamCopied() = runBlocking { + val out = output("joined_mismatched.mp4") + val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out) + + // This is the case a naive implementation gets wrong: the demuxer would accept + // these and produce a corrupt second half. + assertEquals( + "differing resolution must force a re-encode", + ConcatStrategy.REENCODE, + result.strategy, + ) + assertTrue("no output produced", out.exists() && out.length() > 0) + assertTrue( + "joined duration ${durationMs(out)}ms is too short to hold both clips", + durationMs(out) >= 3_500, + ) + } + + @Test + fun reEncodedOutputIsPlayableAndCarriesBothTracks() = runBlocking { + val out = output("joined_playable.mp4") + engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out) + + val extractor = MediaExtractor() + try { + extractor.setDataSource(out.absolutePath) + val mimes = (0 until extractor.trackCount).map { + extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty() + } + assertTrue("no video track in $mimes", mimes.any { it.startsWith("video/") }) + assertTrue("no audio track in $mimes", mimes.any { it.startsWith("audio/") }) + } finally { + extractor.release() + } + } + + // --- guards ------------------------------------------------------------- + + @Test + fun joiningRefusesFewerThanTwoInputs() { + val out = output("joined_single.mp4") + val failure = runCatching { + runBlocking { engine.join(listOf(Uri.fromFile(clipA)), out) } + }.exceptionOrNull() + assertTrue( + "expected an IllegalArgumentException, got $failure", + failure is IllegalArgumentException, + ) + } + + @Test + fun theListFileIsCleanedUpAfterJoining() = runBlocking { + val out = output("joined_cleanup.mp4") + engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out) + assertTrue( + "the concat list file was left behind", + !File(out.parentFile, "concat_list.txt").exists(), + ) + } + + // --- the probe the planner depends on ---------------------------------- + + @Test + fun probeReadsThePropertiesTheStrategyDependsOn() { + val a = MediaProbe.probeForConcat(context, Uri.fromFile(clipA)) + val mismatched = MediaProbe.probeForConcat(context, Uri.fromFile(clipMismatched)) + + assertEquals("h264", a.videoCodec) + assertEquals(320, a.width) + assertEquals(240, a.height) + + assertEquals(640, mismatched.width) + assertEquals(480, mismatched.height) + assertTrue( + "the probe must actually distinguish these clips, or the planner cannot", + a.width != mismatched.width || a.height != mismatched.height, + ) + } +} diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatWorkerTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatWorkerTest.kt new file mode 100644 index 0000000..c0cabe0 --- /dev/null +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/join/ConcatWorkerTest.kt @@ -0,0 +1,109 @@ +package dev.jasonmross.mediaconverter.join + +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.ConcatStrategy +import dev.jasonmross.mediaconverter.work.ConcatWorker +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 + +/** + * Joining as background work. + * + * Covers what the engine test cannot: the WorkManager round trip, the foreground + * service, and the fact that the chosen strategy is reported back to the UI rather than + * lost inside the worker. + */ +@UnstableApi +@RunWith(AndroidJUnit4::class) +class ConcatWorkerTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val workManager = WorkManager.getInstance(context) + private lateinit var clipA: File + private lateinit var clipB: File + + @Before + fun setUp() { + clipA = copyAsset("clip_a.mp4") + clipB = copyAsset("clip_b.mp4") + } + + @After + fun tearDown() { + clipA.delete() + clipB.delete() + File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() } + } + + private fun copyAsset(name: String): File { + val out = File(context.cacheDir, name) + InstrumentationRegistry.getInstrumentation().context.assets + .open(name) + .use { asset -> out.outputStream().use { asset.copyTo(it) } } + return out + } + + @Test + fun joinsTwoFilesAndReportsTheStrategyUsed() = runBlocking { + val request = ConcatWorker.request( + inputs = listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), + totalBytes = clipA.length() + clipB.length(), + ) + workManager.enqueue(request).result.get() + + val terminal = withTimeout(TIMEOUT_MS) { + workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished } + } + + assertEquals( + "join did not succeed: ${terminal?.outputData?.getString(ConcatWorker.KEY_ERROR)}", + WorkInfo.State.SUCCEEDED, + terminal?.state, + ) + // The strategy has to reach the UI: it is what tells the user whether their + // files were re-encoded or copied losslessly. + assertEquals( + ConcatStrategy.STREAM_COPY.name, + terminal?.outputData?.getString(ConcatWorker.KEY_STRATEGY), + ) + val out = File(terminal!!.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)!!) + assertTrue("no joined file written", out.exists() && out.length() > 0) + } + + @Test + fun aSingleInputFailsWithAnActionableMessage() = runBlocking { + val request = ConcatWorker.request( + inputs = listOf(Uri.fromFile(clipA)), + totalBytes = clipA.length(), + ) + workManager.enqueue(request).result.get() + + val terminal = withTimeout(TIMEOUT_MS) { + workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished } + } + + assertEquals(WorkInfo.State.FAILED, terminal?.state) + val message = terminal?.outputData?.getString(ConcatWorker.KEY_ERROR).orEmpty() + assertTrue( + "the message should tell the user what to do, was: '$message'", + message.contains("two", ignoreCase = true), + ) + } + + private companion object { + const val TIMEOUT_MS = 180_000L + } +}