From 08c6891a7aab81ece01ec69fe9c72d351cff6779 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 19 Oct 2026 00:42:53 -0700 Subject: [PATCH] Fix premature Dolby Vision decoder rejection --- app/build.gradle.kts | 3 ++ .../us/viewmodel/CompressorViewModel.kt | 9 +++- .../us/viewmodel/CompressorViewModelTest.kt | 48 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 800b60b0..5bdfc336 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -39,6 +39,9 @@ android { } // Built-in Kotlin derives the Kotlin jvmTarget from compileOptions, so the // deprecated kotlinOptions block is no longer needed. + testOptions { + unitTests.isIncludeAndroidResources = true + } buildFeatures { compose = true buildConfig = true diff --git a/app/src/main/java/compress/joshattic/us/viewmodel/CompressorViewModel.kt b/app/src/main/java/compress/joshattic/us/viewmodel/CompressorViewModel.kt index 9d4298f0..38c828b5 100644 --- a/app/src/main/java/compress/joshattic/us/viewmodel/CompressorViewModel.kt +++ b/app/src/main/java/compress/joshattic/us/viewmodel/CompressorViewModel.kt @@ -1636,7 +1636,14 @@ class CompressorViewModel(application: Application) : AndroidViewModel(applicati val sourceHeight = sourceInfo?.height ?: 0 val sourceFps = if ((sourceInfo?.frameRate ?: 0f) > 0f) sourceInfo!!.frameRate else state.originalFps - if (!sourceMime.isNullOrBlank() && sourceWidth > 0 && sourceHeight > 0) { + // Dolby Vision may have an HEVC-compatible base layer even when the device + // advertises no video/dolby-vision decoder. Let Media3 inspect the actual + // profile and select a compatible decoder; an exact MIME check here would + // reject the file before DefaultDecoderFactory can try that fallback. + // Keep the original format intact so Media3 can validate compatibility. + if (!sourceMime.isNullOrBlank() && sourceWidth > 0 && sourceHeight > 0 && + sourceMime != MimeTypes.VIDEO_DOLBY_VISION + ) { val decoderSupported = isCodecConfigurationSupported( mimeType = sourceMime, width = sourceWidth, diff --git a/app/src/test/java/compress/joshattic/us/viewmodel/CompressorViewModelTest.kt b/app/src/test/java/compress/joshattic/us/viewmodel/CompressorViewModelTest.kt index 8097c0be..dcfa1951 100644 --- a/app/src/test/java/compress/joshattic/us/viewmodel/CompressorViewModelTest.kt +++ b/app/src/test/java/compress/joshattic/us/viewmodel/CompressorViewModelTest.kt @@ -1,6 +1,7 @@ package compress.joshattic.us.viewmodel import android.app.Application +import android.media.MediaCodecList import androidx.media3.common.MimeTypes import androidx.test.core.app.ApplicationProvider import compress.joshattic.us.model.CompressorUiState @@ -110,6 +111,53 @@ class CompressorViewModelTest { assertEquals(QualityPreset.CUSTOM, viewModel.uiState.value.activePreset) } + @Test + fun dolbyVisionWithoutAdvertisedDecoderReachesOutputPlanning() { + val error = planWithNoAdvertisedCodecs(MimeTypes.VIDEO_DOLBY_VISION) + assertTrue(error.startsWith("This device can't encode")) + } + + @Test + fun unsupportedHevcStillReportsDecoderFailure() { + val error = planWithNoAdvertisedCodecs(MimeTypes.VIDEO_H265) + assertEquals( + application.getString( + compress.joshattic.us.R.string.error_decoder_config_unsupported_details, + 3840, 2160, 60f, "hevc" + ), + error + ) + } + + private fun planWithNoAdvertisedCodecs(sourceMime: String): String { + // With no advertised decoders, Dolby Vision should still reach output + // planning so Media3 can later validate the profile and try HEVC fallback. + assertTrue(MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.isEmpty()) + val videoInfoClass = CompressorViewModel::class.java.declaredClasses + .single { it.simpleName == "VideoTrackInfo" } + val videoInfo = videoInfoClass.getDeclaredConstructor( + String::class.java, Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, Float::class.javaPrimitiveType + ).apply { isAccessible = true }.newInstance(sourceMime, 3840, 2160, 60f) + val probeClass = CompressorViewModel::class.java.declaredClasses + .single { it.simpleName == "TrackProbe" } + val probe = probeClass.getDeclaredConstructor( + videoInfoClass, String::class.java, + Int::class.javaPrimitiveType, Int::class.javaPrimitiveType + ).apply { isAccessible = true }.newInstance(videoInfo, null, 0, -1) + val method = CompressorViewModel::class.java.getDeclaredMethod( + "buildCompressionPlan", probeClass, CompressorUiState::class.java + ).apply { isAccessible = true } + val state = CompressorUiState( + originalVideoMime = sourceMime, originalWidth = 3840, + originalHeight = 2160, originalFps = 60f, + targetResolutionHeight = 2160, targetFps = 0 + ) + val plan = method.invoke(viewModel, probe, state) + return plan.javaClass.getDeclaredMethod("getBlockingError") + .apply { isAccessible = true }.invoke(plan) as String + } + @Test fun testCompressedOutputFileName() { val state = CompressorUiState(