From 123a009c2011256626adec40c84c03098045f479 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 19 Oct 2026 00:40:48 -0700 Subject: [PATCH] Fix premature Dolby Vision decoder rejection --- app/build.gradle.kts | 5 +- .../us/viewmodel/CompressorViewModel.kt | 9 +++- .../us/viewmodel/CompressorViewModelTest.kt | 46 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b32bba6..9ecea6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -40,6 +40,9 @@ android { compose = true buildConfig = true } + testOptions { + unitTests.isIncludeAndroidResources = true + } dependenciesInfo { includeInBundle = false includeInApk = false @@ -76,4 +79,4 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) -} \ No newline at end of file +} diff --git a/app/src/main/java/compressedge/joshattic/us/viewmodel/CompressorViewModel.kt b/app/src/main/java/compressedge/joshattic/us/viewmodel/CompressorViewModel.kt index 01acb79..8cbf5cf 100644 --- a/app/src/main/java/compressedge/joshattic/us/viewmodel/CompressorViewModel.kt +++ b/app/src/main/java/compressedge/joshattic/us/viewmodel/CompressorViewModel.kt @@ -1939,7 +1939,14 @@ class CompressorViewModel(application: Application) : AndroidViewModel(applicati val sourceHeight = sourceInfo?.height ?: state.originalHeight 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/compressedge/joshattic/us/viewmodel/CompressorViewModelTest.kt b/app/src/test/java/compressedge/joshattic/us/viewmodel/CompressorViewModelTest.kt index 3b4aa9c..8e4dc8c 100644 --- a/app/src/test/java/compressedge/joshattic/us/viewmodel/CompressorViewModelTest.kt +++ b/app/src/test/java/compressedge/joshattic/us/viewmodel/CompressorViewModelTest.kt @@ -1,6 +1,10 @@ package compressedge.joshattic.us.viewmodel import android.app.Application +import android.content.Context +import android.media.MediaCodecList +import android.media.MediaExtractor +import android.net.Uri import androidx.media3.common.MimeTypes import androidx.test.core.app.ApplicationProvider import compressedge.joshattic.us.model.CompressorUiState @@ -10,6 +14,8 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.mockConstruction +import org.mockito.Mockito.`when` import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -110,6 +116,46 @@ 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( + compressedge.joshattic.us.R.string.error_decoder_config_unsupported_details, + 3840, 2160, 60f, "hevc" + ), + error + ) + } + + private fun planWithNoAdvertisedCodecs(sourceMime: String): String { + // Model the failing phone: no decoder advertises Dolby Vision. A source + // probe with no tracks falls back to the metadata already in UI state. + mockConstruction(MediaExtractor::class.java).use { + mockConstruction(MediaCodecList::class.java) { codecList, _ -> + `when`(codecList.codecInfos).thenReturn(emptyArray()) + }.use { + val method = CompressorViewModel::class.java.getDeclaredMethod( + "buildCompressionPlan", Context::class.java, CompressorUiState::class.java, + Uri::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType + ).apply { isAccessible = true } + val state = CompressorUiState( + originalVideoMime = sourceMime, originalWidth = 3840, + originalHeight = 2160, originalFps = 60f + ) + val plan = method.invoke(viewModel, application, state, Uri.EMPTY, 2160, 0) + return plan.javaClass.getDeclaredMethod("getBlockingError") + .apply { isAccessible = true }.invoke(plan) as String + } + } + } + @Test fun testCompressedOutputFileName() { val state = CompressorUiState(