From 771d49bfa8dfdcfe5b54569368abd0d553a9708d Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 19 Feb 2026 11:32:57 -0500 Subject: [PATCH] Add an instrumentation test for video transcoding. --- .gitignore | 1 + demo/video/build.gradle.kts | 19 ++ .../VideoTranscodeInstrumentationTest.kt | 171 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 demo/video/src/androidTest/java/org/thoughtcrime/video/app/transcode/VideoTranscodeInstrumentationTest.kt diff --git a/.gitignore b/.gitignore index c397d8b78f..ad86315785 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ maps.key kls_database.db .kotlin lefthook-local.yml +sample-videos/ diff --git a/demo/video/build.gradle.kts b/demo/video/build.gradle.kts index c988eeb4b6..fd9d08bb6d 100644 --- a/demo/video/build.gradle.kts +++ b/demo/video/build.gradle.kts @@ -3,11 +3,20 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import java.util.Properties + plugins { id("signal-sample-app") alias(libs.plugins.compose.compiler) } +val localPropertiesFile = File(rootProject.projectDir, "local.properties") +val localProperties: Properties? = if (localPropertiesFile.exists()) { + Properties().apply { localPropertiesFile.inputStream().use { load(it) } } +} else { + null +} + android { namespace = "org.thoughtcrime.video.app" compileSdkVersion = libs.versions.compileSdk.get() @@ -49,6 +58,16 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + sourceSets { + val sampleVideosPath = localProperties?.getProperty("sample.videos.dir") + if (sampleVideosPath != null) { + val sampleVideosDir = File(rootProject.projectDir, sampleVideosPath) + if (sampleVideosDir.isDirectory) { + getByName("androidTest").assets.srcDir(sampleVideosDir) + } + } + } } dependencies { diff --git a/demo/video/src/androidTest/java/org/thoughtcrime/video/app/transcode/VideoTranscodeInstrumentationTest.kt b/demo/video/src/androidTest/java/org/thoughtcrime/video/app/transcode/VideoTranscodeInstrumentationTest.kt new file mode 100644 index 0000000000..be6b31c413 --- /dev/null +++ b/demo/video/src/androidTest/java/org/thoughtcrime/video/app/transcode/VideoTranscodeInstrumentationTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.video.app.transcode + +import android.content.Context +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.video.StreamingTranscoder +import org.thoughtcrime.securesms.video.TranscodingPreset +import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource +import java.io.File +import java.io.FileInputStream +import java.io.InputStream + +/** + * Instrumentation test that transcodes all sample video files found in the androidTest assets. + * + * To use, set `sample.videos.dir` in `local.properties` to a directory containing video files. + * Those files will be packaged as test assets and transcoded with each [TranscodingPreset]. + * + * If no sample videos are configured, the tests will be skipped (not failed). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class VideoTranscodeInstrumentationTest { + + companion object { + private const val TAG = "VideoTranscodeTest" + private val VIDEO_EXTENSIONS = setOf("mp4", "mkv", "webm", "3gp", "mov") + } + + private lateinit var testContext: Context + private lateinit var appContext: Context + private val tempFiles = mutableListOf() + + @Before + fun setUp() { + testContext = InstrumentationRegistry.getInstrumentation().context + appContext = InstrumentationRegistry.getInstrumentation().targetContext + } + + @After + fun tearDown() { + tempFiles.forEach { it.delete() } + tempFiles.clear() + } + + @Test + fun transcodeAllVideos_level1() { + transcodeAllVideos(TranscodingPreset.LEVEL_1) + } + + @Test + fun transcodeAllVideos_level2() { + transcodeAllVideos(TranscodingPreset.LEVEL_2) + } + + @Test + fun transcodeAllVideos_level3() { + transcodeAllVideos(TranscodingPreset.LEVEL_3) + } + + @Test + fun transcodeAllVideos_level3H265() { + transcodeAllVideos(TranscodingPreset.LEVEL_3_H265) + } + + private fun transcodeAllVideos(preset: TranscodingPreset) { + val videoFiles = getVideoFileNames() + Assume.assumeTrue( + "No sample videos found in test assets. Set 'sample.videos.dir' in local.properties to a directory containing video files.", + videoFiles.isNotEmpty() + ) + + Log.i(TAG, "Found ${videoFiles.size} sample video(s): $videoFiles") + + val failures = mutableListOf() + + for (videoFileName in videoFiles) { + Log.i(TAG, "Transcoding '$videoFileName' with preset ${preset.name}...") + try { + transcodeVideo(videoFileName, preset) + Log.i(TAG, "Successfully transcoded '$videoFileName' with preset ${preset.name}") + } catch (e: Exception) { + Log.e(TAG, "Failed to transcode '$videoFileName' with preset ${preset.name}", e) + failures.add("$videoFileName: ${e::class.simpleName}: ${e.message}") + } + } + + if (failures.isNotEmpty()) { + Assert.fail( + "${failures.size}/${videoFiles.size} video(s) failed transcoding with ${preset.name}:\n" + + failures.joinToString("\n") + ) + } + } + + private fun transcodeVideo(videoFileName: String, preset: TranscodingPreset) { + val inputFile = createTempFile("input-", "-$videoFileName") + val outputFile = createTempFile("output-${preset.name}-", "-$videoFileName") + + testContext.assets.open(videoFileName).use { input -> + inputFile.outputStream().use { output -> + input.copyTo(output) + } + } + + Log.i(TAG, "Copied '$videoFileName' to temp file (${inputFile.length()} bytes)") + + val dataSource = FileMediaDataSource(inputFile) + val transcoder = StreamingTranscoder.createManuallyForTesting( + dataSource, + null, + preset.videoCodec, + preset.videoBitRate, + preset.audioBitRate, + preset.videoShortEdge, + true + ) + + outputFile.outputStream().use { outputStream -> + transcoder.transcode( + { percent -> Log.d(TAG, " $videoFileName [${preset.name}]: $percent%") }, + outputStream, + null + ) + } + + Assert.assertTrue( + "Transcoded output for '$videoFileName' with ${preset.name} is empty", + outputFile.length() > 0 + ) + + Log.i(TAG, "Output for '$videoFileName' with ${preset.name}: ${outputFile.length()} bytes") + } + + private fun getVideoFileNames(): List { + val allFiles = testContext.assets.list("") ?: emptyArray() + return allFiles.filter { fileName -> + val ext = fileName.substringAfterLast('.', "").lowercase() + ext in VIDEO_EXTENSIONS + }.sorted() + } + + private fun createTempFile(prefix: String, suffix: String): File { + val file = File.createTempFile(prefix, suffix, appContext.cacheDir) + tempFiles.add(file) + return file + } + + private class FileMediaDataSource(private val file: File) : InputStreamMediaDataSource() { + override fun close() {} + override fun getSize(): Long = file.length() + override fun createInputStream(position: Long): InputStream { + val stream = FileInputStream(file) + stream.skip(position) + return stream + } + } +}