diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e621f9a6cd..575b5ade92 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -164,6 +164,7 @@ dependencies { implementation(project(":data:settings")) implementation(project(":core:settings:datastore-prefs")) implementation(project(":core:settings")) + implementation(project(":core:model")) implementation(libs.androidx.datastore.preferences) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt index 5cbba37bb3..572786a8e7 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt @@ -32,6 +32,10 @@ import com.google.common.truth.Truth.assertThat import com.google.common.truth.TruthJUnit.assume import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.ConcurrentCameraMode +import com.google.jetpackcamera.model.ImageOutputFormat +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.SettingConfig import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.ui.components.capture.CAPTURE_MODE_TOGGLE_BUTTON import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_CAPTURE_MODE @@ -58,6 +62,7 @@ import com.google.jetpackcamera.utils.waitForCaptureButton import com.google.jetpackcamera.utils.waitForCaptureModeToggleState import com.google.jetpackcamera.utils.waitForNodeWithTag import com.google.jetpackcamera.utils.waitForNodeWithTagToDisappear +import org.junit.After import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -73,6 +78,12 @@ internal class CaptureModeSettingsTest { private val instrumentation = InstrumentationRegistry.getInstrumentation() private val uiDevice = UiDevice.getInstance(instrumentation) + + @After + fun tearDown() { + AppModule.testCameraFeaturePolicy = null + } + private fun ComposeTestRule.checkCaptureModeSettingState(captureMode: CaptureMode? = null) = visitQuickSettings { captureMode?.let { @@ -157,11 +168,8 @@ internal class CaptureModeSettingsTest { // should not be able to switch between capture modes onNodeWithTag(ROW_QUICK_SETTINGS_CAPTURE_MODE).assertDoesNotExist() } - // verify switch is disabled and locked on video only - assertThat(composeTestRule.isCaptureModeToggleEnabled()).isFalse() - assertThat( - composeTestRule.getCaptureModeToggleState() - ).isEqualTo(CaptureMode.VIDEO_ONLY) + // verify switch is removed when mode switching is not supported + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertDoesNotExist() // set concurrent camera mode back to off in settings composeTestRule.setConcurrentCameraModeInSettings(ConcurrentCameraMode.OFF) @@ -228,30 +236,36 @@ internal class CaptureModeSettingsTest { @Test fun hdr_supports_video_only() { - runMainActivityScenarioTest { - composeTestRule.waitForCaptureButton() + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + imageFormat = SettingConfig( + defaultValue = ImageOutputFormat.JPEG, + visibility = OptionVisibility.Hidden + ) + ) + try { + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() - // Switch to VIDEO_ONLY first since STANDARD doesn't support HDR - composeTestRule.setCaptureMode(CaptureMode.VIDEO_ONLY) - composeTestRule.setHdrEnabled(true) + // Switch to VIDEO_ONLY first since STANDARD doesn't support HDR + composeTestRule.setCaptureMode(CaptureMode.VIDEO_ONLY) + composeTestRule.setHdrEnabled(true) - // check that switch is disabled and only supports video - composeTestRule.waitForNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON) - // should not be able use capture toggle - assume().that(composeTestRule.isCaptureModeToggleEnabled()).isFalse() - assume().that(composeTestRule.getCaptureModeToggleState()) - .isEqualTo(CaptureMode.VIDEO_ONLY) + // check that switch is removed when mode switching is not supported + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertDoesNotExist() - composeTestRule.visitQuickSettings { - // capture mode should be video only - assertThat(getCurrentCaptureMode()).isEqualTo(CaptureMode.VIDEO_ONLY) - onNodeWithTag(ROW_QUICK_SETTINGS_CAPTURE_MODE).assertDoesNotExist() - } - assertThat(composeTestRule.isCaptureModeToggleEnabled()).isFalse() + composeTestRule.visitQuickSettings { + // capture mode should be video only + assertThat(getCurrentCaptureMode()).isEqualTo(CaptureMode.VIDEO_ONLY) + onNodeWithTag(ROW_QUICK_SETTINGS_CAPTURE_MODE).assertDoesNotExist() + } + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertDoesNotExist() - composeTestRule.setHdrEnabled(false) - // Remains VIDEO_ONLY since we explicitly switched to it - composeTestRule.checkCaptureModeSettingState(CaptureMode.VIDEO_ONLY) + composeTestRule.setHdrEnabled(false) + // Remains VIDEO_ONLY since we explicitly switched to it + composeTestRule.checkCaptureModeSettingState(CaptureMode.VIDEO_ONLY) + } + } finally { + AppModule.testCameraFeaturePolicy = null } } @@ -326,7 +340,7 @@ internal class CaptureModeSettingsTest { } @Test - fun image_intent_disables_capture_mode_toggle() { + fun image_intent_removes_capture_mode_toggle() { val timeStamp = System.currentTimeMillis() val uri = getTestUri(PICTURES_DIR_PATH, timeStamp, "jpg") val result = @@ -338,10 +352,7 @@ internal class CaptureModeSettingsTest { composeTestRule.visitQuickSettings { checkCaptureModeSettingState(CaptureMode.IMAGE_ONLY) } - assertThat(composeTestRule.isCaptureModeToggleEnabled()).isFalse() - assertThat( - composeTestRule.getCaptureModeToggleState() - ).isEqualTo(CaptureMode.IMAGE_ONLY) + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertDoesNotExist() uiDevice.pressBack() } @@ -370,7 +381,7 @@ internal class CaptureModeSettingsTest { } @Test - fun video_intent_disables_capture_mode_toggle() { + fun video_intent_removes_capture_mode_toggle() { val timeStamp = System.currentTimeMillis() val uri = getTestUri(MOVIES_DIR_PATH, timeStamp, "mp4") val result = @@ -382,10 +393,7 @@ internal class CaptureModeSettingsTest { composeTestRule.visitQuickSettings { checkCaptureModeSettingState(CaptureMode.VIDEO_ONLY) } - assertThat(composeTestRule.isCaptureModeToggleEnabled()).isFalse() - assertThat( - composeTestRule.getCaptureModeToggleState() - ).isEqualTo(CaptureMode.VIDEO_ONLY) + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertDoesNotExist() uiDevice.pressBack() } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/AspectRatioFeaturePolicyDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/AspectRatioFeaturePolicyDeviceTest.kt new file mode 100644 index 0000000000..e9d480f414 --- /dev/null +++ b/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/AspectRatioFeaturePolicyDeviceTest.kt @@ -0,0 +1,171 @@ +/* + * 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.featurepolicy + +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import com.google.jetpackcamera.AppModule +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.SettingConfig +import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_RATIO_1_1_BUTTON +import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_RATIO_3_4_BUTTON +import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_RATIO_9_16_BUTTON +import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_ASPECT_RATIO +import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS +import com.google.jetpackcamera.utils.runMainActivityScenarioTest +import com.google.jetpackcamera.utils.visitQuickSettings +import com.google.jetpackcamera.utils.waitForCaptureButton +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class AspectRatioFeaturePolicyDeviceTest { + + @get:Rule + val permissionsRule: GrantPermissionRule = + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + @After + fun tearDown() { + AppModule.testCameraFeaturePolicy = null + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section A: Default Values + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun defaultAspectRatio_oneOne_startsInOneOne() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + aspectRatio = SettingConfig(defaultValue = AspectRatio.ONE_ONE) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.visitQuickSettings { + onNodeWithTag(QUICK_SETTINGS_RATIO_1_1_BUTTON).assertIsOn() + } + } + } + + @Test + fun defaultAspectRatio_threeFour_startsInThreeFour() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + aspectRatio = SettingConfig(defaultValue = AspectRatio.THREE_FOUR) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.visitQuickSettings { + onNodeWithTag(QUICK_SETTINGS_RATIO_3_4_BUTTON).assertIsOn() + } + } + } + + @Test + fun defaultAspectRatio_nineSixteen_startsInNineSixteen() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + aspectRatio = SettingConfig(defaultValue = AspectRatio.NINE_SIXTEEN) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.visitQuickSettings { + onNodeWithTag(QUICK_SETTINGS_RATIO_9_16_BUTTON).assertIsOn() + } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section B: Hidden + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun hiddenAspectRatio_removesAspectRatioControlFromQuickSettings() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + aspectRatio = SettingConfig( + defaultValue = AspectRatio.ONE_ONE, + visibility = OptionVisibility.Hidden + ) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_ASPECT_RATIO).assertDoesNotExist() + } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section C: OptionsEnabled / Filtering + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun optionsEnabled_oneOneAndThreeFour_displaysOnlyThoseInQuickSettings() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + aspectRatio = SettingConfig( + defaultValue = AspectRatio.ONE_ONE, + visibility = OptionVisibility.Only( + setOf(AspectRatio.ONE_ONE, AspectRatio.THREE_FOUR) + ) + ) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_ASPECT_RATIO).assertExists() + onNodeWithTag(QUICK_SETTINGS_RATIO_1_1_BUTTON).assertExists() + onNodeWithTag(QUICK_SETTINGS_RATIO_3_4_BUTTON).assertExists() + onNodeWithTag(QUICK_SETTINGS_RATIO_9_16_BUTTON).assertDoesNotExist() + } + } + } + + @Test + fun optionsEnabled_singleOptionViaFactory_removesAspectRatioControlFromQuickSettings() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + aspectRatio = SettingConfig( + defaultValue = AspectRatio.ONE_ONE, + visibility = OptionVisibility.from(setOf(AspectRatio.ONE_ONE)) + ) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_ASPECT_RATIO).assertDoesNotExist() + } + } + } +} diff --git a/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/CaptureModeFeaturePolicyDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/CaptureModeFeaturePolicyDeviceTest.kt new file mode 100644 index 0000000000..16bd383f3d --- /dev/null +++ b/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/CaptureModeFeaturePolicyDeviceTest.kt @@ -0,0 +1,277 @@ +/* + * 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.featurepolicy + +import android.provider.MediaStore +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.AppModule +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.SettingConfig +import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_CAPTURE_MODE_OPTION_IMAGE_ONLY +import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_CAPTURE_MODE_OPTION_STANDARD +import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_CAPTURE_MODE_OPTION_VIDEO_ONLY +import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON +import com.google.jetpackcamera.ui.components.capture.CAPTURE_MODE_TOGGLE_BUTTON +import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_CAPTURE_MODE +import com.google.jetpackcamera.ui.uistateadapter.capture.R as StateR +import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS +import com.google.jetpackcamera.utils.getCaptureModeToggleState +import com.google.jetpackcamera.utils.getCurrentCaptureMode +import com.google.jetpackcamera.utils.isCaptureModeToggleEnabled +import com.google.jetpackcamera.utils.runMainActivityMediaStoreAutoDeleteScenarioTest +import com.google.jetpackcamera.utils.runMainActivityScenarioTest +import com.google.jetpackcamera.utils.tapStartLockedVideoRecording +import com.google.jetpackcamera.utils.visitQuickSettings +import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForSnackbarWithText +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class CaptureModeFeaturePolicyDeviceTest { + + @get:Rule + val permissionsRule: GrantPermissionRule = + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + @After + fun tearDown() { + AppModule.testCameraFeaturePolicy = null + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section A: Default Values + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun defaultCaptureMode_videoOnly_startsInVideoOnly() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.VIDEO_ONLY) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + assertThat( + composeTestRule.getCaptureModeToggleState() + ).isEqualTo(CaptureMode.VIDEO_ONLY) + composeTestRule.visitQuickSettings { + assertThat(getCurrentCaptureMode()).isEqualTo(CaptureMode.VIDEO_ONLY) + } + } + } + + @Test + fun defaultCaptureMode_imageOnly_startsInImageOnly() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.IMAGE_ONLY) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + assertThat( + composeTestRule.getCaptureModeToggleState() + ).isEqualTo(CaptureMode.IMAGE_ONLY) + composeTestRule.visitQuickSettings { + assertThat(getCurrentCaptureMode()).isEqualTo(CaptureMode.IMAGE_ONLY) + } + } + } + + @Test + fun defaultCaptureMode_standard_startsInStandard() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.STANDARD) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.visitQuickSettings { + assertThat(getCurrentCaptureMode()).isEqualTo(CaptureMode.STANDARD) + onNodeWithTag(BTN_QUICK_SETTINGS_CAPTURE_MODE_OPTION_STANDARD).assertIsOn() + } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section B: Hidden + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun hiddenCaptureMode_standard_removesToggleAndQuickSettingsControl() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig( + defaultValue = CaptureMode.STANDARD, + visibility = OptionVisibility.Hidden + ) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertDoesNotExist() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_CAPTURE_MODE).assertDoesNotExist() + } + } + } + + @Test + fun hiddenCaptureMode_imageOnly_removesToggleAndQuickSettingsControl() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig( + defaultValue = CaptureMode.IMAGE_ONLY, + visibility = OptionVisibility.Hidden + ) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertDoesNotExist() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_CAPTURE_MODE).assertDoesNotExist() + } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section C: OptionsEnabled / Filtering + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun optionsEnabled_standardAndImageOnly_disablesVideoOnlyAndHidesToggle() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig( + defaultValue = CaptureMode.STANDARD, + visibility = OptionVisibility.Only( + setOf(CaptureMode.STANDARD, CaptureMode.IMAGE_ONLY) + ) + ) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertDoesNotExist() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_CAPTURE_MODE).assertExists() + onNodeWithTag( + BTN_QUICK_SETTINGS_CAPTURE_MODE_OPTION_VIDEO_ONLY + ).assertIsNotEnabled() + } + } + } + + @Test + fun optionsEnabled_standardAndVideoOnly_disablesImageOnlyAndHidesToggle() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig( + defaultValue = CaptureMode.STANDARD, + visibility = OptionVisibility.Only( + setOf(CaptureMode.STANDARD, CaptureMode.VIDEO_ONLY) + ) + ) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertDoesNotExist() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_CAPTURE_MODE).assertExists() + onNodeWithTag( + BTN_QUICK_SETTINGS_CAPTURE_MODE_OPTION_IMAGE_ONLY + ).assertIsNotEnabled() + } + } + } + + @Test + fun optionsEnabled_imageOnlyAndVideoOnly_disablesStandardInQuickSettingsAndShowsToggle() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig( + defaultValue = CaptureMode.IMAGE_ONLY, + visibility = OptionVisibility.Only( + setOf(CaptureMode.IMAGE_ONLY, CaptureMode.VIDEO_ONLY) + ) + ) + ) + + runMainActivityScenarioTest { + composeTestRule.waitForCaptureButton() + assertThat(composeTestRule.isCaptureModeToggleEnabled()).isTrue() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_CAPTURE_MODE).assertDoesNotExist() + } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section D: Media Capture Execution + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun defaultCaptureMode_imageOnly_executesImageCaptureOnCaptureButtonClick() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.IMAGE_ONLY) + ) + + runMainActivityMediaStoreAutoDeleteScenarioTest( + mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + ) { + composeTestRule.waitForCaptureButton() + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() + composeTestRule.waitForSnackbarWithText( + StateR.string.toast_image_capture_success, + IMAGE_CAPTURE_TIMEOUT_MILLIS + ) + } + } + + @Test + fun defaultCaptureMode_videoOnly_executesVideoCaptureOnCaptureButtonClick() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.VIDEO_ONLY) + ) + + runMainActivityMediaStoreAutoDeleteScenarioTest( + mediaUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + ) { + composeTestRule.waitForCaptureButton() + composeTestRule.tapStartLockedVideoRecording() + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() + } + } +} diff --git a/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/FlashModeFeaturePolicyDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/FlashModeFeaturePolicyDeviceTest.kt new file mode 100644 index 0000000000..7c237de9ec --- /dev/null +++ b/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/FlashModeFeaturePolicyDeviceTest.kt @@ -0,0 +1,321 @@ +/* + * 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.featurepolicy + +import android.content.pm.PackageManager +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.TruthJUnit.assume +import com.google.jetpackcamera.AppModule +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.SettingConfig +import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FLASH_OPTION_AUTO +import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FLASH_OPTION_LOW_LIGHT_BOOST +import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FLASH_OPTION_OFF +import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FLASH_OPTION_ON +import com.google.jetpackcamera.ui.components.capture.FLIP_CAMERA_BUTTON +import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_FLASH +import com.google.jetpackcamera.utils.DEFAULT_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS +import com.google.jetpackcamera.utils.assume +import com.google.jetpackcamera.utils.getCurrentFlashMode +import com.google.jetpackcamera.utils.getCurrentLensFacing +import com.google.jetpackcamera.utils.runMainActivityScenarioTest +import com.google.jetpackcamera.utils.visitQuickSettings +import com.google.jetpackcamera.utils.waitForCaptureButton +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +internal class FlashModeFeaturePolicyDeviceTest( + private val lensFacing: LensFacing +) { + @get:Rule + val permissionsRule: GrantPermissionRule = + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + @Before + fun setUp() { + val pm = InstrumentationRegistry.getInstrumentation().targetContext.packageManager + if (lensFacing == LensFacing.FRONT) { + assume() + .withMessage("Device does not have a front camera") + .that(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT)) + .isTrue() + } else { + assume() + .withMessage("Device does not have a back camera") + .that(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA)) + .isTrue() + } + } + + @After + fun tearDown() { + AppModule.testCameraFeaturePolicy = null + } + + private fun ComposeTestRule.prepareLens() { + waitForCaptureButton() + val currentLens = getCurrentLensFacing() + if (currentLens != lensFacing) { + onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) { + "Device does not have a $lensFacing camera to flip to." + }.performClick() + waitUntil(DEFAULT_TIMEOUT_MILLIS) { getCurrentLensFacing() == lensFacing } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section A: Default Values + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun defaultFlashMode_on_startsInFlashOn() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + flashMode = SettingConfig(defaultValue = FlashMode.ON) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + val onExists = onAllNodesWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_ON) + .fetchSemanticsNodes().isNotEmpty() + if (onExists) { + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_ON).assertIsOn() + assertThat(getCurrentFlashMode()).isEqualTo(FlashMode.ON) + } else { + // If the lens does not support flash/screen-flash ON, verify fallback + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_ON).assertDoesNotExist() + if (onAllNodesWithTag( + ROW_QUICK_SETTINGS_FLASH + ).fetchSemanticsNodes().isNotEmpty() + ) { + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_OFF).assertIsOn() + } + } + } + } + } + + @Test + fun defaultFlashMode_auto_startsInAutoOrFallsBackToOff() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + flashMode = SettingConfig(defaultValue = FlashMode.AUTO) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + val autoExists = onAllNodesWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_AUTO) + .fetchSemanticsNodes().isNotEmpty() + if (autoExists) { + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_AUTO).assertIsOn() + assertThat(getCurrentFlashMode()).isEqualTo(FlashMode.AUTO) + } else { + // If AUTO is not supported on this lens, option must not be in view + // and active selection must fall back to OFF (if flash row is present) + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_AUTO).assertDoesNotExist() + if (onAllNodesWithTag( + ROW_QUICK_SETTINGS_FLASH + ).fetchSemanticsNodes().isNotEmpty() + ) { + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_OFF).assertIsOn() + } + } + } + } + } + + @Test + fun defaultFlashMode_lowLightBoost_startsInLowLightBoostOrFallsBackToOff() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + flashMode = SettingConfig(defaultValue = FlashMode.LOW_LIGHT_BOOST) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + val llbExists = onAllNodesWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_LOW_LIGHT_BOOST) + .fetchSemanticsNodes().isNotEmpty() + if (llbExists) { + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_LOW_LIGHT_BOOST).assertIsOn() + assertThat(getCurrentFlashMode()).isEqualTo(FlashMode.LOW_LIGHT_BOOST) + } else { + // If Low Light Boost is not supported on this lens, option must not be in view + // and active selection must fall back to OFF (if flash row is present) + onNodeWithTag( + BTN_QUICK_SETTINGS_FLASH_OPTION_LOW_LIGHT_BOOST + ).assertDoesNotExist() + if (onAllNodesWithTag( + ROW_QUICK_SETTINGS_FLASH + ).fetchSemanticsNodes().isNotEmpty() + ) { + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_OFF).assertIsOn() + } + } + } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section B: Hidden + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun hiddenFlashMode_removesFlashControlFromQuickSettings() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Hidden + ) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_FLASH).assertDoesNotExist() + } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section C: OptionsEnabled / Filtering + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun optionsEnabled_offAndOn_displaysOnlyOffAndOnInQuickSettings() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Only( + setOf(FlashMode.OFF, FlashMode.ON) + ) + ) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + // AUTO and LOW_LIGHT_BOOST must never be present + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_AUTO).assertDoesNotExist() + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_LOW_LIGHT_BOOST).assertDoesNotExist() + + val onExists = onAllNodesWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_ON) + .fetchSemanticsNodes().isNotEmpty() + if (onExists) { + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_OFF).assertExists() + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_OFF).assertIsOn() + } else { + onNodeWithTag(ROW_QUICK_SETTINGS_FLASH).assertDoesNotExist() + } + } + } + } + + @Test + fun optionsEnabled_offAndAuto_displaysOnlyAllowedOptionsInQuickSettings() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Only( + setOf(FlashMode.OFF, FlashMode.AUTO) + ) + ) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + // ON and LOW_LIGHT_BOOST must never be present + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_ON).assertDoesNotExist() + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_LOW_LIGHT_BOOST).assertDoesNotExist() + + val autoExists = onAllNodesWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_AUTO) + .fetchSemanticsNodes().isNotEmpty() + if (autoExists) { + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_OFF).assertExists() + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_OFF).assertIsOn() + } else { + onNodeWithTag(ROW_QUICK_SETTINGS_FLASH).assertDoesNotExist() + } + } + } + } + + @Test + fun optionsEnabled_offAndLowLightBoost_displaysOnlyAllowedOptionsInQuickSettings() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Only( + setOf(FlashMode.OFF, FlashMode.LOW_LIGHT_BOOST) + ) + ) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + // ON and AUTO must never be present + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_ON).assertDoesNotExist() + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_AUTO).assertDoesNotExist() + + val llbExists = onAllNodesWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_LOW_LIGHT_BOOST) + .fetchSemanticsNodes().isNotEmpty() + if (llbExists) { + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_OFF).assertExists() + onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_OFF).assertIsOn() + } else { + onNodeWithTag(ROW_QUICK_SETTINGS_FLASH).assertDoesNotExist() + } + } + } + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "lensFacing_{0}") + fun data(): Collection> = listOf( + arrayOf(LensFacing.BACK), + arrayOf(LensFacing.FRONT) + ) + } +} diff --git a/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/HdrFeaturePolicyDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/HdrFeaturePolicyDeviceTest.kt new file mode 100644 index 0000000000..354961e550 --- /dev/null +++ b/app/src/androidTest/java/com/google/jetpackcamera/featurepolicy/HdrFeaturePolicyDeviceTest.kt @@ -0,0 +1,195 @@ +/* + * 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.featurepolicy + +import android.content.pm.PackageManager +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.google.common.truth.TruthJUnit.assume +import com.google.jetpackcamera.AppModule +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.ImageOutputFormat +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.SettingConfig +import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_HDR_OPTION_ON +import com.google.jetpackcamera.ui.components.capture.FLIP_CAMERA_BUTTON +import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_HDR +import com.google.jetpackcamera.utils.DEFAULT_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS +import com.google.jetpackcamera.utils.assume +import com.google.jetpackcamera.utils.getCurrentLensFacing +import com.google.jetpackcamera.utils.runMainActivityScenarioTest +import com.google.jetpackcamera.utils.visitQuickSettings +import com.google.jetpackcamera.utils.waitForCaptureButton +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +internal class HdrFeaturePolicyDeviceTest( + private val lensFacing: LensFacing +) { + @get:Rule + val permissionsRule: GrantPermissionRule = + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + @Before + fun setUp() { + val pm = InstrumentationRegistry.getInstrumentation().targetContext.packageManager + if (lensFacing == LensFacing.FRONT) { + assume() + .withMessage("Device does not have a front camera") + .that(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT)) + .isTrue() + } else { + assume() + .withMessage("Device does not have a back camera") + .that(pm.hasSystemFeature(PackageManager.FEATURE_CAMERA)) + .isTrue() + } + } + + @After + fun tearDown() { + AppModule.testCameraFeaturePolicy = null + } + + private fun ComposeTestRule.prepareLens() { + waitForCaptureButton() + val currentLens = getCurrentLensFacing() + if (currentLens != lensFacing) { + onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) { + "Device does not have a $lensFacing camera to flip to." + }.performClick() + waitUntil(DEFAULT_TIMEOUT_MILLIS) { getCurrentLensFacing() == lensFacing } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section A: Default Values + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun defaultImageFormat_ultraHdr_startsHdrOnIfSupportedInImageOnly() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.IMAGE_ONLY), + imageFormat = SettingConfig(defaultValue = ImageOutputFormat.JPEG_ULTRA_HDR) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + val hdrExists = onAllNodesWithTag(ROW_QUICK_SETTINGS_HDR) + .fetchSemanticsNodes().isNotEmpty() + if (hdrExists) { + onNodeWithTag(BTN_QUICK_SETTINGS_HDR_OPTION_ON).assertIsOn() + } else { + onNodeWithTag(ROW_QUICK_SETTINGS_HDR).assertDoesNotExist() + } + } + } + } + + @Test + fun defaultDynamicRange_hlg10_startsHdrOnIfSupportedInVideoOnly() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.VIDEO_ONLY), + dynamicRange = SettingConfig(defaultValue = DynamicRange.HLG10) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + val hdrExists = onAllNodesWithTag(ROW_QUICK_SETTINGS_HDR) + .fetchSemanticsNodes().isNotEmpty() + if (hdrExists) { + onNodeWithTag(BTN_QUICK_SETTINGS_HDR_OPTION_ON).assertIsOn() + } else { + onNodeWithTag(ROW_QUICK_SETTINGS_HDR).assertDoesNotExist() + } + } + } + } + + // ///////////////////////////////////////////////////////////////////////// + // + // Section B: Hidden + // + // ///////////////////////////////////////////////////////////////////////// + + @Test + fun hiddenImageFormat_removesHdrControlInImageOnly() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.IMAGE_ONLY), + imageFormat = SettingConfig( + defaultValue = ImageOutputFormat.JPEG, + visibility = OptionVisibility.Hidden + ) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_HDR).assertDoesNotExist() + } + } + } + + @Test + fun hiddenDynamicRange_removesHdrControlInVideoOnly() { + AppModule.testCameraFeaturePolicy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.VIDEO_ONLY), + dynamicRange = SettingConfig( + defaultValue = DynamicRange.SDR, + visibility = OptionVisibility.Hidden + ) + ) + + runMainActivityScenarioTest { + composeTestRule.prepareLens() + composeTestRule.visitQuickSettings { + onNodeWithTag(ROW_QUICK_SETTINGS_HDR).assertDoesNotExist() + } + } + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "lensFacing_{0}") + fun data(): Collection> = listOf( + arrayOf(LensFacing.BACK), + arrayOf(LensFacing.FRONT) + ) + } +} diff --git a/app/src/main/java/com/google/jetpackcamera/AppModule.kt b/app/src/main/java/com/google/jetpackcamera/AppModule.kt index 46e30da70a..87a82bbfac 100644 --- a/app/src/main/java/com/google/jetpackcamera/AppModule.kt +++ b/app/src/main/java/com/google/jetpackcamera/AppModule.kt @@ -15,11 +15,13 @@ */ package com.google.jetpackcamera +import androidx.annotation.VisibleForTesting import com.google.jetpackcamera.core.common.FilePathGenerator import com.google.jetpackcamera.di.DefaultCaptureModeOverride import com.google.jetpackcamera.di.DefaultFilePathGenerator import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.SaveMode +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -28,6 +30,18 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) object AppModule { + + @Volatile + @VisibleForTesting + var testCameraFeaturePolicy: CameraFeaturePolicy? = null + + /** + * Provides the [CameraFeaturePolicy] configuration. + */ + @Provides + fun providesCameraFeaturePolicy(): CameraFeaturePolicy = + testCameraFeaturePolicy ?: CameraFeaturePolicy() + /** * provides the default [CaptureMode] to override by the app */ diff --git a/app/src/main/java/com/google/jetpackcamera/AppSettingsModule.kt b/app/src/main/java/com/google/jetpackcamera/AppSettingsModule.kt index e2e8e741c5..00ac297216 100644 --- a/app/src/main/java/com/google/jetpackcamera/AppSettingsModule.kt +++ b/app/src/main/java/com/google/jetpackcamera/AppSettingsModule.kt @@ -24,6 +24,7 @@ import com.google.jetpackcamera.core.settings.datastoreprefs.PrefsDataStoreSetti import com.google.jetpackcamera.di.DefaultCaptureModeOverride import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.settings.SettingsDataSource +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -47,8 +48,13 @@ object AppSettingsModule { @Singleton fun provideSettingsDataSource( dataStore: DataStore, - @DefaultCaptureModeOverride defaultCaptureMode: CaptureMode + @DefaultCaptureModeOverride defaultCaptureMode: CaptureMode, + cameraFeaturePolicy: CameraFeaturePolicy ): SettingsDataSource { - return PrefsDataStoreSettingsDataSource(dataStore, defaultCaptureMode) + return PrefsDataStoreSettingsDataSource( + dataStore = dataStore, + defaultCaptureModeOverride = defaultCaptureMode, + cameraFeaturePolicy = cameraFeaturePolicy + ) } } 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 c7031c6525..813df9cc77 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 @@ -799,6 +799,8 @@ class CameraXCameraSystem( ): CameraAppSettings = when (captureMode) { CaptureMode.STANDARD -> this CaptureMode.IMAGE_ONLY -> + // TODO: Consider preserving user preference or initial CameraFeaturePolicy aspect ratio + // when switching back from VIDEO_ONLY mode instead of hardcoding 3:4. this.copy(aspectRatio = AspectRatio.THREE_FOUR) CaptureMode.VIDEO_ONLY -> diff --git a/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt b/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt index 3ed8e101dc..2e1d7f05a4 100644 --- a/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt +++ b/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt @@ -71,6 +71,7 @@ class FakeCameraSystem(defaultCameraSettings: CameraAppSettings = CameraAppSetti cameraAppSettings: CameraAppSettings, cameraPropertiesJSONCallback: (result: String) -> Unit ) { + currentSettings.value = cameraAppSettings initialized = true } diff --git a/core/camera/testing/src/test/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystemTest.kt b/core/camera/testing/src/test/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystemTest.kt index 984e01870e..cd7596f2fc 100644 --- a/core/camera/testing/src/test/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystemTest.kt +++ b/core/camera/testing/src/test/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystemTest.kt @@ -18,6 +18,7 @@ package com.google.jetpackcamera.core.camera.testing import com.google.common.truth.Truth import com.google.jetpackcamera.core.camera.CameraState import com.google.jetpackcamera.core.camera.CameraSystem +import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS @@ -167,6 +168,15 @@ class FakeCameraSystemTest { Truth.assertThat(cameraSystem.getCurrentCameraState().value).isEqualTo(newState) } + @Test + fun setCaptureMode_updatesSettings() = runTest(testDispatcher) { + initAndRunCamera() + cameraSystem.setCaptureMode(CaptureMode.VIDEO_ONLY) + advanceUntilIdle() + Truth.assertThat(cameraSystem.getCurrentSettings().value?.captureMode) + .isEqualTo(CaptureMode.VIDEO_ONLY) + } + private fun TestScope.initAndRunCamera() { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { cameraSystem.initialize( diff --git a/core/settings/build.gradle.kts b/core/settings/build.gradle.kts index 8a5913b24b..548458c35b 100644 --- a/core/settings/build.gradle.kts +++ b/core/settings/build.gradle.kts @@ -49,4 +49,7 @@ dependencies { // Domain models implementation(project(":core:model")) + + testImplementation(libs.junit) + testImplementation(libs.truth) } diff --git a/core/settings/datastore-prefs/src/androidTest/java/com/google/jetpackcamera/core/settings/datastoreprefs/PrefsDataStoreSettingsDataSourceInstrumentedTest.kt b/core/settings/datastore-prefs/src/androidTest/java/com/google/jetpackcamera/core/settings/datastoreprefs/PrefsDataStoreSettingsDataSourceInstrumentedTest.kt index af1977f823..e68c2fdd32 100644 --- a/core/settings/datastore-prefs/src/androidTest/java/com/google/jetpackcamera/core/settings/datastoreprefs/PrefsDataStoreSettingsDataSourceInstrumentedTest.kt +++ b/core/settings/datastore-prefs/src/androidTest/java/com/google/jetpackcamera/core/settings/datastoreprefs/PrefsDataStoreSettingsDataSourceInstrumentedTest.kt @@ -34,7 +34,9 @@ import com.google.jetpackcamera.model.StabilizationMode import com.google.jetpackcamera.model.UNLIMITED_VIDEO_DURATION import com.google.jetpackcamera.model.VideoQuality import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS +import com.google.jetpackcamera.settings.model.SettingConfig import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -247,4 +249,44 @@ class PrefsDataStoreSettingsDataSourceInstrumentedTest { assertThat(initialAudioEnabled).isTrue() assertThat(newAudioEnabled).isFalse() } + + @Test + fun unconfiguredKeys_fallBackToCameraFeaturePolicyDefaults() = runTest { + val policy = CameraFeaturePolicy( + aspectRatio = SettingConfig(defaultValue = AspectRatio.ONE_ONE), + flashMode = SettingConfig(defaultValue = FlashMode.ON), + dynamicRange = SettingConfig(defaultValue = DynamicRange.HLG10), + imageFormat = SettingConfig(defaultValue = ImageOutputFormat.JPEG_ULTRA_HDR) + ) + val customSource = PrefsDataStoreSettingsDataSource( + dataStore = testDataStore, + cameraFeaturePolicy = policy + ) + val settings = customSource.getCurrentDefaultCameraAppSettings() + assertThat(settings.aspectRatio).isEqualTo(AspectRatio.ONE_ONE) + assertThat(settings.flashMode).isEqualTo(FlashMode.ON) + assertThat(settings.dynamicRange).isEqualTo(DynamicRange.HLG10) + assertThat(settings.imageFormat).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR) + } + + @Test + fun configuredKeys_preserveStoredUserPreferences() = runTest { + // User sets aspectRatio to 3:4 + dataSource.updateAspectRatio(AspectRatio.THREE_FOUR) + advanceUntilIdle() + + // Policy configures flashMode to ON, but leaves aspectRatio unconfigured + val policy = CameraFeaturePolicy( + flashMode = SettingConfig(defaultValue = FlashMode.ON) + ) + val customSource = PrefsDataStoreSettingsDataSource( + dataStore = testDataStore, + cameraFeaturePolicy = policy + ) + val settings = customSource.getCurrentDefaultCameraAppSettings() + // Stored user preference is preserved + assertThat(settings.aspectRatio).isEqualTo(AspectRatio.THREE_FOUR) + // Baseline default for unconfigured key is used + assertThat(settings.flashMode).isEqualTo(FlashMode.ON) + } } diff --git a/core/settings/datastore-prefs/src/main/java/com/google/jetpackcamera/core/settings/datastoreprefs/PrefsDataStoreSettingsDataSource.kt b/core/settings/datastore-prefs/src/main/java/com/google/jetpackcamera/core/settings/datastoreprefs/PrefsDataStoreSettingsDataSource.kt index 8c0fa53d44..c3e8996e56 100644 --- a/core/settings/datastore-prefs/src/main/java/com/google/jetpackcamera/core/settings/datastoreprefs/PrefsDataStoreSettingsDataSource.kt +++ b/core/settings/datastore-prefs/src/main/java/com/google/jetpackcamera/core/settings/datastoreprefs/PrefsDataStoreSettingsDataSource.kt @@ -35,6 +35,7 @@ import com.google.jetpackcamera.model.UNLIMITED_VIDEO_DURATION import com.google.jetpackcamera.model.VideoQuality import com.google.jetpackcamera.settings.SettingsDataSource import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -44,17 +45,20 @@ import kotlinx.coroutines.flow.map */ class PrefsDataStoreSettingsDataSource( private val dataStore: DataStore, - private val defaultCaptureModeOverride: CaptureMode + private val defaultCaptureModeOverride: CaptureMode = CaptureMode.STANDARD, + private val cameraFeaturePolicy: CameraFeaturePolicy = CameraFeaturePolicy() ) : SettingsDataSource { override val defaultCameraAppSettings: Flow = dataStore.data.map { prefs -> + val baselineDefaults = cameraFeaturePolicy.toCameraAppSettings() CameraAppSettings( cameraLensFacing = prefs[PreferenceKeys.KEY_LENS_FACING] .toEnumOrDefault(LensFacing.BACK), darkMode = prefs[PreferenceKeys.KEY_DARK_MODE].toEnumOrDefault(DarkMode.DARK), - flashMode = prefs[PreferenceKeys.KEY_FLASH_MODE].toEnumOrDefault(FlashMode.OFF), + flashMode = prefs[PreferenceKeys.KEY_FLASH_MODE] + .toEnumOrDefault(baselineDefaults.flashMode), aspectRatio = prefs[PreferenceKeys.KEY_ASPECT_RATIO] - .toEnumOrDefault(AspectRatio.NINE_SIXTEEN), + .toEnumOrDefault(baselineDefaults.aspectRatio), stabilizationMode = prefs[PreferenceKeys.KEY_STABILIZATION_MODE] .toEnumOrDefault(StabilizationMode.AUTO), targetFrameRate = prefs[PreferenceKeys.KEY_TARGET_FRAME_RATE] ?: TARGET_FPS_AUTO, @@ -64,9 +68,9 @@ class PrefsDataStoreSettingsDataSource( lowLightBoostPriority = prefs[PreferenceKeys.KEY_LOW_LIGHT_BOOST_PRIORITY] .toEnumOrDefault(LowLightBoostPriority.PRIORITIZE_AE_MODE), dynamicRange = prefs[PreferenceKeys.KEY_DYNAMIC_RANGE] - .toEnumOrDefault(DynamicRange.SDR), + .toEnumOrDefault(baselineDefaults.dynamicRange), imageFormat = prefs[PreferenceKeys.KEY_IMAGE_FORMAT] - .toEnumOrDefault(ImageOutputFormat.JPEG), + .toEnumOrDefault(baselineDefaults.imageFormat), maxVideoDurationMillis = prefs[PreferenceKeys.KEY_MAX_VIDEO_DURATION] ?: UNLIMITED_VIDEO_DURATION, videoQuality = prefs[PreferenceKeys.KEY_VIDEO_QUALITY] @@ -74,7 +78,8 @@ class PrefsDataStoreSettingsDataSource( audioEnabled = prefs[PreferenceKeys.KEY_AUDIO_ENABLED] ?: true, concurrentCameraMode = prefs[PreferenceKeys.KEY_CONCURRENT_CAMERA_MODE] .toEnumOrDefault(ConcurrentCameraMode.OFF), - captureMode = defaultCaptureModeOverride + captureMode = cameraFeaturePolicy.captureMode?.defaultValue + ?: defaultCaptureModeOverride ) } diff --git a/core/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraFeaturePolicy.kt b/core/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraFeaturePolicy.kt new file mode 100644 index 0000000000..d06af60902 --- /dev/null +++ b/core/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraFeaturePolicy.kt @@ -0,0 +1,224 @@ +/* + * 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.settings.model + +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.ImageOutputFormat + +/** + * Configuration for the Jetpack Camera App that allows developers and host applications + * to customize the default camera experience and constrain user-facing controls. + * + * Each configured setting can specify: + * 1. An initial [SettingConfig.defaultValue] applied on startup (overriding stored preferences). + * 2. A [SettingConfig.visibility] policy ([OptionVisibility]) restricting or hiding the control. + * + * ### Invariants & Safety Rules: + * - **Flash Mode:** Cannot be [OptionVisibility.Hidden] unless its default value is [FlashMode.OFF]. + * If [OptionVisibility.Only] is used, [FlashMode.OFF] must always be included. + * - **HDR Image Format & Video Dynamic Range:** When [OptionVisibility.Hidden] is used, developers may + * default or lock capture to Ultra HDR ([ImageOutputFormat.JPEG_ULTRA_HDR]) or HDR video ([DynamicRange.HLG10]). + * Hardware fallbacks to SDR will be handled at runtime if the active camera does not support HDR. + * + * ### Example: + * ```kotlin + * val featurePolicy = CameraFeaturePolicy( + * flashMode = SettingConfig( + * defaultValue = FlashMode.OFF, + * visibility = OptionVisibility.Hidden + * ), + * captureMode = SettingConfig( + * defaultValue = CaptureMode.IMAGE_ONLY, + * visibility = OptionVisibility.from(CaptureMode.IMAGE_ONLY, CaptureMode.VIDEO_ONLY) + * ) + * ) + * ``` + * + * @param captureMode Configuration for camera capture mode (e.g. Standard, Image-only, Video-only). + * @param aspectRatio Configuration for preview and capture aspect ratio. + * @param flashMode Configuration for camera flash mode. Must have defaultValue of [FlashMode.OFF] + * if [OptionVisibility.Hidden]. + * @param imageFormat Configuration for captured photo format (e.g. JPEG, Ultra HDR). + * @param dynamicRange Configuration for captured video dynamic range (e.g. SDR, HLG10). + * + * TODO (kc): Defer audioEnabled configuration to a follow-up PR, pending design for visual UX. + */ +data class CameraFeaturePolicy( + val captureMode: SettingConfig? = null, + val aspectRatio: SettingConfig? = null, + val flashMode: SettingConfig? = null, + val imageFormat: SettingConfig? = null, + val dynamicRange: SettingConfig? = null +) { + // Ensures that all individual setting configurations are valid. + init { + flashMode?.let { config -> + when (val visibility = config.visibility) { + is OptionVisibility.Only -> require( + FlashMode.OFF in visibility.enabledOptions + ) { + "FlashMode.OFF must always be included in enabledOptions for flashMode." + } + + is OptionVisibility.Hidden -> require( + config.defaultValue == FlashMode.OFF + ) { + "When flashMode is Hidden, defaultValue must be FlashMode.OFF." + } + + is OptionVisibility.Visible -> Unit + } + } + } + + /** + * Generates a baseline [CameraAppSettings] with developer-defined default values applied. + * + * Used by the settings storage layer to establish baseline defaults when preferences are unconfigured, + * and in testing environments. + * + * @param defaultSettings The baseline settings to apply overrides onto. Defaults to [DEFAULT_CAMERA_APP_SETTINGS]. + * @return A merged [CameraAppSettings] with developer-specified default values applied. + */ + fun toCameraAppSettings( + defaultSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS + ): CameraAppSettings { + return defaultSettings.copy( + aspectRatio = this.aspectRatio?.defaultValue ?: defaultSettings.aspectRatio, + flashMode = this.flashMode?.defaultValue ?: defaultSettings.flashMode, + captureMode = this.captureMode?.defaultValue ?: defaultSettings.captureMode, + imageFormat = this.imageFormat?.defaultValue ?: defaultSettings.imageFormat, + dynamicRange = this.dynamicRange?.defaultValue ?: defaultSettings.dynamicRange + ) + } +} + +/** + * Represents a single configurable setting in the application, including its + * default value and UI visibility policy. + * + * @param T The enum or model type representing the setting's values (e.g. [FlashMode], [CaptureMode]). + * @property defaultValue The initial value for this setting applied on launch. + * @property visibility The UI visibility and option restriction policy for this setting. + * Defaults to [OptionVisibility.Visible]. + * @throws IllegalArgumentException if [visibility] is [OptionVisibility.Only] and [defaultValue] + * is not present in [OptionVisibility.Only.enabledOptions]. + */ +data class SettingConfig @JvmOverloads constructor( + val defaultValue: T, + val visibility: OptionVisibility = OptionVisibility.Visible +) { + init { + // Validate that if options are enabled for this setting, the default value + // is always included in the set of enabled options. + if (visibility is OptionVisibility.Only) { + require(defaultValue in visibility.enabledOptions) { + "The defaultValue ('$defaultValue') must be one of the enabledOptions: " + + "${visibility.enabledOptions}" + } + } + } +} + +/** + * Defines the UI visibility and option availability policy applied to a camera setting. + * + * Use [Visible] to permit all device-supported options, [Hidden] to lock a setting to its default + * value and hide the control, or [Only] to restrict the UI to a subset of options. + * + * When creating option restrictions, prefer using [OptionVisibility.from] for safe instantiation + * that automatically falls back to [Hidden] if fewer than 2 options are available. + * + * @param T The type of setting options governed by this policy. + * @see OptionVisibility.from + */ +sealed interface OptionVisibility { + /** All device-supported options are visible and selectable in the UI. */ + data object Visible : OptionVisibility + + /** + * The setting is completely hidden and inaccessible in the user interface. + * + * Note: The setting remains active and locked to its configured [SettingConfig.defaultValue]. + * + * @see OptionVisibility.Only + */ + data object Hidden : OptionVisibility + + /** + * Restricts the user interface to display only the specified subset of [enabledOptions], + * provided they are supported by the current device hardware. + * + * @property enabledOptions The permitted options. Must contain at least 2 options and must + * include the setting's [SettingConfig.defaultValue]. If only a single option is desired, + * use [Hidden] with that default value instead, or use [OptionVisibility.from] to safely + * fall back to [Hidden] when options are computed dynamically. + * @throws IllegalArgumentException if [enabledOptions] contains fewer than 2 items. + * @see OptionVisibility.from + * @see OptionVisibility.Hidden + */ + data class Only(val enabledOptions: Set) : OptionVisibility { + /** + * Creates an [Only] policy from vararg [options]. + * + * Note: Prefer [OptionVisibility.from] if [options] may contain fewer than 2 items at runtime. + * + * @param options The permitted options. Must contain at least 2 unique items. + * @throws IllegalArgumentException if fewer than 2 unique options are provided. + * @see OptionVisibility.from + */ + constructor(vararg options: T) : this(options.toSet()) + + init { + require(enabledOptions.size >= 2) { + "enabledOptions must contain at least 2 options. Use Hidden to lock a single option and hide the control." + } + } + } + + companion object { + /** + * Safely creates an [OptionVisibility] policy from the specified [options]. + * + * - **2 or more options (`size >= 2`):** Returns [OptionVisibility.Only] containing the specified options. + * - **Single option or empty set (`size < 2`):** Always resolves to [OptionVisibility.Hidden] rather than + * throwing an [IllegalArgumentException]. When resolved to [Hidden], the control is hidden from + * the user interface and locked to the setting's configured [SettingConfig.defaultValue]. + * + * This factory function is recommended when options are filtered or resolved dynamically at runtime + * (e.g. against remote flags or device capabilities) to avoid runtime crash traps. + * + * @see OptionVisibility.Only + * @see OptionVisibility.Hidden + */ + fun from(options: Set): OptionVisibility = + if (options.size >= 2) Only(options) else Hidden + + /** + * Safely creates an [OptionVisibility] policy from vararg [options]. + * + * Resolves to [OptionVisibility.Only] if 2 or more options are provided, or [OptionVisibility.Hidden] + * if 0 or 1 option is provided. + * + * @see OptionVisibility.Only + * @see OptionVisibility.Hidden + */ + fun from(vararg options: T): OptionVisibility = from(options.toSet()) + } +} diff --git a/core/settings/src/test/java/com/google/jetpackcamera/settings/model/CameraFeaturePolicyTest.kt b/core/settings/src/test/java/com/google/jetpackcamera/settings/model/CameraFeaturePolicyTest.kt new file mode 100644 index 0000000000..1067aafbb2 --- /dev/null +++ b/core/settings/src/test/java/com/google/jetpackcamera/settings/model/CameraFeaturePolicyTest.kt @@ -0,0 +1,254 @@ +/* + * 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.settings.model + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.ImageOutputFormat +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class CameraFeaturePolicyTest { + + @Test + fun settingConfig_whenOptionVisibilityOnlyMissingDefaultValue_throwsException() { + assertThrows(IllegalArgumentException::class.java) { + SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Only( + setOf(FlashMode.ON, FlashMode.AUTO) + ) + ) + } + } + + @Test + fun settingConfig_whenOptionVisibilityOnlyHasSingleOption_throwsException() { + assertThrows(IllegalArgumentException::class.java) { + SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Only( + setOf(FlashMode.OFF) + ) + ) + } + } + + @Test + fun optionVisibilityOnly_whenLessThanTwoOptions_throwsException() { + assertThrows(IllegalArgumentException::class.java) { + OptionVisibility.Only(emptySet()) + } + assertThrows(IllegalArgumentException::class.java) { + OptionVisibility.Only(setOf(FlashMode.OFF)) + } + } + + @Test + fun optionVisibility_from_withTwoOrMoreOptions_returnsOnly() { + val visibility = OptionVisibility.from(FlashMode.OFF, FlashMode.ON) + assertThat(visibility).isEqualTo( + OptionVisibility.Only(setOf(FlashMode.OFF, FlashMode.ON)) + ) + } + + @Test + fun optionVisibility_from_withSingleOption_returnsHidden() { + val visibility = OptionVisibility.from(FlashMode.OFF) + assertThat(visibility).isEqualTo(OptionVisibility.Hidden) + } + + @Test + fun optionVisibility_from_withEmptySet_returnsHidden() { + val visibility = OptionVisibility.from(emptySet()) + assertThat(visibility).isEqualTo(OptionVisibility.Hidden) + } + + @Test + fun cameraFeaturePolicy_defaultConstructor_allPropertiesNull() { + val config = CameraFeaturePolicy() + assertThat(config.aspectRatio).isNull() + assertThat(config.flashMode).isNull() + assertThat(config.captureMode).isNull() + assertThat(config.imageFormat).isNull() + assertThat(config.dynamicRange).isNull() + } + + @Test + fun cameraFeaturePolicy_whenFlashModeExcludesOff_throwsException() { + assertThrows(IllegalArgumentException::class.java) { + CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.ON, + visibility = OptionVisibility.Only( + setOf(FlashMode.ON, FlashMode.AUTO) + ) + ) + ) + } + } + + @Test + fun cameraFeaturePolicy_whenFlashModeHiddenAndNotOff_throwsException() { + assertThrows(IllegalArgumentException::class.java) { + CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.ON, + visibility = OptionVisibility.Hidden + ) + ) + } + } + + @Test + fun cameraFeaturePolicy_whenFlashModeHiddenAndOff_succeeds() { + val config = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Hidden + ) + ) + assertThat(config.flashMode?.defaultValue).isEqualTo(FlashMode.OFF) + assertThat(config.flashMode?.visibility).isEqualTo(OptionVisibility.Hidden) + } + + @Test + fun cameraFeaturePolicy_whenImageFormatHiddenAndNotJpeg_succeeds() { + val config = CameraFeaturePolicy( + imageFormat = SettingConfig( + defaultValue = ImageOutputFormat.JPEG_ULTRA_HDR, + visibility = OptionVisibility.Hidden + ) + ) + assertThat(config.imageFormat?.defaultValue).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR) + assertThat(config.imageFormat?.visibility).isEqualTo(OptionVisibility.Hidden) + } + + @Test + fun cameraFeaturePolicy_whenImageFormatHiddenAndJpeg_succeeds() { + val config = CameraFeaturePolicy( + imageFormat = SettingConfig( + defaultValue = ImageOutputFormat.JPEG, + visibility = OptionVisibility.Hidden + ) + ) + assertThat(config.imageFormat?.defaultValue).isEqualTo(ImageOutputFormat.JPEG) + assertThat(config.imageFormat?.visibility).isEqualTo(OptionVisibility.Hidden) + } + + @Test + fun cameraFeaturePolicy_whenDynamicRangeHiddenAndNotSdr_succeeds() { + val config = CameraFeaturePolicy( + dynamicRange = SettingConfig( + defaultValue = DynamicRange.HLG10, + visibility = OptionVisibility.Hidden + ) + ) + assertThat(config.dynamicRange?.defaultValue).isEqualTo(DynamicRange.HLG10) + assertThat(config.dynamicRange?.visibility).isEqualTo(OptionVisibility.Hidden) + } + + @Test + fun cameraFeaturePolicy_whenDynamicRangeHiddenAndSdr_succeeds() { + val config = CameraFeaturePolicy( + dynamicRange = SettingConfig( + defaultValue = DynamicRange.SDR, + visibility = OptionVisibility.Hidden + ) + ) + assertThat(config.dynamicRange?.defaultValue).isEqualTo(DynamicRange.SDR) + assertThat(config.dynamicRange?.visibility).isEqualTo(OptionVisibility.Hidden) + } + + @Test + fun settingConfig_structuralEquality_matches() { + val config1 = SettingConfig(FlashMode.OFF, OptionVisibility.Visible) + val config2 = SettingConfig(FlashMode.OFF, OptionVisibility.Visible) + assertThat(config1).isEqualTo(config2) + + val policy1 = CameraFeaturePolicy() + val policy2 = CameraFeaturePolicy() + assertThat(policy1).isEqualTo(policy2) + } + + @Test + fun toCameraAppSettings_overridesDefaults() { + val developerConfig = CameraFeaturePolicy( + aspectRatio = SettingConfig(AspectRatio.THREE_FOUR), + flashMode = SettingConfig(FlashMode.ON), + captureMode = SettingConfig(CaptureMode.VIDEO_ONLY), + imageFormat = SettingConfig(ImageOutputFormat.JPEG_ULTRA_HDR), + dynamicRange = SettingConfig(DynamicRange.HLG10) + ) + + val appSettings = developerConfig.toCameraAppSettings() + + assertThat(appSettings.aspectRatio).isEqualTo(AspectRatio.THREE_FOUR) + assertThat(appSettings.flashMode).isEqualTo(FlashMode.ON) + assertThat(appSettings.captureMode).isEqualTo(CaptureMode.VIDEO_ONLY) + assertThat(appSettings.imageFormat).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR) + assertThat(appSettings.dynamicRange).isEqualTo(DynamicRange.HLG10) + } + + @Test + fun toCameraAppSettings_withCustomDefaults_preservesUnoverriddenSettings() { + val customDefaults = DEFAULT_CAMERA_APP_SETTINGS.copy( + aspectRatio = AspectRatio.ONE_ONE, + flashMode = FlashMode.AUTO, + imageFormat = ImageOutputFormat.JPEG_ULTRA_HDR, + dynamicRange = DynamicRange.HLG10, + maxVideoDurationMillis = 60_000L + ) + val developerConfig = CameraFeaturePolicy( + captureMode = SettingConfig(CaptureMode.VIDEO_ONLY) + ) + + val appSettings = developerConfig.toCameraAppSettings(customDefaults) + + assertThat(appSettings.captureMode).isEqualTo(CaptureMode.VIDEO_ONLY) + assertThat(appSettings.aspectRatio).isEqualTo(AspectRatio.ONE_ONE) + assertThat(appSettings.flashMode).isEqualTo(FlashMode.AUTO) + assertThat(appSettings.imageFormat).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR) + assertThat(appSettings.dynamicRange).isEqualTo(DynamicRange.HLG10) + assertThat(appSettings.maxVideoDurationMillis).isEqualTo(60_000L) + } + + @Test + fun cameraFeaturePolicy_whenFlashModeOnlyIncludesOff_succeeds() { + val config = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Only(FlashMode.OFF, FlashMode.ON) + ) + ) + assertThat(config.flashMode?.defaultValue).isEqualTo(FlashMode.OFF) + assertThat(config.flashMode?.visibility).isEqualTo( + OptionVisibility.Only(setOf(FlashMode.OFF, FlashMode.ON)) + ) + } + + @Test + fun toCameraAppSettings_withDefaultConfig_returnsDefaultSettings() { + val config = CameraFeaturePolicy() + assertThat(config.toCameraAppSettings()).isEqualTo(DEFAULT_CAMERA_APP_SETTINGS) + } +} diff --git a/data/camera/src/test/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepositoryTest.kt b/data/camera/src/test/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepositoryTest.kt index 9cacd70610..01b4f89c94 100644 --- a/data/camera/src/test/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepositoryTest.kt +++ b/data/camera/src/test/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepositoryTest.kt @@ -18,11 +18,15 @@ package com.google.jetpackcamera.data.camera import com.google.common.truth.Truth.assertThat import com.google.jetpackcamera.core.camera.CameraSystem import com.google.jetpackcamera.core.camera.testing.FakeCameraSystem +import com.google.jetpackcamera.model.AspectRatio import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.DebugSettings import com.google.jetpackcamera.model.ExternalCaptureMode +import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy import com.google.jetpackcamera.settings.model.CameraSystemConstraints +import com.google.jetpackcamera.settings.model.SettingConfig import com.google.jetpackcamera.settings.testing.FakeSettingsRepository import javax.inject.Provider import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -109,4 +113,48 @@ class CameraXCameraSystemRepositoryTest { assertThat(mimeTypes).isNotNull() assertThat(testCamera.initializedSettings).isNotNull() } + + @Test + fun getCameraSystem_withCameraFeaturePolicy_appliesDefaultValues() = testScope.runTest { + val testCamera = TestCameraSystem() + val policy = CameraFeaturePolicy( + flashMode = SettingConfig(defaultValue = FlashMode.ON), + aspectRatio = SettingConfig(defaultValue = AspectRatio.ONE_ONE), + captureMode = SettingConfig(defaultValue = CaptureMode.STANDARD) + ) + val repository = CameraXCameraSystemRepository( + cameraXCameraSystemProvider = Provider { testCamera }, + settingsRepository = FakeSettingsRepository(cameraFeaturePolicy = policy), + launchConfig = CameraLaunchConfig(), + scope = testScope + ) + + repository.getCameraSystem() + assertThat(testCamera.initializedSettings?.flashMode).isEqualTo(FlashMode.ON) + assertThat(testCamera.initializedSettings?.aspectRatio).isEqualTo(AspectRatio.ONE_ONE) + assertThat(testCamera.initializedSettings?.captureMode).isEqualTo(CaptureMode.STANDARD) + } + + @Test + fun getCameraSystem_withVideoOnlyCaptureMode_initializesWithNineSixteenAspectRatio() = + testScope.runTest { + val testCamera = TestCameraSystem() + val policy = CameraFeaturePolicy( + captureMode = SettingConfig(defaultValue = CaptureMode.VIDEO_ONLY) + ) + val repository = CameraXCameraSystemRepository( + cameraXCameraSystemProvider = Provider { testCamera }, + settingsRepository = FakeSettingsRepository(cameraFeaturePolicy = policy), + launchConfig = CameraLaunchConfig(), + scope = testScope + ) + + repository.getCameraSystem() + assertThat( + testCamera.initializedSettings?.captureMode + ).isEqualTo(CaptureMode.VIDEO_ONLY) + assertThat( + testCamera.initializedSettings?.aspectRatio + ).isEqualTo(AspectRatio.NINE_SIXTEEN) + } } diff --git a/data/settings/build.gradle.kts b/data/settings/build.gradle.kts index 2b90e40fad..a2ea4d1356 100644 --- a/data/settings/build.gradle.kts +++ b/data/settings/build.gradle.kts @@ -81,6 +81,8 @@ dependencies { // Testing testImplementation(libs.junit) testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(project(":data:settings:testing")) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.truth) diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt index 4d20f53cc8..99ec176cd9 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt @@ -27,21 +27,29 @@ import com.google.jetpackcamera.model.LowLightBoostPriority import com.google.jetpackcamera.model.StabilizationMode import com.google.jetpackcamera.model.VideoQuality import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.SettingConfig import javax.inject.Inject import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map /** * Implementation of [SettingsRepository] delegating to [SettingsDataSource]. */ class LocalSettingsRepository @Inject constructor( - private val settingsDataSource: SettingsDataSource + private val settingsDataSource: SettingsDataSource, + private val cameraFeaturePolicy: CameraFeaturePolicy = CameraFeaturePolicy() ) : SettingsRepository { override val defaultCameraAppSettings: Flow = - settingsDataSource.defaultCameraAppSettings + settingsDataSource.defaultCameraAppSettings.map { storedSettings -> + applyFeaturePolicy(storedSettings, cameraFeaturePolicy) + } override suspend fun getCurrentDefaultCameraAppSettings(): CameraAppSettings = - settingsDataSource.getCurrentDefaultCameraAppSettings() + defaultCameraAppSettings.first() override suspend fun updateDefaultLensFacing(lensFacing: LensFacing) { settingsDataSource.updateDefaultLensFacing(lensFacing) @@ -98,4 +106,28 @@ class LocalSettingsRepository @Inject constructor( override suspend fun updateConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) { settingsDataSource.updateConcurrentCameraMode(concurrentCameraMode) } + + private fun applyFeaturePolicy( + storedSettings: CameraAppSettings, + policy: CameraFeaturePolicy + ): CameraAppSettings { + return storedSettings.copy( + aspectRatio = enforceRestrictions(storedSettings.aspectRatio, policy.aspectRatio), + flashMode = enforceRestrictions(storedSettings.flashMode, policy.flashMode), + imageFormat = enforceRestrictions(storedSettings.imageFormat, policy.imageFormat), + dynamicRange = enforceRestrictions(storedSettings.dynamicRange, policy.dynamicRange), + captureMode = enforceRestrictions(storedSettings.captureMode, policy.captureMode) + ) + } + + private fun enforceRestrictions(currentValue: T, config: SettingConfig?): T { + if (config == null) return currentValue + return when (val visibility = config.visibility) { + is OptionVisibility.Hidden -> config.defaultValue + is OptionVisibility.Only -> { + if (currentValue in visibility.enabledOptions) currentValue else config.defaultValue + } + is OptionVisibility.Visible -> currentValue + } + } } diff --git a/data/settings/src/test/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryTest.kt b/data/settings/src/test/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryTest.kt new file mode 100644 index 0000000000..10caca16f5 --- /dev/null +++ b/data/settings/src/test/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryTest.kt @@ -0,0 +1,174 @@ +/* + * 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.settings + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.SettingConfig +import com.google.jetpackcamera.settings.testing.FakeSettingsDataSource +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class LocalSettingsRepositoryTest { + + @Test + fun unconfiguredSettingInPolicy_preservesStoredUserPreference() = runTest { + // User previously saved 3:4 aspect ratio preference + val storedSettings = CameraAppSettings( + aspectRatio = AspectRatio.THREE_FOUR, + flashMode = FlashMode.ON + ) + val dataSource = FakeSettingsDataSource(initialSettings = storedSettings) + + // Policy only restricts flash mode to Hidden (OFF), leaving aspect ratio unconfigured + val policy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Hidden + ) + ) + val repository = LocalSettingsRepository(dataSource, policy) + + val current = repository.defaultCameraAppSettings.first() + // Stored user aspect ratio must be preserved, not overwritten by global default (16:9) + assertThat(current.aspectRatio).isEqualTo(AspectRatio.THREE_FOUR) + // Restricted flash mode enforces the policy + assertThat(current.flashMode).isEqualTo(FlashMode.OFF) + } + + @Test + fun hiddenSetting_enforcesDeveloperDefault() = runTest { + // User had FlashMode.ON stored + val storedSettings = CameraAppSettings(flashMode = FlashMode.ON) + val dataSource = FakeSettingsDataSource(initialSettings = storedSettings) + + // Policy hides flash mode and defaults to OFF + val policy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Hidden + ) + ) + val repository = LocalSettingsRepository(dataSource, policy) + + val current = repository.defaultCameraAppSettings.first() + assertThat(current.flashMode).isEqualTo(FlashMode.OFF) + } + + @Test + fun onlySetting_whenStoredValueConflicts_fallsBackToDeveloperDefault() = runTest { + // User had FlashMode.ON stored + val storedSettings = CameraAppSettings(flashMode = FlashMode.ON) + val dataSource = FakeSettingsDataSource(initialSettings = storedSettings) + + // Policy allows only OFF and AUTO, defaulting to OFF + val policy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Only(setOf(FlashMode.OFF, FlashMode.AUTO)) + ) + ) + val repository = LocalSettingsRepository(dataSource, policy) + + val current = repository.defaultCameraAppSettings.first() + // FlashMode.ON is not in {OFF, AUTO}, so it falls back to developer default (OFF) + assertThat(current.flashMode).isEqualTo(FlashMode.OFF) + } + + @Test + fun onlySetting_whenStoredValuePermitted_preservesUserPreference() = runTest { + // User had FlashMode.AUTO stored + val storedSettings = CameraAppSettings(flashMode = FlashMode.AUTO) + val dataSource = FakeSettingsDataSource(initialSettings = storedSettings) + + // Policy allows only OFF and AUTO, defaulting to OFF + val policy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Only(setOf(FlashMode.OFF, FlashMode.AUTO)) + ) + ) + val repository = LocalSettingsRepository(dataSource, policy) + + val current = repository.defaultCameraAppSettings.first() + // FlashMode.AUTO is permitted in {OFF, AUTO}, so user preference is preserved + assertThat(current.flashMode).isEqualTo(FlashMode.AUTO) + } + + @Test + fun getCurrentDefaultCameraAppSettings_enforcesPolicy() = runTest { + val storedSettings = CameraAppSettings(flashMode = FlashMode.ON) + val dataSource = FakeSettingsDataSource(initialSettings = storedSettings) + val policy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Hidden + ) + ) + val repository = LocalSettingsRepository(dataSource, policy) + + val snapshot = repository.getCurrentDefaultCameraAppSettings() + assertThat(snapshot.flashMode).isEqualTo(FlashMode.OFF) + } + + @Test + fun dynamicUpdates_continueToBeEnforcedByPolicy() = runTest { + val dataSource = FakeSettingsDataSource() + val policy = CameraFeaturePolicy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Hidden + ) + ) + val repository = LocalSettingsRepository(dataSource, policy) + + // Attempt to update flash mode while policy has it Hidden + repository.updateFlashModeStatus(FlashMode.ON) + + val current = repository.defaultCameraAppSettings.first() + assertThat(current.flashMode).isEqualTo(FlashMode.OFF) + } + + @Test + fun aspectRatioOnly_whenStoredValueConflicts_fallsBackToDeveloperDefault() = runTest { + // User had AspectRatio.THREE_FOUR stored + val storedSettings = CameraAppSettings(aspectRatio = AspectRatio.THREE_FOUR) + val dataSource = FakeSettingsDataSource(initialSettings = storedSettings) + + // Policy allows only 1:1 and 9:16, defaulting to 1:1 + val policy = CameraFeaturePolicy( + aspectRatio = SettingConfig( + defaultValue = AspectRatio.ONE_ONE, + visibility = OptionVisibility.Only( + setOf(AspectRatio.ONE_ONE, AspectRatio.NINE_SIXTEEN) + ) + ) + ) + val repository = LocalSettingsRepository(dataSource, policy) + + val current = repository.defaultCameraAppSettings.first() + // THREE_FOUR is not in {1:1, 9:16}, so falls back to developer default (1:1) + assertThat(current.aspectRatio).isEqualTo(AspectRatio.ONE_ONE) + } +} diff --git a/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeSettingsDataSource.kt b/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeSettingsDataSource.kt new file mode 100644 index 0000000000..ee41a909b0 --- /dev/null +++ b/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeSettingsDataSource.kt @@ -0,0 +1,114 @@ +/* + * 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.settings.testing + +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CameraEffectId +import com.google.jetpackcamera.model.ConcurrentCameraMode +import com.google.jetpackcamera.model.DarkMode +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.ImageOutputFormat +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.model.LowLightBoostPriority +import com.google.jetpackcamera.model.StabilizationMode +import com.google.jetpackcamera.model.VideoQuality +import com.google.jetpackcamera.settings.SettingsDataSource +import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeSettingsDataSource( + initialSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS +) : SettingsDataSource { + private val _defaultCameraAppSettings = MutableStateFlow(initialSettings) + override val defaultCameraAppSettings: Flow = + _defaultCameraAppSettings.asStateFlow() + + override suspend fun getCurrentDefaultCameraAppSettings() = _defaultCameraAppSettings.value + + override suspend fun updateDefaultLensFacing(lensFacing: LensFacing) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(cameraLensFacing = lensFacing) + } + + override suspend fun updateDarkModeStatus(darkMode: DarkMode) { + _defaultCameraAppSettings.value = _defaultCameraAppSettings.value.copy(darkMode = darkMode) + } + + override suspend fun updateFlashModeStatus(flashMode: FlashMode) { + _defaultCameraAppSettings.value = _defaultCameraAppSettings.value.copy( + flashMode = flashMode + ) + } + + override suspend fun updateSelectedCameraEffect(selectedCameraEffect: CameraEffectId) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(selectedCameraEffect = selectedCameraEffect) + } + + override suspend fun updateLowLightBoostPriority(lowLightBoostPriority: LowLightBoostPriority) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(lowLightBoostPriority = lowLightBoostPriority) + } + + override suspend fun updateStabilizationMode(stabilizationMode: StabilizationMode) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(stabilizationMode = stabilizationMode) + } + + override suspend fun updateDynamicRange(dynamicRange: DynamicRange) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(dynamicRange = dynamicRange) + } + + override suspend fun updateAspectRatio(aspectRatio: AspectRatio) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(aspectRatio = aspectRatio) + } + + override suspend fun updateTargetFrameRate(targetFrameRate: Int) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(targetFrameRate = targetFrameRate) + } + + override suspend fun updateImageFormat(imageFormat: ImageOutputFormat) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(imageFormat = imageFormat) + } + + override suspend fun updateMaxVideoDuration(durationMillis: Long) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(maxVideoDurationMillis = durationMillis) + } + + override suspend fun updateVideoQuality(videoQuality: VideoQuality) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(videoQuality = videoQuality) + } + + override suspend fun updateAudioEnabled(isAudioEnabled: Boolean) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(audioEnabled = isAudioEnabled) + } + + override suspend fun updateConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) { + _defaultCameraAppSettings.value = + _defaultCameraAppSettings.value.copy(concurrentCameraMode = concurrentCameraMode) + } +} diff --git a/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeSettingsRepository.kt b/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeSettingsRepository.kt index 805665c096..987af8a636 100644 --- a/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeSettingsRepository.kt +++ b/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeSettingsRepository.kt @@ -28,6 +28,7 @@ import com.google.jetpackcamera.model.StabilizationMode import com.google.jetpackcamera.model.VideoQuality import com.google.jetpackcamera.settings.SettingsRepository import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -36,6 +37,11 @@ import kotlinx.coroutines.flow.asStateFlow class FakeSettingsRepository( initialSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS ) : SettingsRepository { + + constructor( + cameraFeaturePolicy: CameraFeaturePolicy, + initialSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS + ) : this(cameraFeaturePolicy.toCameraAppSettings(initialSettings)) private val _defaultCameraAppSettings = MutableStateFlow(initialSettings) override val defaultCameraAppSettings: Flow = _defaultCameraAppSettings.asStateFlow() diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index e2054c8818..95107acea4 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -124,6 +124,7 @@ import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomControlUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch @@ -601,14 +602,18 @@ private fun ContentScreen( } } + // TODO: When QuickSettingsUiState is Unavailable, coordinate with optional onNavigateToSettings + // if a direct navigation path to settings from the toggle button is desired. val quickSettingsButtonLambda = remember( isVideoRecordingActive, isQuickSettingsOpen, + quickSettingsState, scaffoldState.bottomSheetState, scope ) { @Composable { modifier: Modifier -> - val isQuickSettingsVisible = !isVideoRecordingActive.value + val isQuickSettingsVisible = !isVideoRecordingActive.value && + quickSettingsState.value is QuickSettingsUiState.Available val disableAnimations = LocalDisableAnimations.current AnimatedVisibility( visible = isQuickSettingsVisible, diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt index 7126cb9bfd..1eb56d733b 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt @@ -41,6 +41,7 @@ import com.google.jetpackcamera.model.VideoCaptureEvent import com.google.jetpackcamera.settings.SettableConstraintsRepository import com.google.jetpackcamera.settings.SettingsRepository import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy import com.google.jetpackcamera.ui.components.capture.R import com.google.jetpackcamera.ui.controller.CameraController import com.google.jetpackcamera.ui.controller.CaptureController @@ -91,6 +92,7 @@ class PreviewViewModel @Inject constructor( private val cameraSystemRepository: CameraSystemRepository, private val savedStateHandle: SavedStateHandle, private val defaultSaveMode: SaveMode, + private val cameraFeaturePolicy: CameraFeaturePolicy = CameraFeaturePolicy(), private val settingsRepository: SettingsRepository, private val constraintsRepository: SettableConstraintsRepository, private val mediaRepository: MediaRepository @@ -124,6 +126,7 @@ class PreviewViewModel @Inject constructor( val captureUiState: StateFlow = captureUiState( currentSettings = cameraSystemRepository.currentSettings, + cameraFeaturePolicy = cameraFeaturePolicy, systemConstraints = constraintsRepository.systemConstraints, currentCameraState = cameraSystemRepository.currentCameraState, trackedCaptureUiState = trackedCaptureUiState, diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/navigation/PreviewNavigation.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/navigation/PreviewNavigation.kt index 1021d6e685..54410af1ff 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/navigation/PreviewNavigation.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/navigation/PreviewNavigation.kt @@ -20,6 +20,7 @@ import android.net.Uri import android.os.Build import androidx.compose.animation.fadeIn import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController @@ -61,7 +62,7 @@ fun NavController.navigateToPreview( externalCaptureMode: ExternalCaptureMode? = null, captureUris: List? = null, debugSettings: DebugSettings? = null, - saveMode: Boolean? = null, + shouldReviewAfterCapture: Boolean? = null, builder: (NavOptionsBuilder.() -> Unit) = {} ) { var route = BASE_ROUTE_DEF // Start with the base route @@ -76,7 +77,7 @@ fun NavController.navigateToPreview( ).serializeAsValue(it)}" ) } - saveMode?.let { + shouldReviewAfterCapture?.let { queryParams.add( "${ARG_REVIEW_AFTER_CAPTURE}=${ NavType.BoolType.serializeAsValue(it)}" @@ -138,19 +139,23 @@ fun NavGraphBuilder.previewScreen( enterTransition = { fadeIn() } ) { val permissionStates = rememberMultiplePermissionsState( - permissions = - buildList { - add(Manifest.permission.CAMERA) - add(Manifest.permission.RECORD_AUDIO) - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - add(Manifest.permission.WRITE_EXTERNAL_STORAGE) - add(Manifest.permission.READ_EXTERNAL_STORAGE) + remember { + buildList { + add(Manifest.permission.CAMERA) + add(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + add(Manifest.permission.READ_EXTERNAL_STORAGE) + } } } ) // Automatically navigate to permissions screen when camera permission revoked - LaunchedEffect(key1 = permissionStates.permissions[0].status) { - if (!permissionStates.permissions[0].status.isGranted) { + val cameraPermissionStatus = permissionStates.permissions.firstOrNull { + it.permission == Manifest.permission.CAMERA + }?.status + LaunchedEffect(key1 = cameraPermissionStatus) { + if (cameraPermissionStatus?.isGranted == false) { onNavigateToPermissions() } } diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt index f404486bae..0a79c0be81 100644 --- a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt +++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt @@ -24,16 +24,28 @@ import com.google.jetpackcamera.core.camera.CameraSystem import com.google.jetpackcamera.core.camera.testing.FakeCameraSystem import com.google.jetpackcamera.data.camera.CameraSystemRepository import com.google.jetpackcamera.data.media.testing.FakeMediaRepository +import com.google.jetpackcamera.feature.preview.navigation.PreviewRoute +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.SaveMode import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy +import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.SettingConfig import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS +import com.google.jetpackcamera.settings.model.applyExternalCaptureMode import com.google.jetpackcamera.settings.testing.FakeSettingsRepository +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeUiState import com.google.jetpackcamera.ui.uistate.capture.FlashModeUiState import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -41,8 +53,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -52,8 +66,10 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class PreviewViewModelTest { - private val cameraSystem = FakeCameraSystem() - private val cameraSystemRepository = object : CameraSystemRepository { + private fun createFakeCameraSystemRepository( + cameraSystem: FakeCameraSystem, + externalCaptureMode: ExternalCaptureMode = ExternalCaptureMode.Standard + ) = object : CameraSystemRepository { override val surfaceRequest = cameraSystem.getSurfaceRequest() override val systemConstraints = cameraSystem.getSystemConstraints() override val currentSettings = cameraSystem.getCurrentSettings() @@ -61,14 +77,26 @@ class PreviewViewModelTest { override val cameraPropertiesJSON: StateFlow = MutableStateFlow(null) override suspend fun getCameraSystem(): CameraSystem { - cameraSystem.initialize(CameraAppSettings()) {} + cameraSystem.initialize( + CameraAppSettings().applyExternalCaptureMode(externalCaptureMode) + ) {} return cameraSystem } override suspend fun getSupportedMimeTypes(): List = emptyList() } + + private val cameraSystem = FakeCameraSystem() + private val cameraSystemRepository = createFakeCameraSystemRepository(cameraSystem) private val constraintsRepository = SettableConstraintsRepositoryImpl().apply { updateSystemConstraints(TYPICAL_SYSTEM_CONSTRAINTS) } + private val defaultTestPolicy = CameraFeaturePolicy( + aspectRatio = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.aspectRatio), + flashMode = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.flashMode), + captureMode = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.captureMode), + imageFormat = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.imageFormat), + dynamicRange = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.dynamicRange) + ) private lateinit var previewViewModel: PreviewViewModel @Before @@ -80,11 +108,17 @@ class PreviewViewModelTest { settingsRepository = FakeSettingsRepository(), mediaRepository = FakeMediaRepository(), savedStateHandle = SavedStateHandle(), - defaultSaveMode = SaveMode.Immediate + defaultSaveMode = SaveMode.Immediate, + cameraFeaturePolicy = defaultTestPolicy ) advanceUntilIdle() } + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun getPreviewUiState() = runTest(StandardTestDispatcher()) { startCameraUntilRunning() @@ -183,8 +217,126 @@ class PreviewViewModelTest { assertThat(cameraSystem.isLensFacingFront).isTrue() } - private fun TestScope.startCameraUntilRunning() { - previewViewModel.cameraController.startCamera() + @Test + fun captureUiState_withRestrictedPolicy_appliesRestrictions() = + runTest(StandardTestDispatcher()) { + val restrictedPolicy = defaultTestPolicy.copy( + captureMode = SettingConfig( + defaultValue = CaptureMode.IMAGE_ONLY, + visibility = OptionVisibility.Hidden + ) + ) + val viewModel = PreviewViewModel( + cameraSystemRepository = cameraSystemRepository, + constraintsRepository = constraintsRepository, + settingsRepository = FakeSettingsRepository(), + mediaRepository = FakeMediaRepository(), + savedStateHandle = SavedStateHandle(), + defaultSaveMode = SaveMode.Immediate, + cameraFeaturePolicy = restrictedPolicy + ) + advanceUntilIdle() + startCameraUntilRunning(viewModel) + + val uiState = viewModel.captureUiState.value + assertThat(uiState).isInstanceOf(CaptureUiState.Ready::class.java) + val readyState = uiState as CaptureUiState.Ready + val quickSettings = readyState.quickSettingsUiState as QuickSettingsUiState.Available + assertThat( + quickSettings.captureModeUiState + ).isInstanceOf(CaptureModeUiState.Unavailable::class.java) + assertThat(readyState.captureModeToggleUiState) + .isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun captureUiState_withDefaultPolicy_doesNotApplyRestrictions() = + runTest(StandardTestDispatcher()) { + val viewModel = PreviewViewModel( + cameraSystemRepository = cameraSystemRepository, + constraintsRepository = constraintsRepository, + settingsRepository = FakeSettingsRepository(), + mediaRepository = FakeMediaRepository(), + savedStateHandle = SavedStateHandle(), + defaultSaveMode = SaveMode.Immediate, + cameraFeaturePolicy = defaultTestPolicy + ) + advanceUntilIdle() + startCameraUntilRunning(viewModel) + + val uiState = viewModel.captureUiState.value + assertThat(uiState).isInstanceOf(CaptureUiState.Ready::class.java) + val readyState = uiState as CaptureUiState.Ready + val quickSettings = readyState.quickSettingsUiState as QuickSettingsUiState.Available + val captureModeState = quickSettings.captureModeUiState as CaptureModeUiState.Available + val standardState = captureModeState.availableCaptureModes.find { + it.value == CaptureMode.STANDARD + } + assertThat(standardState).isInstanceOf(SingleSelectableUiState.SelectableUi::class.java) + } + + @Test + fun captureUiState_whenExternalCaptureModeImageCapture_captureModeToggleIsUnavailable() = + runTest(StandardTestDispatcher()) { + val testCameraSystem = FakeCameraSystem() + val viewModel = PreviewViewModel( + cameraSystemRepository = createFakeCameraSystemRepository( + testCameraSystem, + ExternalCaptureMode.ImageCapture + ), + constraintsRepository = constraintsRepository, + settingsRepository = FakeSettingsRepository(), + mediaRepository = FakeMediaRepository(), + savedStateHandle = SavedStateHandle( + mapOf( + PreviewRoute.ARG_EXTERNAL_CAPTURE_MODE to ExternalCaptureMode.ImageCapture + ) + ), + defaultSaveMode = SaveMode.Immediate, + cameraFeaturePolicy = defaultTestPolicy + ) + advanceUntilIdle() + startCameraUntilRunning(viewModel) + + val uiState = viewModel.captureUiState.value + assertThat(uiState).isInstanceOf(CaptureUiState.Ready::class.java) + val readyState = uiState as CaptureUiState.Ready + assertThat(readyState.captureModeToggleUiState) + .isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun captureUiState_whenExternalCaptureModeVideoCapture_captureModeToggleIsUnavailable() = + runTest(StandardTestDispatcher()) { + val testCameraSystem = FakeCameraSystem() + val viewModel = PreviewViewModel( + cameraSystemRepository = createFakeCameraSystemRepository( + testCameraSystem, + ExternalCaptureMode.VideoCapture + ), + constraintsRepository = constraintsRepository, + settingsRepository = FakeSettingsRepository(), + mediaRepository = FakeMediaRepository(), + savedStateHandle = SavedStateHandle( + mapOf( + PreviewRoute.ARG_EXTERNAL_CAPTURE_MODE to ExternalCaptureMode.VideoCapture + ) + ), + defaultSaveMode = SaveMode.Immediate, + cameraFeaturePolicy = defaultTestPolicy + ) + advanceUntilIdle() + startCameraUntilRunning(viewModel) + + val uiState = viewModel.captureUiState.value + assertThat(uiState).isInstanceOf(CaptureUiState.Ready::class.java) + val readyState = uiState as CaptureUiState.Ready + assertThat(readyState.captureModeToggleUiState) + .isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + private fun TestScope.startCameraUntilRunning(viewModel: PreviewViewModel? = null) { + (viewModel ?: previewViewModel).cameraController.startCamera() advanceUntilIdle() } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt index c442bec9b8..3c06c3192d 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt @@ -256,7 +256,7 @@ internal fun HdrRow( enumMapper = { hdrEnabled -> if (hdrEnabled) CameraDynamicRange.HDR else CameraDynamicRange.SDR }, - isItemEnabled = { isSupported } + isItemEnabled = { item -> !item.value || isSupported } ) } diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/AspectRatioUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/AspectRatioUiStateAdapter.kt index 4b72fe8770..774fa503c7 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/AspectRatioUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/AspectRatioUiStateAdapter.kt @@ -17,6 +17,7 @@ package com.google.jetpackcamera.ui.uistateadapter.capture import com.google.jetpackcamera.model.AspectRatio import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.OptionVisibility import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState import com.google.jetpackcamera.ui.uistateadapter.Utils @@ -30,25 +31,41 @@ private val ORDERED_UI_SUPPORTED_ASPECT_RATIOS = listOf( * Creates an [AspectRatioUiState] from [CameraAppSettings]. * * @param cameraAppSettings The current camera application settings. + * @param optionVisibility Optional developer visibility policy for aspect ratio. * * @return An [AspectRatioUiState] representing the available aspect ratios and the currently - * selected one. If only one or no aspect ratios are supported, it returns + * selected one. If only one or no aspect ratios are supported or allowed, it returns * [AspectRatioUiState.Unavailable]. */ -fun AspectRatioUiState.Companion.from(cameraAppSettings: CameraAppSettings): AspectRatioUiState { - val supportedAspectRatios = ORDERED_UI_SUPPORTED_ASPECT_RATIOS.toSet() - val availableAspectRatios = - Utils.getSelectableListFromValues( - supportedAspectRatios, - ORDERED_UI_SUPPORTED_ASPECT_RATIOS - ) +fun AspectRatioUiState.Companion.from( + cameraAppSettings: CameraAppSettings, + optionVisibility: OptionVisibility? = null +): AspectRatioUiState { + if (optionVisibility is OptionVisibility.Hidden) { + return AspectRatioUiState.Unavailable + } + + val supportedAspectRatios = if (optionVisibility is OptionVisibility.Only) { + ORDERED_UI_SUPPORTED_ASPECT_RATIOS.filter { it in optionVisibility.enabledOptions }.toSet() + } else { + ORDERED_UI_SUPPORTED_ASPECT_RATIOS.toSet() + } return if (supportedAspectRatios.size <= 1) { - // If we only support one lens, then return "Unavailable". AspectRatioUiState.Unavailable } else { + val availableAspectRatios = + Utils.getSelectableListFromValues( + supportedAspectRatios, + ORDERED_UI_SUPPORTED_ASPECT_RATIOS + ) + val selectedAspectRatio = if (cameraAppSettings.aspectRatio in supportedAspectRatios) { + cameraAppSettings.aspectRatio + } else { + supportedAspectRatios.first() + } AspectRatioUiState.Available( - selectedAspectRatio = cameraAppSettings.aspectRatio, + selectedAspectRatio = selectedAspectRatio, availableAspectRatios = availableAspectRatios ) } diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureModeUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureModeUiStateAdapter.kt index 01bb33ddd4..302b44bb8f 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureModeUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureModeUiStateAdapter.kt @@ -22,10 +22,10 @@ import com.google.jetpackcamera.model.ConcurrentCameraMode import com.google.jetpackcamera.model.DynamicRange import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.model.ImageOutputFormat -import com.google.jetpackcamera.model.LensFacing 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.settings.model.OptionVisibility import com.google.jetpackcamera.settings.model.forCurrentLens import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState @@ -40,14 +40,21 @@ private val ORDERED_UI_SUPPORTED_CAPTURE_MODES = listOf( /** * Creates a [CaptureModeToggleUiState] based on the current camera and system state. * - * This adapter determines whether the simplified capture mode toggle (between IMAGE_ONLY and - * VIDEO_ONLY) should be available and what its state should be. The toggle is generally - * unavailable if video is recording or if the current capture mode is STANDARD. + * This adapter determines whether the simplified capture mode toggle (between [CaptureMode.IMAGE_ONLY] + * and [CaptureMode.VIDEO_ONLY]) should be available and what its state should be. + * + * Note: [CaptureModeToggleUiState] is intentionally scoped only to binary quick-toggle widgets that + * require two valid, selectable states. If either [CaptureMode.IMAGE_ONLY] or [CaptureMode.VIDEO_ONLY] + * is disabled or restricted, this resolves to [CaptureModeToggleUiState.Unavailable]. Integrating + * apps or custom UI layouts wanting to inspect or display disabled states and their associated + * [DisabledReason] should observe [CaptureModeUiState] directly via [CaptureModeUiState.Companion.from], + * rather than relying on [CaptureModeToggleUiState]. * * @param systemConstraints The constraints of the entire camera system. * @param cameraAppSettings The current settings of the camera. * @param cameraState The real-time state of the camera hardware. * @param externalCaptureMode The mode influencing UI based on how the camera was launched. + * @param optionVisibility The [OptionVisibility] policy enforced for this component. * @return A [CaptureModeToggleUiState] which is either [CaptureModeToggleUiState.Available] * containing the states for the image and video-only modes, or * [CaptureModeToggleUiState.Unavailable] if the toggle should not be shown. @@ -56,37 +63,38 @@ fun CaptureModeToggleUiState.Companion.from( systemConstraints: CameraSystemConstraints, cameraAppSettings: CameraAppSettings, cameraState: CameraState, - externalCaptureMode: ExternalCaptureMode -): CaptureModeToggleUiState = - if (cameraState.videoRecordingState !is VideoRecordingState.Inactive || - cameraAppSettings.captureMode == CaptureMode.STANDARD + externalCaptureMode: ExternalCaptureMode, + optionVisibility: OptionVisibility? = null +): CaptureModeToggleUiState { + val config = optionVisibility ?: OptionVisibility.Visible + return if (cameraState.videoRecordingState !is VideoRecordingState.Inactive || + cameraAppSettings.captureMode == CaptureMode.STANDARD || + config is OptionVisibility.Hidden ) { CaptureModeToggleUiState.Unavailable } else { val availableCaptureModes = getAvailableCaptureModes( systemConstraints, cameraAppSettings, - externalCaptureMode + externalCaptureMode, + config ) // Find the IMAGE_ONLY and VIDEO_ONLY states - val imageOnlyState = availableCaptureModes.first { item -> - when (item) { - is SingleSelectableUiState.SelectableUi -> item.value == CaptureMode.IMAGE_ONLY - is SingleSelectableUiState.Disabled -> item.value == CaptureMode.IMAGE_ONLY - } - } - val videoOnlyState = availableCaptureModes.first { item -> - when (item) { - is SingleSelectableUiState.SelectableUi -> item.value == CaptureMode.VIDEO_ONLY - is SingleSelectableUiState.Disabled -> item.value == CaptureMode.VIDEO_ONLY - } + val imageOnlyState = availableCaptureModes.first { it.value == CaptureMode.IMAGE_ONLY } + val videoOnlyState = availableCaptureModes.first { it.value == CaptureMode.VIDEO_ONLY } + if (imageOnlyState is SingleSelectableUiState.Disabled || + videoOnlyState is SingleSelectableUiState.Disabled + ) { + CaptureModeToggleUiState.Unavailable + } else { + CaptureModeToggleUiState.Available( + selectedCaptureMode = cameraAppSettings.captureMode, + imageOnlyUiState = imageOnlyState, + videoOnlyUiState = videoOnlyState + ) } - CaptureModeToggleUiState.Available( - selectedCaptureMode = cameraAppSettings.captureMode, - imageOnlyUiState = imageOnlyState, - videoOnlyUiState = videoOnlyState - ) } +} /** * Creates a [CaptureModeUiState] for the full capture mode selection UI (e.g., in quick settings). @@ -98,59 +106,88 @@ fun CaptureModeToggleUiState.Companion.from( * @param systemConstraints The constraints of the entire camera system. * @param cameraAppSettings The current settings of the camera. * @param externalCaptureMode The mode influencing UI based on how the camera was launched. + * @param optionVisibility The optional [OptionVisibility] defining developer restrictions. * @return A [CaptureModeUiState.Available] object containing the currently selected capture mode - * and a list of all available modes, each represented as a [SingleSelectableUiState]. + * and a list of all available modes, each represented as a [SingleSelectableUiState], or + * [CaptureModeUiState.Unavailable] if capture mode selection is disabled or hidden. */ fun CaptureModeUiState.Companion.from( systemConstraints: CameraSystemConstraints, cameraAppSettings: CameraAppSettings, - externalCaptureMode: ExternalCaptureMode + externalCaptureMode: ExternalCaptureMode, + optionVisibility: OptionVisibility? = null ): CaptureModeUiState { + val config = optionVisibility ?: OptionVisibility.Visible + if (config is OptionVisibility.Hidden) { + return CaptureModeUiState.Unavailable + } val availableCaptureModes = getAvailableCaptureModes( systemConstraints, cameraAppSettings, - externalCaptureMode - ) - return CaptureModeUiState.Available( - selectedCaptureMode = cameraAppSettings.captureMode, - availableCaptureModes = availableCaptureModes + externalCaptureMode, + config ) + val isSelectedModeSelectable = availableCaptureModes.any { + it is SingleSelectableUiState.SelectableUi && it.value == cameraAppSettings.captureMode + } + return if (isSelectedModeSelectable) { + CaptureModeUiState.Available( + selectedCaptureMode = cameraAppSettings.captureMode, + availableCaptureModes = availableCaptureModes + ) + } else { + CaptureModeUiState.Unavailable + } } private fun getSupportedCaptureModes( cameraAppSettings: CameraAppSettings, + config: OptionVisibility, isHdrOn: Boolean, currentHdrDynamicRangeSupported: Boolean, currentHdrImageFormatSupported: Boolean, externalCaptureMode: ExternalCaptureMode -): List = if ( - externalCaptureMode != ExternalCaptureMode.ImageCapture && - externalCaptureMode != ExternalCaptureMode.VideoCapture && - currentHdrDynamicRangeSupported && - currentHdrImageFormatSupported && - cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF -) { - // do not allow both use cases to be bound if hdr is on - if (isHdrOn) { - listOf(CaptureMode.IMAGE_ONLY, CaptureMode.VIDEO_ONLY) - } else { - listOf(CaptureMode.STANDARD, CaptureMode.IMAGE_ONLY, CaptureMode.VIDEO_ONLY) +): List { + return when (config) { + is OptionVisibility.Visible -> ORDERED_UI_SUPPORTED_CAPTURE_MODES + is OptionVisibility.Hidden -> emptyList() + is OptionVisibility.Only -> + ORDERED_UI_SUPPORTED_CAPTURE_MODES + .filter { it in config.enabledOptions } + }.filter { captureMode -> + when (captureMode) { + // image-only supported if externalcaptureMode is NOT VideoCapture and Concurrent Camera is off + CaptureMode.IMAGE_ONLY -> + externalCaptureMode != ExternalCaptureMode.VideoCapture && + cameraAppSettings + .concurrentCameraMode == ConcurrentCameraMode.OFF + + // video-only supported if externalcapturemode is neither imageCapture nor multipleImageCapture + CaptureMode.VIDEO_ONLY -> + externalCaptureMode != ExternalCaptureMode.ImageCapture && + externalCaptureMode != ExternalCaptureMode.MultipleImageCapture + + // hybrid capture supported if external capture mode is standard, if HDR mode is off, and if concurrent camera is off + CaptureMode.STANDARD -> + externalCaptureMode == ExternalCaptureMode.Standard && + currentHdrDynamicRangeSupported && + currentHdrImageFormatSupported && + !isHdrOn && + cameraAppSettings + .concurrentCameraMode == ConcurrentCameraMode.OFF + } } -} else if ( - cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF && - externalCaptureMode == ExternalCaptureMode.ImageCapture || - cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR -) { - listOf(CaptureMode.IMAGE_ONLY) -} else { - listOf(CaptureMode.VIDEO_ONLY) } private fun getAvailableCaptureModes( systemConstraints: CameraSystemConstraints, cameraAppSettings: CameraAppSettings, - externalCaptureMode: ExternalCaptureMode + externalCaptureMode: ExternalCaptureMode, + optionVisibility: OptionVisibility ): List> { + // 1. start with all UI supported modes + // 2. filter out modes that are restricted by optionVisibility policy + // 3. filter out modes that are not supported by the device given current settings val cameraConstraints: CameraConstraints? = systemConstraints.forCurrentLens( cameraAppSettings ) @@ -180,75 +217,24 @@ private fun getAvailableCaptureModes( } val supportedCaptureModes = getSupportedCaptureModes( cameraAppSettings, + optionVisibility, isHdrOn, currentHdrDynamicRangeSupported, currentHdrImageFormatSupported, externalCaptureMode ) - // if all capture modes are supported, return capturemodeuistate - if (supportedCaptureModes.containsAll(ORDERED_UI_SUPPORTED_CAPTURE_MODES)) { - return ORDERED_UI_SUPPORTED_CAPTURE_MODES.filter { - it in supportedCaptureModes - }.map { supportedCaptureMode -> - SingleSelectableUiState.SelectableUi(supportedCaptureMode) - } - } - // if all capture modes are not supported, give disabledReason - // if image or video is not supported, default will also be disabled - else { - if (!supportedCaptureModes.contains(CaptureMode.VIDEO_ONLY)) { - val disabledReason = - getCaptureModeDisabledReason( - disabledCaptureMode = CaptureMode.VIDEO_ONLY, - currentHdrDynamicRangeSupported, - currentHdrImageFormatSupported, - systemConstraints = systemConstraints, - cameraAppSettings.cameraLensFacing, - affectsImageCapture, - cameraAppSettings.concurrentCameraMode, - externalCaptureMode = externalCaptureMode - ) - return listOf( - SingleSelectableUiState.SelectableUi(CaptureMode.IMAGE_ONLY), - SingleSelectableUiState.Disabled( - CaptureMode.VIDEO_ONLY, - disabledReason = disabledReason - ), - SingleSelectableUiState.Disabled( - CaptureMode.STANDARD, - disabledReason = disabledReason - ) - ) - } else if (!supportedCaptureModes.contains(CaptureMode.IMAGE_ONLY)) { - val disabledReason = - getCaptureModeDisabledReason( - disabledCaptureMode = CaptureMode.IMAGE_ONLY, - currentHdrDynamicRangeSupported, - currentHdrImageFormatSupported, - systemConstraints, - cameraAppSettings.cameraLensFacing, - affectsImageCapture, - cameraAppSettings.concurrentCameraMode, - externalCaptureMode = externalCaptureMode - ) - return listOf( - SingleSelectableUiState.SelectableUi(CaptureMode.VIDEO_ONLY), - SingleSelectableUiState.Disabled( - CaptureMode.IMAGE_ONLY, - disabledReason = disabledReason - ), - SingleSelectableUiState.Disabled( - CaptureMode.STANDARD, - disabledReason = disabledReason - ) - ) + return ORDERED_UI_SUPPORTED_CAPTURE_MODES.map { mode -> + if (mode in supportedCaptureModes) { + SingleSelectableUiState.SelectableUi(mode) } else { - return listOf( - SingleSelectableUiState.SelectableUi(CaptureMode.VIDEO_ONLY), - SingleSelectableUiState.SelectableUi(CaptureMode.IMAGE_ONLY), - SingleSelectableUiState.Disabled( - CaptureMode.STANDARD, - disabledReason = DisabledReason.HDR_SIMULTANEOUS_IMAGE_VIDEO_UNSUPPORTED + SingleSelectableUiState.Disabled( + value = mode, + disabledReason = getCaptureModeDisabledReason( + disabledCaptureMode = mode, + optionVisibility = optionVisibility, + concurrentCameraMode = cameraAppSettings.concurrentCameraMode, + externalCaptureMode = externalCaptureMode, + isHdrOn = isHdrOn ) ) } @@ -257,13 +243,10 @@ private fun getAvailableCaptureModes( private fun getCaptureModeDisabledReason( disabledCaptureMode: CaptureMode, - hdrDynamicRangeSupported: Boolean, - hdrImageFormatSupported: Boolean, - systemConstraints: CameraSystemConstraints, - currentLensFacing: LensFacing, - affectsImageCapture: Boolean, + optionVisibility: OptionVisibility, concurrentCameraMode: ConcurrentCameraMode, - externalCaptureMode: ExternalCaptureMode + externalCaptureMode: ExternalCaptureMode, + isHdrOn: Boolean ): DisabledReason { when (disabledCaptureMode) { CaptureMode.IMAGE_ONLY -> { @@ -271,36 +254,22 @@ private fun getCaptureModeDisabledReason( return DisabledReason .IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED } - - if (concurrentCameraMode == ConcurrentCameraMode.DUAL) { - return DisabledReason - .IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA - } - - if (!hdrImageFormatSupported) { - // First check if Ultra HDR image is supported on other capture modes - if (systemConstraints - .perLensConstraints[currentLensFacing] - ?.supportedImageFormatsMap - ?.anySupportsUltraHdr { it != affectsImageCapture } == true - ) { - return if (affectsImageCapture) { - DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM - } else { - DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM + when (optionVisibility) { + is OptionVisibility.Only -> { + if (disabledCaptureMode !in optionVisibility.enabledOptions) { + return DisabledReason.IMAGE_CAPTURE_RESTRICTED } } - // Check if any other lens supports HDR image - if (systemConstraints.anySupportsUltraHdr { it != currentLensFacing }) { - return DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_LENS - } + is OptionVisibility.Visible, OptionVisibility.Hidden -> {} + } - // No lenses support HDR image on device - return DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_DEVICE + if (concurrentCameraMode == ConcurrentCameraMode.DUAL) { + return DisabledReason + .IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA } - throw RuntimeException("Unknown DisabledReason for capture mode.") + return DisabledReason.UNKNOWN } CaptureMode.VIDEO_ONLY -> { @@ -311,40 +280,44 @@ private fun getCaptureModeDisabledReason( .VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED } - if (!hdrDynamicRangeSupported) { - if (systemConstraints.anySupportsHdrDynamicRange { it != currentLensFacing }) { - return DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_LENS + when (optionVisibility) { + is OptionVisibility.Only -> { + if (disabledCaptureMode !in optionVisibility.enabledOptions) { + return DisabledReason.VIDEO_CAPTURE_RESTRICTED + } } - return DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_DEVICE + + is OptionVisibility.Visible, OptionVisibility.Hidden -> {} } - throw RuntimeException("Unknown DisabledReason for video mode.") + return DisabledReason.UNKNOWN } CaptureMode.STANDARD -> { - TODO() + if (externalCaptureMode == ExternalCaptureMode.VideoCapture) { + return DisabledReason.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED + } + if (externalCaptureMode == ExternalCaptureMode.ImageCapture || + externalCaptureMode == ExternalCaptureMode.MultipleImageCapture + ) { + return DisabledReason.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED + } + when (optionVisibility) { + is OptionVisibility.Only -> { + if (disabledCaptureMode !in optionVisibility.enabledOptions) { + return DisabledReason.HYBRID_CAPTURE_RESTRICTED + } + } + + is OptionVisibility.Visible, OptionVisibility.Hidden -> {} + } + if (concurrentCameraMode == ConcurrentCameraMode.DUAL) { + return DisabledReason.IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA + } + if (isHdrOn) { + return DisabledReason.HDR_SIMULTANEOUS_IMAGE_VIDEO_UNSUPPORTED + } + return DisabledReason.UNKNOWN } } } - -private fun CameraSystemConstraints.anySupportsHdrDynamicRange( - lensFilter: (LensFacing) -> Boolean -): Boolean = perLensConstraints.asSequence().firstOrNull { - lensFilter(it.key) && it.value.supportedDynamicRanges.size > 1 -} != null - -private fun Map>.anySupportsUltraHdr( - captureModeFilter: (Boolean) -> Boolean -): Boolean = asSequence().firstOrNull { - captureModeFilter(it.key) && it.value.contains(ImageOutputFormat.JPEG_ULTRA_HDR) -} != null - -private fun CameraSystemConstraints.anySupportsUltraHdr( - captureModeFilter: (Boolean) -> Boolean = { true }, - lensFilter: (LensFacing) -> Boolean -): Boolean = perLensConstraints.asSequence().firstOrNull { lensConstraints -> - lensFilter(lensConstraints.key) && - lensConstraints.value.supportedImageFormatsMap.anySupportsUltraHdr { - captureModeFilter(it) - } -} != null diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/DisabledReason.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/DisabledReason.kt index ad983c5873..2a3cf240cb 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/DisabledReason.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/DisabledReason.kt @@ -24,6 +24,16 @@ import com.google.jetpackcamera.ui.uistate.DisableRationale enum class DisabledReason( override val reasonTextResId: Int ) : DisableRationale { + UNKNOWN(R.string.toast_unknown_disabled_rationale), + IMAGE_CAPTURE_RESTRICTED( + R.string.toast_image_capture_restricted + ), + VIDEO_CAPTURE_RESTRICTED( + R.string.toast_video_capture_restricted + ), + HYBRID_CAPTURE_RESTRICTED( + R.string.toast_hybrid_capture_restricted + ), VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED( R.string.toast_video_capture_external_unsupported ), diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlashModeUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlashModeUiStateAdapter.kt index 46c55c909f..9d9bfeee56 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlashModeUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlashModeUiStateAdapter.kt @@ -24,6 +24,7 @@ import com.google.jetpackcamera.model.ImageOutputFormat import com.google.jetpackcamera.model.LowLightBoostState import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CameraSystemConstraints +import com.google.jetpackcamera.settings.model.OptionVisibility import com.google.jetpackcamera.settings.model.forCurrentLens import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.FlashModeUiState @@ -43,24 +44,28 @@ private val ORDERED_UI_SUPPORTED_FLASH_MODES = listOf( * * This factory function determines the set of displayable flash modes based on: * 1. Overall device support. - * 2. Developer-defined visibility (via `visibleFlashModes`). + * 2. Developer-defined visibility (via [optionVisibility]). * 3. Support by the currently active lens. * 4. Interactions with other settings (e.g., HDR, Concurrent Camera). * - * Modes not supported by the device or not in `visibleFlashModes` are hidden. + * Modes not supported by the device or not allowed by [optionVisibility] are hidden. * Modes not supported by the current lens are hidden. * Modes supported by the current lens are shown as enabled, or disabled if in conflict. * * @param cameraAppSettings The current settings of the camera. * @param systemConstraints The hardware capabilities of the camera system. + * @param optionVisibility Optional developer visibility policy for flash mode. * @return A [FlashModeUiState] which is either [Available] or [Unavailable]. */ internal fun FlashModeUiState.Companion.from( cameraAppSettings: CameraAppSettings, - systemConstraints: CameraSystemConstraints - // todo(kc): supply visible flash modes from developer options - // visibleFlashModes: Set = ORDERED_UI_SUPPORTED_FLASH_MODES.toSet() + systemConstraints: CameraSystemConstraints, + optionVisibility: OptionVisibility? = null ): FlashModeUiState { + if (optionVisibility is OptionVisibility.Hidden) { + return Unavailable + } + val selectedFlashMode = cameraAppSettings.flashMode // All modes potentially supported by the device @@ -90,6 +95,13 @@ internal fun FlashModeUiState.Companion.from( continue } + // 2. Hide if restricted by developer visibility configuration. + if (optionVisibility is OptionVisibility.Only && + !optionVisibility.enabledOptions.contains(mode) + ) { + continue + } + // 3. Check if supported on the current lens if (!currentLensSupportedFlashModes.contains(mode)) { continue @@ -166,22 +178,27 @@ internal fun FlashModeUiState.Companion.from( * @param cameraAppSettings The current application settings for the camera. * @param systemConstraints The hardware capabilities of the camera system. * @param cameraState The real-time state from the camera, used to check [LowLightBoostState]. + * @param optionVisibility Optional developer visibility policy for flash mode. * @return An updated [FlashModeUiState]. */ internal fun FlashModeUiState.updateFrom( cameraAppSettings: CameraAppSettings, systemConstraints: CameraSystemConstraints, - cameraState: CameraState + cameraState: CameraState, + optionVisibility: OptionVisibility? = null ): FlashModeUiState { return when (this) { is Unavailable -> { // When previous state was "Unavailable", we'll try to create a new FlashModeUiState - FlashModeUiState.from(cameraAppSettings, systemConstraints) + FlashModeUiState.from(cameraAppSettings, systemConstraints, optionVisibility) } is Available -> { // Regenerate the potential new state based on the latest settings - when (val newUiState = FlashModeUiState.from(cameraAppSettings, systemConstraints)) { + when ( + val newUiState = + FlashModeUiState.from(cameraAppSettings, systemConstraints, optionVisibility) + ) { is Unavailable -> newUiState is Available -> { val currentLlbActive = 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 4f1476661b..f3604c9ec3 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 @@ -23,6 +23,7 @@ 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.settings.model.OptionVisibility import com.google.jetpackcamera.settings.model.forCurrentLens import com.google.jetpackcamera.ui.uistate.capture.HdrUiState @@ -44,13 +45,17 @@ import com.google.jetpackcamera.ui.uistate.capture.HdrUiState * * @param cameraAppSettings The current application and camera settings. * @param systemConstraints The capabilities and limitations of the device's camera hardware. + * @param imageFormatOptionVisibility Optional developer visibility configuration for image format. + * @param dynamicRangeOptionVisibility Optional developer visibility configuration for dynamic range. * * @return [HdrUiState.Available] if the feature is supported and not blocked by other settings, * otherwise returns [HdrUiState.Unavailable]. */ internal fun HdrUiState.Companion.from( cameraAppSettings: CameraAppSettings, - systemConstraints: CameraSystemConstraints + systemConstraints: CameraSystemConstraints, + imageFormatOptionVisibility: OptionVisibility? = null, + dynamicRangeOptionVisibility: OptionVisibility? = null ): HdrUiState { val cameraConstraints: CameraConstraints? = systemConstraints.forCurrentLens( cameraAppSettings @@ -61,6 +66,16 @@ internal fun HdrUiState.Companion.from( return when (cameraAppSettings.captureMode) { CaptureMode.IMAGE_ONLY -> { + if (imageFormatOptionVisibility is OptionVisibility.Hidden || + ( + imageFormatOptionVisibility is OptionVisibility.Only && + ImageOutputFormat.JPEG_ULTRA_HDR !in + imageFormatOptionVisibility.enabledOptions + ) + ) { + return HdrUiState.Unavailable + } + val supportsHdrImage = cameraConstraints ?.supportedImageFormatsMap?.get(affectsImageCapture) ?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) ?: false @@ -78,6 +93,15 @@ internal fun HdrUiState.Companion.from( } CaptureMode.VIDEO_ONLY -> { + if (dynamicRangeOptionVisibility is OptionVisibility.Hidden || + ( + dynamicRangeOptionVisibility is OptionVisibility.Only && + DynamicRange.HLG10 !in dynamicRangeOptionVisibility.enabledOptions + ) + ) { + return HdrUiState.Unavailable + } + val supportsHdrVideo = cameraConstraints ?.supportedDynamicRanges @@ -96,6 +120,7 @@ internal fun HdrUiState.Companion.from( HdrUiState.Unavailable } } + CaptureMode.STANDARD -> { HdrUiState.Unavailable } diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt index db3b262ac6..571f2063d1 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt @@ -19,7 +19,9 @@ import com.google.jetpackcamera.core.camera.CameraState import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy import com.google.jetpackcamera.settings.model.CameraSystemConstraints +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState import com.google.jetpackcamera.ui.uistate.capture.AudioUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState @@ -61,6 +63,7 @@ import kotlinx.coroutines.flow.filterNotNull * @param externalCaptureMode The [ExternalCaptureMode] influencing UI behavior based on how the * camera is launched (e.g., from an external intent). * @param timePrecision The precision to use for rounding the elapsed time of video recording. + * @param cameraFeaturePolicy The optional [CameraFeaturePolicy] providing session restrictions, or null for default behavior. * * @return A [Flow] that emits a new [CaptureUiState] whenever any of its underlying * data sources change. @@ -71,7 +74,8 @@ fun captureUiState( currentCameraState: StateFlow, trackedCaptureUiState: StateFlow, externalCaptureMode: ExternalCaptureMode, - timePrecision: TimeUnit = TimeUnit.SECONDS + timePrecision: TimeUnit = TimeUnit.SECONDS, + cameraFeaturePolicy: CameraFeaturePolicy? = null ): Flow { var flashModeUiState: FlashModeUiState? = null var focusMeteringUiState: FocusMeteringUiState? = null @@ -88,34 +92,48 @@ fun captureUiState( val roundedCameraState = cameraState.copy(videoRecordingState = roundedVideoRecordingState) val captureModeUiState = CaptureModeUiState.from( - systemConstraints, - cameraAppSettings, - externalCaptureMode + systemConstraints = systemConstraints, + cameraAppSettings = cameraAppSettings, + externalCaptureMode = externalCaptureMode, + optionVisibility = cameraFeaturePolicy?.captureMode?.visibility ) val flipLensUiState = FlipLensUiState.from( cameraAppSettings, systemConstraints ) - val aspectRatioUiState = AspectRatioUiState.from(cameraAppSettings) - val hdrUiState = HdrUiState.from( - cameraAppSettings, - systemConstraints + val aspectRatioUiState = AspectRatioUiState.from( + cameraAppSettings = cameraAppSettings, + optionVisibility = cameraFeaturePolicy?.aspectRatio?.visibility ) - - flashModeUiState = flashModeUiState.let { - it?.updateFrom( - cameraAppSettings = cameraAppSettings, - systemConstraints = systemConstraints, - cameraState = roundedCameraState - ) - ?: FlashModeUiState.from(cameraAppSettings, systemConstraints) - } - focusMeteringUiState = focusMeteringUiState.let { - it?.updateFrom( - cameraState = roundedCameraState + val previewAspectRatioUiState = when (aspectRatioUiState) { + is AspectRatioUiState.Available -> aspectRatioUiState + is AspectRatioUiState.Unavailable -> AspectRatioUiState.Available( + selectedAspectRatio = cameraAppSettings.aspectRatio, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi(cameraAppSettings.aspectRatio) + ) ) - ?: FocusMeteringUiState.from(roundedCameraState) } + val hdrUiState = HdrUiState.from( + cameraAppSettings = cameraAppSettings, + systemConstraints = systemConstraints, + imageFormatOptionVisibility = cameraFeaturePolicy?.imageFormat?.visibility, + dynamicRangeOptionVisibility = cameraFeaturePolicy?.dynamicRange?.visibility + ) + + flashModeUiState = flashModeUiState?.updateFrom( + cameraAppSettings = cameraAppSettings, + systemConstraints = systemConstraints, + cameraState = roundedCameraState, + optionVisibility = cameraFeaturePolicy?.flashMode?.visibility + ) ?: FlashModeUiState.from( + cameraAppSettings = cameraAppSettings, + systemConstraints = systemConstraints, + optionVisibility = cameraFeaturePolicy?.flashMode?.visibility + ) + focusMeteringUiState = focusMeteringUiState?.updateFrom( + cameraState = roundedCameraState + ) ?: FocusMeteringUiState.from(roundedCameraState) CaptureUiState.Ready( externalCaptureMode = externalCaptureMode, videoRecordingState = roundedVideoRecordingState, @@ -123,7 +141,7 @@ fun captureUiState( aspectRatioUiState = aspectRatioUiState, previewDisplayUiState = PreviewDisplayUiState( trackedUiState.lastBlinkTimeStamp, - aspectRatioUiState + previewAspectRatioUiState ), // TODO: add updateFrom() for all ui states to prevent re-updating if // values are the same @@ -163,10 +181,11 @@ fun captureUiState( roundedCameraState ), captureModeToggleUiState = CaptureModeToggleUiState.from( - systemConstraints, - cameraAppSettings, - roundedCameraState, - externalCaptureMode + systemConstraints = systemConstraints, + cameraAppSettings = cameraAppSettings, + cameraState = roundedCameraState, + externalCaptureMode = externalCaptureMode, + optionVisibility = cameraFeaturePolicy?.captureMode?.visibility ), hdrUiState = hdrUiState, focusMeteringUiState = focusMeteringUiState, @@ -195,6 +214,7 @@ internal fun roundVideoRecordingState( is VideoRecordingState.Active.Recording -> videoRecordingState.copy( elapsedTimeNanos = roundedNanos ) + is VideoRecordingState.Active.Paused -> videoRecordingState.copy( elapsedTimeNanos = roundedNanos ) diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/QuickSettingsUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/QuickSettingsUiStateAdapter.kt index 243598a01a..a6a53feb12 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/QuickSettingsUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/QuickSettingsUiStateAdapter.kt @@ -35,6 +35,13 @@ import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState * @param hdrUiState The UI state for the HDR setting. * @return A [QuickSettingsUiState.Available] instance containing the consolidated states. */ +// TODO: Consider returning QuickSettingsUiState.Unavailable if all option rows (aspectRatio, +// captureMode, flashMode, hdr) are Unavailable due to policy restrictions or device constraints. +// In a follow-up, coordinate with PreviewScreen to support optional onNavigateToSettings: +// 1. Has quick settings + onNavigateToSettings: open Quick Settings with "More settings". +// 2. Has quick settings + no onNavigateToSettings: open Quick Settings without "More settings". +// 3. No quick settings + onNavigateToSettings: skip bottom sheet and call onNavigateToSettings(). +// 4. No quick settings + no onNavigateToSettings: hide the button altogether. fun QuickSettingsUiState.Companion.from( captureModeUiState: CaptureModeUiState, flashModeUiState: FlashModeUiState, diff --git a/ui/uistateadapter/capture/src/main/res/values/strings.xml b/ui/uistateadapter/capture/src/main/res/values/strings.xml index 22486d5653..23a8ad6158 100644 --- a/ui/uistateadapter/capture/src/main/res/values/strings.xml +++ b/ui/uistateadapter/capture/src/main/res/values/strings.xml @@ -15,6 +15,10 @@ ~ limitations under the License. --> + Unknown Disabled Rationale + Image Capture option is restricted + Video Capture option is restricted + Hybrid Capture option is restricted Image Capture Success Video Capture Success Image Capture Failure diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/AspectRatioUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/AspectRatioUiStateAdapterTest.kt new file mode 100644 index 0000000000..b448269446 --- /dev/null +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/AspectRatioUiStateAdapterTest.kt @@ -0,0 +1,97 @@ +/* + * 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.AspectRatio +import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class AspectRatioUiStateAdapterTest { + + @Test + fun from_defaultVisibility_returnsAllSupportedAspectRatiosAvailable() { + val settings = DEFAULT_CAMERA_APP_SETTINGS.copy(aspectRatio = AspectRatio.THREE_FOUR) + val state = AspectRatioUiState.from(settings) + + assertThat(state).isInstanceOf(AspectRatioUiState.Available::class.java) + val available = state as AspectRatioUiState.Available + assertThat(available.selectedAspectRatio).isEqualTo(AspectRatio.THREE_FOUR) + val aspectRatios = available.availableAspectRatios.map { it.value } + assertThat(aspectRatios).containsExactly( + AspectRatio.NINE_SIXTEEN, + AspectRatio.THREE_FOUR, + AspectRatio.ONE_ONE + ).inOrder() + } + + @Test + fun from_hiddenVisibility_returnsUnavailable() { + val settings = DEFAULT_CAMERA_APP_SETTINGS + val state = AspectRatioUiState.from(settings, OptionVisibility.Hidden) + + assertThat(state).isEqualTo(AspectRatioUiState.Unavailable) + } + + @Test + fun from_onlySingleOptionViaFactory_returnsUnavailable() { + val settings = DEFAULT_CAMERA_APP_SETTINGS.copy(aspectRatio = AspectRatio.THREE_FOUR) + val state = AspectRatioUiState.from( + settings, + OptionVisibility.from(setOf(AspectRatio.THREE_FOUR)) + ) + + // When only 1 option is provided, OptionVisibility.from resolves to Hidden and returns Unavailable + assertThat(state).isEqualTo(AspectRatioUiState.Unavailable) + } + + @Test + fun from_onlySubsetOptions_returnsAvailableWithOnlyThoseOptions() { + val settings = DEFAULT_CAMERA_APP_SETTINGS.copy(aspectRatio = AspectRatio.THREE_FOUR) + val state = AspectRatioUiState.from( + settings, + OptionVisibility.Only(setOf(AspectRatio.ONE_ONE, AspectRatio.THREE_FOUR)) + ) + + assertThat(state).isInstanceOf(AspectRatioUiState.Available::class.java) + val available = state as AspectRatioUiState.Available + assertThat(available.selectedAspectRatio).isEqualTo(AspectRatio.THREE_FOUR) + val aspectRatios = available.availableAspectRatios.map { it.value } + assertThat(aspectRatios).containsExactly( + AspectRatio.THREE_FOUR, + AspectRatio.ONE_ONE + ).inOrder() + } + + @Test + fun from_onlySubsetOptions_fallsBackSelectedIfCurrentNotAllowed() { + // Current settings is 9:16, but policy only allows 1:1 and 3:4 + val settings = DEFAULT_CAMERA_APP_SETTINGS.copy(aspectRatio = AspectRatio.NINE_SIXTEEN) + val state = AspectRatioUiState.from( + settings, + OptionVisibility.Only(setOf(AspectRatio.ONE_ONE, AspectRatio.THREE_FOUR)) + ) + + assertThat(state).isInstanceOf(AspectRatioUiState.Available::class.java) + val available = state as AspectRatioUiState.Available + assertThat(available.selectedAspectRatio).isEqualTo(AspectRatio.THREE_FOUR) + } +} diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureModeUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureModeUiStateAdapterTest.kt new file mode 100644 index 0000000000..1760178104 --- /dev/null +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureModeUiStateAdapterTest.kt @@ -0,0 +1,405 @@ +/* + * 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.core.camera.AudioStreamState +import com.google.jetpackcamera.core.camera.CameraState +import com.google.jetpackcamera.core.camera.VideoRecordingState +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.UNLIMITED_VIDEO_DURATION +import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeUiState +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class CaptureModeUiStateAdapterTest { + + @Test + fun from_notRestricted_enablesSupportedModes() { + val uiState = CaptureModeUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + optionVisibility = OptionVisibility.Visible, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS, + externalCaptureMode = ExternalCaptureMode.Standard + ) + + assertThat(uiState).isInstanceOf(CaptureModeUiState.Available::class.java) + val available = uiState as CaptureModeUiState.Available + assertThat(available.availableCaptureModes).isNotEmpty() + assertThat( + available.availableCaptureModes.all { it is SingleSelectableUiState.SelectableUi } + ).isTrue() + } + + @Test + fun from_hidden_returnsUnavailable() { + val uiState = CaptureModeUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + optionVisibility = OptionVisibility.Hidden, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS, + externalCaptureMode = ExternalCaptureMode.Standard + ) + + assertThat(uiState).isEqualTo(CaptureModeUiState.Unavailable) + } + + @Test + fun from_optionsEnabled_enablesOnlySpecifiedModes() { + val uiState = CaptureModeUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + optionVisibility = OptionVisibility.Only( + setOf(CaptureMode.IMAGE_ONLY, CaptureMode.VIDEO_ONLY) + ), + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + externalCaptureMode = ExternalCaptureMode.Standard + ) + + assertThat(uiState).isInstanceOf(CaptureModeUiState.Available::class.java) + val available = uiState as CaptureModeUiState.Available + + val standardState = available.availableCaptureModes.find { + it.value == CaptureMode.STANDARD + } + assertThat(standardState).isInstanceOf(SingleSelectableUiState.Disabled::class.java) + assertThat((standardState as SingleSelectableUiState.Disabled).disabledReason) + .isEqualTo(DisabledReason.HYBRID_CAPTURE_RESTRICTED) + + val imageState = available.availableCaptureModes.find { it.value == CaptureMode.IMAGE_ONLY } + assertThat(imageState).isInstanceOf(SingleSelectableUiState.SelectableUi::class.java) + + val videoState = available.availableCaptureModes.find { it.value == CaptureMode.VIDEO_ONLY } + assertThat(videoState).isInstanceOf(SingleSelectableUiState.SelectableUi::class.java) + } + + @Test + fun from_optionsEnabledExcludingImageMode_disablesImageModeWithRestrictedReason() { + val uiState = CaptureModeUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + optionVisibility = OptionVisibility.Only( + setOf(CaptureMode.STANDARD, CaptureMode.VIDEO_ONLY) + ), + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.VIDEO_ONLY + ), + externalCaptureMode = ExternalCaptureMode.Standard + ) + + assertThat(uiState).isInstanceOf(CaptureModeUiState.Available::class.java) + val available = uiState as CaptureModeUiState.Available + + val imageState = available.availableCaptureModes.find { + it.value == CaptureMode.IMAGE_ONLY + } + assertThat(imageState).isInstanceOf(SingleSelectableUiState.Disabled::class.java) + assertThat((imageState as SingleSelectableUiState.Disabled).disabledReason) + .isEqualTo(DisabledReason.IMAGE_CAPTURE_RESTRICTED) + } + + @Test + fun from_optionsEnabledExcludingVideoMode_disablesVideoModeWithRestrictedReason() { + val uiState = CaptureModeUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + optionVisibility = OptionVisibility.Only( + setOf(CaptureMode.STANDARD, CaptureMode.IMAGE_ONLY) + ), + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + externalCaptureMode = ExternalCaptureMode.Standard + ) + + assertThat(uiState).isInstanceOf(CaptureModeUiState.Available::class.java) + val available = uiState as CaptureModeUiState.Available + + val videoState = available.availableCaptureModes.find { + it.value == CaptureMode.VIDEO_ONLY + } + assertThat(videoState).isInstanceOf(SingleSelectableUiState.Disabled::class.java) + assertThat((videoState as SingleSelectableUiState.Disabled).disabledReason) + .isEqualTo(DisabledReason.VIDEO_CAPTURE_RESTRICTED) + } + + // Toggle tests + @Test + fun toggleFrom_notRestricted_returnsAvailable() { + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + cameraState = CameraState(), + externalCaptureMode = ExternalCaptureMode.Standard, + optionVisibility = OptionVisibility.Visible + ) + + assertThat(uiState).isInstanceOf(CaptureModeToggleUiState.Available::class.java) + val available = uiState as CaptureModeToggleUiState.Available + assertThat(available.selectedCaptureMode).isEqualTo(CaptureMode.IMAGE_ONLY) + } + + @Test + fun toggleFrom_optionsEnabledWithImageAndVideo_returnsAvailable() { + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + cameraState = CameraState(), + externalCaptureMode = ExternalCaptureMode.Standard, + optionVisibility = OptionVisibility.Only( + setOf(CaptureMode.IMAGE_ONLY, CaptureMode.VIDEO_ONLY) + ) + ) + assertThat(uiState).isInstanceOf(CaptureModeToggleUiState.Available::class.java) + } + + @Test + fun toggleFrom_videoRecordingActive_returnsUnavailable() { + val activeState = CameraState( + videoRecordingState = VideoRecordingState.Active.Recording( + maxDurationMillis = UNLIMITED_VIDEO_DURATION, + audioStreamState = AudioStreamState.Active(0.0), + elapsedTimeNanos = 1000L + ) + ) + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + cameraState = activeState, + externalCaptureMode = ExternalCaptureMode.Standard, + optionVisibility = OptionVisibility.Visible + ) + + assertThat(uiState).isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun toggleFrom_captureModeStandard_returnsUnavailable() { + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.STANDARD + ), + cameraState = CameraState(), + externalCaptureMode = ExternalCaptureMode.Standard, + optionVisibility = OptionVisibility.Visible + ) + + assertThat(uiState).isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun toggleFrom_hiddenCaptureMode_returnsUnavailable() { + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + cameraState = CameraState(), + externalCaptureMode = ExternalCaptureMode.Standard, + optionVisibility = OptionVisibility.Hidden + ) + + assertThat(uiState).isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun toggleFrom_externalCaptureModeImageCapture_returnsUnavailable() { + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + cameraState = CameraState(), + externalCaptureMode = ExternalCaptureMode.ImageCapture, + optionVisibility = OptionVisibility.Visible + ) + + assertThat(uiState).isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun toggleFrom_externalCaptureModeMultipleImageCapture_returnsUnavailable() { + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + cameraState = CameraState(), + externalCaptureMode = ExternalCaptureMode.MultipleImageCapture, + optionVisibility = OptionVisibility.Visible + ) + + assertThat(uiState).isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun toggleFrom_externalCaptureModeVideoCapture_returnsUnavailable() { + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.VIDEO_ONLY + ), + cameraState = CameraState(), + externalCaptureMode = ExternalCaptureMode.VideoCapture, + optionVisibility = OptionVisibility.Visible + ) + + assertThat(uiState).isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun toggleFrom_optionsEnabledExcludingVideo_returnsUnavailable() { + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + cameraState = CameraState(), + externalCaptureMode = ExternalCaptureMode.Standard, + optionVisibility = OptionVisibility.Only( + setOf(CaptureMode.STANDARD, CaptureMode.IMAGE_ONLY) + ) + ) + + assertThat(uiState).isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun toggleFrom_optionsEnabledExcludingImage_returnsUnavailable() { + val uiState = CaptureModeToggleUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.VIDEO_ONLY + ), + cameraState = CameraState(), + externalCaptureMode = ExternalCaptureMode.Standard, + optionVisibility = OptionVisibility.Only( + setOf(CaptureMode.STANDARD, CaptureMode.VIDEO_ONLY) + ) + ) + + assertThat(uiState).isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun from_externalCaptureModeImageCapture_disablesVideoAndHybrid() { + val uiState = CaptureModeUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + optionVisibility = OptionVisibility.Visible, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.IMAGE_ONLY + ), + externalCaptureMode = ExternalCaptureMode.ImageCapture + ) + + assertThat(uiState).isInstanceOf(CaptureModeUiState.Available::class.java) + val available = uiState as CaptureModeUiState.Available + + val imageMode = available.availableCaptureModes.find { + it.value == CaptureMode.IMAGE_ONLY + } + assertThat(imageMode).isInstanceOf(SingleSelectableUiState.SelectableUi::class.java) + + val videoMode = available.availableCaptureModes.find { + it.value == CaptureMode.VIDEO_ONLY + } + assertThat(videoMode).isInstanceOf(SingleSelectableUiState.Disabled::class.java) + assertThat((videoMode as SingleSelectableUiState.Disabled).disabledReason) + .isEqualTo(DisabledReason.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED) + } + + @Test + fun from_externalCaptureModeVideoCapture_disablesImageAndHybrid() { + val uiState = CaptureModeUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + optionVisibility = OptionVisibility.Visible, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + captureMode = CaptureMode.VIDEO_ONLY + ), + externalCaptureMode = ExternalCaptureMode.VideoCapture + ) + + assertThat(uiState).isInstanceOf(CaptureModeUiState.Available::class.java) + val available = uiState as CaptureModeUiState.Available + + val videoMode = available.availableCaptureModes.find { + it.value == CaptureMode.VIDEO_ONLY + } + assertThat(videoMode).isInstanceOf(SingleSelectableUiState.SelectableUi::class.java) + + val imageMode = available.availableCaptureModes.find { + it.value == CaptureMode.IMAGE_ONLY + } + assertThat(imageMode).isInstanceOf(SingleSelectableUiState.Disabled::class.java) + assertThat((imageMode as SingleSelectableUiState.Disabled).disabledReason) + .isEqualTo(DisabledReason.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED) + } + + @Test + fun from_whenConcurrentCameraDual_disablesStandardAndImageOnly() { + val uiState = CaptureModeUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + concurrentCameraMode = ConcurrentCameraMode.DUAL, + captureMode = CaptureMode.VIDEO_ONLY + ), + externalCaptureMode = ExternalCaptureMode.Standard + ) + val available = uiState as CaptureModeUiState.Available + val standard = available.availableCaptureModes.first { it.value == CaptureMode.STANDARD } + val imageOnly = available.availableCaptureModes.first { it.value == CaptureMode.IMAGE_ONLY } + + assertThat(standard).isInstanceOf(SingleSelectableUiState.Disabled::class.java) + assertThat((standard as SingleSelectableUiState.Disabled).disabledReason) + .isEqualTo(DisabledReason.IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA) + assertThat(imageOnly).isInstanceOf(SingleSelectableUiState.Disabled::class.java) + assertThat((imageOnly as SingleSelectableUiState.Disabled).disabledReason) + .isEqualTo(DisabledReason.IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA) + } + + @Test + fun from_whenHdrOn_disablesStandardCaptureMode() { + val uiState = CaptureModeUiState.from( + systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS.copy( + dynamicRange = DynamicRange.HLG10, + captureMode = CaptureMode.VIDEO_ONLY + ), + externalCaptureMode = ExternalCaptureMode.Standard + ) + val available = uiState as CaptureModeUiState.Available + val standard = available.availableCaptureModes.first { it.value == CaptureMode.STANDARD } + + assertThat(standard).isInstanceOf(SingleSelectableUiState.Disabled::class.java) + assertThat((standard as SingleSelectableUiState.Disabled).disabledReason) + .isEqualTo(DisabledReason.HDR_SIMULTANEOUS_IMAGE_VIDEO_UNSUPPORTED) + } +} diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt index d99d448a24..ed88e86d0d 100644 --- a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt @@ -20,18 +20,30 @@ import com.google.jetpackcamera.core.camera.AudioStreamState import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.core.camera.testing.FakeCameraSystem import com.google.jetpackcamera.model.AspectRatio +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.Illuminant +import com.google.jetpackcamera.model.ImageOutputFormat import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl import com.google.jetpackcamera.settings.model.CameraConstraints +import com.google.jetpackcamera.settings.model.CameraFeaturePolicy import com.google.jetpackcamera.settings.model.CameraSystemConstraints +import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS +import com.google.jetpackcamera.settings.model.OptionVisibility +import com.google.jetpackcamera.settings.model.SettingConfig import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeUiState import com.google.jetpackcamera.ui.uistate.capture.FlashModeUiState +import com.google.jetpackcamera.ui.uistate.capture.HdrUiState import com.google.jetpackcamera.ui.uistate.capture.TrackedCaptureUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState import com.google.jetpackcamera.ui.uistateadapter.capture.compound.captureUiState import com.google.jetpackcamera.ui.uistateadapter.capture.compound.roundVideoRecordingState import java.util.concurrent.TimeUnit @@ -56,8 +68,19 @@ internal class CaptureUiStateAdapterTest { private val trackedCaptureUiState = MutableStateFlow(TrackedCaptureUiState()) private val externalCaptureMode = ExternalCaptureMode.Standard - private fun createCaptureUiStateFlow() = captureUiState( + private val defaultPolicy = CameraFeaturePolicy( + aspectRatio = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.aspectRatio), + flashMode = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.flashMode), + captureMode = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.captureMode), + imageFormat = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.imageFormat), + dynamicRange = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.dynamicRange) + ) + + private fun createCaptureUiStateFlow( + cameraFeaturePolicy: CameraFeaturePolicy? = defaultPolicy + ) = captureUiState( currentSettings = cameraSystem.getCurrentSettings(), + cameraFeaturePolicy = cameraFeaturePolicy, systemConstraints = constraintsRepository.systemConstraints, currentCameraState = cameraSystem.getCurrentCameraState(), trackedCaptureUiState = trackedCaptureUiState, @@ -192,6 +215,100 @@ internal class CaptureUiStateAdapterTest { assertThat(updatedFlash).isEqualTo(FlashMode.ON) } + @Test + fun captureUiState_withRestrictedPolicy_emitsRestrictedUiStates() = runTest { + val restrictedConfig = defaultPolicy.copy( + captureMode = SettingConfig( + defaultValue = CaptureMode.IMAGE_ONLY, + visibility = OptionVisibility.Hidden + ) + ) + val uiStateFlow = createCaptureUiStateFlow(cameraFeaturePolicy = restrictedConfig) + val state = assertIsReady(uiStateFlow.first()) + assertThat( + state.quickSettingsUiState + ).isInstanceOf(QuickSettingsUiState.Available::class.java) + val quickSettings = state.quickSettingsUiState as QuickSettingsUiState.Available + assertThat(quickSettings.captureModeUiState).isEqualTo(CaptureModeUiState.Unavailable) + assertThat(state.captureModeToggleUiState).isEqualTo(CaptureModeToggleUiState.Unavailable) + } + + @Test + fun captureUiState_withNullPolicy_defaultsCleanly() = runTest { + val uiStateFlow = createCaptureUiStateFlow(cameraFeaturePolicy = null) + val state = assertIsReady(uiStateFlow.first()) + assertThat(state).isInstanceOf(CaptureUiState.Ready::class.java) + } + + @Test + fun captureUiState_withFlashModeAndHdrHidden_emitsUnavailableUiStates() = runTest { + constraintsRepository.updateSystemConstraints( + CameraSystemConstraints( + availableLenses = listOf(LensFacing.BACK), + perLensConstraints = mapOf( + LensFacing.BACK to CameraConstraints( + supportedFixedFrameRates = emptySet(), + supportedStabilizationModes = emptySet(), + supportedDynamicRanges = emptySet(), + supportedVideoQualitiesMap = emptyMap(), + supportedImageFormatsMap = emptyMap(), + supportedIlluminants = setOf(Illuminant.FLASH_UNIT), + supportedFlashModes = setOf(FlashMode.OFF, FlashMode.ON), + supportedZoomRange = null, + unsupportedStabilizationFpsMap = emptyMap(), + supportedTestPatterns = emptySet() + ) + ) + ) + ) + + val restrictedConfig = defaultPolicy.copy( + flashMode = SettingConfig( + defaultValue = FlashMode.OFF, + visibility = OptionVisibility.Hidden + ), + imageFormat = SettingConfig( + defaultValue = ImageOutputFormat.JPEG, + visibility = OptionVisibility.Hidden + ), + dynamicRange = SettingConfig( + defaultValue = DynamicRange.SDR, + visibility = OptionVisibility.Hidden + ) + ) + + val uiStateFlow = createCaptureUiStateFlow(cameraFeaturePolicy = restrictedConfig) + val state = assertIsReady(uiStateFlow.first()) + assertThat(state.flashModeUiState).isEqualTo(FlashModeUiState.Unavailable) + val quickSettings = state.quickSettingsUiState as QuickSettingsUiState.Available + assertThat(quickSettings.flashModeUiState).isEqualTo(FlashModeUiState.Unavailable) + assertThat(quickSettings.hdrUiState).isEqualTo(HdrUiState.Unavailable) + } + + @Test + fun captureUiState_withARHidden_emitsUnavailableAndPreservesPreviewAspectRatio() = runTest { + val restrictedConfig = defaultPolicy.copy( + aspectRatio = SettingConfig( + defaultValue = AspectRatio.ONE_ONE, + visibility = OptionVisibility.Hidden + ) + ) + + val uiStateFlow = createCaptureUiStateFlow(cameraFeaturePolicy = restrictedConfig) + val state = assertIsReady(uiStateFlow.first()) + assertThat(state.aspectRatioUiState).isEqualTo(AspectRatioUiState.Unavailable) + val quickSettings = state.quickSettingsUiState as QuickSettingsUiState.Available + assertThat(quickSettings.aspectRatioUiState).isEqualTo(AspectRatioUiState.Unavailable) + assertThat(state.previewDisplayUiState.aspectRatioUiState).isEqualTo( + AspectRatioUiState.Available( + selectedAspectRatio = AspectRatio.NINE_SIXTEEN, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN) + ) + ) + ) + } + private fun assertIsReady(uiState: CaptureUiState): CaptureUiState.Ready = when (uiState) { is CaptureUiState.Ready -> uiState else -> throw AssertionError( diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlashModeUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlashModeUiStateAdapterTest.kt index 493d0440dd..4574b03510 100644 --- a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlashModeUiStateAdapterTest.kt +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlashModeUiStateAdapterTest.kt @@ -27,6 +27,7 @@ import com.google.jetpackcamera.model.LowLightBoostState 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.settings.model.OptionVisibility import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.FlashModeUiState import org.junit.Assume.assumeTrue @@ -513,4 +514,49 @@ class FlashModeUiStateAdapterTest { FlashMode.LOW_LIGHT_BOOST ) } + + @Test + fun from_withHiddenOptionVisibility_returnsUnavailable() { + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + defaultCameraAppSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedFlashModes = setOf(FlashMode.OFF, FlashMode.ON, FlashMode.AUTO) + ) + ) + ) + + val uiState = FlashModeUiState.from( + cameraAppSettings = defaultCameraAppSettings, + systemConstraints = systemConstraints, + optionVisibility = OptionVisibility.Hidden + ) + + assertThat(uiState).isEqualTo(FlashModeUiState.Unavailable) + } + + @Test + fun from_withOptionsEnabled_filtersAvailableFlashModes() { + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + defaultCameraAppSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedFlashModes = setOf(FlashMode.OFF, FlashMode.ON, FlashMode.AUTO) + ) + ) + ) + + val uiState = FlashModeUiState.from( + cameraAppSettings = defaultCameraAppSettings, + systemConstraints = systemConstraints, + optionVisibility = OptionVisibility.Only( + setOf(FlashMode.OFF, FlashMode.ON) + ) + ) + + assertThat(uiState).isInstanceOf(FlashModeUiState.Available::class.java) + val available = uiState as FlashModeUiState.Available + assertThat(available.availableFlashModes.map { it.value }).containsExactly( + FlashMode.OFF, + FlashMode.ON + ) + } } 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 index 2c4a32c535..07b142e297 100644 --- 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 @@ -27,6 +27,7 @@ import com.google.jetpackcamera.model.LensFacing 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.settings.model.OptionVisibility import com.google.jetpackcamera.ui.uistate.capture.HdrUiState import org.junit.Test import org.junit.runner.RunWith @@ -379,4 +380,104 @@ internal class HdrUiStateAdapterTest { assertThat(hdrUiState).isEqualTo(HdrUiState.Unavailable) } + + @Test + fun from_imageOnlyMode_hiddenImageFormat_hdrUnavailable() { + val appSettings = defaultCameraAppSettings.copy( + captureMode = CaptureMode.IMAGE_ONLY, + imageFormat = ImageOutputFormat.JPEG + ) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedImageFormatsMap = mapOf( + false to setOf(ImageOutputFormat.JPEG, ImageOutputFormat.JPEG_ULTRA_HDR) + ) + ) + ) + ) + + val hdrUiState = HdrUiState.from( + cameraAppSettings = appSettings, + systemConstraints = systemConstraints, + imageFormatOptionVisibility = OptionVisibility.Hidden + ) + + assertThat(hdrUiState).isEqualTo(HdrUiState.Unavailable) + } + + @Test + fun from_imageOnlyMode_optionsEnabledWithUltraHdr_hdrAvailable() { + val appSettings = defaultCameraAppSettings.copy( + captureMode = CaptureMode.IMAGE_ONLY, + imageFormat = ImageOutputFormat.JPEG + ) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedImageFormatsMap = mapOf( + false to setOf(ImageOutputFormat.JPEG, ImageOutputFormat.JPEG_ULTRA_HDR) + ) + ) + ) + ) + + val hdrUiState = HdrUiState.from( + cameraAppSettings = appSettings, + systemConstraints = systemConstraints, + imageFormatOptionVisibility = OptionVisibility.Only( + setOf(ImageOutputFormat.JPEG, ImageOutputFormat.JPEG_ULTRA_HDR) + ) + ) + + assertThat(hdrUiState).isInstanceOf(HdrUiState.Available::class.java) + } + + @Test + fun from_videoOnlyMode_hiddenDynamicRange_hdrUnavailable() { + val appSettings = defaultCameraAppSettings.copy( + captureMode = CaptureMode.VIDEO_ONLY, + dynamicRange = DynamicRange.SDR + ) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedDynamicRanges = setOf(DynamicRange.SDR, DynamicRange.HLG10) + ) + ) + ) + + val hdrUiState = HdrUiState.from( + cameraAppSettings = appSettings, + systemConstraints = systemConstraints, + dynamicRangeOptionVisibility = OptionVisibility.Hidden + ) + + assertThat(hdrUiState).isEqualTo(HdrUiState.Unavailable) + } + + @Test + fun from_videoOnlyMode_optionsEnabledWithHlg10_hdrAvailable() { + val appSettings = defaultCameraAppSettings.copy( + captureMode = CaptureMode.VIDEO_ONLY, + dynamicRange = DynamicRange.SDR + ) + val systemConstraints = CameraSystemConstraints( + perLensConstraints = mapOf( + appSettings.cameraLensFacing to emptyCameraConstraints.copy( + supportedDynamicRanges = setOf(DynamicRange.SDR, DynamicRange.HLG10) + ) + ) + ) + + val hdrUiState = HdrUiState.from( + cameraAppSettings = appSettings, + systemConstraints = systemConstraints, + dynamicRangeOptionVisibility = OptionVisibility.Only( + setOf(DynamicRange.SDR, DynamicRange.HLG10) + ) + ) + + assertThat(hdrUiState).isInstanceOf(HdrUiState.Available::class.java) + } }