diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80010bcb4..1b115a682 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -152,6 +152,8 @@ dependencies { // Access settings & model data implementation(project(":data:settings")) + implementation(project(":data:settings:api")) + implementation(project(":core:model")) // Camera Preview diff --git a/app/src/main/java/com/google/jetpackcamera/AppModule.kt b/app/src/main/java/com/google/jetpackcamera/AppModule.kt index 0c4f6ff72..2a21abcb3 100644 --- a/app/src/main/java/com/google/jetpackcamera/AppModule.kt +++ b/app/src/main/java/com/google/jetpackcamera/AppModule.kt @@ -15,12 +15,16 @@ */ package com.google.jetpackcamera +import com.google.jetpackcamera.core.common.DefaultAppConfig import com.google.jetpackcamera.core.common.DefaultCaptureModeOverride import com.google.jetpackcamera.core.common.DefaultFilePathGenerator import com.google.jetpackcamera.core.common.DefaultSaveMode import com.google.jetpackcamera.core.common.FilePathGenerator import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.SaveMode +import com.google.jetpackcamera.settings.api.DeveloperAppConfig +import com.google.jetpackcamera.settings.api.OptionRestrictionConfig +import com.google.jetpackcamera.settings.api.SettingConfig import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -29,6 +33,17 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) object AppModule { + + @Provides + @DefaultAppConfig + fun providesDeveloperAppConfig(): DeveloperAppConfig = DeveloperAppConfig.LibraryDefaults.copy( + captureMode = SettingConfig( + CaptureMode.IMAGE_ONLY, + uiRestriction = OptionRestrictionConfig.FullyRestricted() + ), + hdrEnabled = SettingConfig(defaultValue = true) + ) + /** * provides the default [CaptureMode] to override by the app */ diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt index b9e72c9c1..24cc6206f 100644 --- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt +++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt @@ -145,6 +145,7 @@ class MainActivity : ComponentActivity() { JcaApp( externalCaptureMode = externalCaptureMode, shouldReviewAfterCapture = shouldReviewAfterCapture, + useDeveloperConfig = useDeveloperConfig, captureUris = captureUris, debugSettings = debugSettings, openAppSettings = ::openAppSettings, @@ -211,6 +212,9 @@ class MainActivity : ComponentActivity() { private val shouldReviewAfterCapture: Boolean get() = intent?.shouldReviewAfterCapture == true + private val useDeveloperConfig: Boolean + get() = intent?.getBooleanExtra(KEY_USE_DEVELOPER_CONFIG, false) ?: false + private val Intent.externalCaptureUri: Uri? get() = IntentCompat.getParcelableExtra( this, @@ -310,6 +314,7 @@ class MainActivity : ComponentActivity() { private const val KEY_DEBUG_MODE = "KEY_DEBUG_MODE" const val KEY_DEBUG_SINGLE_LENS_MODE = "KEY_DEBUG_SINGLE_LENS_MODE" + const val KEY_USE_DEVELOPER_CONFIG = "KEY_USE_DEVELOPER_CONFIG" } } diff --git a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt index 99f79a6d4..a44dc6ee7 100644 --- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt +++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt @@ -51,6 +51,7 @@ import com.google.jetpackcamera.ui.Routes.SETTINGS_ROUTE fun JcaApp( externalCaptureMode: ExternalCaptureMode, shouldReviewAfterCapture: Boolean, + useDeveloperConfig: Boolean = false, captureUris: List, debugSettings: DebugSettings, onRequestWindowColorMode: (Int) -> Unit, @@ -63,6 +64,7 @@ fun JcaApp( modifier = modifier, externalCaptureMode = externalCaptureMode, shouldReviewAfterCapture = shouldReviewAfterCapture, + useDeveloperConfig = useDeveloperConfig, captureUris = captureUris, debugSettings = debugSettings, onOpenAppSettings = openAppSettings, @@ -78,6 +80,7 @@ private fun JetpackCameraNavHost( modifier: Modifier = Modifier, externalCaptureMode: ExternalCaptureMode, shouldReviewAfterCapture: Boolean, + useDeveloperConfig: Boolean = false, captureUris: List, debugSettings: DebugSettings, onOpenAppSettings: () -> Unit, @@ -113,6 +116,7 @@ private fun JetpackCameraNavHost( previewScreen( externalCaptureMode = externalCaptureMode, shouldCacheReview = shouldReviewAfterCapture, + useDeveloperConfig = useDeveloperConfig, captureUris = captureUris, debugSettings = debugSettings, onRequestWindowColorMode = onRequestWindowColorMode, diff --git a/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt b/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt index 998ab951e..feb03cf26 100644 --- a/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt +++ b/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt @@ -74,3 +74,7 @@ annotation class IODispatcher @Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultCoroutineScope + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultAppConfig diff --git a/data/settings/api/.gitignore b/data/settings/api/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/data/settings/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/settings/api/build.gradle.kts b/data/settings/api/build.gradle.kts new file mode 100644 index 000000000..593cac60d --- /dev/null +++ b/data/settings/api/build.gradle.kts @@ -0,0 +1,64 @@ +/* + * 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. + */ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.google.jetpackcamera.settings.api" + compileSdk = 35 + + defaultConfig { + minSdk = 23 + targetSdk = 35 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + // Access Model data + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":data:settings")) + +} \ No newline at end of file diff --git a/data/settings/api/consumer-rules.pro b/data/settings/api/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/data/settings/api/proguard-rules.pro b/data/settings/api/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/data/settings/api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/settings/api/src/main/AndroidManifest.xml b/data/settings/api/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8322d22ca --- /dev/null +++ b/data/settings/api/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/data/settings/api/src/main/java/com/google/jetpackcamera/settings/api/RestrictionConfig.kt b/data/settings/api/src/main/java/com/google/jetpackcamera/settings/api/RestrictionConfig.kt new file mode 100644 index 000000000..60e73eaa2 --- /dev/null +++ b/data/settings/api/src/main/java/com/google/jetpackcamera/settings/api/RestrictionConfig.kt @@ -0,0 +1,136 @@ +/* + * 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.api + +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 com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS + +/** + * Defines a configuration for the Jetpack Camera App that can be used by developers + * to override the default app settings. + */ +data class DeveloperAppConfig( + val captureMode: SettingConfig, + val aspectRatio: SettingConfig, + val flashMode: SettingConfig, + val audio: SettingConfig, + val hdrEnabled: SettingConfig +) { + // Ensures that all individual setting configurations are valid. + init { + fun SettingConfig.containsIfOptionsEnabled(options: Set): Boolean { + return when (val restriction = this.uiRestriction) { + is OptionRestrictionConfig.OptionsEnabled -> { + restriction.enabledOptions.containsAll(options) + } + + else -> true + } + } + + require(flashMode.containsIfOptionsEnabled(setOf(FlashMode.OFF))) + } + + companion object { + // Provides a foundation based on JCA's DEFAULT_CAMERA_APP_SETTINGS + val LibraryDefaults: DeveloperAppConfig = DeveloperAppConfig( + aspectRatio = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.aspectRatio), + flashMode = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.flashMode), + captureMode = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.captureMode), + audio = SettingConfig(DEFAULT_CAMERA_APP_SETTINGS.audioEnabled), + hdrEnabled = SettingConfig( + DEFAULT_CAMERA_APP_SETTINGS.dynamicRange != DynamicRange.SDR + ) + ) + } + + /** + * Converts this [DeveloperAppConfig] into a [CameraAppSettings] object. + * + * This function maps the developer-defined settings to the internal camera app settings model. + */ + fun toCameraAppSettings( + defaultSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS + ): CameraAppSettings { + val imageOutputFormat = if (this.hdrEnabled.defaultValue) { + ImageOutputFormat.JPEG_ULTRA_HDR + } else { + ImageOutputFormat.JPEG + } + + val dynamicRange = + if (this.hdrEnabled.defaultValue) DynamicRange.HLG10 else DynamicRange.SDR + + return defaultSettings.copy( + aspectRatio = this.aspectRatio.defaultValue, + flashMode = this.flashMode.defaultValue, + captureMode = this.captureMode.defaultValue, + audioEnabled = this.audio.defaultValue, + imageFormat = imageOutputFormat, + dynamicRange = dynamicRange + ) + } +} + +/** + * Represents a single configurable setting in the application, including its + * default value and any UI restrictions that apply to it. + * + * @param defaultValue The initial value for this setting. + * @param uiRestriction The restrictions applied to this setting in the UI. + */ +data class SettingConfig( + val defaultValue: T, + val uiRestriction: OptionRestrictionConfig = OptionRestrictionConfig.NotRestricted() +) { + init { + // Validate that if options are enabled for this setting, the default value + // is always included in the set of enabled options. + (uiRestriction as? OptionRestrictionConfig.OptionsEnabled)?.let { + require( + uiRestriction.enabledOptions.size >= 2 && + uiRestriction.enabledOptions.contains( + defaultValue + ) + ) { + "OptionsRestrictionConfig.OptionsEnabled#enabledOptions must also contain the defaultValue" + } + } + } +} + +sealed interface OptionRestrictionConfig { + /** All device-supported options are available. */ + class NotRestricted : OptionRestrictionConfig + + /** The entire setting is unavailable and hidden from the UI. */ + class FullyRestricted : OptionRestrictionConfig + + /** ONLY the options in this set are allowed, if supported by the device. */ + data class OptionsEnabled(val enabledOptions: Set) : OptionRestrictionConfig { + init { + require(enabledOptions.isNotEmpty()) { + "enabledOptions must not be empty. " + + "Use FullyRestricted to disable the feature." + } + } + } +} diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts index 0e63717c7..10c0db336 100644 --- a/feature/preview/build.gradle.kts +++ b/feature/preview/build.gradle.kts @@ -140,6 +140,7 @@ dependencies { implementation(project(":core:common")) implementation(project(":data:media")) implementation(project(":data:settings")) + implementation(project(":data:settings:api")) implementation(project(":core:model")) testImplementation(project(":core:common")) implementation(project(":ui:components:capture")) 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 3cf0b7004..19fa33900 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 @@ -23,12 +23,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.jetpackcamera.core.camera.CameraSystem import com.google.jetpackcamera.core.camera.CameraSystem.Companion.applyDiffs +import com.google.jetpackcamera.core.common.DefaultAppConfig import com.google.jetpackcamera.core.common.DefaultSaveMode import com.google.jetpackcamera.data.media.MediaRepository import com.google.jetpackcamera.feature.preview.navigation.getCaptureUris import com.google.jetpackcamera.feature.preview.navigation.getDebugSettings import com.google.jetpackcamera.feature.preview.navigation.getExternalCaptureMode import com.google.jetpackcamera.feature.preview.navigation.getRequestedSaveMode +import com.google.jetpackcamera.feature.preview.navigation.getUseDeveloperConfig import com.google.jetpackcamera.model.CaptureEvent import com.google.jetpackcamera.model.DebugSettings import com.google.jetpackcamera.model.ExternalCaptureMode @@ -38,6 +40,7 @@ import com.google.jetpackcamera.model.SaveLocation import com.google.jetpackcamera.model.SaveMode import com.google.jetpackcamera.settings.ConstraintsRepository import com.google.jetpackcamera.settings.SettingsRepository +import com.google.jetpackcamera.settings.api.DeveloperAppConfig import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.applyExternalCaptureMode import com.google.jetpackcamera.ui.components.capture.LOW_LIGHT_BOOST_FAILURE_TAG @@ -91,6 +94,7 @@ class PreviewViewModel @Inject constructor( private val cameraSystem: CameraSystem, private val savedStateHandle: SavedStateHandle, @DefaultSaveMode private val defaultSaveMode: SaveMode, + @DefaultAppConfig private val appConfig: DeveloperAppConfig, private val settingsRepository: SettingsRepository, private val constraintsRepository: ConstraintsRepository, private val mediaRepository: MediaRepository @@ -112,6 +116,8 @@ class PreviewViewModel @Inject constructor( private val externalUris: List = savedStateHandle.getCaptureUris() private lateinit var externalUriProgress: IntProgress + private val useDeveloperConfig: Boolean = savedStateHandle.getUseDeveloperConfig() + private val debugSettings: DebugSettings = savedStateHandle.getDebugSettings() private var cameraPropertiesJSON = "" @@ -122,7 +128,12 @@ class PreviewViewModel @Inject constructor( // used to ensure we don't start the camera before initialization is complete. private var initializationDeferred: Deferred = viewModelScope.async { cameraSystem.initialize( - cameraAppSettings = settingsRepository.defaultCameraAppSettings.first() + cameraAppSettings = + if (useDeveloperConfig) { + appConfig.toCameraAppSettings() + } else { + settingsRepository.defaultCameraAppSettings.first() + } .applyExternalCaptureMode(externalCaptureMode) .copy(debugSettings = debugSettings) ) { cameraPropertiesJSON = it } @@ -130,6 +141,7 @@ class PreviewViewModel @Inject constructor( val captureUiState: StateFlow = captureUiState( cameraSystem, + if (useDeveloperConfig) appConfig else DeveloperAppConfig.LibraryDefaults, constraintsRepository, trackedCaptureUiState, externalCaptureMode 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 60b91ba52..8d0d16ea5 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 @@ -36,6 +36,7 @@ import com.google.jetpackcamera.feature.preview.navigation.PreviewRoute.ARG_CAPT import com.google.jetpackcamera.feature.preview.navigation.PreviewRoute.ARG_DEBUG_SETTINGS import com.google.jetpackcamera.feature.preview.navigation.PreviewRoute.ARG_EXTERNAL_CAPTURE_MODE import com.google.jetpackcamera.feature.preview.navigation.PreviewRoute.ARG_REVIEW_AFTER_CAPTURE +import com.google.jetpackcamera.feature.preview.navigation.PreviewRoute.ARG_USE_DEVELOPER_CONFIG import com.google.jetpackcamera.model.CaptureEvent import com.google.jetpackcamera.model.DebugSettings import com.google.jetpackcamera.model.ExternalCaptureMode @@ -47,6 +48,7 @@ object PreviewRoute { internal const val ARG_REVIEW_AFTER_CAPTURE: String = "reviewAfterCapture" internal const val ARG_CAPTURE_URIS: String = "captureUris" internal const val ARG_DEBUG_SETTINGS: String = "debugSettings" + internal const val ARG_USE_DEVELOPER_CONFIG: String = "useDeveloperConfig" } private const val BASE_ROUTE_DEF: String = "preview" @@ -55,13 +57,15 @@ private const val FULL_ROUTE_DEF: String = "?${ARG_EXTERNAL_CAPTURE_MODE}={$ARG_EXTERNAL_CAPTURE_MODE}" + "&${ARG_REVIEW_AFTER_CAPTURE}={$ARG_REVIEW_AFTER_CAPTURE}" + "&${ARG_CAPTURE_URIS}={$ARG_CAPTURE_URIS}" + - "&${ARG_DEBUG_SETTINGS}={$ARG_DEBUG_SETTINGS}" + "&${ARG_DEBUG_SETTINGS}={$ARG_DEBUG_SETTINGS}" + + "&${ARG_USE_DEVELOPER_CONFIG}={$ARG_USE_DEVELOPER_CONFIG}" fun NavController.navigateToPreview( externalCaptureMode: ExternalCaptureMode? = null, captureUris: List? = null, debugSettings: DebugSettings? = null, - saveMode: Boolean? = null, + shouldReviewAfterCapture: Boolean? = null, + useDeveloperConfig: Boolean? = null, builder: (NavOptionsBuilder.() -> Unit) = {} ) { var route = BASE_ROUTE_DEF // Start with the base route @@ -76,12 +80,18 @@ fun NavController.navigateToPreview( ).serializeAsValue(it)}" ) } - saveMode?.let { + shouldReviewAfterCapture?.let { queryParams.add( "${ARG_REVIEW_AFTER_CAPTURE}=${ NavType.BoolType.serializeAsValue(it)}" ) } + useDeveloperConfig?.let { + queryParams.add( + "${PreviewRoute.ARG_USE_DEVELOPER_CONFIG}=${ + NavType.BoolType.serializeAsValue(it)}" + ) + } captureUris?.let { queryParams.add( "${ARG_CAPTURE_URIS}=${ @@ -106,6 +116,7 @@ fun NavController.navigateToPreview( fun NavGraphBuilder.previewScreen( externalCaptureMode: ExternalCaptureMode, shouldCacheReview: Boolean, + useDeveloperConfig: Boolean, captureUris: List, debugSettings: DebugSettings, onRequestWindowColorMode: (Int) -> Unit, @@ -133,6 +144,10 @@ fun NavGraphBuilder.previewScreen( navArgument(name = ARG_DEBUG_SETTINGS) { type = DebugSettingsNavType defaultValue = debugSettings + }, + navArgument(name = PreviewRoute.ARG_USE_DEVELOPER_CONFIG) { + type = NavType.BoolType + defaultValue = useDeveloperConfig } ), enterTransition = { fadeIn() } @@ -190,3 +205,6 @@ internal fun SavedStateHandle.getDebugSettings( defaultIfMissing: DebugSettings = DebugSettings() ): DebugSettings = get(ARG_DEBUG_SETTINGS)?.let(DebugSettings::parseFromByteArray) ?: defaultIfMissing + +internal fun SavedStateHandle.getUseDeveloperConfig(defaultIfMissing: Boolean = false): Boolean = + get(PreviewRoute.ARG_USE_DEVELOPER_CONFIG) ?: defaultIfMissing 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 6742a8d28..21cfe97a4 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 @@ -63,7 +63,8 @@ class PreviewViewModelTest { settingsRepository = FakeSettingsRepository, mediaRepository = FakeMediaRepository(), savedStateHandle = SavedStateHandle(), - defaultSaveMode = SaveMode.Immediate + defaultSaveMode = SaveMode.Immediate, + appConfig = null ) advanceUntilIdle() } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6c18183c7..3034c549c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -58,3 +58,4 @@ include(":ui:uistateadapter:postcapture") include(":core:camera:postprocess") include(":ui:controller") include(":ui:controller:impl") +include(":data:settings:api") diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DisabledReason.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DisabledReason.kt index 50c33e06b..80993d684 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DisabledReason.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DisabledReason.kt @@ -27,6 +27,18 @@ enum class DisabledReason( // 'override' is required override val reasonTextResId: Int ) : DisableRationale { + IMAGE_CAPTURE_RESTRICTED( + testTag = IMAGE_CAPTURE_RESTRICTED_TAG, + R.string.toast_image_capture_restricted + ), + VIDEO_CAPTURE_RESTRICTED( + testTag = IMAGE_CAPTURE_RESTRICTED_TAG, + R.string.toast_image_capture_restricted + ), + HYBRID_CAPTURE_RESTRICTED( + testTag = IMAGE_CAPTURE_RESTRICTED_TAG, + R.string.toast_image_capture_restricted + ), VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED( VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG, R.string.toast_video_capture_external_unsupported diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt index 9bf92ba3c..73580b9ae 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt @@ -21,6 +21,11 @@ const val FLIP_CAMERA_BUTTON = "FlipCameraButton" const val IMAGE_CAPTURE_SUCCESS_TAG = "ImageCaptureSuccessTag" const val IMAGE_CAPTURE_FAILURE_TAG = "ImageCaptureFailureTag" const val IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG = "ImageCaptureExternalUnsupportedTag" + +const val IMAGE_CAPTURE_RESTRICTED_TAG = "ImageCaptureRestrictedTag" +const val VIDEO_CAPTURE_RESTRICTED_TAG = "ImageCaptureRestrictedTag" +const val HYBRID_CAPTURE_RESTRICTED_TAG = "ImageCaptureRestrictedTag" + const val IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG = "ImageCaptureUnsupportedConcurrentCameraTag" const val VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG = "VideoCaptureExternalUnsupportedTag" diff --git a/ui/components/capture/src/main/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml index b551cfec0..cb2317f48 100644 --- a/ui/components/capture/src/main/res/values/strings.xml +++ b/ui/components/capture/src/main/res/values/strings.xml @@ -42,6 +42,10 @@ + Hybrid Capture option is restricted + Image Capture option is restricted + Image Capture option is restricted + Image Capture Success Video Capture Success Image Capture Failure diff --git a/ui/uistateadapter/capture/build.gradle.kts b/ui/uistateadapter/capture/build.gradle.kts index c26f63359..d423700c7 100644 --- a/ui/uistateadapter/capture/build.gradle.kts +++ b/ui/uistateadapter/capture/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation(libs.compose.material3) implementation(project(":data:settings")) + implementation(project(":data:settings:api")) implementation(project(":core:model")) implementation(project(":data:media")) implementation(project(":core:camera")) 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 11642f18e..f90ebc51a 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 @@ -24,6 +24,7 @@ import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.model.ImageOutputFormat import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.StreamConfig +import com.google.jetpackcamera.settings.api.OptionRestrictionConfig import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CameraConstraints import com.google.jetpackcamera.settings.model.CameraSystemConstraints @@ -58,7 +59,8 @@ fun CaptureModeToggleUiState.Companion.from( systemConstraints: CameraSystemConstraints, cameraAppSettings: CameraAppSettings, cameraState: CameraState, - externalCaptureMode: ExternalCaptureMode + externalCaptureMode: ExternalCaptureMode, + restrictionConfig: OptionRestrictionConfig ): CaptureModeToggleUiState = if (cameraState.videoRecordingState !is VideoRecordingState.Inactive || cameraAppSettings.captureMode == CaptureMode.STANDARD @@ -68,7 +70,8 @@ fun CaptureModeToggleUiState.Companion.from( val availableCaptureModes = getAvailableCaptureModes( systemConstraints, cameraAppSettings, - externalCaptureMode + externalCaptureMode, + restrictionConfig ) // Find the IMAGE_ONLY and VIDEO_ONLY states val imageOnlyState = availableCaptureModes.first { item -> @@ -83,11 +86,17 @@ fun CaptureModeToggleUiState.Companion.from( is SingleSelectableUiState.Disabled -> item.value == CaptureMode.VIDEO_ONLY } } - CaptureModeToggleUiState.Available( - selectedCaptureMode = cameraAppSettings.captureMode, - imageOnlyUiState = imageOnlyState, - videoOnlyUiState = videoOnlyState - ) + if (imageOnlyState is SingleSelectableUiState.Disabled || + videoOnlyState is SingleSelectableUiState.Disabled + ) { + CaptureModeToggleUiState.Unavailable + } else { + CaptureModeToggleUiState.Available( + selectedCaptureMode = cameraAppSettings.captureMode, + imageOnlyUiState = imageOnlyState, + videoOnlyUiState = videoOnlyState + ) + } } /** @@ -105,13 +114,15 @@ fun CaptureModeToggleUiState.Companion.from( */ fun CaptureModeUiState.Companion.from( systemConstraints: CameraSystemConstraints, + restrictionConfig: OptionRestrictionConfig, cameraAppSettings: CameraAppSettings, externalCaptureMode: ExternalCaptureMode ): CaptureModeUiState { val availableCaptureModes = getAvailableCaptureModes( systemConstraints, cameraAppSettings, - externalCaptureMode + externalCaptureMode, + restrictionConfig ) return CaptureModeUiState.Available( selectedCaptureMode = cameraAppSettings.captureMode, @@ -121,38 +132,52 @@ fun CaptureModeUiState.Companion.from( private fun getSupportedCaptureModes( cameraAppSettings: CameraAppSettings, + config: OptionRestrictionConfig, 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 OptionRestrictionConfig.NotRestricted -> ORDERED_UI_SUPPORTED_CAPTURE_MODES + is OptionRestrictionConfig.FullyRestricted -> emptyList() + is OptionRestrictionConfig.OptionsEnabled -> + 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, + config: OptionRestrictionConfig ): List> { + // 1. start with all UI supported modes + // 2. filter out modes that are restricted by config + // 3. filter out modes that are not supported by the device given current settings val cameraConstraints: CameraConstraints? = systemConstraints.forCurrentLens( cameraAppSettings ) @@ -175,6 +200,7 @@ private fun getAvailableCaptureModes( } val supportedCaptureModes = getSupportedCaptureModes( cameraAppSettings, + config, isHdrOn, currentHdrDynamicRangeSupported, currentHdrImageFormatSupported, @@ -198,6 +224,7 @@ private fun getAvailableCaptureModes( hdrDynamicRangeSupported = currentHdrDynamicRangeSupported, hdrImageFormatSupported = currentHdrImageFormatSupported, systemConstraints = systemConstraints, + restrictionConfig = config, cameraAppSettings.cameraLensFacing, cameraAppSettings.streamConfig, cameraAppSettings.concurrentCameraMode, @@ -221,6 +248,7 @@ private fun getAvailableCaptureModes( currentHdrDynamicRangeSupported, currentHdrImageFormatSupported, systemConstraints, + config, cameraAppSettings.cameraLensFacing, cameraAppSettings.streamConfig, cameraAppSettings.concurrentCameraMode, @@ -255,6 +283,7 @@ private fun getCaptureModeDisabledReason( hdrDynamicRangeSupported: Boolean, hdrImageFormatSupported: Boolean, systemConstraints: CameraSystemConstraints, + restrictionConfig: OptionRestrictionConfig, currentLensFacing: LensFacing, currentStreamConfig: StreamConfig, concurrentCameraMode: ConcurrentCameraMode, @@ -266,6 +295,17 @@ private fun getCaptureModeDisabledReason( return DisabledReason .IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED } + when (restrictionConfig) { + is OptionRestrictionConfig.FullyRestricted -> + return DisabledReason.IMAGE_CAPTURE_RESTRICTED + is OptionRestrictionConfig.OptionsEnabled -> { + if (!restrictionConfig.enabledOptions.contains(disabledCaptureMode)) { + return DisabledReason.IMAGE_CAPTURE_RESTRICTED + } + } + + is OptionRestrictionConfig.NotRestricted -> {} + } if (concurrentCameraMode == ConcurrentCameraMode.DUAL) { return DisabledReason @@ -310,6 +350,19 @@ private fun getCaptureModeDisabledReason( .VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED } + when (restrictionConfig) { + is OptionRestrictionConfig.FullyRestricted -> { + return DisabledReason.VIDEO_CAPTURE_RESTRICTED + } + is OptionRestrictionConfig.OptionsEnabled -> { + if (!restrictionConfig.enabledOptions.contains(disabledCaptureMode)) { + return DisabledReason.VIDEO_CAPTURE_RESTRICTED + } + } + + is OptionRestrictionConfig.NotRestricted -> {} + } + if (!hdrDynamicRangeSupported) { if (systemConstraints.anySupportsHdrDynamicRange { it != currentLensFacing }) { return DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_LENS @@ -321,7 +374,19 @@ private fun getCaptureModeDisabledReason( } CaptureMode.STANDARD -> { - TODO() + when (restrictionConfig) { + is OptionRestrictionConfig.FullyRestricted -> { + return DisabledReason.HYBRID_CAPTURE_RESTRICTED + } + is OptionRestrictionConfig.OptionsEnabled -> { + if (!restrictionConfig.enabledOptions.contains(disabledCaptureMode)) { + return DisabledReason.HYBRID_CAPTURE_RESTRICTED + } + } + + is OptionRestrictionConfig.NotRestricted -> {} + } + throw RuntimeException("Unknown DisabledReason for hybrid mode.") } } } 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 eabc94353..b519c7e6d 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 @@ -18,6 +18,7 @@ package com.google.jetpackcamera.ui.uistateadapter.capture.compound import com.google.jetpackcamera.core.camera.CameraSystem import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.settings.ConstraintsRepository +import com.google.jetpackcamera.settings.api.DeveloperAppConfig 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 +62,7 @@ import kotlinx.coroutines.flow.filterNotNull */ fun captureUiState( cameraSystem: CameraSystem, + appConfig: DeveloperAppConfig, constraintsRepository: ConstraintsRepository, trackedCaptureUiState: MutableStateFlow, externalCaptureMode: ExternalCaptureMode @@ -76,6 +78,7 @@ fun captureUiState( ) { cameraAppSettings, systemConstraints, cameraState, trackedUiState -> val captureModeUiState = CaptureModeUiState.from( systemConstraints, + appConfig.captureMode.uiRestriction, cameraAppSettings, externalCaptureMode ) @@ -160,7 +163,8 @@ fun captureUiState( systemConstraints, cameraAppSettings, cameraState, - externalCaptureMode + externalCaptureMode, + appConfig.captureMode.uiRestriction ), hdrUiState = hdrUiState, focusMeteringUiState = focusMeteringUiState,