diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt index f38b7e2e2..44e635f24 100644 --- a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt +++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt @@ -34,8 +34,10 @@ import com.google.jetpackcamera.core.camera.utils.APP_REQUIRED_PERMISSIONS import com.google.jetpackcamera.core.camera.utils.provideUpdatingSurface import com.google.jetpackcamera.core.common.testing.FakeFilePathGenerator import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DynamicRange import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.Illuminant +import com.google.jetpackcamera.model.ImageOutputFormat import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.SaveLocation import com.google.jetpackcamera.model.StabilizationMode @@ -454,6 +456,238 @@ class CameraXCameraSystemTest { ) ) } + + @Test + fun switchCaptureMode_toStandard_disablesHdr_back(): Unit = runBlocking { + runSwitchCaptureMode_toStandard_disablesHdr_test(LensFacing.BACK) + } + + @Test + fun switchCaptureMode_toStandard_disablesHdr_front(): Unit = runBlocking { + runSwitchCaptureMode_toStandard_disablesHdr_test(LensFacing.FRONT) + } + + @Test + fun switchCaptureMode_toStandard_disablesImageHdr_back(): Unit = runBlocking { + runSwitchCaptureMode_toStandard_disablesImageHdr_test(LensFacing.BACK) + } + + @Test + fun switchCaptureMode_toStandard_disablesImageHdr_front(): Unit = runBlocking { + runSwitchCaptureMode_toStandard_disablesImageHdr_test(LensFacing.FRONT) + } + + @Test + fun switchCaptureMode_preservesVideoHdr_back(): Unit = runBlocking { + runSwitchCaptureMode_preservesVideoHdr_test(LensFacing.BACK) + } + + @Test + fun switchCaptureMode_preservesVideoHdr_front(): Unit = runBlocking { + runSwitchCaptureMode_preservesVideoHdr_test(LensFacing.FRONT) + } + + @Test + fun switchCaptureMode_preservesImageHdr_back(): Unit = runBlocking { + runSwitchCaptureMode_preservesImageHdr_test(LensFacing.BACK) + } + + @Test + fun switchCaptureMode_preservesImageHdr_front(): Unit = runBlocking { + runSwitchCaptureMode_preservesImageHdr_test(LensFacing.FRONT) + } + + private suspend fun CoroutineScope.runSwitchCaptureMode_toStandard_disablesHdr_test( + lensFacing: LensFacing + ) { + // Arrange. Initialize with default settings to query constraints safely. + val cameraSystem = createAndInitCameraXCameraSystem() + val systemConstraints = cameraSystem.getSystemConstraints().value + val cameraConstraints = systemConstraints?.perLensConstraints?.get(lensFacing) + + // This instrumented test runs on real hardware/emulator. Since we cannot mock the + // device's actual HDR capabilities, we use assume() to gracefully skip the test + // if the specified lens is not available or does not support HDR video (HLG10). + assume().withMessage("HDR video not supported on $lensFacing, skip the test.") + .that( + cameraConstraints != null && + cameraConstraints.supportedDynamicRanges.contains(DynamicRange.HLG10) + ).isTrue() + + // Configure the camera to use the target lens and enable HDR video + cameraSystem.setLensFacing(lensFacing) + cameraSystem.setCaptureMode(CaptureMode.VIDEO_ONLY) + cameraSystem.setDynamicRange(DynamicRange.HLG10) + + cameraSystem.startCameraAndWaitUntilRunning() + + val dynamicRangeCheck = cameraSystem.getCurrentSettings() + .filterNotNull() + .map { it.dynamicRange } + .produceIn(this) + + // Ensure we start in HLG10 + dynamicRangeCheck.awaitValue(DynamicRange.HLG10) + + // Act. Switch to STANDARD mode + cameraSystem.setCaptureMode(CaptureMode.STANDARD) + + // Assert. Dynamic range should fallback to SDR because STANDARD doesn't support HDR + dynamicRangeCheck.awaitValue(DynamicRange.SDR) + + // Clean-up. + dynamicRangeCheck.cancel() + } + + private suspend fun CoroutineScope.runSwitchCaptureMode_toStandard_disablesImageHdr_test( + lensFacing: LensFacing + ) { + val cameraSystem = createAndInitCameraXCameraSystem() + val systemConstraints = cameraSystem.getSystemConstraints().value + val cameraConstraints = systemConstraints?.perLensConstraints?.get(lensFacing) + + // Skip test if Ultra HDR is not supported on the target lens + assume().withMessage("Ultra HDR not supported on $lensFacing, skip the test.") + .that( + cameraConstraints != null && + cameraConstraints.supportedImageFormatsMap[ + DEFAULT_CAMERA_APP_SETTINGS.streamConfig + ]?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) == true + ).isTrue() + + cameraSystem.setLensFacing(lensFacing) + cameraSystem.setCaptureMode(CaptureMode.IMAGE_ONLY) + cameraSystem.setImageFormat(ImageOutputFormat.JPEG_ULTRA_HDR) + + cameraSystem.startCameraAndWaitUntilRunning() + + val imageFormatCheck = cameraSystem.getCurrentSettings() + .filterNotNull() + .map { it.imageFormat } + .produceIn(this) + + imageFormatCheck.awaitValue(ImageOutputFormat.JPEG_ULTRA_HDR) + + cameraSystem.setCaptureMode(CaptureMode.STANDARD) + + imageFormatCheck.awaitValue(ImageOutputFormat.JPEG) + + imageFormatCheck.cancel() + } + + private suspend fun CoroutineScope.runSwitchCaptureMode_preservesVideoHdr_test( + lensFacing: LensFacing + ) { + // Arrange. Initialize with default settings to query constraints safely. + val cameraSystem = createAndInitCameraXCameraSystem() + val systemConstraints = cameraSystem.getSystemConstraints().value + val cameraConstraints = systemConstraints?.perLensConstraints?.get(lensFacing) + + // This instrumented test runs on real hardware/emulator. Since we cannot mock the + // device's actual HDR capabilities, we use assume() to gracefully skip the test + // if the specified lens is not available or does not support HDR video (HLG10). + assume().withMessage("HDR video not supported on $lensFacing, skip the test.") + .that( + cameraConstraints != null && + cameraConstraints.supportedDynamicRanges.contains(DynamicRange.HLG10) + ).isTrue() + + // Configure the camera to use the target lens and enable HDR video + cameraSystem.setLensFacing(lensFacing) + cameraSystem.setCaptureMode(CaptureMode.VIDEO_ONLY) + cameraSystem.setDynamicRange(DynamicRange.HLG10) + cameraSystem.setImageFormat(ImageOutputFormat.JPEG) + + cameraSystem.startCameraAndWaitUntilRunning() + + val settingsCheck = cameraSystem.getCurrentSettings() + .filterNotNull() + .produceIn(this) + + // Ensure we start in VIDEO_ONLY with HLG10 + var settings = settingsCheck.receive() + assertThat(settings.captureMode).isEqualTo(CaptureMode.VIDEO_ONLY) + assertThat(settings.dynamicRange).isEqualTo(DynamicRange.HLG10) + assertThat(settings.imageFormat).isEqualTo(ImageOutputFormat.JPEG) + + // Act. Switch to IMAGE_ONLY + cameraSystem.setCaptureMode(CaptureMode.IMAGE_ONLY) + + // Assert. Image format should be JPEG (SDR), but dynamicRange should still be HLG10 in settings + settings = settingsCheck.receive() + assertThat(settings.captureMode).isEqualTo(CaptureMode.IMAGE_ONLY) + assertThat(settings.imageFormat).isEqualTo(ImageOutputFormat.JPEG) + assertThat(settings.dynamicRange).isEqualTo(DynamicRange.HLG10) // Preserved! + + // Act. Switch back to VIDEO_ONLY + cameraSystem.setCaptureMode(CaptureMode.VIDEO_ONLY) + + // Assert. Should be back to VIDEO_ONLY with HLG10 + settings = settingsCheck.receive() + assertThat(settings.captureMode).isEqualTo(CaptureMode.VIDEO_ONLY) + assertThat(settings.dynamicRange).isEqualTo(DynamicRange.HLG10) + + // Clean-up. + settingsCheck.cancel() + } + + private suspend fun CoroutineScope.runSwitchCaptureMode_preservesImageHdr_test( + lensFacing: LensFacing + ) { + // Arrange. Initialize with default settings to query constraints safely. + val cameraSystem = createAndInitCameraXCameraSystem() + val systemConstraints = cameraSystem.getSystemConstraints().value + val cameraConstraints = systemConstraints?.perLensConstraints?.get(lensFacing) + + // This instrumented test runs on real hardware/emulator. Since we cannot mock the + // device's actual Ultra HDR capabilities, we use assume() to gracefully skip the test + // if the specified lens is not available or does not support Ultra HDR. + assume().withMessage("Ultra HDR not supported on $lensFacing, skip the test.") + .that( + cameraConstraints != null && + cameraConstraints.supportedImageFormatsMap[ + DEFAULT_CAMERA_APP_SETTINGS.streamConfig + ]?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) == true + ).isTrue() + + // Configure the camera to use the target lens and enable Ultra HDR + cameraSystem.setLensFacing(lensFacing) + cameraSystem.setCaptureMode(CaptureMode.IMAGE_ONLY) + cameraSystem.setImageFormat(ImageOutputFormat.JPEG_ULTRA_HDR) + cameraSystem.setDynamicRange(DynamicRange.SDR) + + cameraSystem.startCameraAndWaitUntilRunning() + + val settingsCheck = cameraSystem.getCurrentSettings() + .filterNotNull() + .produceIn(this) + + // Ensure we start in IMAGE_ONLY with ULTRA_HDR + var settings = settingsCheck.receive() + assertThat(settings.captureMode).isEqualTo(CaptureMode.IMAGE_ONLY) + assertThat(settings.imageFormat).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR) + assertThat(settings.dynamicRange).isEqualTo(DynamicRange.SDR) + + // Act. Switch to VIDEO_ONLY + cameraSystem.setCaptureMode(CaptureMode.VIDEO_ONLY) + + // Assert. Dynamic range should be SDR, but imageFormat should still be ULTRA_HDR in settings + settings = settingsCheck.receive() + assertThat(settings.captureMode).isEqualTo(CaptureMode.VIDEO_ONLY) + assertThat(settings.dynamicRange).isEqualTo(DynamicRange.SDR) + assertThat(settings.imageFormat).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR) // Preserved! + + // Act. Switch back to IMAGE_ONLY + cameraSystem.setCaptureMode(CaptureMode.IMAGE_ONLY) + + // Assert. Should be back to IMAGE_ONLY with ULTRA_HDR + settings = settingsCheck.receive() + assertThat(settings.captureMode).isEqualTo(CaptureMode.IMAGE_ONLY) + assertThat(settings.imageFormat).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR) + + // Clean-up. + settingsCheck.cancel() + } } object FakeImagePostProcessorFeatureKey : ImagePostProcessorFeatureKey diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt index 2e23459c4..8e5bb9824 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt @@ -607,7 +607,7 @@ internal fun createUseCaseGroup( // only create image use case in image or standard val imageCaptureUseCase = if (captureMode != CaptureMode.VIDEO_ONLY) { - createImageUseCase(cameraInfo, aspectRatio, dynamicRange, imageFormat) + createImageUseCase(cameraInfo, aspectRatio, imageFormat) } else { null } @@ -666,15 +666,13 @@ private fun getHeightFromCropRect(cropRect: Rect?): Int { private fun createImageUseCase( cameraInfo: CameraInfo, aspectRatio: AspectRatio, - dynamicRange: DynamicRange, imageFormat: ImageOutputFormat ): ImageCapture { val builder = ImageCapture.Builder() builder.setResolutionSelector( getResolutionSelector(cameraInfo.sensorLandscapeRatio, aspectRatio) ) - if (dynamicRange != DynamicRange.SDR && imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR - ) { + if (imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR) { builder.setOutputFormat(ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR) } return builder.build() diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt index 134e3bcfb..afee91dcd 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt @@ -672,6 +672,9 @@ class CameraXCameraSystem( // Sets the camera to the designated lensFacing direction override suspend fun setLensFacing(lensFacing: LensFacing) { + // TODO: Handle lens flipping during recording when only one lens supports HDR. + // We should define the expected behavior (e.g., disable flip button, stop recording with error, + // or fallback to SDR mid-recording if supported by CameraX). currentSettings.update { old -> if (systemConstraints.availableLenses.contains(lensFacing)) { old?.copy(cameraLensFacing = lensFacing) @@ -706,29 +709,6 @@ class CameraXCameraSystem( // concurrent currently only supports VIDEO_ONLY if (concurrentCameraMode == ConcurrentCameraMode.DUAL) { CaptureMode.VIDEO_ONLY - } - - // if hdr is enabled... - else if (imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR || - dynamicRange == DynamicRange.HLG10 - ) { - // if both hdr video and image capture are supported, default to VIDEO_ONLY - if (constraints.supportedDynamicRanges.contains(DynamicRange.HLG10) && - constraints.supportedImageFormatsMap[streamConfig] - ?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) == true - ) { - if (captureMode == CaptureMode.STANDARD) { - CaptureMode.VIDEO_ONLY - } else { - return this - } - } - // return appropriate capture mode if only one is supported - else if (imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR) { - CaptureMode.IMAGE_ONLY - } else { - CaptureMode.VIDEO_ONLY - } } else { defaultCaptureMode ?: return this } @@ -784,10 +764,13 @@ class CameraXCameraSystem( systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints -> with(constraints.supportedDynamicRanges) { val newDynamicRange = if (contains(dynamicRange) && - flashMode != FlashMode.LOW_LIGHT_BOOST + flashMode != FlashMode.LOW_LIGHT_BOOST && + captureMode != CaptureMode.STANDARD ) { dynamicRange } else { + // TODO: Consider preserving user preference for HDR instead of permanently + // resetting to SDR here when switching lenses. DynamicRange.SDR } @@ -811,9 +794,15 @@ class CameraXCameraSystem( private fun CameraAppSettings.tryApplyImageFormatConstraints(): CameraAppSettings = systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints -> with(constraints.supportedImageFormatsMap[streamConfig]) { - val newImageFormat = if (this != null && contains(imageFormat)) { + // Prioritize Low Light Boost over Ultra HDR to maintain consistency with + // Video HDR / Low Light Boost conflict resolution. + val newImageFormat = if (this != null && contains(imageFormat) && + captureMode != CaptureMode.STANDARD && + flashMode != FlashMode.LOW_LIGHT_BOOST + ) { imageFormat } else { + // TODO: Consider preserving user preference for HDR instead of permanently resetting to JPEG here when switching lenses. ImageOutputFormat.JPEG } @@ -861,6 +850,7 @@ class CameraXCameraSystem( ConcurrentCameraMode.OFF -> this else -> if (systemConstraints.concurrentCamerasSupported && + captureMode == CaptureMode.VIDEO_ONLY && dynamicRange == DynamicRange.SDR && streamConfig == StreamConfig.MULTI_STREAM && flashMode != FlashMode.LOW_LIGHT_BOOST @@ -927,6 +917,7 @@ class CameraXCameraSystem( currentSettings.update { old -> old?.copy(flashMode = flashMode) ?.tryApplyDynamicRangeConstraints() + ?.tryApplyImageFormatConstraints() ?.tryApplyConcurrentCameraModeConstraints() } } @@ -969,7 +960,7 @@ class CameraXCameraSystem( old?.copy(dynamicRange = dynamicRange) ?.tryApplyDynamicRangeConstraints() ?.tryApplyConcurrentCameraModeConstraints() - ?.tryApplyCaptureModeConstraints(CaptureMode.STANDARD) + ?.tryApplyCaptureModeConstraints() } } @@ -983,7 +974,7 @@ class CameraXCameraSystem( currentSettings.update { old -> old?.copy(concurrentCameraMode = concurrentCameraMode) ?.tryApplyConcurrentCameraModeConstraints() - ?.tryApplyCaptureModeConstraints(CaptureMode.STANDARD) + ?.tryApplyCaptureModeConstraints() } } @@ -991,7 +982,7 @@ class CameraXCameraSystem( currentSettings.update { old -> old?.copy(imageFormat = imageFormat) ?.tryApplyImageFormatConstraints() - ?.tryApplyCaptureModeConstraints(CaptureMode.STANDARD) + ?.tryApplyCaptureModeConstraints() } } @@ -1025,6 +1016,9 @@ class CameraXCameraSystem( override suspend fun setCaptureMode(captureMode: CaptureMode) { currentSettings.update { old -> old?.copy(captureMode = captureMode) + ?.tryApplyDynamicRangeConstraints() + ?.tryApplyImageFormatConstraints() + ?.tryApplyConcurrentCameraModeConstraints() } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt index e4b7d02e0..b53adb327 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt @@ -139,11 +139,22 @@ fun QuickSettingsBottomSheet( } add { + val captureModeUi = quickSettingsUiState.captureModeUiState + val captureMode = (captureModeUi as? CaptureModeUiState.Available) + ?.selectedCaptureMode QuickSetHdr( modifier = Modifier.testTag(QUICK_SETTINGS_HDR_BUTTON), onClick = { d: DynamicRange, i: ImageOutputFormat -> - quickSettingsController.setDynamicRange(d) - quickSettingsController.setImageFormat(i) + when (captureMode) { + CaptureMode.IMAGE_ONLY -> + quickSettingsController.setImageFormat(i) + CaptureMode.VIDEO_ONLY -> + quickSettingsController.setDynamicRange(d) + else -> { + quickSettingsController.setDynamicRange(d) + quickSettingsController.setImageFormat(i) + } + } }, hdrUiState = quickSettingsUiState.hdrUiState ) diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/HdrUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/HdrUiStateAdapter.kt index e40480d00..aff4aa3a3 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/HdrUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/HdrUiStateAdapter.kt @@ -15,9 +15,11 @@ */ package com.google.jetpackcamera.ui.uistateadapter.capture +import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.ConcurrentCameraMode import com.google.jetpackcamera.model.DynamicRange import com.google.jetpackcamera.model.ExternalCaptureMode +import com.google.jetpackcamera.model.ExternalCaptureMode.Companion.toCaptureMode import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.ImageOutputFormat import com.google.jetpackcamera.settings.model.CameraAppSettings @@ -58,45 +60,44 @@ fun HdrUiState.Companion.from( val cameraConstraints: CameraConstraints? = systemConstraints.forCurrentLens( cameraAppSettings ) - return when (externalCaptureMode) { - ExternalCaptureMode.ImageCapture, - ExternalCaptureMode.MultipleImageCapture -> if ( - cameraConstraints + + // Determine active capture mode, respecting external override + val activeCaptureMode = + externalCaptureMode.toCaptureMode() ?: cameraAppSettings.captureMode + + return when (activeCaptureMode) { + CaptureMode.IMAGE_ONLY -> { + val supportsHdrImage = cameraConstraints ?.supportedImageFormatsMap?.get(cameraAppSettings.streamConfig) - ?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) ?: false && - cameraAppSettings.flashMode != FlashMode.LOW_LIGHT_BOOST - ) { - HdrUiState.Available(cameraAppSettings.imageFormat, cameraAppSettings.dynamicRange) - } else { - HdrUiState.Unavailable - } + ?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) ?: false + val isFlashHdrConflict = cameraAppSettings.flashMode == FlashMode.LOW_LIGHT_BOOST - ExternalCaptureMode.VideoCapture -> if ( - cameraConstraints?.supportedDynamicRanges?.contains(DynamicRange.HLG10) == true && - cameraAppSettings.concurrentCameraMode != ConcurrentCameraMode.DUAL && - cameraAppSettings.flashMode != FlashMode.LOW_LIGHT_BOOST - ) { - HdrUiState.Available( - cameraAppSettings.imageFormat, - cameraAppSettings.dynamicRange - ) - } else { - HdrUiState.Unavailable + if (supportsHdrImage && !isFlashHdrConflict) { + HdrUiState.Available( + selectedImageFormat = cameraAppSettings.imageFormat, + selectedDynamicRange = DynamicRange.SDR // Force SDR in UI state for video + ) + } else { + HdrUiState.Unavailable + } } + CaptureMode.VIDEO_ONLY -> { + val supportsHdrVideo = + cameraConstraints?.supportedDynamicRanges?.contains(DynamicRange.HLG10) == true + val isFlashHdrConflict = cameraAppSettings.flashMode == FlashMode.LOW_LIGHT_BOOST + val isConcurrentConflict = + cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.DUAL - ExternalCaptureMode.Standard -> if (( - cameraConstraints?.supportedDynamicRanges?.contains(DynamicRange.HLG10) == - true || - cameraConstraints?.supportedImageFormatsMap?.get( - cameraAppSettings.streamConfig - ) - ?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) ?: false - ) && - cameraAppSettings.concurrentCameraMode != ConcurrentCameraMode.DUAL && - cameraAppSettings.flashMode != FlashMode.LOW_LIGHT_BOOST - ) { - HdrUiState.Available(cameraAppSettings.imageFormat, cameraAppSettings.dynamicRange) - } else { + if (supportsHdrVideo && !isFlashHdrConflict && !isConcurrentConflict) { + HdrUiState.Available( + selectedImageFormat = ImageOutputFormat.JPEG, // Force SDR in UI state for image + selectedDynamicRange = cameraAppSettings.dynamicRange + ) + } else { + HdrUiState.Unavailable + } + } + CaptureMode.STANDARD -> { HdrUiState.Unavailable } } diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/HdrUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/HdrUiStateAdapterTest.kt new file mode 100644 index 000000000..4f172572a --- /dev/null +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/HdrUiStateAdapterTest.kt @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.ui.uistateadapter.capture + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.ExternalCaptureMode +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.ImageOutputFormat +import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraConstraints +import com.google.jetpackcamera.settings.model.CameraSystemConstraints +import com.google.jetpackcamera.ui.uistate.capture.HdrUiState +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +internal class HdrUiStateAdapterTest { + + private val emptyCameraConstraints = CameraConstraints( + supportedStabilizationModes = emptySet(), + supportedFixedFrameRates = emptySet(), + supportedDynamicRanges = emptySet(), + supportedVideoQualitiesMap = emptyMap(), + supportedImageFormatsMap = emptyMap(), + supportedIlluminants = emptySet(), + supportedFlashModes = emptySet(), + supportedZoomRange = null, + unsupportedStabilizationFpsMap = emptyMap(), + supportedTestPatterns = emptySet() + ) + + private val defaultCameraAppSettings = CameraAppSettings() + + @Test + fun from_standardMode_returnsUnavailable() { + // Given in STANDARD capture mode + val appSettings = defaultCameraAppSettings.copy(captureMode = CaptureMode.STANDARD) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedDynamicRanges = setOf(DynamicRange.SDR, DynamicRange.HLG10), + supportedImageFormatsMap = mapOf( + appSettings.streamConfig to setOf( + ImageOutputFormat.JPEG, + ImageOutputFormat.JPEG_ULTRA_HDR + ) + ) + ) + ) + ) + + // When + val hdrUiState = + HdrUiState.from(appSettings, systemConstraints, ExternalCaptureMode.Standard) + + // Then HDR is unavailable in Standard mode + assertThat(hdrUiState).isInstanceOf(HdrUiState.Unavailable::class.java) + } + + @Test + fun from_imageOnlyMode_hdrSupported_returnsAvailableWithRelevantSettings() { + // Given in IMAGE_ONLY capture mode, with HDR image supported + val appSettings = defaultCameraAppSettings.copy( + captureMode = CaptureMode.IMAGE_ONLY, + imageFormat = ImageOutputFormat.JPEG_ULTRA_HDR, + dynamicRange = DynamicRange.HLG10 + ) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedImageFormatsMap = mapOf( + appSettings.streamConfig to setOf( + ImageOutputFormat.JPEG, + ImageOutputFormat.JPEG_ULTRA_HDR + ) + ) + ) + ) + ) + + // When + val hdrUiState = + HdrUiState.from(appSettings, systemConstraints, ExternalCaptureMode.Standard) + + // Then HDR is available + assertThat(hdrUiState).isInstanceOf(HdrUiState.Available::class.java) + val availableState = hdrUiState as HdrUiState.Available + // Image format should be what is in settings + assertThat(availableState.selectedImageFormat).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR) + // Dynamic range should be forced to SDR because we are in IMAGE_ONLY + assertThat(availableState.selectedDynamicRange).isEqualTo(DynamicRange.SDR) + } + + @Test + fun from_imageOnlyMode_hdrNotSupported_returnsUnavailable() { + // Given in IMAGE_ONLY capture mode, but HDR image NOT supported + val appSettings = defaultCameraAppSettings.copy(captureMode = CaptureMode.IMAGE_ONLY) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedImageFormatsMap = mapOf( + appSettings.streamConfig to setOf(ImageOutputFormat.JPEG) + ) + ) + ) + ) + + // When + val hdrUiState = + HdrUiState.from(appSettings, systemConstraints, ExternalCaptureMode.Standard) + + // Then HDR is unavailable + assertThat(hdrUiState).isInstanceOf(HdrUiState.Unavailable::class.java) + } + + @Test + fun from_imageOnlyMode_lowLightBoostOn_returnsUnavailable() { + // Given in IMAGE_ONLY capture mode with Low Light Boost ON, even though Ultra HDR is supported + val appSettings = defaultCameraAppSettings.copy( + captureMode = CaptureMode.IMAGE_ONLY, + flashMode = FlashMode.LOW_LIGHT_BOOST + ) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedImageFormatsMap = mapOf( + appSettings.streamConfig to setOf( + ImageOutputFormat.JPEG, + ImageOutputFormat.JPEG_ULTRA_HDR + ) + ) + ) + ) + ) + + // When + val hdrUiState = + HdrUiState.from(appSettings, systemConstraints, ExternalCaptureMode.Standard) + + // Then HDR is unavailable because of the flash mode conflict + assertThat(hdrUiState).isInstanceOf(HdrUiState.Unavailable::class.java) + } + + @Test + fun from_videoOnlyMode_hdrSupported_returnsAvailableWithRelevantSettings() { + // Given in VIDEO_ONLY capture mode, with HDR video supported + val appSettings = defaultCameraAppSettings.copy( + captureMode = CaptureMode.VIDEO_ONLY, + dynamicRange = DynamicRange.HLG10, + imageFormat = ImageOutputFormat.JPEG_ULTRA_HDR + ) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedDynamicRanges = setOf(DynamicRange.SDR, DynamicRange.HLG10) + ) + ) + ) + + // When + val hdrUiState = + HdrUiState.from(appSettings, systemConstraints, ExternalCaptureMode.Standard) + + // Then HDR is available + assertThat(hdrUiState).isInstanceOf(HdrUiState.Available::class.java) + val availableState = hdrUiState as HdrUiState.Available + // Dynamic range should be what is in settings + assertThat(availableState.selectedDynamicRange).isEqualTo(DynamicRange.HLG10) + // Image format should be forced to JPEG because we are in VIDEO_ONLY + assertThat(availableState.selectedImageFormat).isEqualTo(ImageOutputFormat.JPEG) + } + + @Test + fun from_videoOnlyMode_hdrNotSupported_returnsUnavailable() { + // Given in VIDEO_ONLY capture mode, but HDR video NOT supported + val appSettings = defaultCameraAppSettings.copy(captureMode = CaptureMode.VIDEO_ONLY) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedDynamicRanges = setOf(DynamicRange.SDR) + ) + ) + ) + + // When + val hdrUiState = + HdrUiState.from(appSettings, systemConstraints, ExternalCaptureMode.Standard) + + // Then HDR is unavailable + assertThat(hdrUiState).isInstanceOf(HdrUiState.Unavailable::class.java) + } + + @Test + fun from_videoOnlyMode_lowLightBoostOn_returnsUnavailable() { + // Given in VIDEO_ONLY capture mode with Low Light Boost ON, even though HDR video is supported + val appSettings = defaultCameraAppSettings.copy( + captureMode = CaptureMode.VIDEO_ONLY, + flashMode = FlashMode.LOW_LIGHT_BOOST + ) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedDynamicRanges = setOf(DynamicRange.SDR, DynamicRange.HLG10) + ) + ) + ) + + // When + val hdrUiState = + HdrUiState.from(appSettings, systemConstraints, ExternalCaptureMode.Standard) + + // Then HDR is unavailable because of the flash mode conflict + assertThat(hdrUiState).isInstanceOf(HdrUiState.Unavailable::class.java) + } +}