diff options
author | David Jia <davidjia@google.com> | 2024-02-05 23:58:14 +0000 |
---|---|---|
committer | David Jia <davidjia@google.com> | 2024-02-08 21:31:25 +0000 |
commit | 1fbcf3a6c408393401786a876009fe9d18844c31 (patch) | |
tree | 222c6eb4bf6594f9fa9cbaf4184e503f2beb2269 | |
parent | 1280ae1bb0718dbd855c28e9065fe71d448dd057 (diff) | |
parent | aa8801977aa9586d9c7693d09c410201d5a1743e (diff) | |
download | jetpack-camera-app-1fbcf3a6c408393401786a876009fe9d18844c31.tar.gz |
[external/jetpack-camera-app]Merge upstream-main branchplatform-tools-35.0.1
Merge up to aa880197
Also adds camera-video dep and uncommented related codes.
Added tracing dep.
Removed camera-extension dep as it is unused.
Test: Compilation successful
Change-Id: Ib1b4a361c0204067a0c62227068a832ce021a56f
71 files changed, 3460 insertions, 369 deletions
diff --git a/.github/workflows/MergeToMainWorkflow.yaml b/.github/workflows/MergeToMainWorkflow.yaml new file mode 100644 index 0000000..ad4954e --- /dev/null +++ b/.github/workflows/MergeToMainWorkflow.yaml @@ -0,0 +1,53 @@ +name: Merge To Main + +on: + push: + branches: + - main + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +env: + JDK_VERSION: 17 + DISTRIBUTION: 'zulu' + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Validate Gradle Wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Set up JDK + uses: actions/setup-java@v3 + with: + distribution: ${{ env.DISTRIBUTION }} + java-version: ${{ env.JDK_VERSION }} + cache: gradle + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Build all build type and flavor permutations + run: ./gradlew assemble --parallel --build-cache + + - name: Upload build outputs (APKs) + uses: actions/upload-artifact@v3 + with: + name: build-outputs + path: app/build/outputs + + - name: Upload build reports + if: always() + continue-on-error: true + uses: actions/upload-artifact@v3 + with: + name: build-reports + path: "*/build/reports" diff --git a/.github/workflows/PushWorkflow.yaml b/.github/workflows/PullRequestWorkflow.yaml index 06c8921..ea32c21 100644 --- a/.github/workflows/PushWorkflow.yaml +++ b/.github/workflows/PullRequestWorkflow.yaml @@ -1,6 +1,6 @@ name: Presubmit -on: [push] +on: [pull_request] concurrency: group: build-${{ github.ref }} diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 6abdd98..24b6073 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -3,6 +3,19 @@ <component name="AndroidTestResultsUserPreferences"> <option name="androidTestResultsTableState"> <map> + <entry key="-1168588695"> + <value> + <AndroidTestResultsTableState> + <option name="preferredColumnWidths"> + <map> + <entry key="Duration" value="90" /> + <entry key="Pixel_7_Pro_API_34" value="120" /> + <entry key="Tests" value="360" /> + </map> + </option> + </AndroidTestResultsTableState> + </value> + </entry> <entry key="401594821"> <value> <AndroidTestResultsTableState> @@ -16,6 +29,32 @@ </AndroidTestResultsTableState> </value> </entry> + <entry key="571770275"> + <value> + <AndroidTestResultsTableState> + <option name="preferredColumnWidths"> + <map> + <entry key="Duration" value="90" /> + <entry key="Pixel_7_Pro_API_34" value="120" /> + <entry key="Tests" value="360" /> + </map> + </option> + </AndroidTestResultsTableState> + </value> + </entry> + <entry key="632950842"> + <value> + <AndroidTestResultsTableState> + <option name="preferredColumnWidths"> + <map> + <entry key="Duration" value="90" /> + <entry key="Tests" value="360" /> + <entry key="samsung SM-G990U1" value="120" /> + </map> + </option> + </AndroidTestResultsTableState> + </value> + </entry> <entry key="2043991187"> <value> <AndroidTestResultsTableState> diff --git a/.idea/gradle.xml b/.idea/gradle.xml index ba0df2e..37f7544 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -5,12 +5,14 @@ <option name="linkedExternalProjectsSettings"> <GradleProjectSettings> <option name="testRunner" value="GRADLE" /> + <option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="externalProjectPath" value="$PROJECT_DIR$" /> - <option name="gradleJvm" value="JDK" /> + <option name="gradleJvm" value="Android Studio default JDK" /> <option name="modules"> <set> <option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$/app" /> + <option value="$PROJECT_DIR$/benchmark" /> <option value="$PROJECT_DIR$/camera-viewfinder-compose" /> <option value="$PROJECT_DIR$/core" /> <option value="$PROJECT_DIR$/core/common" /> diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fe988de..1c27e4d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,10 +15,10 @@ */ plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - id("kotlin-kapt") - id("com.google.dagger.hilt.android") + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.dagger.hilt.android) } android { @@ -46,6 +46,11 @@ android { "proguard-rules.pro" ) } + create("benchmark") { + initWith(buildTypes.getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -69,46 +74,46 @@ android { dependencies { // Compose - val composeBom = platform("androidx.compose:compose-bom:2023.08.00") + val composeBom = platform(libs.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) // Compose - Material Design 3 - implementation("androidx.compose.material3:material3") + implementation(libs.compose.material3) // Compose - Android Studio Preview support - implementation("androidx.compose.ui:ui-tooling-preview") - debugImplementation("androidx.compose.ui:ui-tooling") + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) // Compose - Integration with ViewModels - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1") + implementation(libs.lifecycle.viewmodel.compose) // Compose - Integration with Activities - implementation("androidx.activity:activity-compose") + implementation(libs.androidx.activity.compose) // Compose - Testing - androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation(libs.compose.junit) // Testing - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") - androidTestImplementation("androidx.test:rules:1.5.0") - androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0") + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + androidTestImplementation(libs.androidx.rules) + androidTestImplementation(libs.androidx.uiautomator) - implementation("androidx.core:core-ktx:1.8.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1") + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) // Hilt - implementation("com.google.dagger:hilt-android:2.44") - kapt("com.google.dagger:hilt-compiler:2.44") + implementation(libs.dagger.hilt.android) + kapt(libs.dagger.hilt.compiler) // Accompanist - Permissions - implementation("com.google.accompanist:accompanist-permissions:0.26.5-rc") + implementation(libs.accompanist.permissions) // Jetpack Navigation - val nav_version = "2.5.3" - implementation("androidx.navigation:navigation-compose:$nav_version") + implementation(libs.androidx.navigation.compose) // Access Settings data implementation(project(":data:settings")) @@ -118,6 +123,10 @@ dependencies { // Settings Screen implementation(project(":feature:settings")) + + // benchmark + implementation(libs.androidx.profileinstaller) + } // Allow references to generated code diff --git a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt index dd90332..fed70d0 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt @@ -21,6 +21,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until import com.google.jetpackcamera.settings.model.FlashMode import kotlinx.coroutines.test.runTest import org.junit.Before @@ -86,4 +87,32 @@ internal class FlashDeviceTest { FlashMode.OFF ) } + + @Test + fun set_screen_flash_and_capture_successfully() = runTest { + uiDevice.waitForIdle() + uiDevice.findObject(By.res("QuickSettingDropDown")).click() + // flash on with front camera will automatically enable screen flash + uiDevice.findObject(By.res("QuickSetFlash")).click() + uiDevice.findObject(By.res("QuickSettingDropDown")).click() + uiDevice.findObject(By.res("CaptureButton")).click() + uiDevice.wait( + Until.findObject(By.res("ImageCaptureSuccessToast")), + 5000 + ) + } + + @Test + fun set_screen_flash_and_capture_with_screen_change_overlay_shown() = runTest { + uiDevice.waitForIdle() + uiDevice.findObject(By.res("QuickSettingDropDown")).click() + // flash on with front camera will automatically enable screen flash + uiDevice.findObject(By.res("QuickSetFlash")).click() + uiDevice.findObject(By.res("QuickSettingDropDown")).click() + uiDevice.findObject(By.res("CaptureButton")).click() + uiDevice.wait( + Until.findObject(By.res("ScreenFlashOverlay")), + 5000 + ) + } } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt new file mode 100644 index 0000000..7e3fc98 --- /dev/null +++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2023 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 + +import android.app.Instrumentation +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityOptionsCompat +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import java.io.File +import java.net.URLConnection +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class ImageCaptureDeviceTest { + // TODO(b/319733374): Return bitmap for external mediastore capture without URI + + @get:Rule + val cameraPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant( + android.Manifest.permission.CAMERA, + android.Manifest.permission.READ_EXTERNAL_STORAGE, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private var activityScenario: ActivityScenario<MainActivity>? = null + private val uiDevice = UiDevice.getInstance(instrumentation) + + @Test + fun image_capture_external() = run { + val timeStamp = System.currentTimeMillis() + val uri = getTestUri(timeStamp) + getTestRegistry { + activityScenario = ActivityScenario.launchActivityForResult(it) + uiDevice.wait( + Until.findObject(By.res("CaptureButton")), + 5000 + ) + uiDevice.findObject(By.res("CaptureButton")).click() + uiDevice.wait( + Until.findObject(By.res("ImageCaptureSuccessToast")), + 5000 + ) + activityScenario!!.result + }.register("key", TEST_CONTRACT) { result -> + assert(result) + assert(doesImageFileExist(uri)) + }.launch(uri) + deleteFilesInDirAfterTimestamp(timeStamp) + } + + @Test + fun image_capture_external_illegal_uri() = runTest { + val timeStamp = System.currentTimeMillis() + val inputUri = Uri.parse("asdfasdf") + getTestRegistry { + activityScenario = ActivityScenario.launchActivityForResult(it) + uiDevice.wait( + Until.findObject(By.res("CaptureButton")), + 5000 + ) + uiDevice.findObject(By.res("CaptureButton")).click() + uiDevice.wait( + Until.findObject(By.res("ImageCaptureFailureToast")), + 5000 + ) + uiDevice.pressBack() + activityScenario!!.result + }.register("key_illegal_uri", TEST_CONTRACT) { result -> + assert(!result) + }.launch(inputUri) + deleteFilesInDirAfterTimestamp(timeStamp) + } + + private fun doesImageFileExist(uri: Uri): Boolean { + val file = File(uri.path) + if (file.exists()) { + val mimeType = URLConnection.guessContentTypeFromName(uri.path) + return mimeType != null && mimeType.startsWith("image") + } + return false + } + + private fun deleteFilesInDirAfterTimestamp(timeStamp: Long) { + for (file in File(DIR_PATH).listFiles()) { + if (file.lastModified() >= timeStamp) { + file.delete() + if (file.exists()) { + file.getCanonicalFile().delete() + if (file.exists()) { + instrumentation.targetContext.applicationContext.deleteFile(file.getName()) + } + } + } + } + } + + private fun getTestRegistry( + launch: (Intent) -> Instrumentation.ActivityResult + ): ActivityResultRegistry { + val testRegistry = object : ActivityResultRegistry() { + override fun <I, O> onLaunch( + requestCode: Int, + contract: ActivityResultContract<I, O>, + input: I, + options: ActivityOptionsCompat? + ) { + // contract.create + val launchIntent = contract.createIntent( + ApplicationProvider.getApplicationContext(), + input + ) + val result: Instrumentation.ActivityResult = launch(launchIntent) + dispatchResult(requestCode, result.resultCode, result.resultData) + } + } + return testRegistry + } + + private fun getTestUri(timeStamp: Long): Uri { + return Uri.fromFile( + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + "$timeStamp.jpg" + ) + ) + } + + companion object { + val DIR_PATH: String = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).path + + val TEST_CONTRACT = object : ActivityResultContracts.TakePicture() { + override fun createIntent(context: Context, uri: Uri): Intent { + return super.createIntent(context, uri).apply { + component = ComponentName( + "com.google.jetpackcamera", + "com.google.jetpackcamera.MainActivity" + ) + } + } + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4e5c9ce..42c1a61 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,9 @@ android:required="false" /> <uses-permission android:name="android.permission.CAMERA" /> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> + <application android:name=".JetpackCameraApplication" @@ -38,6 +41,7 @@ android:supportsRtl="true" android:theme="@style/Theme.JetpackCamera" tools:targetApi="33"> + <profileable android:shell="true"/> <activity android:name=".MainActivity" android:exported="true" @@ -45,6 +49,7 @@ android:theme="@style/Theme.JetpackCamera"> <intent-filter> <action android:name="android.intent.action.MAIN" /> + <action android:name="android.media.action.IMAGE_CAPTURE" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt index a499bb9..42220d1 100644 --- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt +++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt @@ -15,7 +15,10 @@ */ package com.google.jetpackcamera +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.MediaStore import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -44,6 +47,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.jetpackcamera.MainActivityUiState.Loading import com.google.jetpackcamera.MainActivityUiState.Success +import com.google.jetpackcamera.feature.preview.PreviewMode import com.google.jetpackcamera.feature.preview.PreviewViewModel import com.google.jetpackcamera.settings.model.DarkMode import com.google.jetpackcamera.ui.JcaApp @@ -101,13 +105,45 @@ class MainActivity : Hilt_MainActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - JcaApp(onPreviewViewModel = { previewViewModel = it }) + JcaApp( + onPreviewViewModel = { previewViewModel = it }, + previewMode = getPreviewMode() + ) } } } } } } + + private fun getPreviewMode(): PreviewMode { + if (intent == null || MediaStore.ACTION_IMAGE_CAPTURE != intent.action) { + return PreviewMode.StandardMode + } else { + var uri = if (intent.extras == null || + !intent.extras!!.containsKey(MediaStore.EXTRA_OUTPUT) + ) { + null + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.extras!!.getParcelable( + MediaStore.EXTRA_OUTPUT, + Uri::class.java + ) + } else { + @Suppress("DEPRECATION") + intent.extras!!.getParcelable(MediaStore.EXTRA_OUTPUT) + } + if (uri == null && intent.clipData != null && intent.clipData!!.itemCount != 0) { + uri = intent.clipData!!.getItemAt(0).uri + } + return PreviewMode.ExternalImageCaptureMode(uri) { event -> + if (event == PreviewViewModel.ImageCaptureEvent.ImageSaved) { + setResult(RESULT_OK) + finish() + } + } + } + } } /** 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 8fadcbf..8193c03 100644 --- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt +++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt @@ -26,6 +26,7 @@ import androidx.navigation.compose.rememberNavController //import com.google.accompanist.permissions.ExperimentalPermissionsApi //import com.google.accompanist.permissions.isGranted //import com.google.accompanist.permissions.rememberPermissionState +import com.google.jetpackcamera.feature.preview.PreviewMode import com.google.jetpackcamera.feature.preview.PreviewScreen import com.google.jetpackcamera.feature.preview.PreviewViewModel import com.google.jetpackcamera.settings.SettingsScreen @@ -35,14 +36,18 @@ import com.google.jetpackcamera.ui.Routes.SETTINGS_ROUTE //@OptIn(ExperimentalPermissionsApi::class) @Composable fun JcaApp( - onPreviewViewModel: (PreviewViewModel) -> Unit + onPreviewViewModel: (PreviewViewModel) -> Unit, /*TODO(b/306236646): remove after still capture*/ + previewMode: PreviewMode ) { // val permissionState = Manifest.permission.CAMERA // rememberPermissionState(permission = Manifest.permission.CAMERA) // if (permissionState.status.isGranted) { - JetpackCameraNavHost(onPreviewViewModel) + JetpackCameraNavHost( + onPreviewViewModel = onPreviewViewModel, + previewMode = previewMode + ) // } else { // CameraPermission( // modifier = Modifier.fillMaxSize(), @@ -54,13 +59,15 @@ fun JcaApp( @Composable private fun JetpackCameraNavHost( onPreviewViewModel: (PreviewViewModel) -> Unit, - navController: NavHostController = rememberNavController() + navController: NavHostController = rememberNavController(), + previewMode: PreviewMode ) { NavHost(navController = navController, startDestination = PREVIEW_ROUTE) { composable(PREVIEW_ROUTE) { PreviewScreen( onPreviewViewModel = onPreviewViewModel, - onNavigateToSettings = { navController.navigate(SETTINGS_ROUTE) } + onNavigateToSettings = { navController.navigate(SETTINGS_ROUTE) }, + previewMode = previewMode ) } composable(SETTINGS_ROUTE) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c3d708..e435f33 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,4 +22,5 @@ <string name="request_permission">Allow Access</string> <string name="jca_loading">Loading App…</string> <string name="camera_not_available">Camera not available</string> + <string name="external_capture_uri_not_supplied">No parcelable URI is supplied for external capture.</string> </resources>
\ No newline at end of file diff --git a/benchmark/.gitignore b/benchmark/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/benchmark/.gitignore @@ -0,0 +1 @@ +/build
\ No newline at end of file diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 0000000..617294b --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2023 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.test) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.google.jetpackcamera.benchmark" + compileSdk = 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + defaultConfig { + //Our app has a minSDK of 21, but in order for the benchmark tool to function, it must be 23 + minSdk = 23 + targetSdk = 34 + + // allows the benchmark to be run on an emulator + testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = + "EMULATOR,LOW-BATTERY,NOT-PROFILEABLE" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // This benchmark buildType is used for benchmarking, and should function like your + // release build (for example, with minification on). It"s signed with a debug key + // for easy local/CI testing. + create("benchmark") { + isDebuggable = true + signingConfig = getByName("debug").signingConfig + matchingFallbacks += listOf("release") + } + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true + // required for benchmark: + // self instrumentation required for the tests to be able to compile, start, or kill the app + // ensures test and app processes are separate + // see https://source.android.com/docs/core/tests/development/instr-self-e2e +} + +dependencies { + implementation(libs.androidx.junit) + implementation(libs.androidx.benchmark.macro.junit4) +} + +androidComponents { + beforeVariants(selector().all()) { + it.enable = it.buildType == "benchmark" + } +}
\ No newline at end of file diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0370d89 --- /dev/null +++ b/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2023 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. + --> +<manifest />
\ No newline at end of file diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt new file mode 100644 index 0000000..d909bff --- /dev/null +++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2023 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.benchmark + +import android.content.Intent +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.TraceSectionMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ImageCaptureLatencyBenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun rearCameraNoFlashLatency() { + imageCaptureLatency(shouldFaceFront = false, flashMode = FlashMode.OFF) + } + + @Test + fun frontCameraNoFlashLatency() { + imageCaptureLatency(shouldFaceFront = true, flashMode = FlashMode.OFF) + } + + @Test + fun rearCameraWithFlashLatency() { + imageCaptureLatency(shouldFaceFront = false, flashMode = FlashMode.ON) + } + + @Test + fun frontCameraWithFlashLatency() { + imageCaptureLatency(shouldFaceFront = true, flashMode = FlashMode.ON) + } + + @Test + fun rearCameraNoFlashExternalImageCaptureLatency() { + imageCaptureLatency(shouldFaceFront = false, flashMode = FlashMode.OFF) + } + + /** + * Measures the time between an onClick event on the Capture Button and onImageCapture + * callback being fired from + * [takePicture][com.google.jetpackcamera.domain.camera.CameraXCameraUseCase.takePicture]. + * + * @param shouldFaceFront the direction the camera should be facing. + * @param flashMode the designated [FlashMode] for the camera. + * @param timeout option to change the default timeout length after clicking the Image Capture + * button. + * + */ + @OptIn(ExperimentalMetricApi::class) + private fun imageCaptureLatency( + shouldFaceFront: Boolean, + flashMode: FlashMode, + timeout: Long = 15000, + intent: Intent? = null + ) { + benchmarkRule.measureRepeated( + packageName = JCA_PACKAGE_NAME, + metrics = listOf( + TraceSectionMetric(sectionName = IMAGE_CAPTURE_TRACE, targetPackageOnly = false) + ), + iterations = DEFAULT_TEST_ITERATIONS, + setupBlock = { + allowCamera() + pressHome() + if (intent == null) startActivityAndWait() else startActivityAndWait(intent) + toggleQuickSettings(device) + setQuickFrontFacingCamera(shouldFaceFront = shouldFaceFront, device = device) + setQuickSetFlash(flashMode = flashMode, device = device) + toggleQuickSettings(device) + device.waitForIdle() + } + + ) { + device.waitForIdle() + + clickCaptureButton(device) + + // ensure trace is closed + findObjectByRes( + device = device, + testTag = IMAGE_CAPTURE_SUCCESS_TOAST, + timeout = timeout, + shouldFailIfNotFound = true + ) + } + } +} diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt new file mode 100644 index 0000000..af8d7d5 --- /dev/null +++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 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.benchmark + +import android.Manifest.permission +import androidx.benchmark.macro.MacrobenchmarkScope +import org.junit.Assert + +fun MacrobenchmarkScope.allowCamera() { + val command = "pm grant $packageName ${permission.CAMERA}" + val output = device.executeShellCommand(command) + Assert.assertEquals("", output) +} diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt new file mode 100644 index 0000000..f8c1f71 --- /dev/null +++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2023 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.benchmark + +import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Run this benchmark from Studio to see startup measurements, and captured system traces + * for investigating your app's performance. + */ +@RunWith(AndroidJUnit4::class) +class StartupBenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun startupColdWithoutCameraPermission() { + benchmarkStartup() + } + + @Test + fun startupCold() { + benchmarkStartup( + setupBlock = + { allowCamera() } + ) + } + + @Test + fun startupWarm() { + benchmarkStartup( + startupMode = StartupMode.WARM, + setupBlock = + { allowCamera() } + ) + } + + @Test + fun startupHot() { + benchmarkStartup( + startupMode = StartupMode.HOT, + setupBlock = + { allowCamera() } + ) + } + + private fun benchmarkStartup( + setupBlock: MacrobenchmarkScope.() -> Unit = {}, + startupMode: StartupMode? = StartupMode.COLD + ) { + benchmarkRule.measureRepeated( + packageName = JCA_PACKAGE_NAME, + metrics = listOf(StartupTimingMetric()), + iterations = DEFAULT_TEST_ITERATIONS, + startupMode = startupMode, + setupBlock = setupBlock + + ) { + pressHome() + startActivityAndWait() + } + } +} diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt new file mode 100644 index 0000000..4d6f8fd --- /dev/null +++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2023 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.benchmark + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import org.junit.Assert + +const val JCA_PACKAGE_NAME = "com.google.jetpackcamera" +const val DEFAULT_TEST_ITERATIONS = 5 + +// test tags +const val CAPTURE_BUTTON = "CaptureButton" +const val QUICK_SETTINGS_DROP_DOWN_BUTTON = "QuickSettingDropDown" +const val QUICK_SETTINGS_FLASH_BUTTON = "QuickSetFlash" +const val QUICK_SETTINGS_FLIP_CAMERA_BUTTON = "QuickSetFlipCamera" +const val IMAGE_CAPTURE_SUCCESS_TOAST = "ImageCaptureSuccessToast" + +// test descriptions +const val QUICK_SETTINGS_FLASH_OFF = "QUICK SETTINGS FLASH IS OFF" +const val QUICK_SETTINGS_FLASH_ON = "QUICK SETTINGS FLASH IS ON" +const val QUICK_SETTINGS_FLASH_AUTO = "QUICK SETTINGS FLASH IS AUTO" +const val QUICK_SETTINGS_LENS_FRONT = "QUICK SETTINGS LENS FACING FRONT" + +// trace tags +const val IMAGE_CAPTURE_TRACE = "JCA Image Capture" + +// enums +enum class FlashMode { + ON, + OFF, + AUTO +} +// todo(kimblebee): designate "default testing settings" to ensure consistency of benchmarks + +/** + * function to click capture button on device. + * + * @param duration length of the click. + */ +fun clickCaptureButton(device: UiDevice, duration: Long = 0) { + findObjectByRes(device, CAPTURE_BUTTON)!!.click(duration) +} + +/** + * Toggle open or close quick settings menu on a device. + */ +fun toggleQuickSettings(device: UiDevice) { + findObjectByRes( + device = device, + testTag = QUICK_SETTINGS_DROP_DOWN_BUTTON, + shouldFailIfNotFound = true + )!!.click() +} + +/** + * Set device direction using quick settings. + * + * Quick Settings must first be opened with a call to [toggleQuickSettings] + * + * @param shouldFaceFront the direction the camera should be facing + */ +fun setQuickFrontFacingCamera(shouldFaceFront: Boolean, device: UiDevice) { + val isFrontFacing = findObjectByDesc(device, QUICK_SETTINGS_LENS_FRONT) != null + + if (isFrontFacing != shouldFaceFront) { + findObjectByRes( + device = device, + testTag = QUICK_SETTINGS_FLIP_CAMERA_BUTTON, + shouldFailIfNotFound = true + )!!.click() + } +} + +/** + * Set device flash mode using quick settings. + * @param flashMode the designated [FlashMode] for the camera + * + */ + +fun setQuickSetFlash(flashMode: FlashMode, device: UiDevice) { + val selector = + when (flashMode) { + FlashMode.AUTO -> By.desc(QUICK_SETTINGS_FLASH_AUTO) + FlashMode.ON -> By.desc(QUICK_SETTINGS_FLASH_ON) + FlashMode.OFF -> By.desc(QUICK_SETTINGS_FLASH_OFF) + } + while (device.findObject(selector) == null) { + findObjectByRes( + device = device, + testTag = QUICK_SETTINGS_FLASH_BUTTON, + shouldFailIfNotFound = true + )!!.click() + } +} + +/** + * Find a composable by its test tag. + */ +fun findObjectByRes( + device: UiDevice, + testTag: String, + timeout: Long = 2_500, + shouldFailIfNotFound: Boolean = false +): UiObject2? { + val selector = By.res(testTag) + + return if (!device.wait(Until.hasObject(selector), timeout)) { + if (shouldFailIfNotFound) { + Assert.fail("Did not find object with id $testTag") + } + null + } else { + device.findObject(selector) + } +} + +/** + * Find an object by its test description. + */ +fun findObjectByDesc( + device: UiDevice, + testDesc: String, + timeout: Long = 2_500, + shouldFailIfNotFound: Boolean = false +): UiObject2? { + val selector = By.desc(testDesc) + + return if (!device.wait(Until.hasObject(selector), timeout)) { + if (shouldFailIfNotFound) { + Assert.fail("Did not find object with id $testDesc in $timeout ms") + } + null + } else { + device.findObject(selector) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index d22d8a2..676fb11 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,10 +16,13 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.1.1" apply false - id("com.android.library") version "8.1.1" apply false - id("org.jetbrains.kotlin.android") version "1.8.0" apply false - id("com.google.dagger.hilt.android") version "2.44" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.dagger.hilt.android) apply false + alias(libs.plugins.kotlin.kapt) apply false + } tasks.register<Copy>("installGitHooks") { diff --git a/camera-viewfinder-compose/build.gradle.kts b/camera-viewfinder-compose/build.gradle.kts index b40b70a..e1251c3 100644 --- a/camera-viewfinder-compose/build.gradle.kts +++ b/camera-viewfinder-compose/build.gradle.kts @@ -15,8 +15,8 @@ */ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { @@ -57,31 +57,29 @@ android { dependencies { // Compose - val composeBom = platform("androidx.compose:compose-bom:2023.08.00") + val composeBom = platform(libs.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) // Compose - Material Design 3 - implementation("androidx.compose.material3:material3") + implementation(libs.compose.material3) // Compose - Testing - androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation(libs.compose.junit) // Compose - Android Studio Preview support - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.ui:ui-tooling") + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.ui.tooling) // Testing - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) // CameraX - val camerax_version = "1.4.0-SNAPSHOT" - implementation("androidx.camera:camera-core:${camerax_version}") - implementation("androidx.camera:camera-view:${camerax_version}") + implementation(libs.camera.core) + implementation(libs.camera.view) // AndroidX Core - val core_version = "1.9.0" - implementation("androidx.core:core:{$core_version}") + implementation(libs.androidx.core) } diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 9dd6046..f583060 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -15,10 +15,10 @@ */ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - id("kotlin-kapt") - id("com.google.dagger.hilt.android") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.dagger.hilt.android) } android { @@ -51,17 +51,16 @@ android { } dependencies { - - implementation("androidx.core:core-ktx:1.8.0") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.8.0") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.android.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) // Hilt - implementation("com.google.dagger:hilt-android:2.44") - kapt("com.google.dagger:hilt-compiler:2.44") + implementation(libs.dagger.hilt.android) + kapt(libs.dagger.hilt.compiler) } kapt { 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 1bfea1b..519f90f 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 @@ -19,7 +19,11 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob /** * Dagger [Module] for Common dependencies. @@ -28,5 +32,9 @@ import kotlinx.coroutines.CoroutineDispatcher @InstallIn(SingletonComponent::class) class CommonModule { @Provides - fun provideDefaultDispatcher(): CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @Singleton + @Provides + fun providesCoroutineScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default) } diff --git a/data/settings/build.gradle.kts b/data/settings/build.gradle.kts index 5fe0201..54cbb48 100644 --- a/data/settings/build.gradle.kts +++ b/data/settings/build.gradle.kts @@ -15,11 +15,11 @@ */ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - id("kotlin-kapt") - id("com.google.dagger.hilt.android") - id("com.google.protobuf") version "0.9.1" + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.dagger.hilt.android) + alias(libs.plugins.google.protobuf) } android { @@ -53,26 +53,19 @@ android { } dependencies { - implementation("androidx.test:core-ktx:1.4.0") - // Testing - testImplementation("junit:junit:4.13.2") - - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") - - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") - - + testImplementation(libs.junit) + implementation(libs.kotlinx.coroutines.core) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) // Hilt - implementation("com.google.dagger:hilt-android:2.44") - kapt("com.google.dagger:hilt-compiler:2.44") + implementation(libs.dagger.hilt.android) + kapt(libs.dagger.hilt.compiler) // proto datastore - implementation("androidx.datastore:datastore:1.0.0") - implementation("com.google.protobuf:protobuf-kotlin-lite:3.21.12") - + implementation(libs.androidx.datastore) + implementation(libs.protobuf.kotlin.lite) } protobuf { diff --git a/data/settings/consumer-rules.pro b/data/settings/consumer-rules.pro index e69de29..2b9ca75 100644 --- a/data/settings/consumer-rules.pro +++ b/data/settings/consumer-rules.pro @@ -0,0 +1 @@ +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite* {<fields>;}
\ No newline at end of file diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt index e607a6b..2530a2b 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt @@ -31,6 +31,10 @@ object JcaSettingsSerializer : Serializer<JcaSettings> { .setFlashModeStatus(FlashMode.FLASH_MODE_OFF) .setAspectRatioStatus(AspectRatio.ASPECT_RATIO_NINE_SIXTEEN) .setCaptureModeStatus(CaptureMode.CAPTURE_MODE_MULTI_STREAM) + .setStabilizePreview(PreviewStabilization.PREVIEW_STABILIZATION_UNDEFINED) + .setStabilizeVideo(VideoStabilization.VIDEO_STABILIZATION_UNDEFINED) + .setStabilizePreviewSupported(false) + .setStabilizeVideoSupported(false) .build() override suspend fun readFrom(input: InputStream): JcaSettings { 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 eab14ae..f54a1a9 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 @@ -20,11 +20,15 @@ import com.google.jetpackcamera.settings.AspectRatio as AspectRatioProto import com.google.jetpackcamera.settings.CaptureMode as CaptureModeProto import com.google.jetpackcamera.settings.DarkMode as DarkModeProto import com.google.jetpackcamera.settings.FlashMode as FlashModeProto +import com.google.jetpackcamera.settings.PreviewStabilization as PreviewStabilizationProto +import com.google.jetpackcamera.settings.VideoStabilization as VideoStabilizationProto import com.google.jetpackcamera.settings.model.AspectRatio import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DarkMode import com.google.jetpackcamera.settings.model.FlashMode +import com.google.jetpackcamera.settings.model.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -55,6 +59,12 @@ class LocalSettingsRepository @Inject constructor( isFrontCameraAvailable = it.frontCameraAvailable, isBackCameraAvailable = it.backCameraAvailable, aspectRatio = AspectRatio.fromProto(it.aspectRatioStatus), + previewStabilization = Stabilization.fromProto(it.stabilizePreview), + videoCaptureStabilization = Stabilization.fromProto(it.stabilizeVideo), + supportedStabilizationModes = getSupportedStabilization( + previewSupport = it.stabilizePreviewSupported, + videoSupport = it.stabilizeVideoSupported + ), captureMode = when (it.captureModeStatus) { CaptureModeProto.CAPTURE_MODE_SINGLE_STREAM -> CaptureMode.SINGLE_STREAM CaptureModeProto.CAPTURE_MODE_MULTI_STREAM -> CaptureMode.MULTI_STREAM @@ -106,8 +116,13 @@ class LocalSettingsRepository @Inject constructor( // if a front or back lens is not present, the option to change // the direction of the camera should be disabled jcaSettings.updateData { currentSettings -> + val newLensFacing = if (currentSettings.defaultFrontCamera) { + frontLensAvailable + } else { + false + } currentSettings.toBuilder() - .setDefaultFrontCamera(frontLensAvailable) + .setDefaultFrontCamera(newLensFacing) .setFrontCameraAvailable(frontLensAvailable) .setBackCameraAvailable(backLensAvailable) .build() @@ -138,4 +153,60 @@ class LocalSettingsRepository @Inject constructor( .build() } } + + override suspend fun updatePreviewStabilization(stabilization: Stabilization) { + val newStatus = when (stabilization) { + Stabilization.ON -> PreviewStabilizationProto.PREVIEW_STABILIZATION_ON + Stabilization.OFF -> PreviewStabilizationProto.PREVIEW_STABILIZATION_OFF + else -> PreviewStabilizationProto.PREVIEW_STABILIZATION_UNDEFINED + } + jcaSettings.updateData { currentSettings -> + currentSettings.toBuilder() + .setStabilizePreview(newStatus) + .build() + } + } + + override suspend fun updateVideoStabilization(stabilization: Stabilization) { + val newStatus = when (stabilization) { + Stabilization.ON -> VideoStabilizationProto.VIDEO_STABILIZATION_ON + Stabilization.OFF -> VideoStabilizationProto.VIDEO_STABILIZATION_OFF + else -> VideoStabilizationProto.VIDEO_STABILIZATION_UNDEFINED + } + jcaSettings.updateData { currentSettings -> + currentSettings.toBuilder() + .setStabilizeVideo(newStatus) + .build() + } + } + + override suspend fun updateVideoStabilizationSupported(isSupported: Boolean) { + jcaSettings.updateData { currentSettings -> + currentSettings.toBuilder() + .setStabilizeVideoSupported(isSupported) + .build() + } + } + + override suspend fun updatePreviewStabilizationSupported(isSupported: Boolean) { + jcaSettings.updateData { currentSettings -> + currentSettings.toBuilder() + .setStabilizeVideoSupported(isSupported) + .build() + } + } + + private fun getSupportedStabilization( + previewSupport: Boolean, + videoSupport: Boolean + ): List<SupportedStabilizationMode> { + return buildList { + if (previewSupport && videoSupport) { + add(SupportedStabilizationMode.ON) + } + if (!previewSupport && videoSupport) { + add(SupportedStabilizationMode.HIGH_QUALITY) + } + } + } } diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt index 637a1ab..0f622d1 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt @@ -20,6 +20,7 @@ import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DarkMode import com.google.jetpackcamera.settings.model.FlashMode +import com.google.jetpackcamera.settings.model.Stabilization import kotlinx.coroutines.flow.Flow /** @@ -42,5 +43,12 @@ interface SettingsRepository { suspend fun updateCaptureMode(captureMode: CaptureMode) + suspend fun updatePreviewStabilization(stabilization: Stabilization) + suspend fun updateVideoStabilization(stabilization: Stabilization) + + suspend fun updateVideoStabilizationSupported(isSupported: Boolean) + + suspend fun updatePreviewStabilizationSupported(isSupported: Boolean) + suspend fun getCameraAppSettings(): CameraAppSettings } diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt index e16f332..e135089 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt @@ -25,7 +25,10 @@ data class CameraAppSettings( val darkMode: DarkMode = DarkMode.SYSTEM, val flashMode: FlashMode = FlashMode.OFF, val captureMode: CaptureMode = CaptureMode.MULTI_STREAM, - val aspectRatio: AspectRatio = AspectRatio.NINE_SIXTEEN + val aspectRatio: AspectRatio = AspectRatio.NINE_SIXTEEN, + val previewStabilization: Stabilization = Stabilization.UNDEFINED, + val videoCaptureStabilization: Stabilization = Stabilization.UNDEFINED, + val supportedStabilizationModes: List<SupportedStabilizationMode> = emptyList() ) val DEFAULT_CAMERA_APP_SETTINGS = CameraAppSettings() diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Stabilization.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Stabilization.kt new file mode 100644 index 0000000..b0b599e --- /dev/null +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Stabilization.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2023 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.settings.PreviewStabilization as PreviewStabilizationProto +import com.google.jetpackcamera.settings.VideoStabilization as VideoStabilizationProto + +enum class Stabilization { + UNDEFINED, + OFF, + ON; + + companion object { + /** returns the Stabilization enum equivalent of a provided [PreviewStabilizationProto]. */ + fun fromProto(stabilizationProto: PreviewStabilizationProto): Stabilization { + return when (stabilizationProto) { + PreviewStabilizationProto.PREVIEW_STABILIZATION_UNDEFINED -> UNDEFINED + PreviewStabilizationProto.PREVIEW_STABILIZATION_OFF -> OFF + PreviewStabilizationProto.PREVIEW_STABILIZATION_ON -> ON + else -> UNDEFINED + } + } + + /** returns the Stabilization enum equivalent of a provided [VideoStabilizationProto]. */ + + fun fromProto(stabilizationProto: VideoStabilizationProto): Stabilization { + return when (stabilizationProto) { + VideoStabilizationProto.VIDEO_STABILIZATION_UNDEFINED -> UNDEFINED + VideoStabilizationProto.VIDEO_STABILIZATION_OFF -> OFF + VideoStabilizationProto.VIDEO_STABILIZATION_ON -> ON + else -> UNDEFINED + } + } + } +} diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/SupportedStabilizationMode.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/SupportedStabilizationMode.kt new file mode 100644 index 0000000..9cdc8f7 --- /dev/null +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/SupportedStabilizationMode.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2023 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 + +/** Enum class representing the device's supported video stabilization configurations. */ +enum class SupportedStabilizationMode { + /** Device supports Preview stabilization. */ + ON, + + /** Device supports Video stabilization.*/ + HIGH_QUALITY +} diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt index 2193922..c2033bd 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt @@ -17,8 +17,13 @@ package com.google.jetpackcamera.settings.test import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer +import com.google.jetpackcamera.settings.AspectRatio +import com.google.jetpackcamera.settings.CaptureMode import com.google.jetpackcamera.settings.DarkMode +import com.google.jetpackcamera.settings.FlashMode import com.google.jetpackcamera.settings.JcaSettings +import com.google.jetpackcamera.settings.PreviewStabilization +import com.google.jetpackcamera.settings.VideoStabilization import com.google.protobuf.InvalidProtocolBufferException import java.io.IOException import java.io.InputStream @@ -31,6 +36,15 @@ class FakeJcaSettingsSerializer( override val defaultValue: JcaSettings = JcaSettings.newBuilder() .setDarkModeStatus(DarkMode.DARK_MODE_SYSTEM) .setDefaultFrontCamera(false) + .setBackCameraAvailable(true) + .setFrontCameraAvailable(true) + .setFlashModeStatus(FlashMode.FLASH_MODE_OFF) + .setAspectRatioStatus(AspectRatio.ASPECT_RATIO_NINE_SIXTEEN) + .setCaptureModeStatus(CaptureMode.CAPTURE_MODE_MULTI_STREAM) + .setStabilizePreview(PreviewStabilization.PREVIEW_STABILIZATION_UNDEFINED) + .setStabilizeVideo(VideoStabilization.VIDEO_STABILIZATION_UNDEFINED) + .setStabilizeVideoSupported(false) + .setStabilizePreviewSupported(false) .build() override suspend fun readFrom(input: InputStream): JcaSettings { diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt index fbc40f5..2bb4295 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt @@ -22,11 +22,15 @@ import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS import com.google.jetpackcamera.settings.model.DarkMode import com.google.jetpackcamera.settings.model.FlashMode +import com.google.jetpackcamera.settings.model.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow object FakeSettingsRepository : SettingsRepository { var currentCameraSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS + private var isPreviewStabilizationSupported: Boolean = false + private var isVideoStabilizationSupported: Boolean = false override val cameraAppSettings: Flow<CameraAppSettings> = flow { emit(currentCameraSettings) } @@ -35,8 +39,8 @@ object FakeSettingsRepository : SettingsRepository { currentCameraSettings = currentCameraSettings.copy(isFrontCameraFacing = newLensFacing) } - override suspend fun updateDarkModeStatus(darkmode: DarkMode) { - currentCameraSettings = currentCameraSettings.copy(darkMode = darkmode) + override suspend fun updateDarkModeStatus(darkMode: DarkMode) { + currentCameraSettings = currentCameraSettings.copy(darkMode = darkMode) } override suspend fun updateFlashModeStatus(flashMode: FlashMode) { @@ -62,7 +66,43 @@ object FakeSettingsRepository : SettingsRepository { currentCameraSettings.copy(captureMode = captureMode) } + override suspend fun updatePreviewStabilization(stabilization: Stabilization) { + currentCameraSettings = + currentCameraSettings.copy(previewStabilization = stabilization) + } + + override suspend fun updateVideoStabilization(stabilization: Stabilization) { + currentCameraSettings = + currentCameraSettings.copy(videoCaptureStabilization = stabilization) + } + + override suspend fun updateVideoStabilizationSupported(isSupported: Boolean) { + isVideoStabilizationSupported = isSupported + setSupportedStabilizationMode() + } + + override suspend fun updatePreviewStabilizationSupported(isSupported: Boolean) { + isPreviewStabilizationSupported = isSupported + setSupportedStabilizationMode() + } + + private fun setSupportedStabilizationMode() { + val stabilizationModes = + buildList { + if (isPreviewStabilizationSupported) { + add(SupportedStabilizationMode.ON) + } + if (isVideoStabilizationSupported) { + add(SupportedStabilizationMode.HIGH_QUALITY) + } + } + + currentCameraSettings = + currentCameraSettings.copy(supportedStabilizationModes = stabilizationModes) + } + override suspend fun updateAspectRatio(aspectRatio: AspectRatio) { - TODO("Not yet implemented") + currentCameraSettings = + currentCameraSettings.copy(aspectRatio = aspectRatio) } } diff --git a/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto b/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto index 7e27529..288d501 100644 --- a/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto +++ b/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto @@ -20,6 +20,10 @@ import "com/google/jetpackcamera/settings/aspect_ratio.proto"; import "com/google/jetpackcamera/settings/capture_mode.proto"; import "com/google/jetpackcamera/settings/dark_mode.proto"; import "com/google/jetpackcamera/settings/flash_mode.proto"; +import "com/google/jetpackcamera/settings/preview_stabilization.proto"; +import "com/google/jetpackcamera/settings/video_stabilization.proto"; + + option java_package = "com.google.jetpackcamera.settings"; @@ -33,4 +37,8 @@ message JcaSettings { FlashMode flash_mode_status = 6; AspectRatio aspect_ratio_status = 7; CaptureMode capture_mode_status = 8; + PreviewStabilization stabilize_preview = 9; + VideoStabilization stabilize_video = 10; + bool stabilize_video_supported = 11; + bool stabilize_preview_supported = 12; }
\ No newline at end of file diff --git a/data/settings/src/main/proto/com/google/jetpackcamera/settings/preview_stabilization.proto b/data/settings/src/main/proto/com/google/jetpackcamera/settings/preview_stabilization.proto new file mode 100644 index 0000000..f0cf902 --- /dev/null +++ b/data/settings/src/main/proto/com/google/jetpackcamera/settings/preview_stabilization.proto @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 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. + */ + +syntax = "proto3"; + +option java_package = "com.google.jetpackcamera.settings"; +option java_multiple_files = true; + +enum PreviewStabilization { + PREVIEW_STABILIZATION_UNDEFINED = 0; + PREVIEW_STABILIZATION_OFF = 1; + PREVIEW_STABILIZATION_ON = 2; +}
\ No newline at end of file diff --git a/data/settings/src/main/proto/com/google/jetpackcamera/settings/video_stabilization.proto b/data/settings/src/main/proto/com/google/jetpackcamera/settings/video_stabilization.proto new file mode 100644 index 0000000..66e1a8b --- /dev/null +++ b/data/settings/src/main/proto/com/google/jetpackcamera/settings/video_stabilization.proto @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 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. + */ + +syntax = "proto3"; + +option java_package = "com.google.jetpackcamera.settings"; +option java_multiple_files = true; + +enum VideoStabilization { + VIDEO_STABILIZATION_UNDEFINED = 0; + VIDEO_STABILIZATION_OFF = 1; + VIDEO_STABILIZATION_ON = 2; +}
\ No newline at end of file diff --git a/domain/camera/Android.bp b/domain/camera/Android.bp index b22d7e4..e34d990 100644 --- a/domain/camera/Android.bp +++ b/domain/camera/Android.bp @@ -12,14 +12,13 @@ android_library { "hilt_android", "androidx.camera_camera-core", "androidx.camera_camera-viewfinder", + "androidx.camera_camera-video", "androidx.camera_camera-camera2", "androidx.camera_camera-lifecycle", - "androidx.camera_camera-extensions", - "jetpack-camera-app_data_settings", - "jetpack-camera-app_core_common", + "jetpack-camera-app_data_settings", + "jetpack-camera-app_core_common", ], sdk_version: "34", min_sdk_version: "21", - manifest:"src/main/AndroidManifest.xml" + manifest: "src/main/AndroidManifest.xml", } - diff --git a/domain/camera/build.gradle.kts b/domain/camera/build.gradle.kts index 301dab4..670fd8f 100644 --- a/domain/camera/build.gradle.kts +++ b/domain/camera/build.gradle.kts @@ -15,10 +15,10 @@ */ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - id("kotlin-kapt") - id("com.google.dagger.hilt.android") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.dagger.hilt.android) } android { @@ -53,26 +53,30 @@ android { dependencies { // Testing - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("org.mockito:mockito-core:5.2.0") // Futures - implementation("androidx.concurrent:concurrent-futures-ktx:1.1.0") + implementation(libs.futures.ktx) // CameraX - val camerax_version = "1.4.0-SNAPSHOT" - implementation("androidx.camera:camera-core:${camerax_version}") - implementation("androidx.camera:camera-camera2:${camerax_version}") - implementation("androidx.camera:camera-lifecycle:${camerax_version}") - implementation("androidx.camera:camera-video:${camerax_version}") + implementation(libs.camera.core) + implementation(libs.camera.camera2) + implementation(libs.camera.lifecycle) + implementation(libs.camera.video) - implementation("androidx.camera:camera-view:${camerax_version}") - implementation("androidx.camera:camera-extensions:${camerax_version}") + implementation(libs.camera.view) + implementation(libs.camera.extensions) // Hilt - implementation("com.google.dagger:hilt-android:2.44") - kapt("com.google.dagger:hilt-compiler:2.44") + implementation(libs.dagger.hilt.android) + kapt(libs.dagger.hilt.compiler) + + // Tracing + implementation("androidx.tracing:tracing-ktx:1.2.0") // Project dependencies implementation(project(":data:settings")) diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt index 612f520..a2348ac 100644 --- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt +++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt @@ -18,13 +18,13 @@ package com.google.jetpackcamera.domain.camera import dagger.Binds import dagger.Module import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent +import dagger.hilt.android.components.ViewModelComponent /** * Dagger [Module] for camera data layer. */ @Module -@InstallIn(SingletonComponent::class) +@InstallIn(ViewModelComponent::class) interface CameraModule { @Binds fun bindsCameraUseCase(cameraXCameraUseCase: CameraXCameraUseCase): CameraUseCase diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt index 56a3768..1399d0f 100644 --- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt +++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt @@ -15,6 +15,8 @@ */ package com.google.jetpackcamera.domain.camera +import android.content.ContentResolver +import android.net.Uri import android.util.Rational import android.view.Display import androidx.camera.core.Preview @@ -22,6 +24,7 @@ import com.google.jetpackcamera.settings.model.AspectRatio as SettingsAspectRati import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CaptureMode as SettingsCaptureMode import com.google.jetpackcamera.settings.model.FlashMode as SettingsFlashMode +import kotlinx.coroutines.flow.SharedFlow /** * Data layer for camera. @@ -46,17 +49,23 @@ interface CameraUseCase { suspend fun takePicture() + suspend fun takePicture(contentResolver: ContentResolver, imageCaptureUri: Uri?) + suspend fun startVideoRecording() fun stopVideoRecording() fun setZoomScale(scale: Float): Float - fun setFlashMode(flashMode: SettingsFlashMode) + fun getScreenFlashEvents(): SharedFlow<ScreenFlashEvent> + + fun setFlashMode(flashMode: SettingsFlashMode, isFrontFacing: Boolean) + + fun isScreenFlashEnabled(): Boolean suspend fun setAspectRatio(aspectRatio: SettingsAspectRatio, isFrontFacing: Boolean) - suspend fun flipCamera(isFrontFacing: Boolean) + suspend fun flipCamera(isFrontFacing: Boolean, flashMode: SettingsFlashMode) fun tapToFocus(display: Display, surfaceWidth: Int, surfaceHeight: Int, x: Float, y: Float) @@ -109,4 +118,14 @@ interface CameraUseCase { ON, AUTO } + + /** + * Represents the events required for screen flash. + */ + data class ScreenFlashEvent(val type: Type, val onComplete: () -> Unit) { + enum class Type { + APPLY_UI, + CLEAR_UI + } + } } diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt index e04bdd9..a640991 100644 --- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt +++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt @@ -16,7 +16,10 @@ package com.google.jetpackcamera.domain.camera import android.app.Application +import android.content.ContentResolver import android.content.ContentValues +import android.net.Uri +import android.os.Environment import android.provider.MediaStore import android.util.Log import android.view.Display @@ -26,6 +29,8 @@ import androidx.camera.core.CameraSelector.LensFacing import androidx.camera.core.DisplayOrientedMeteringPointFactory import androidx.camera.core.FocusMeteringAction import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCapture.OutputFileOptions +import androidx.camera.core.ImageCapture.ScreenFlash import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy import androidx.camera.core.Preview @@ -33,35 +38,49 @@ import androidx.camera.core.UseCaseGroup import androidx.camera.core.ViewPort import androidx.camera.core.ZoomState import androidx.camera.lifecycle.ProcessCameraProvider -//import androidx.camera.video.MediaStoreOutputOptions -//import androidx.camera.video.Recorder -//import androidx.camera.video.Recording -//import androidx.camera.video.VideoCapture +import androidx.camera.video.MediaStoreOutputOptions +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture import androidx.concurrent.futures.await import androidx.core.content.ContextCompat import com.google.jetpackcamera.domain.camera.CameraUseCase.Companion.INVALID_ZOOM_SCALE +import com.google.jetpackcamera.domain.camera.CameraUseCase.ScreenFlashEvent.Type import com.google.jetpackcamera.settings.SettingsRepository import com.google.jetpackcamera.settings.model.AspectRatio import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.FlashMode +import com.google.jetpackcamera.settings.model.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode +import dagger.hilt.android.scopes.ViewModelScoped +import java.io.FileNotFoundException +import java.lang.RuntimeException +import java.util.Calendar import java.util.Date import javax.inject.Inject import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.asExecutor import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch private const val TAG = "CameraXCameraUseCase" +private const val IMAGE_CAPTURE_TRACE = "JCA Image Capture" /** * CameraX based implementation for [CameraUseCase] */ +@ViewModelScoped class CameraXCameraUseCase @Inject constructor( private val application: Application, + private val coroutineScope: CoroutineScope, private val defaultDispatcher: CoroutineDispatcher, private val settingsRepository: SettingsRepository ) : CameraUseCase { @@ -71,27 +90,34 @@ constructor( // TODO apply flash from settings private val imageCaptureUseCase = ImageCapture.Builder().build() -// private val recorder = Recorder.Builder().setExecutor( -// defaultDispatcher.asExecutor() -// ).build() -// private val videoCaptureUseCase = VideoCapture.withOutput(recorder) -// private var recording: Recording? = null + private val recorder = Recorder.Builder().setExecutor( + defaultDispatcher.asExecutor() + ).build() + private lateinit var videoCaptureUseCase: VideoCapture<Recorder> + private var recording: Recording? = null private lateinit var previewUseCase: Preview private lateinit var useCaseGroup: UseCaseGroup private lateinit var aspectRatio: AspectRatio private lateinit var captureMode: CaptureMode + private lateinit var stabilizePreviewMode: Stabilization + private lateinit var stabilizeVideoMode: Stabilization private lateinit var surfaceProvider: Preview.SurfaceProvider + private lateinit var supportedStabilizationModes: List<SupportedStabilizationMode> private var isFrontFacing = true + private val screenFlashEvents: MutableSharedFlow<CameraUseCase.ScreenFlashEvent> = + MutableSharedFlow() + override suspend fun initialize(currentCameraSettings: CameraAppSettings): List<Int> { this.aspectRatio = currentCameraSettings.aspectRatio this.captureMode = currentCameraSettings.captureMode - setFlashMode(currentCameraSettings.flashMode) - + this.stabilizePreviewMode = currentCameraSettings.previewStabilization + this.stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization + this.supportedStabilizationModes = currentCameraSettings.supportedStabilizationModes + setFlashMode(currentCameraSettings.flashMode, currentCameraSettings.isFrontCameraFacing) cameraProvider = ProcessCameraProvider.getInstance(application).await() - updateUseCaseGroup() val availableCameraLens = listOf( @@ -100,15 +126,16 @@ constructor( ).filter { lensFacing -> cameraProvider.hasCamera(cameraLensToSelector(lensFacing)) } - // updates values for available camera lens if necessary coroutineScope { settingsRepository.updateAvailableCameraLens( availableCameraLens.contains(CameraSelector.LENS_FACING_FRONT), availableCameraLens.contains(CameraSelector.LENS_FACING_BACK) ) + settingsRepository.updateVideoStabilizationSupported(isStabilizationSupported()) } - + videoCaptureUseCase = createVideoUseCase() + updateUseCaseGroup() return availableCameraLens } @@ -139,14 +166,81 @@ constructor( override fun onCaptureSuccess(imageProxy: ImageProxy) { Log.d(TAG, "onCaptureSuccess") imageDeferred.complete(imageProxy) + imageProxy.close() } override fun onError(exception: ImageCaptureException) { super.onError(exception) Log.d(TAG, "takePicture onError: $exception") + imageDeferred.completeExceptionally(exception) + } + } + ) + imageDeferred.await() + } + + // TODO(b/319733374): Return bitmap for external mediastore capture without URI + override suspend fun takePicture(contentResolver: ContentResolver, imageCaptureUri: Uri?) { + val imageDeferred = CompletableDeferred<ImageCapture.OutputFileResults>() + val eligibleContentValues = getEligibleContentValues() + val outputFileOptions: OutputFileOptions + if (imageCaptureUri == null) { + val e = RuntimeException("Null Uri is provided.") + Log.d(TAG, "takePicture onError: $e") + throw e + } else { + try { + val outputStream = contentResolver.openOutputStream(imageCaptureUri) + if (outputStream != null) { + outputFileOptions = + OutputFileOptions.Builder( + contentResolver.openOutputStream(imageCaptureUri)!! + ).build() + } else { + val e = RuntimeException("Provider recently crashed.") + Log.d(TAG, "takePicture onError: $e") + throw e + } + } catch (e: FileNotFoundException) { + Log.d(TAG, "takePicture onError: $e") + throw e + } + } + imageCaptureUseCase.takePicture( + outputFileOptions, + defaultDispatcher.asExecutor(), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + val relativePath = + eligibleContentValues.getAsString(MediaStore.Images.Media.RELATIVE_PATH) + val displayName = eligibleContentValues.getAsString( + MediaStore.Images.Media.DISPLAY_NAME + ) + Log.d(TAG, "Saved image to $relativePath/$displayName") + imageDeferred.complete(outputFileResults) + } + + override fun onError(exception: ImageCaptureException) { + Log.d(TAG, "takePicture onError: $exception") + imageDeferred.completeExceptionally(exception) } } ) + imageDeferred.await() + } + + private fun getEligibleContentValues(): ContentValues { + val eligibleContentValues = ContentValues() + eligibleContentValues.put( + MediaStore.Images.Media.DISPLAY_NAME, + Calendar.getInstance().time.toString() + ) + eligibleContentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + eligibleContentValues.put( + MediaStore.Images.Media.RELATIVE_PATH, + Environment.DIRECTORY_PICTURES + ) + return eligibleContentValues } override suspend fun startVideoRecording() { @@ -161,27 +255,27 @@ constructor( ContentValues().apply { put(MediaStore.Video.Media.DISPLAY_NAME, name) } -// val mediaStoreOutput = -// MediaStoreOutputOptions.Builder( -// application.contentResolver, -// MediaStore.Video.Media.EXTERNAL_CONTENT_URI -// ) -// .setContentValues(contentValues) -// .build() - -// recording = -// videoCaptureUseCase.output -// .prepareRecording(application, mediaStoreOutput) -// .start(ContextCompat.getMainExecutor(application)) { videoRecordEvent -> -// run { -// Log.d(TAG, videoRecordEvent.toString()) -// } -// } + + val mediaStoreOutput = + MediaStoreOutputOptions.Builder( + application.contentResolver, + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + ) + .setContentValues(contentValues) + .build() + recording = + videoCaptureUseCase.output + .prepareRecording(application, mediaStoreOutput) + .start(ContextCompat.getMainExecutor(application)) { videoRecordEvent -> + run { + Log.d(TAG, videoRecordEvent.toString()) + } + } } override fun stopVideoRecording() { Log.d(TAG, "stopRecording") -// recording?.stop() + recording?.stop() } override fun setZoomScale(scale: Float): Float { @@ -198,8 +292,10 @@ constructor( private fun getZoomState(): ZoomState? = camera?.cameraInfo?.zoomState?.value // flips the camera to the designated lensFacing direction - override suspend fun flipCamera(isFrontFacing: Boolean) { + override suspend fun flipCamera(isFrontFacing: Boolean, flashMode: FlashMode) { this.isFrontFacing = isFrontFacing + // screen flash needs to be reset during switching camera + setFlashMode(flashMode, isFrontFacing) updateUseCaseGroup() rebindUseCases() } @@ -228,15 +324,61 @@ constructor( } } - override fun setFlashMode(flashMode: FlashMode) { + override fun getScreenFlashEvents() = screenFlashEvents.asSharedFlow() + + override fun setFlashMode(flashMode: FlashMode, isFrontFacing: Boolean) { + val isScreenFlashRequired = + isFrontFacing && (flashMode == FlashMode.ON || flashMode == FlashMode.AUTO) + + if (isScreenFlashRequired) { + imageCaptureUseCase.screenFlash = object : ScreenFlash { + override fun apply( + expirationTimeMillis: Long, + listener: ImageCapture.ScreenFlashListener + ) { + Log.d(TAG, "ImageCapture.ScreenFlash: apply") + coroutineScope.launch { + screenFlashEvents.emit( + CameraUseCase.ScreenFlashEvent(Type.APPLY_UI) { + listener.onCompleted() + } + ) + } + } + + override fun clear() { + Log.d(TAG, "ImageCapture.ScreenFlash: clear") + coroutineScope.launch { + screenFlashEvents.emit( + CameraUseCase.ScreenFlashEvent(Type.CLEAR_UI) {} + ) + } + } + } + } + imageCaptureUseCase.flashMode = when (flashMode) { FlashMode.OFF -> ImageCapture.FLASH_MODE_OFF // 2 - FlashMode.ON -> ImageCapture.FLASH_MODE_ON // 1 - FlashMode.AUTO -> ImageCapture.FLASH_MODE_AUTO // 0 + + FlashMode.ON -> if (isScreenFlashRequired) { + ImageCapture.FLASH_MODE_SCREEN // 3 + } else { + ImageCapture.FLASH_MODE_ON // 1 + } + + FlashMode.AUTO -> if (isScreenFlashRequired) { + ImageCapture.FLASH_MODE_SCREEN // 3 + } else { + ImageCapture.FLASH_MODE_AUTO // 0 + } } Log.d(TAG, "Set flash mode to: ${imageCaptureUseCase.flashMode}") } + override fun isScreenFlashEnabled() = + imageCaptureUseCase.flashMode == ImageCapture.FLASH_MODE_SCREEN && + imageCaptureUseCase.screenFlash != null + override suspend fun setAspectRatio(aspectRatio: AspectRatio, isFrontFacing: Boolean) { this.aspectRatio = aspectRatio updateUseCaseGroup() @@ -267,7 +409,7 @@ constructor( ) .addUseCase(previewUseCase) .addUseCase(imageCaptureUseCase) -// .addUseCase(videoCaptureUseCase) + .addUseCase(videoCaptureUseCase) if (captureMode == CaptureMode.SINGLE_STREAM) { useCaseGroupBuilder.addEffect(SingleSurfaceForcingEffect()) @@ -276,25 +418,78 @@ constructor( useCaseGroup = useCaseGroupBuilder.build() } - private fun createPreviewUseCase(): Preview { + /** + * Checks if video stabilization is supported by the device. + * + */ + private fun isStabilizationSupported(): Boolean { val availableCameraInfo = cameraProvider.availableCameraInfos val cameraSelector = if (isFrontFacing) { CameraSelector.DEFAULT_FRONT_CAMERA } else { CameraSelector.DEFAULT_BACK_CAMERA } - val isPreviewStabilizationSupported = + val isVideoStabilizationSupported = cameraSelector.filter(availableCameraInfo).firstOrNull()?.let { - Preview.getPreviewCapabilities(it).isStabilizationSupported + Recorder.getVideoCapabilities(it).isStabilizationSupported } ?: false + return isVideoStabilizationSupported + } + + private fun createVideoUseCase(): VideoCapture<Recorder> { + val videoCaptureBuilder = VideoCapture.Builder(recorder) + + // set video stabilization + + if (shouldVideoBeStabilized()) { + val isStabilized = when (stabilizeVideoMode) { + Stabilization.ON -> true + Stabilization.OFF, Stabilization.UNDEFINED -> false + } + videoCaptureBuilder.setVideoStabilizationEnabled(isStabilized) + } + return videoCaptureBuilder.build() + } + + private fun shouldVideoBeStabilized(): Boolean { + // video is supported by the device AND + // video is on OR preview is on + return (supportedStabilizationModes.contains(SupportedStabilizationMode.HIGH_QUALITY)) && + ( + // high quality (video only) selected + ( + stabilizeVideoMode == Stabilization.ON && + stabilizePreviewMode == Stabilization.UNDEFINED + ) || + // or on is selected + ( + stabilizePreviewMode == Stabilization.ON && + stabilizeVideoMode != Stabilization.OFF + ) + ) + } + + private fun createPreviewUseCase(): Preview { val previewUseCaseBuilder = Preview.Builder() - if (isPreviewStabilizationSupported) { - previewUseCaseBuilder.setPreviewStabilizationEnabled(true) + // set preview stabilization + if (shouldPreviewBeStabilized()) { + val isStabilized = when (stabilizePreviewMode) { + Stabilization.ON -> true + else -> false + } + previewUseCaseBuilder.setPreviewStabilizationEnabled(isStabilized) } return previewUseCaseBuilder.build() } + private fun shouldPreviewBeStabilized(): Boolean { + return ( + supportedStabilizationModes.contains(SupportedStabilizationMode.ON) && + stabilizePreviewMode == Stabilization.ON + ) + } + // converts LensFacing from datastore to @LensFacing Int value private fun getLensFacing(isFrontFacing: Boolean): Int = when (isFrontFacing) { true -> CameraSelector.LENS_FACING_FRONT diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt index b57bdb0..b243301 100644 --- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt +++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt @@ -15,6 +15,8 @@ */ package com.google.jetpackcamera.domain.camera.test +import android.content.ContentResolver +import android.net.Uri import android.view.Display import androidx.camera.core.CameraSelector import androidx.camera.core.Preview @@ -23,8 +25,16 @@ import com.google.jetpackcamera.settings.model.AspectRatio import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.FlashMode - -class FakeCameraUseCase : CameraUseCase { +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +class FakeCameraUseCase( + private val coroutineScope: CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) +) : CameraUseCase { private val availableLenses = listOf(CameraSelector.LENS_FACING_FRONT, CameraSelector.LENS_FACING_BACK) private var initialized = false @@ -39,6 +49,9 @@ class FakeCameraUseCase : CameraUseCase { private var flashMode = FlashMode.OFF private var aspectRatio = AspectRatio.THREE_FOUR + private var isScreenFlash = true + private var screenFlashEvents = MutableSharedFlow<CameraUseCase.ScreenFlashEvent>() + override suspend fun initialize(currentCameraSettings: CameraAppSettings): List<Int> { initialized = true flashMode = currentCameraSettings.flashMode @@ -71,8 +84,27 @@ class FakeCameraUseCase : CameraUseCase { if (!useCasesBinded) { throw IllegalStateException("Usecases not binded") } + if (isScreenFlash) { + coroutineScope.launch { + screenFlashEvents.emit( + CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.APPLY_UI) { } + ) + screenFlashEvents.emit( + CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { } + ) + } + } numPicturesTaken += 1 } + override suspend fun takePicture(contentResolver: ContentResolver, imageCaptureUri: Uri?) { + takePicture() + } + + fun emitScreenFlashEvent(event: CameraUseCase.ScreenFlashEvent) { + coroutineScope.launch { + screenFlashEvents.emit(event) + } + } override suspend fun startVideoRecording() { recordingInProgress = true @@ -86,15 +118,23 @@ class FakeCameraUseCase : CameraUseCase { return -1f } - override fun setFlashMode(flashMode: FlashMode) { + override fun getScreenFlashEvents() = screenFlashEvents + + override fun setFlashMode(flashMode: FlashMode, isFrontFacing: Boolean) { this.flashMode = flashMode + isLensFacingFront = isFrontFacing + + isScreenFlash = + isLensFacingFront && (flashMode == FlashMode.AUTO || flashMode == FlashMode.ON) } + override fun isScreenFlashEnabled() = isScreenFlash + override suspend fun setAspectRatio(aspectRatio: AspectRatio, isFrontFacing: Boolean) { this.aspectRatio = aspectRatio } - override suspend fun flipCamera(isFrontFacing: Boolean) { + override suspend fun flipCamera(isFrontFacing: Boolean, flashMode: FlashMode) { isLensFacingFront = isFrontFacing } diff --git a/domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt b/domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt new file mode 100644 index 0000000..646d110 --- /dev/null +++ b/domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2023 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.domain.camera.test + +import androidx.camera.core.Preview +import com.google.jetpackcamera.domain.camera.CameraUseCase +import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS +import com.google.jetpackcamera.settings.model.FlashMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito + +@OptIn(ExperimentalCoroutinesApi::class) +class FakeCameraUseCaseTest { + private val testScope = TestScope() + private val testDispatcher = StandardTestDispatcher(testScope.testScheduler) + + private val cameraUseCase = FakeCameraUseCase(testScope) + + private val surfaceProvider: Preview.SurfaceProvider = Mockito.mock() + + @Before + fun setup() = runTest(testDispatcher) { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun canInitialize() = runTest(testDispatcher) { + cameraUseCase.initialize(DEFAULT_CAMERA_APP_SETTINGS) + } + + @Test + fun canRunCamera() = runTest(testDispatcher) { + initAndRunCamera() + } + + @Test + fun screenFlashDisabled_whenFlashModeOffAndFrontCamera() = runTest(testDispatcher) { + initAndRunCamera() + + cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = false) + + assertEquals(false, cameraUseCase.isScreenFlashEnabled()) + } + + @Test + fun screenFlashDisabled_whenFlashModeOnAndNotFrontCamera() = runTest(testDispatcher) { + initAndRunCamera() + + cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = false) + + assertEquals(false, cameraUseCase.isScreenFlashEnabled()) + } + + @Test + fun screenFlashDisabled_whenFlashModeAutoAndNotFrontCamera() = runTest(testDispatcher) { + initAndRunCamera() + + cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = false) + + assertEquals(false, cameraUseCase.isScreenFlashEnabled()) + } + + @Test + fun screenFlashEnabled_whenFlashModeOnAndFrontCamera() = runTest(testDispatcher) { + initAndRunCamera() + + cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = true) + + assertEquals(true, cameraUseCase.isScreenFlashEnabled()) + } + + @Test + fun screenFlashEnabled_whenFlashModeAutoAndFrontCamera() = runTest(testDispatcher) { + initAndRunCamera() + + cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = true) + + assertEquals(true, cameraUseCase.isScreenFlashEnabled()) + } + + @Test + fun captureScreenFlashImage_screenFlashEventsEmittedInCorrectSequence() = runTest( + testDispatcher + ) { + initAndRunCamera() + val events = mutableListOf<CameraUseCase.ScreenFlashEvent>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + cameraUseCase.getScreenFlashEvents().toList(events) + } + + // FlashMode.ON in front facing camera automatically enables screen flash + cameraUseCase.setFlashMode(FlashMode.ON, true) + cameraUseCase.takePicture() + + advanceUntilIdle() + assertEquals( + listOf( + CameraUseCase.ScreenFlashEvent.Type.APPLY_UI, + CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI + ), + events.map { it.type } + ) + } + + private suspend fun initAndRunCamera() { + cameraUseCase.initialize(DEFAULT_CAMERA_APP_SETTINGS) + cameraUseCase.runCamera(surfaceProvider, DEFAULT_CAMERA_APP_SETTINGS) + } +} diff --git a/feature/preview/Android.bp b/feature/preview/Android.bp index ed5c86e..19485f4 100644 --- a/feature/preview/Android.bp +++ b/feature/preview/Android.bp @@ -14,22 +14,22 @@ android_library { "androidx.compose.runtime_runtime", "androidx.compose.material3_material3", "androidx.compose.ui_ui-tooling-preview", + "androidx.tracing_tracing-ktx", "hilt_android", - "androidx.hilt_hilt-navigation-compose", + "androidx.hilt_hilt-navigation-compose", "androidx.compose.ui_ui-tooling", "kotlinx_coroutines_guava", "androidx.datastore_datastore", "libprotobuf-java-lite", - "androidx.camera_camera-core", + "androidx.camera_camera-core", "androidx.camera_camera-viewfinder", "jetpack-camera-app_data_settings", - "jetpack-camera-app_domain_camera", - "jetpack-camera-app_camera-viewfinder-compose", - "jetpack-camera-app_feature_quicksettings", + "jetpack-camera-app_domain_camera", + "jetpack-camera-app_camera-viewfinder-compose", + "jetpack-camera-app_feature_quicksettings", ], sdk_version: "34", min_sdk_version: "21", - manifest:"src/main/AndroidManifest.xml" + manifest: "src/main/AndroidManifest.xml", } - diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts index 121ac17..126fe88 100644 --- a/feature/preview/build.gradle.kts +++ b/feature/preview/build.gradle.kts @@ -15,10 +15,10 @@ */ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - id("kotlin-kapt") - id("com.google.dagger.hilt.android") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.dagger.hilt.android) } android { @@ -58,47 +58,57 @@ android { testOptions { unitTests { isReturnDefaultValues = true + isIncludeAndroidResources = true } } } dependencies { // Compose - val composeBom = platform("androidx.compose:compose-bom:2023.08.00") + val composeBom = platform(libs.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) // Compose - Material Design 3 - implementation("androidx.compose.material3:material3") + implementation(libs.compose.material3) // Compose - Android Studio Preview support - implementation("androidx.compose.ui:ui-tooling-preview") - debugImplementation("androidx.compose.ui:ui-tooling") + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) // Compose - Integration with ViewModels with Navigation and Hilt - implementation("androidx.hilt:hilt-navigation-compose:1.0.0") + implementation(libs.hilt.navigation.compose) // Compose - Testing - androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation(libs.compose.junit) + debugImplementation(libs.compose.test.manifest) + // noinspection TestManifestGradleConfiguration: required for release build unit tests + testImplementation(libs.compose.test.manifest) + testImplementation(libs.compose.junit) // Testing - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") - testImplementation("org.mockito:mockito-core:5.2.0") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6") + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + testImplementation(libs.mockito.core) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + debugImplementation(libs.androidx.test.monitor) + implementation(libs.androidx.junit) // Guava - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.4.1") + implementation(libs.kotlinx.coroutines.guava) // CameraX - val camerax_version = "1.4.0-SNAPSHOT" - implementation("androidx.camera:camera-core:${camerax_version}") - implementation("androidx.camera:camera-view:${camerax_version}") + implementation(libs.camera.core) + implementation(libs.camera.view) // Hilt - implementation("com.google.dagger:hilt-android:2.44") - kapt("com.google.dagger:hilt-compiler:2.44") + implementation(libs.dagger.hilt.android) + kapt(libs.dagger.hilt.compiler) + + //Tracing + implementation(libs.androidx.tracing) // Project dependencies implementation(project(":data:settings")) diff --git a/feature/preview/src/androidTest/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt b/feature/preview/src/androidTest/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt new file mode 100644 index 0000000..7c369e3 --- /dev/null +++ b/feature/preview/src/androidTest/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2023 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.feature.preview.ui + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertWidthIsAtLeast +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.jetpackcamera.feature.preview.ScreenFlash +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScreenFlashComponentsKtTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val screenFlashUiState: MutableState<ScreenFlash.ScreenFlashUiState> = + mutableStateOf(ScreenFlash.ScreenFlashUiState()) + + @Before + fun setUp() { + composeTestRule.setContent { + ScreenFlashScreen( + screenFlashUiState = screenFlashUiState.value, + onInitialBrightnessCalculated = {} + ) + } + } + + @Test + fun screenFlashOverlay_doesNotExistByDefault() = runTest { + composeTestRule.awaitIdle() + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertDoesNotExist() + } + + @Test + fun screenFlashOverlay_existsAfterStateIsEnabled() = runTest { + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true) + + composeTestRule.awaitIdle() + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertExists() + } + + @Test + fun screenFlashOverlay_doesNotExistWhenDisabledAfterEnabled() = runTest { + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true) + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = false) + + composeTestRule.awaitIdle() + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertDoesNotExist() + } + + @Test + fun screenFlashOverlay_sizeFillsMaxSize() = runTest { + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true) + + composeTestRule.awaitIdle() + val rootBounds = composeTestRule.onRoot().getBoundsInRoot() + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")) + .assertWidthIsAtLeast(rootBounds.width) + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")) + .assertHeightIsAtLeast(rootBounds.height) + } + + @Test + fun screenFlashOverlay_fullWhiteWhenEnabled() = runTest { + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true) + + composeTestRule.awaitIdle() + val overlayScreenShot = + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).captureToImage() + + // check a few pixels near center instead of whole image to save time + val overlayPixels = IntArray(4) + overlayScreenShot.readPixels( + overlayPixels, + overlayScreenShot.width / 2, + overlayScreenShot.height / 2, + 2, + 2 + ) + overlayPixels.forEach { + assertEquals(Color.White.toArgb(), it) + } + } +} 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 f3366cf..c5cfdd2 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 @@ -15,6 +15,7 @@ */ package com.google.jetpackcamera.feature.preview +import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log @@ -46,6 +47,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -55,13 +57,19 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle +import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON import com.google.jetpackcamera.feature.preview.ui.CaptureButton import com.google.jetpackcamera.feature.preview.ui.FlipCameraButton import com.google.jetpackcamera.feature.preview.ui.PreviewDisplay +import com.google.jetpackcamera.feature.preview.ui.ScreenFlashScreen import com.google.jetpackcamera.feature.preview.ui.SettingsNavButton +import com.google.jetpackcamera.feature.preview.ui.ShowTestableToast +import com.google.jetpackcamera.feature.preview.ui.StabilizationIcon import com.google.jetpackcamera.feature.preview.ui.TestingButton import com.google.jetpackcamera.feature.preview.ui.ZoomScaleText -import com.google.jetpackcamera.feature.quicksettings.QuickSettingsScreen +import com.google.jetpackcamera.feature.quicksettings.QuickSettingsScreenOverlay +import com.google.jetpackcamera.feature.quicksettings.ui.QuickSettingsIndicators +import com.google.jetpackcamera.feature.quicksettings.ui.ToggleQuickSettingsButton import com.google.jetpackcamera.settings.model.CaptureMode import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitCancellation @@ -77,12 +85,16 @@ private const val ZOOM_SCALE_SHOW_TIMEOUT_MS = 3000L fun PreviewScreen( onPreviewViewModel: (PreviewViewModel) -> Unit, onNavigateToSettings: () -> Unit, - viewModel: PreviewViewModel = hiltViewModel() + viewModel: PreviewViewModel = hiltViewModel(), + previewMode: PreviewMode ) { Log.d(TAG, "PreviewScreen") val previewUiState: PreviewUiState by viewModel.previewUiState.collectAsState() + val screenFlashUiState: ScreenFlash.ScreenFlashUiState + by viewModel.screenFlash.screenFlashUiState.collectAsState() + val lifecycleOwner = LocalLifecycleOwner.current val deferredSurfaceProvider = remember { CompletableDeferred<SurfaceProvider>() } @@ -118,74 +130,127 @@ fun PreviewScreen( Text(text = stringResource(R.string.camera_not_ready), color = Color.White) } } else if (previewUiState.cameraState == CameraState.READY) { - // display camera feed. this stays behind everything else - PreviewDisplay( - onFlipCamera = viewModel::flipCamera, - onTapToFocus = viewModel::tapToFocus, - onZoomChange = { zoomChange: Float -> - viewModel.setZoomScale(zoomChange) - zoomScaleShow = true - zoomHandler.postDelayed({ zoomScaleShow = false }, ZOOM_SCALE_SHOW_TIMEOUT_MS) - }, - aspectRatio = previewUiState.currentCameraSettings.aspectRatio, - deferredSurfaceProvider = deferredSurfaceProvider - ) - // overlay Box( - modifier = Modifier - .semantics { - testTagsAsResourceId = true - } - .fillMaxSize() + modifier = Modifier.semantics { + testTagsAsResourceId = true + } ) { - // hide settings, quickSettings, and quick capture mode button - when (previewUiState.videoRecordingState) { - VideoRecordingState.ACTIVE -> {} - VideoRecordingState.INACTIVE -> { - QuickSettingsScreen( - modifier = Modifier - .align(Alignment.TopCenter), - isOpen = previewUiState.quickSettingsIsOpen, - toggleIsOpen = { viewModel.toggleQuickSettings() }, - currentCameraSettings = previewUiState.currentCameraSettings, - onLensFaceClick = viewModel::flipCamera, - onFlashModeClick = viewModel::setFlash, - onAspectRatioClick = { - viewModel.setAspectRatio(it) - } - // onTimerClick = {}/*TODO*/ - ) + // display camera feed. this stays behind everything else + PreviewDisplay( + onFlipCamera = viewModel::flipCamera, + onTapToFocus = viewModel::tapToFocus, + onZoomChange = { zoomChange: Float -> + viewModel.setZoomScale(zoomChange) + zoomScaleShow = true + zoomHandler.postDelayed({ zoomScaleShow = false }, ZOOM_SCALE_SHOW_TIMEOUT_MS) + }, + aspectRatio = previewUiState.currentCameraSettings.aspectRatio, + deferredSurfaceProvider = deferredSurfaceProvider + ) - SettingsNavButton( - modifier = Modifier - .align(Alignment.TopStart) - .padding(12.dp), - onNavigateToSettings = onNavigateToSettings - ) + QuickSettingsScreenOverlay( + modifier = Modifier, + isOpen = previewUiState.quickSettingsIsOpen, + toggleIsOpen = { viewModel.toggleQuickSettings() }, + currentCameraSettings = previewUiState.currentCameraSettings, + onLensFaceClick = viewModel::flipCamera, + onFlashModeClick = viewModel::setFlash, + onAspectRatioClick = { + viewModel.setAspectRatio(it) + } + // onTimerClick = {}/*TODO*/ + ) + // relative-grid style overlay on top of preview display + Column( + modifier = Modifier + .fillMaxSize() + ) { + // hide settings, quickSettings, and quick capture mode button + when (previewUiState.videoRecordingState) { + VideoRecordingState.ACTIVE -> {} + VideoRecordingState.INACTIVE -> { + // 3-segmented row to keep quick settings button centered + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + // row to left of quick settings button + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + // button to open default settings page + SettingsNavButton( + modifier = Modifier + .padding(12.dp), + onNavigateToSettings = onNavigateToSettings + ) + if (!previewUiState.quickSettingsIsOpen) { + QuickSettingsIndicators( + currentCameraSettings = previewUiState + .currentCameraSettings, + onFlashModeClick = viewModel::setFlash + ) + } + } + // quick settings button + ToggleQuickSettingsButton( + toggleDropDown = { viewModel.toggleQuickSettings() }, + isOpen = previewUiState.quickSettingsIsOpen + ) - TestingButton( - modifier = Modifier - .testTag("ToggleCaptureMode") - .align(Alignment.TopEnd) - .padding(12.dp), - onClick = { viewModel.toggleCaptureMode() }, - text = stringResource( - when (previewUiState.currentCameraSettings.captureMode) { - CaptureMode.SINGLE_STREAM -> R.string.capture_mode_single_stream - CaptureMode.MULTI_STREAM -> R.string.capture_mode_multi_stream + // Row to right of quick settings + Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + TestingButton( + modifier = Modifier + .testTag("ToggleCaptureMode"), + onClick = { viewModel.toggleCaptureMode() }, + text = stringResource( + when (previewUiState.currentCameraSettings.captureMode) { + CaptureMode.SINGLE_STREAM -> + R.string.capture_mode_single_stream + + CaptureMode.MULTI_STREAM -> + R.string.capture_mode_multi_stream + } + ) + ) + StabilizationIcon( + supportedStabilizationMode = previewUiState + .currentCameraSettings.supportedStabilizationModes, + videoStabilization = previewUiState + .currentCameraSettings.videoCaptureStabilization, + previewStabilization = previewUiState + .currentCameraSettings.previewStabilization + ) } - ) - ) + } + } } - } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.align(Alignment.BottomCenter) - ) { + // this component places a gap in the center of the column that will push out the top + // and bottom edges. This will also allow the addition of vertical button bars on the + // sides of the screen + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) {} + if (zoomScaleShow) { ZoomScaleText(zoomScale = zoomScale) } + + // 3-segmented row to keep capture button centered Row( modifier = Modifier @@ -193,6 +258,7 @@ fun PreviewScreen( .height(IntrinsicSize.Min) ) { when (previewUiState.videoRecordingState) { + // hide first segment while recording in progress VideoRecordingState.ACTIVE -> { Spacer( modifier = Modifier @@ -200,41 +266,114 @@ fun PreviewScreen( .weight(1f) ) } - + // show first segment when not recording VideoRecordingState.INACTIVE -> { - FlipCameraButton( + Row( modifier = Modifier .weight(1f) .fillMaxHeight(), - onClick = { viewModel.flipCamera() }, - // enable only when phone has front and rear camera - enabledCondition = - previewUiState.currentCameraSettings.isBackCameraAvailable && - previewUiState.currentCameraSettings.isFrontCameraAvailable - ) + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (!previewUiState.quickSettingsIsOpen) { + FlipCameraButton( + onClick = { viewModel.flipCamera() }, + // enable only when phone has front and rear camera + enabledCondition = + previewUiState + .currentCameraSettings + .isBackCameraAvailable && + previewUiState + .currentCameraSettings + .isFrontCameraAvailable + ) + } + } } } val multipleEventsCutter = remember { MultipleEventsCutter() } - /*todo: close quick settings on start record/image capture*/ + val context = LocalContext.current CaptureButton( + modifier = Modifier + .testTag(CAPTURE_BUTTON), onClick = { - multipleEventsCutter.processEvent { viewModel.captureImage() } + multipleEventsCutter.processEvent { + when (previewMode) { + is PreviewMode.StandardMode -> { + viewModel.captureImage() + } + + is PreviewMode.ExternalImageCaptureMode -> { + viewModel.captureImage( + context.contentResolver, + previewMode.imageCaptureUri, + previewMode.onImageCapture + ) + } + } + } + if (previewUiState.quickSettingsIsOpen) { + viewModel.toggleQuickSettings() + } + }, + onLongPress = { + viewModel.startVideoRecording() + if (previewUiState.quickSettingsIsOpen) { + viewModel.toggleQuickSettings() + } }, - onLongPress = { viewModel.startVideoRecording() }, onRelease = { viewModel.stopVideoRecording() }, videoRecordingState = previewUiState.videoRecordingState ) - /* spacer is a placeholder to maintain the proportionate location of this row of - UI elements. if you want to add another element, replace it with ONE element. - If you want to add multiple components, use a container (Box, Row, Column, etc.) - */ - Spacer( + // You can replace this row so long as the weight of the component is 1f to + // ensure the capture button remains centered. + Row( modifier = Modifier .fillMaxHeight() .weight(1f) - ) + ) { + /*TODO("Place other components here") */ + } } } + // displays toast when there is a message to show + if (previewUiState.toastMessageToShow != null) { + ShowTestableToast( + modifier = Modifier + .testTag(previewUiState.toastMessageToShow!!.testTag), + toastMessage = previewUiState.toastMessageToShow!!, + onToastShown = viewModel::onToastShown + ) + } + + // Screen flash overlay that stays on top of everything but invisible normally. This should + // not be enabled based on whether screen flash is enabled because a previous image capture + // may still be running after flash mode change and clear actions (e.g. brightness restore) + // may need to be handled later. Compose smart recomposition should be able to optimize this + // if the relevant states are no longer changing. + ScreenFlashScreen( + screenFlashUiState = screenFlashUiState, + onInitialBrightnessCalculated = viewModel.screenFlash::setClearUiScreenBrightness + ) } } } + +/** + * This interface is determined before the Preview UI is launched and passed into PreviewScreen. The + * UX differs depends on which mode the Preview is launched under. + */ +sealed interface PreviewMode { + /** + * The default mode for the app. + */ + object StandardMode : PreviewMode + + /** + * Under this mode, the app is launched by an external intent to capture an image. + */ + data class ExternalImageCaptureMode( + val imageCaptureUri: Uri?, + val onImageCapture: (PreviewViewModel.ImageCaptureEvent) -> Unit + ) : PreviewMode +} diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt index 1c368b2..976a9f9 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt @@ -16,6 +16,7 @@ package com.google.jetpackcamera.feature.preview import androidx.camera.core.CameraSelector +import com.google.jetpackcamera.feature.preview.ui.ToastMessage import com.google.jetpackcamera.settings.model.CameraAppSettings /** @@ -27,7 +28,9 @@ data class PreviewUiState( val currentCameraSettings: CameraAppSettings, val lensFacing: Int = CameraSelector.LENS_FACING_BACK, val videoRecordingState: VideoRecordingState = VideoRecordingState.INACTIVE, - val quickSettingsIsOpen: Boolean = false + val quickSettingsIsOpen: Boolean = false, + // todo: remove after implementing post capture screen + val toastMessageToShow: ToastMessage? = null ) /** 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 e7b1544..5a43e60 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 @@ -15,26 +15,37 @@ */ package com.google.jetpackcamera.feature.preview +import android.content.ContentResolver +import android.net.Uri import android.util.Log import android.view.Display -import androidx.camera.core.ImageCaptureException import androidx.camera.core.Preview.SurfaceProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.tracing.traceAsync import com.google.jetpackcamera.domain.camera.CameraUseCase +import com.google.jetpackcamera.feature.preview.ui.ToastMessage import com.google.jetpackcamera.settings.SettingsRepository import com.google.jetpackcamera.settings.model.AspectRatio import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS import com.google.jetpackcamera.settings.model.FlashMode import dagger.hilt.android.lifecycle.HiltViewModel +import java.lang.Exception import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch private const val TAG = "PreviewViewModel" +private const val IMAGE_CAPTURE_TRACE = "JCA Image Capture" + +// toast test descriptions +const val IMAGE_CAPTURE_SUCCESS_TOAST_TAG = "ImageCaptureSuccessToast" +const val IMAGE_CAPTURE_FAIL_TOAST_TAG = "ImageCaptureFailureToast" /** * [ViewModel] for [PreviewScreen]. @@ -45,7 +56,6 @@ class PreviewViewModel @Inject constructor( private val settingsRepository: SettingsRepository // only reads from settingsRepository. do not push changes to repository from here ) : ViewModel() { - private val _previewUiState: MutableStateFlow<PreviewUiState> = MutableStateFlow(PreviewUiState(currentCameraSettings = DEFAULT_CAMERA_APP_SETTINGS)) @@ -54,6 +64,8 @@ class PreviewViewModel @Inject constructor( private var recordingJob: Job? = null + val screenFlash = ScreenFlash(cameraUseCase, viewModelScope) + init { viewModelScope.launch { settingsRepository.cameraAppSettings.collect { @@ -112,7 +124,10 @@ class PreviewViewModel @Inject constructor( ) ) // apply to cameraUseCase - cameraUseCase.setFlashMode(previewUiState.value.currentCameraSettings.flashMode) + cameraUseCase.setFlashMode( + previewUiState.value.currentCameraSettings.flashMode, + previewUiState.value.currentCameraSettings.isFrontCameraFacing + ) } } @@ -181,8 +196,10 @@ class PreviewViewModel @Inject constructor( ) ) // apply to cameraUseCase - cameraUseCase - .flipCamera(previewUiState.value.currentCameraSettings.isFrontCameraFacing) + cameraUseCase.flipCamera( + previewUiState.value.currentCameraSettings.isFrontCameraFacing, + previewUiState.value.currentCameraSettings.flashMode + ) } } } @@ -190,12 +207,71 @@ class PreviewViewModel @Inject constructor( fun captureImage() { Log.d(TAG, "captureImage") viewModelScope.launch { - try { - cameraUseCase.takePicture() - Log.d(TAG, "cameraUseCase.takePicture success") - } catch (exception: ImageCaptureException) { - Log.d(TAG, "cameraUseCase.takePicture error") - Log.d(TAG, exception.toString()) + traceAsync(IMAGE_CAPTURE_TRACE, 0) { + try { + cameraUseCase.takePicture() + // todo: remove toast after postcapture screen implemented + _previewUiState.emit( + previewUiState.value.copy( + toastMessageToShow = ToastMessage( + stringResource = R.string.toast_image_capture_success, + testTag = IMAGE_CAPTURE_SUCCESS_TOAST_TAG + ) + ) + ) + Log.d(TAG, "cameraUseCase.takePicture success") + } catch (exception: Exception) { + // todo: remove toast after postcapture screen implemented + _previewUiState.emit( + previewUiState.value.copy( + toastMessageToShow = ToastMessage( + stringResource = R.string.toast_capture_failure, + testTag = IMAGE_CAPTURE_FAIL_TOAST_TAG + ) + ) + ) + Log.d(TAG, "cameraUseCase.takePicture error") + Log.d(TAG, exception.toString()) + } + } + } + } + + fun captureImage( + contentResolver: ContentResolver, + imageCaptureUri: Uri?, + onImageCapture: (ImageCaptureEvent) -> Unit + ) { + Log.d(TAG, "captureImageWithUri") + viewModelScope.launch { + traceAsync(IMAGE_CAPTURE_TRACE, 0) { + try { + cameraUseCase.takePicture(contentResolver, imageCaptureUri) + // todo: remove toast after postcapture screen implemented + _previewUiState.emit( + previewUiState.value.copy( + toastMessageToShow = ToastMessage( + stringResource = R.string.toast_image_capture_success, + testTag = IMAGE_CAPTURE_SUCCESS_TOAST_TAG + ) + ) + ) + onImageCapture(ImageCaptureEvent.ImageSaved) + Log.d(TAG, "cameraUseCase.takePicture success") + } catch (exception: Exception) { + // todo: remove toast after postcapture screen implemented + _previewUiState.emit( + previewUiState.value.copy( + toastMessageToShow = ToastMessage( + stringResource = R.string.toast_capture_failure, + testTag = IMAGE_CAPTURE_FAIL_TOAST_TAG + ) + ) + ) + Log.d(TAG, "cameraUseCase.takePicture error") + Log.d(TAG, exception.toString()) + onImageCapture(ImageCaptureEvent.ImageCaptureError(exception)) + } } } } @@ -259,4 +335,24 @@ class PreviewViewModel @Inject constructor( y = y ) } + + fun onToastShown() { + viewModelScope.launch { + // keeps the composable up on screen longer to be detected by UiAutomator + delay(2.seconds) + _previewUiState.emit( + previewUiState.value.copy( + toastMessageToShow = null + ) + ) + } + } + + sealed interface ImageCaptureEvent { + object ImageSaved : ImageCaptureEvent + + data class ImageCaptureError( + val exception: Exception + ) : ImageCaptureEvent + } } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt new file mode 100644 index 0000000..d29b4b4 --- /dev/null +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2023 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.feature.preview + +import com.google.jetpackcamera.domain.camera.CameraUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +private const val TAG = "ScreenFlash" + +/** + * Contains the UI state maintaining logic for screen flash feature. + */ +// TODO: Add this to ViewModelScoped so that it can be injected automatically. However, the current +// ViewModel and Hilt APIs probably don't support injecting the viewModelScope. +class ScreenFlash( + private val cameraUseCase: CameraUseCase, + private val scope: CoroutineScope +) { + data class ScreenFlashUiState( + val enabled: Boolean = false, + val onChangeComplete: () -> Unit = {}, + // restored during CLEAR_UI event + val screenBrightnessToRestore: Float? = null + ) + + private val _screenFlashUiState: MutableStateFlow<ScreenFlashUiState> = + MutableStateFlow(ScreenFlashUiState()) + val screenFlashUiState: StateFlow<ScreenFlashUiState> = _screenFlashUiState + + init { + scope.launch { + cameraUseCase.getScreenFlashEvents().collect { event -> + _screenFlashUiState.emit( + when (event.type) { + CameraUseCase.ScreenFlashEvent.Type.APPLY_UI -> + screenFlashUiState.value.copy( + enabled = true, + onChangeComplete = event.onComplete + ) + + CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI -> + screenFlashUiState.value.copy( + enabled = false, + onChangeComplete = { + event.onComplete() + // reset ui state on CLEAR_UI event completion + scope.launch { + _screenFlashUiState.emit( + ScreenFlashUiState() + ) + } + } + ) + } + ) + } + } + } + + /** + * Sets the screenBrightness value to the value right before APPLY_UI event for the next + * CLEAR_UI event, will be set to unknown (null) again after CLEAR_UI event is completed. + */ + fun setClearUiScreenBrightness(brightness: Float) { + scope.launch { + _screenFlashUiState.emit( + screenFlashUiState.value.copy(screenBrightnessToRestore = brightness) + ) + } + } +} diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt index a952bcf..01f09c8 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt @@ -18,6 +18,7 @@ package com.google.jetpackcamera.feature.preview.ui import android.util.Log import android.view.Display import android.view.View +import android.widget.Toast import androidx.camera.core.Preview import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -44,24 +45,67 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.jetpackcamera.feature.preview.R import com.google.jetpackcamera.feature.preview.VideoRecordingState import com.google.jetpackcamera.settings.model.AspectRatio +import com.google.jetpackcamera.settings.model.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode import com.google.jetpackcamera.viewfinder.CameraPreview import kotlinx.coroutines.CompletableDeferred private const val TAG = "PreviewScreen" -/** this is the preview surface display. This view implements gestures tap to focus, pinch to zoom, - * and double tap to flip camera */ +/** + * An invisible box that will display a [Toast] with specifications set by a [ToastMessage]. + * + * @param toastMessage the specifications for the [Toast]. + * @param onToastShown called once the Toast has been displayed. + */ +@Composable +fun ShowTestableToast( + modifier: Modifier = Modifier, + toastMessage: ToastMessage, + onToastShown: () -> Unit +) { + val toastShownStatus = remember { mutableStateOf(false) } + Box( + // box seems to need to have some size to be detected by UiAutomator + modifier = modifier + .size(20.dp) + .testTag(toastMessage.testTag) + ) { + // prevents toast from being spammed + if (!toastShownStatus.value) { + Toast.makeText( + LocalContext.current, + stringResource(id = toastMessage.stringResource), + toastMessage.toastLength + ) + .show() + toastShownStatus.value = true + onToastShown() + } + } + Log.d(TAG, "Toast Displayed with message: ${stringResource(id = toastMessage.stringResource)}") +} + +/** + * this is the preview surface display. This view implements gestures tap to focus, pinch to zoom, + * and double-tap to flip camera + */ @Composable fun PreviewDisplay( onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, @@ -140,6 +184,29 @@ fun PreviewDisplay( } } +@Composable +fun StabilizationIcon( + supportedStabilizationMode: List<SupportedStabilizationMode>, + videoStabilization: Stabilization, + previewStabilization: Stabilization +) { + if (supportedStabilizationMode.isNotEmpty() && + (videoStabilization == Stabilization.ON || previewStabilization == Stabilization.ON) + ) { + val descriptionText = if (videoStabilization == Stabilization.ON) { + stringResource(id = R.string.stabilization_icon_description_preview_and_video) + } else { + // previewStabilization will not be on for high quality + stringResource(id = R.string.stabilization_icon_description_video_only) + } + Icon( + painter = painterResource(id = R.drawable.baseline_video_stable_24), + contentDescription = descriptionText, + tint = Color.White + ) + } +} + /** * A temporary button that can be added to preview for quick testing purposes */ @@ -160,21 +227,18 @@ fun FlipCameraButton( enabledCondition: Boolean, onClick: () -> Unit ) { - Box(modifier = modifier) { - IconButton( - modifier = Modifier - .align(Alignment.Center) - .size(40.dp), - onClick = onClick, - enabled = enabledCondition - ) { - Icon( - imageVector = Icons.Filled.Refresh, - tint = Color.White, - contentDescription = stringResource(id = R.string.flip_camera_content_description), - modifier = Modifier.size(72.dp) - ) - } + IconButton( + modifier = modifier + .size(40.dp), + onClick = onClick, + enabled = enabledCondition + ) { + Icon( + imageVector = Icons.Filled.Refresh, + tint = Color.White, + contentDescription = stringResource(id = R.string.flip_camera_content_description), + modifier = Modifier.size(72.dp) + ) } } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponents.kt new file mode 100644 index 0000000..4b02195 --- /dev/null +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponents.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2023 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.feature.preview.ui + +import android.app.Activity +import android.util.Log +import android.view.Window +import android.view.WindowManager +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import com.google.jetpackcamera.feature.preview.ScreenFlash + +private const val TAG = "ScreenFlashComponents" + +@Composable +fun ScreenFlashScreen( + screenFlashUiState: ScreenFlash.ScreenFlashUiState, + onInitialBrightnessCalculated: (Float) -> Unit +) { + ScreenFlashOverlay(screenFlashUiState) + + if (screenFlashUiState.enabled) { + BrightnessMaximization(onInitialBrightnessCalculated = onInitialBrightnessCalculated) + } else { + screenFlashUiState.screenBrightnessToRestore?.let { + // non-null brightness value means there is a value to restore + BrightnessRestoration( + brightness = it + ) + } + } +} + +@Composable +fun ScreenFlashOverlay(screenFlashUiState: ScreenFlash.ScreenFlashUiState) { + // Update overlay transparency gradually + val alpha by animateFloatAsState( + targetValue = if (screenFlashUiState.enabled) 1f else 0f, + label = "screenFlashAlphaAnimation", + animationSpec = tween(), + finishedListener = { screenFlashUiState.onChangeComplete() } + ) + Box( + modifier = Modifier + .run { + if (screenFlashUiState.enabled) { + this.testTag("ScreenFlashOverlay") + } else { + this + } + } + .fillMaxSize() + .background(color = Color.White.copy(alpha = alpha)) + ) +} + +@Composable +fun BrightnessMaximization(onInitialBrightnessCalculated: (Float) -> Unit) { + // This Composable is attached to Activity in current code, so will have Activity context. + // If the Composable is attached to somewhere else in future, this needs to be updated too. + val activity = LocalContext.current as? Activity ?: run { + Log.e(TAG, "ScreenBrightness: could not find Activity context") + return + } + + val initialScreenBrightness = remember { + getScreenBrightness(activity.window) + } + LaunchedEffect(initialScreenBrightness) { + onInitialBrightnessCalculated(initialScreenBrightness) + } + + LaunchedEffect(Unit) { + setBrightness(activity, WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL) + } +} + +@Composable +fun BrightnessRestoration(brightness: Float) { + // This Composable is attached to Activity right now, so will have Activity context. + // If the Composable is attached to somewhere else in future, this needs to be updated too. + val activity = LocalContext.current as? Activity ?: run { + Log.e(TAG, "ScreenBrightness: could not find Activity context") + return + } + + LaunchedEffect(brightness) { + setBrightness(activity, brightness) + } +} + +fun getScreenBrightness(window: Window): Float = window.attributes.screenBrightness + +fun setBrightness(activity: Activity, value: Float) { + Log.d(TAG, "setBrightness: value = $value") + val layoutParams: WindowManager.LayoutParams = activity.window.attributes + layoutParams.screenBrightness = value + activity.window.attributes = layoutParams +} diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt new file mode 100644 index 0000000..da4204a --- /dev/null +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2023 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.feature.preview.ui + +const val CAPTURE_BUTTON = "CaptureButton" +const val SETTINGS_BUTTON = "SettingsButton" +const val DEFAULT_CAMERA_FACING_SETTING = "SetDefaultCameraFacingSwitch" diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ToastMessage.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ToastMessage.kt new file mode 100644 index 0000000..b7003da --- /dev/null +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ToastMessage.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023 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.feature.preview.ui + +import android.widget.Toast + +/** + * Helper class containing information used to create a [Toast]. + * + * @param stringResource the resource ID of to be displayed. + * @param isLongToast determines if the display time is [Toast.LENGTH_LONG] or [Toast.LENGTH_SHORT]. + * @property testTag the identifiable resource ID of a [ShowTestableToast] on screen. + */ +class ToastMessage( + val stringResource: Int, + isLongToast: Boolean = false, + val testTag: String = "" +) { + val toastLength: Int = when (isLongToast) { + true -> Toast.LENGTH_LONG + false -> Toast.LENGTH_SHORT + } +} diff --git a/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml b/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml new file mode 100644 index 0000000..54f9651 --- /dev/null +++ b/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2023 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> + + <path android:fillColor="@android:color/white" android:pathData="M20,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.1,4 20,4zM4,18V6h2.95l-2.33,8.73L16.82,18H4zM20,18h-2.95l2.34,-8.73L7.18,6H20V18z"/> + +</vector> diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml index 4d11b0a..b2713f5 100644 --- a/feature/preview/src/main/res/values/strings.xml +++ b/feature/preview/src/main/res/values/strings.xml @@ -20,4 +20,10 @@ <string name="capture_mode_single_stream">Single Stream</string> <string name="capture_mode_multi_stream">Multi Stream</string> <string name="flip_camera_content_description">Flip Camera</string> -</resources>
\ No newline at end of file + + <string name="toast_image_capture_success">Image Capture Success</string> + <string name="toast_capture_failure">Image Capture Failure</string> + <string name="stabilization_icon_description_preview_and_video">Preview is Stabilized</string> + <string name="stabilization_icon_description_video_only">Only Video is Stabilized</string> + +</resources> 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 70dc496..4e98b0d 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 @@ -15,6 +15,7 @@ */ package com.google.jetpackcamera.feature.preview +import android.content.ContentResolver import androidx.camera.core.Preview.SurfaceProvider import com.google.jetpackcamera.domain.camera.test.FakeCameraUseCase import com.google.jetpackcamera.settings.model.FlashMode @@ -69,6 +70,16 @@ class PreviewViewModelTest { } @Test + fun captureImageWithUri() = runTest(StandardTestDispatcher()) { + val surfaceProvider: SurfaceProvider = mock() + val contentResolver: ContentResolver = mock() + previewViewModel.runCamera(surfaceProvider) + previewViewModel.captureImage(contentResolver, null) {} + advanceUntilIdle() + assertEquals(cameraUseCase.numPicturesTaken, 1) + } + + @Test fun startVideoRecording() = runTest(StandardTestDispatcher()) { previewViewModel.runCamera(mock()) previewViewModel.startVideoRecording() diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt new file mode 100644 index 0000000..2c0e8d8 --- /dev/null +++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2023 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.feature.preview + +import android.content.ContentResolver +import androidx.camera.core.Preview +import com.google.jetpackcamera.domain.camera.CameraUseCase +import com.google.jetpackcamera.domain.camera.test.FakeCameraUseCase +import com.google.jetpackcamera.feature.preview.rules.MainDispatcherRule +import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS +import com.google.jetpackcamera.settings.model.FlashMode +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito + +@OptIn(ExperimentalCoroutinesApi::class) +class ScreenFlashTest { + private val testScope = TestScope() + private val testDispatcher = StandardTestDispatcher(testScope.testScheduler) + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + private val cameraUseCase = FakeCameraUseCase(testScope) + private lateinit var screenFlash: ScreenFlash + + @Before + fun setup() = runTest(testDispatcher) { + screenFlash = ScreenFlash(cameraUseCase, testScope) + + val surfaceProvider: Preview.SurfaceProvider = Mockito.mock() + cameraUseCase.initialize(DEFAULT_CAMERA_APP_SETTINGS) + cameraUseCase.runCamera(surfaceProvider, DEFAULT_CAMERA_APP_SETTINGS) + } + + @Test + fun initialScreenFlashUiState_disabledByDefault() { + assertEquals(false, screenFlash.screenFlashUiState.value.enabled) + } + + @Test + fun captureScreenFlashImage_screenFlashUiStateChangedInCorrectSequence() = + runTest(testDispatcher) { + val states = mutableListOf<ScreenFlash.ScreenFlashUiState>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + screenFlash.screenFlashUiState.toList(states) + } + + // FlashMode.ON in front facing camera automatically enables screen flash + cameraUseCase.setFlashMode(FlashMode.ON, true) + val contentResolver: ContentResolver = Mockito.mock() + cameraUseCase.takePicture(contentResolver, null) + + advanceUntilIdle() + assertEquals( + listOf( + false, + true, + false + ), + states.map { it.enabled } + ) + } + + @Test + fun emitClearUiEvent_screenFlashUiStateContainsClearUiScreenBrightness() = + runTest(testDispatcher) { + screenFlash.setClearUiScreenBrightness(5.0f) + cameraUseCase.emitScreenFlashEvent( + CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { } + ) + + advanceUntilIdle() + assertEquals( + 5.0f, + screenFlash.screenFlashUiState.value.screenBrightnessToRestore + ) + } + + @Test + fun invokeOnChangeCompleteAfterClearUiEvent_screenFlashUiStateReset() = + runTest(testDispatcher) { + screenFlash.setClearUiScreenBrightness(5.0f) + cameraUseCase.emitScreenFlashEvent( + CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { } + ) + + advanceUntilIdle() + screenFlash.screenFlashUiState.value.onChangeComplete() + + advanceUntilIdle() + assertEquals( + ScreenFlash.ScreenFlashUiState(), + screenFlash.screenFlashUiState.value + ) + } +} diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/rules/MainDispatcherRule.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/rules/MainDispatcherRule.kt new file mode 100644 index 0000000..d1ddd15 --- /dev/null +++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/rules/MainDispatcherRule.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 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.feature.preview.rules + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class MainDispatcherRule(private val dispatcher: CoroutineDispatcher) : TestRule { + @OptIn(ExperimentalCoroutinesApi::class) + override fun apply(base: Statement?, description: Description?) = object : Statement() { + override fun evaluate() { + Dispatchers.setMain(dispatcher) + try { + base!!.evaluate() + } finally { + Dispatchers.resetMain() + } + } + } +} diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt new file mode 100644 index 0000000..3dae6fe --- /dev/null +++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2023 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.feature.preview.ui + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertWidthIsAtLeast +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width +import com.google.jetpackcamera.feature.preview.ScreenFlash +import com.google.jetpackcamera.feature.preview.rules.MainDispatcherRule +import com.google.jetpackcamera.feature.preview.workaround.captureToImage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowPixelCopy + +// TODO: After device tests are added to github workflow, remove the tests here since they are +// duplicated in androidTest and fits there better +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ScreenFlashComponentsKtTest { + private val testScope = TestScope() + private val testDispatcher = StandardTestDispatcher(testScope.testScheduler) + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + @get:Rule + val composeTestRule = createComposeRule() + + private val screenFlashUiState: MutableState<ScreenFlash.ScreenFlashUiState> = + mutableStateOf(ScreenFlash.ScreenFlashUiState()) + + @Before + fun setUp() { + composeTestRule.setContent { + ScreenFlashScreen( + screenFlashUiState = screenFlashUiState.value, + onInitialBrightnessCalculated = {} + ) + } + } + + @Test + fun screenFlashOverlay_doesNotExistByDefault() = runTest { + advanceUntilIdle() + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertDoesNotExist() + } + + @Test + fun screenFlashOverlay_existsAfterStateIsEnabled() = runTest { + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true) + + advanceUntilIdle() + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertExists() + } + + @Test + fun screenFlashOverlay_doesNotExistWhenDisabledAfterEnabled() = runTest { + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true) + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = false) + + advanceUntilIdle() + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertDoesNotExist() + } + + @Test + fun screenFlashOverlay_sizeFillsMaxSize() = runTest { + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true) + + advanceUntilIdle() + val rootBounds = composeTestRule.onRoot().getBoundsInRoot() + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")) + .assertWidthIsAtLeast(rootBounds.width) + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")) + .assertHeightIsAtLeast(rootBounds.height) + } + + @Test + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Config(shadows = [ShadowPixelCopy::class]) + fun screenFlashOverlay_fullWhiteWhenEnabled() = runTest { + screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true) + + advanceUntilIdle() + val overlayScreenShot = + composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).captureToImage() + + // check a few pixels near center instead of whole image to save time + val overlayPixels = IntArray(4) + overlayScreenShot.readPixels( + overlayPixels, + overlayScreenShot.width / 2, + overlayScreenShot.height / 2, + 2, + 2 + ) + overlayPixels.forEach { + assertEquals(Color.White.toArgb(), it) + } + } +} diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/workaround/ComposableCaptureToImage.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/workaround/ComposableCaptureToImage.kt new file mode 100644 index 0000000..00b0bc2 --- /dev/null +++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/workaround/ComposableCaptureToImage.kt @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2023 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.feature.preview.workaround + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Bitmap +import android.graphics.Rect +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.View +import android.view.Window +import androidx.annotation.DoNotInline +import androidx.annotation.RequiresApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.ViewRootForTest +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.window.DialogWindowProvider +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.platform.graphics.HardwareRendererCompat +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.math.roundToInt + +/** + * Workaround captureToImage method. + * + * Once composable + robolectric graphics bugs are fixed, this can be replaced with the actual + * [androidx.compose.ui.test.SemanticsNodeInteraction.captureToImage]. Alternative is to use + * instrumentations tests, but they are not run at github workflows. + * + * See [robolectric issue 8071](https://github.com/robolectric/robolectric/issues/8071) for details. + */ +@OptIn(ExperimentalTestApi::class) +@RequiresApi(Build.VERSION_CODES.O) +fun SemanticsNodeInteraction.captureToImage(): ImageBitmap { + val node = fetchSemanticsNode("Failed to capture a node to bitmap.") + // Validate we are in popup + val popupParentMaybe = node.findClosestParentNode(includeSelf = true) { + it.config.contains(SemanticsProperties.IsPopup) + } + if (popupParentMaybe != null) { + return processMultiWindowScreenshot(node) + } + + val view = (node.root as ViewRootForTest).view + + // If we are in dialog use its window to capture the bitmap + val dialogParentNodeMaybe = node.findClosestParentNode(includeSelf = true) { + it.config.contains(SemanticsProperties.IsDialog) + } + var dialogWindow: Window? = null + if (dialogParentNodeMaybe != null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + // TODO(b/163023027) + throw IllegalArgumentException("Cannot currently capture dialogs on API lower than 28!") + } + + dialogWindow = findDialogWindowProviderInParent(view)?.window + ?: throw IllegalArgumentException( + "Could not find a dialog window provider to capture its bitmap" + ) + } + + val windowToUse = dialogWindow ?: view.context.getActivityWindow() + + val nodeBounds = node.boundsInRoot + val nodeBoundsRect = Rect( + nodeBounds.left.roundToInt(), + nodeBounds.top.roundToInt(), + nodeBounds.right.roundToInt(), + nodeBounds.bottom.roundToInt() + ) + + val locationInWindow = intArrayOf(0, 0) + view.getLocationInWindow(locationInWindow) + val x = locationInWindow[0] + val y = locationInWindow[1] + + // Now these are bounds in window + nodeBoundsRect.offset(x, y) + + return windowToUse.captureRegionToImage(nodeBoundsRect) +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun SemanticsNode.findClosestParentNode( + includeSelf: Boolean = false, + selector: (SemanticsNode) -> Boolean +): SemanticsNode? { + var currentParent = if (includeSelf) this else parent + while (currentParent != null) { + if (selector(currentParent)) { + return currentParent + } else { + currentParent = currentParent.parent + } + } + + return null +} + +@ExperimentalTestApi +@RequiresApi(Build.VERSION_CODES.O) +private fun processMultiWindowScreenshot(node: SemanticsNode): ImageBitmap { + val nodePositionInScreen = findNodePosition(node) + val nodeBoundsInRoot = node.boundsInRoot + + val combinedBitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + + val finalBitmap = Bitmap.createBitmap( + combinedBitmap, + (nodePositionInScreen.x + nodeBoundsInRoot.left).roundToInt(), + (nodePositionInScreen.y + nodeBoundsInRoot.top).roundToInt(), + nodeBoundsInRoot.width.roundToInt(), + nodeBoundsInRoot.height.roundToInt() + ) + return finalBitmap.asImageBitmap() +} + +private fun findNodePosition(node: SemanticsNode): Offset { + val view = (node.root as ViewRootForTest).view + val locationOnScreen = intArrayOf(0, 0) + view.getLocationOnScreen(locationOnScreen) + val x = locationOnScreen[0] + val y = locationOnScreen[1] + + return Offset(x.toFloat(), y.toFloat()) +} + +internal fun findDialogWindowProviderInParent(view: View): DialogWindowProvider? { + if (view is DialogWindowProvider) { + return view + } + val parent = view.parent ?: return null + if (parent is View) { + return findDialogWindowProviderInParent(parent) + } + return null +} + +private fun Context.getActivityWindow(): Window { + fun Context.getActivity(): Activity { + return when (this) { + is Activity -> this + is ContextWrapper -> this.baseContext.getActivity() + else -> throw IllegalStateException( + "Context is not an Activity context, but a ${javaClass.simpleName} context. " + + "An Activity context is required to get a Window instance" + ) + } + } + return getActivity().window +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun Window.captureRegionToImage(boundsInWindow: Rect): ImageBitmap { + // Turn on hardware rendering, if necessary + return withDrawingEnabled { + // Then we generate the bitmap + generateBitmap(boundsInWindow).asImageBitmap() + } +} + +private fun <R> withDrawingEnabled(block: () -> R): R { + val wasDrawingEnabled = HardwareRendererCompat.isDrawingEnabled() + try { + if (!wasDrawingEnabled) { + HardwareRendererCompat.setDrawingEnabled(true) + } + return block.invoke() + } finally { + if (!wasDrawingEnabled) { + HardwareRendererCompat.setDrawingEnabled(false) + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun Window.generateBitmap(boundsInWindow: Rect): Bitmap { + val destBitmap = + Bitmap.createBitmap( + boundsInWindow.width(), + boundsInWindow.height(), + Bitmap.Config.ARGB_8888 + ) + generateBitmapFromPixelCopy(boundsInWindow, destBitmap) + return destBitmap +} + +@RequiresApi(Build.VERSION_CODES.O) +private object PixelCopyHelper { + @DoNotInline + fun request( + source: Window, + srcRect: Rect?, + dest: Bitmap, + listener: PixelCopy.OnPixelCopyFinishedListener, + listenerThread: Handler + ) { + PixelCopy.request(source, srcRect, dest, listener, listenerThread) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun Window.generateBitmapFromPixelCopy(boundsInWindow: Rect, destBitmap: Bitmap) { + val latch = CountDownLatch(1) + var copyResult = 0 + val onCopyFinished = PixelCopy.OnPixelCopyFinishedListener { result -> + copyResult = result + latch.countDown() + } + PixelCopyHelper.request( + this, + boundsInWindow, + destBitmap, + onCopyFinished, + Handler(Looper.getMainLooper()) + ) + + if (!latch.await(1, TimeUnit.SECONDS)) { + throw AssertionError("Failed waiting for PixelCopy!") + } + if (copyResult != PixelCopy.SUCCESS) { + throw AssertionError("PixelCopy failed!") + } +} diff --git a/feature/quicksettings/build.gradle.kts b/feature/quicksettings/build.gradle.kts index a8da369..58f8b5c 100644 --- a/feature/quicksettings/build.gradle.kts +++ b/feature/quicksettings/build.gradle.kts @@ -15,9 +15,9 @@ */ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - id("kotlin-kapt") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) } android { @@ -58,27 +58,27 @@ android { dependencies { // Compose - val composeBom = platform("androidx.compose:compose-bom:2023.08.00") + val composeBom = platform(libs.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) // Compose - Material Design 3 - implementation("androidx.compose.material3:material3") + implementation(libs.compose.material3) // Compose - Android Studio Preview support - implementation("androidx.compose.ui:ui-tooling-preview") - debugImplementation("androidx.compose.ui:ui-tooling") + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) // Compose - Testing - androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation(libs.compose.junit) // Testing - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) // Guava - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.4.1") + implementation(libs.kotlinx.coroutines.guava) implementation(project(":data:settings")) } diff --git a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt b/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt index b2b9f32..8a8a60f 100644 --- a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt +++ b/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt @@ -30,12 +30,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.dimensionResource -import com.google.jetpackcamera.feature.quicksettings.ui.DropDownIcon import com.google.jetpackcamera.feature.quicksettings.ui.ExpandedQuickSetRatio import com.google.jetpackcamera.feature.quicksettings.ui.QuickFlipCamera import com.google.jetpackcamera.feature.quicksettings.ui.QuickSetFlash @@ -49,8 +49,9 @@ import com.google.jetpackcamera.settings.model.FlashMode /** * The UI component for quick settings. */ +@OptIn(ExperimentalComposeUiApi::class) @Composable -fun QuickSettingsScreen( +fun QuickSettingsScreenOverlay( modifier: Modifier = Modifier, currentCameraSettings: CameraAppSettings, isOpen: Boolean = false, @@ -79,7 +80,7 @@ fun QuickSettingsScreen( if (isOpen) { Column( modifier = - Modifier + modifier .fillMaxSize() .background(color = backgroundColor.value) .alpha(alpha = contentAlpha.value) @@ -108,11 +109,6 @@ fun QuickSettingsScreen( } else { shouldShowQuickSetting = IsExpandedQuickSetting.NONE } - DropDownIcon( - modifier = modifier, - toggleDropDown = toggleIsOpen, - isOpen = isOpen - ) } // enum representing which individual quick setting is currently expanded diff --git a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt b/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt index 420a130..3242f35 100644 --- a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt +++ b/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt @@ -38,6 +38,8 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.google.jetpackcamera.feature.quicksettings.CameraAspectRatio import com.google.jetpackcamera.feature.quicksettings.CameraFlashMode @@ -45,6 +47,7 @@ import com.google.jetpackcamera.feature.quicksettings.CameraLensFace import com.google.jetpackcamera.feature.quicksettings.QuickSettingsEnum import com.google.jetpackcamera.quicksettings.R import com.google.jetpackcamera.settings.model.AspectRatio +import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.FlashMode import kotlin.math.min @@ -118,16 +121,20 @@ fun QuickSetFlash( FlashMode.ON -> CameraFlashMode.ON } QuickSettingUiItem( - modifier = modifier, + modifier = modifier + .semantics { + contentDescription = + when (enum) { + CameraFlashMode.OFF -> "QUICK SETTINGS FLASH IS OFF" + CameraFlashMode.AUTO -> "QUICK SETTINGS FLASH IS AUTO" + CameraFlashMode.ON -> "QUICK SETTINGS FLASH IS ON" + } + }, enum = enum, isHighLighted = currentFlashMode == FlashMode.ON, onClick = { - when (currentFlashMode) { - FlashMode.OFF -> onClick(FlashMode.ON) - FlashMode.ON -> onClick(FlashMode.AUTO) - FlashMode.AUTO -> onClick(FlashMode.OFF) - } + onClick(currentFlashMode.getNextFlashMode()) } ) } @@ -144,14 +151,28 @@ fun QuickFlipCamera( false -> CameraLensFace.BACK } QuickSettingUiItem( - modifier = modifier, + modifier = modifier + .semantics { + contentDescription = + when (enum) { + CameraLensFace.FRONT -> "QUICK SETTINGS LENS FACING FRONT" + CameraLensFace.BACK -> "QUICK SETTINGS LENS FACING BACK" + } + }, enum = enum, onClick = { flipCamera(!currentFacingFront) } ) } +/** + * Button to toggle quick settings + */ @Composable -fun DropDownIcon(modifier: Modifier = Modifier, toggleDropDown: () -> Unit, isOpen: Boolean) { +fun ToggleQuickSettingsButton( + modifier: Modifier = Modifier, + toggleDropDown: () -> Unit, + isOpen: Boolean +) { Row( modifier = modifier, horizontalArrangement = Arrangement.Center, @@ -300,3 +321,51 @@ fun QuickSettingsGrid( } } } + +/** + * The top bar indicators for quick settings items. + */ +@Composable +fun Indicator(enum: QuickSettingsEnum, onClick: () -> Unit) { + Icon( + painter = painterResource(enum.getDrawableResId()), + contentDescription = stringResource(id = enum.getDescriptionResId()), + tint = Color.White, + modifier = Modifier + .size(dimensionResource(id = R.dimen.quick_settings_indicator_size)) + .clickable { onClick() } + ) +} + +@Composable +fun FlashModeIndicator(currentFlashMode: FlashMode, onClick: (flashMode: FlashMode) -> Unit) { + val enum = when (currentFlashMode) { + FlashMode.OFF -> CameraFlashMode.OFF + FlashMode.AUTO -> CameraFlashMode.AUTO + FlashMode.ON -> CameraFlashMode.ON + } + Indicator( + enum = enum, + onClick = { + onClick(currentFlashMode.getNextFlashMode()) + } + ) +} + +@Composable +fun QuickSettingsIndicators( + currentCameraSettings: CameraAppSettings, + onFlashModeClick: (flashMode: FlashMode) -> Unit +) { + Row { + FlashModeIndicator(currentCameraSettings.flashMode, onFlashModeClick) + } +} + +fun FlashMode.getNextFlashMode(): FlashMode { + return when (this) { + FlashMode.OFF -> FlashMode.ON + FlashMode.ON -> FlashMode.AUTO + FlashMode.AUTO -> FlashMode.OFF + } +} diff --git a/feature/quicksettings/src/main/res/values/dimens.xml b/feature/quicksettings/src/main/res/values/dimens.xml index 24ad7a6..0028eac 100644 --- a/feature/quicksettings/src/main/res/values/dimens.xml +++ b/feature/quicksettings/src/main/res/values/dimens.xml @@ -18,5 +18,6 @@ <dimen name="quick_settings_ui_horizontal_padding">30dp</dimen> <dimen name="quick_settings_ui_item_icon_size">60dp</dimen> <dimen name="quick_settings_ui_item_padding">20dp</dimen> + <dimen name="quick_settings_indicator_size">36dp</dimen> <dimen name="quick_settings_spacer_height">170dp</dimen> </resources>
\ No newline at end of file diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index d9a1dbb..78d8f60 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -15,10 +15,10 @@ */ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - id("kotlin-kapt") - id("com.google.dagger.hilt.android") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.dagger.hilt.android) } android { @@ -59,41 +59,42 @@ android { dependencies { // Compose - val composeBom = platform("androidx.compose:compose-bom:2022.12.00") + val composeBom = platform(libs.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) // Compose - Material Design 3 - implementation("androidx.compose.material3:material3") + implementation(libs.compose.material3) // Compose - Android Studio Preview support - implementation("androidx.compose.ui:ui-tooling-preview") - debugImplementation("androidx.compose.ui:ui-tooling") + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) // Compose - Integration with ViewModels with Navigation and Hilt - implementation("androidx.hilt:hilt-navigation-compose:1.0.0") + implementation(libs.hilt.navigation.compose) // Compose - Testing - androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation(libs.compose.junit) // Testing - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") - testImplementation("org.mockito:mockito-core:5.2.0") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6") - implementation("androidx.test:core-ktx:1.5.0") + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + testImplementation(libs.mockito.core) + testImplementation(libs.kotlinx.coroutines.test) + + implementation(libs.androidx.core.ktx) // Guava - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.4.1") + implementation(libs.kotlinx.coroutines.guava) // Hilt - implementation("com.google.dagger:hilt-android:2.44") - kapt("com.google.dagger:hilt-compiler:2.44") + implementation(libs.dagger.hilt.android) + kapt(libs.dagger.hilt.compiler) // Proto Datastore - implementation("androidx.datastore:datastore:1.0.0") - implementation("com.google.protobuf:protobuf-kotlin-lite:3.21.12") + implementation(libs.androidx.datastore) + implementation(libs.protobuf.kotlin.lite) implementation(project(":data:settings")) } diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt index c28a558..fd8b32c 100644 --- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt @@ -31,6 +31,7 @@ import com.google.jetpackcamera.settings.ui.DefaultCameraFacing import com.google.jetpackcamera.settings.ui.FlashModeSetting import com.google.jetpackcamera.settings.ui.SectionHeader import com.google.jetpackcamera.settings.ui.SettingsPageHeader +import com.google.jetpackcamera.settings.ui.StabilizationSetting /** * Screen used for the Settings feature. @@ -78,6 +79,16 @@ fun SettingsList(uiState: SettingsUiState, viewModel: SettingsViewModel) { setCaptureMode = viewModel::setCaptureMode ) + // todo: b/313647247 - query device and disable setting if preview stabilization isn't supported. + // todo: b/313647809 - query device and disable setting if video stabilization isn't supported. + StabilizationSetting( + currentVideoStabilization = uiState.cameraAppSettings.videoCaptureStabilization, + currentPreviewStabilization = uiState.cameraAppSettings.previewStabilization, + supportedStabilizationMode = uiState.cameraAppSettings.supportedStabilizationModes, + setVideoStabilization = viewModel::setVideoStabilization, + setPreviewStabilization = viewModel::setPreviewStabilization + ) + SectionHeader(title = stringResource(id = R.string.section_title_app_settings)) DarkModeSetting( diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt index c0005db..ca55517 100644 --- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt @@ -23,6 +23,7 @@ import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS import com.google.jetpackcamera.settings.model.DarkMode import com.google.jetpackcamera.settings.model.FlashMode +import com.google.jetpackcamera.settings.model.Stabilization import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -61,8 +62,7 @@ class SettingsViewModel @Inject constructor( Log.d( TAG, - "updated setting" + - settingsRepository.getCameraAppSettings().captureMode + "updated setting ${settingsRepository.getCameraAppSettings().captureMode}" ) } } @@ -82,7 +82,7 @@ class SettingsViewModel @Inject constructor( Log.d( TAG, "set camera default facing: " + - settingsRepository.getCameraAppSettings().isFrontCameraFacing + "${settingsRepository.getCameraAppSettings().isFrontCameraFacing}" ) } } @@ -92,8 +92,7 @@ class SettingsViewModel @Inject constructor( settingsRepository.updateDarkModeStatus(darkMode) Log.d( TAG, - "set dark mode theme: " + - settingsRepository.getCameraAppSettings().darkMode + "set dark mode theme: ${settingsRepository.getCameraAppSettings().darkMode}" ) } } @@ -109,7 +108,7 @@ class SettingsViewModel @Inject constructor( settingsRepository.updateAspectRatio(aspectRatio) Log.d( TAG, - "set aspect ratio " + + "set aspect ratio: " + "${settingsRepository.getCameraAppSettings().aspectRatio}" ) } @@ -121,8 +120,32 @@ class SettingsViewModel @Inject constructor( Log.d( TAG, - "set default capture mode " + - settingsRepository.getCameraAppSettings().captureMode + "set default capture mode: " + + "${settingsRepository.getCameraAppSettings().captureMode}" + ) + } + } + + fun setPreviewStabilization(stabilization: Stabilization) { + viewModelScope.launch { + settingsRepository.updatePreviewStabilization(stabilization) + + Log.d( + TAG, + "set preview stabilization: " + + "${settingsRepository.getCameraAppSettings().previewStabilization}" + ) + } + } + + fun setVideoStabilization(stabilization: Stabilization) { + viewModelScope.launch { + settingsRepository.updateVideoStabilization(stabilization) + + Log.d( + TAG, + "set video stabilization: " + + "${settingsRepository.getCameraAppSettings().previewStabilization}" ) } } diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt index 738d489..01ea1a4 100644 --- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt +++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt @@ -20,8 +20,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.toggleable @@ -32,6 +32,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch @@ -52,6 +53,8 @@ import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DarkMode import com.google.jetpackcamera.settings.model.FlashMode +import com.google.jetpackcamera.settings.model.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode /** * MAJOR SETTING UI COMPONENTS @@ -211,7 +214,6 @@ fun AspectRatioSetting(currentAspectRatio: AspectRatio, setAspectRatio: (AspectR @Composable fun CaptureModeSetting(currentCaptureMode: CaptureMode, setCaptureMode: (CaptureMode) -> Unit) { - // todo: string resources BasicPopupSetting( title = stringResource(R.string.capture_mode_title), leadingIcon = null, @@ -219,6 +221,7 @@ fun CaptureModeSetting(currentCaptureMode: CaptureMode, setCaptureMode: (Capture CaptureMode.MULTI_STREAM -> stringResource( id = R.string.capture_mode_description_multi_stream ) + CaptureMode.SINGLE_STREAM -> stringResource( id = R.string.capture_mode_description_single_stream ) @@ -241,6 +244,110 @@ fun CaptureModeSetting(currentCaptureMode: CaptureMode, setCaptureMode: (Capture } /** + * Returns the description text depending on the preview/video stabilization configuration. + * On - preview is on and video is NOT off. + * High Quality - preview is unspecified and video is ON. + * Off - Every other configuration. + */ +private fun getStabilizationStringRes( + previewStabilization: Stabilization, + videoStabilization: Stabilization +): Int { + return if (previewStabilization == Stabilization.ON && + videoStabilization != Stabilization.OFF + ) { + R.string.stabilization_description_on + } else if (previewStabilization == Stabilization.UNDEFINED && + videoStabilization == Stabilization.ON + ) { + R.string.stabilization_description_high_quality + } else { + R.string.stabilization_description_off + } +} + +/** + * A Setting to set preview and video stabilization. + * + * ON - Both preview and video are stabilized. + * HIGH_QUALITY - Video will be stabilized, preview might be stabilized, depending on the device. + * OFF - Preview and video stabilization is disabled. + * + * @param supportedStabilizationMode the enabled condition for this setting. + */ +@Composable +fun StabilizationSetting( + currentPreviewStabilization: Stabilization, + currentVideoStabilization: Stabilization, + supportedStabilizationMode: List<SupportedStabilizationMode>, + setVideoStabilization: (Stabilization) -> Unit, + setPreviewStabilization: (Stabilization) -> Unit +) { + BasicPopupSetting( + title = stringResource(R.string.video_stabilization_title), + leadingIcon = null, + enabled = supportedStabilizationMode.isNotEmpty(), + description = if (supportedStabilizationMode.isEmpty()) { + stringResource(id = R.string.stabilization_description_unsupported) + } else { + stringResource( + id = getStabilizationStringRes( + previewStabilization = currentPreviewStabilization, + videoStabilization = currentVideoStabilization + ) + ) + }, + popupContents = { + Column(Modifier.selectableGroup()) { + Spacer(modifier = Modifier.height(10.dp)) + + // on selector + SingleChoiceSelector( + text = stringResource(id = R.string.stabilization_selector_on), + secondaryText = stringResource(id = R.string.stabilization_selector_on_info), + enabled = supportedStabilizationMode.contains(SupportedStabilizationMode.ON), + selected = (currentPreviewStabilization == Stabilization.ON) && + (currentVideoStabilization != Stabilization.OFF), + onClick = { + setVideoStabilization(Stabilization.UNDEFINED) + setPreviewStabilization(Stabilization.ON) + } + ) + + // high quality selector + SingleChoiceSelector( + text = stringResource(id = R.string.stabilization_selector_high_quality), + secondaryText = stringResource( + id = R.string.stabilization_selector_high_quality_info + ), + enabled = supportedStabilizationMode.contains( + SupportedStabilizationMode.HIGH_QUALITY + ), + + selected = (currentPreviewStabilization == Stabilization.UNDEFINED) && + (currentVideoStabilization == Stabilization.ON), + onClick = { + setVideoStabilization(Stabilization.ON) + setPreviewStabilization(Stabilization.UNDEFINED) + } + ) + + // off selector + SingleChoiceSelector( + text = stringResource(id = R.string.stabilization_selector_off), + selected = (currentPreviewStabilization != Stabilization.ON) && + (currentVideoStabilization != Stabilization.ON), + onClick = { + setVideoStabilization(Stabilization.OFF) + setPreviewStabilization(Stabilization.OFF) + } + ) + } + } + ) +} + +/* * Setting UI sub-Components * small and whimsical :) * don't use these directly, use them to build the ready-to-use setting components @@ -261,6 +368,7 @@ fun BasicPopupSetting( SettingUI( modifier = modifier.clickable(enabled = enabled) { popupStatus.value = true }, title = title, + enabled = enabled, description = description, leadingIcon = leadingIcon, trailingContent = null @@ -303,6 +411,7 @@ fun SwitchSettingUI( value = settingValue, onValueChange = { _ -> onClick() } ), + enabled = enabled, title = title, description = description, leadingIcon = leadingIcon, @@ -321,22 +430,34 @@ fun SwitchSettingUI( /** * A composable used as a template used to construct other settings components */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingUI( modifier: Modifier = Modifier, title: String, + enabled: Boolean = true, description: String? = null, leadingIcon: @Composable (() -> Unit)?, trailingContent: @Composable (() -> Unit)? ) { ListItem( modifier = modifier, - headlineContent = { Text(title) }, - supportingContent = when (description) { - null -> null - else -> { - { Text(description) } + headlineContent = { + when (enabled) { + true -> Text(title) + false -> { + Text(text = title, color = LocalContentColor.current.copy(alpha = .7f)) + } + } + }, + supportingContent = { + if (description != null) { + when (enabled) { + true -> Text(description) + false -> Text( + text = description, + color = LocalContentColor.current.copy(alpha = .7f) + ) + } } }, leadingContent = leadingIcon, @@ -351,6 +472,7 @@ fun SettingUI( fun SingleChoiceSelector( modifier: Modifier = Modifier, text: String, + secondaryText: String? = null, selected: Boolean, onClick: () -> Unit, enabled: Boolean = true @@ -361,16 +483,23 @@ fun SingleChoiceSelector( .selectable( selected = selected, role = Role.RadioButton, - onClick = onClick + onClick = onClick, + enabled = enabled ), verticalAlignment = Alignment.CenterVertically ) { - RadioButton( - selected = selected, - onClick = onClick, - enabled = enabled + SettingUI( + title = text, + description = secondaryText, + enabled = enabled, + leadingIcon = { + RadioButton( + selected = selected, + onClick = onClick, + enabled = enabled + ) + }, + trailingContent = null ) - Spacer(Modifier.width(8.dp)) - Text(text) } } diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 3df861f..44a0bbc 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -59,7 +59,7 @@ <string name="capture_mode_description_multi_stream">Multi Stream</string> <string name="capture_mode_description_single_stream">Single Stream</string> - <!-- Aspect Ratio setting strings --> + <!-- Aspect Ratio setting strings --> <string name="aspect_ratio_title">Set Aspect Ratio</string> <string name="aspect_ratio_selector_9_16">9:16</string> @@ -70,4 +70,18 @@ <string name="aspect_ratio_description_3_4">Aspect Ratio is 3:4</string> <string name="aspect_ratio_description_1_1">Aspect Ratio is 1:1</string> + <!-- Stabilization setting strings --> + <string name="video_stabilization_title">Set Video Stabilization</string> + + <string name="stabilization_selector_on">On</string> + <string name="stabilization_selector_high_quality">High Quality</string> + <string name="stabilization_selector_off">Off</string> + + <string name="stabilization_selector_on_info">Both preview and video streams will be stabilized</string> + <string name="stabilization_selector_high_quality_info">Video stream will be stabilized, but preview might not be. This mode ensures highest-quality video stream.</string> + + <string name="stabilization_description_on">Stabilization On</string> + <string name="stabilization_description_high_quality">Stabilization High Quality</string> + <string name="stabilization_description_off">Stabilization Off</string> + <string name="stabilization_description_unsupported">Stabilization unsupported</string> </resources>
\ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..e867155 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,88 @@ +[versions] +accompanistPermissions = "0.26.5-rc" +androidJunit = "1.1.5" +androidLibrary = "8.1.4" +androidxActivityCompose = "1.8.2" +androidxAppCompat = "1.6.1" +androidxCore = "1.12.0" +androidxCoreKtx = "1.12.0" +androidxTracing = "1.2.0" +benchmarkMacroJunit4 = "1.2.2" +camerax = "1.4.0-SNAPSHOT" +compose = "1.5.4" +composeBom = "2023.10.01" +composeMaterial3 = "1.1.2" +coreKtx = "1.5.0" +coroutinesCore = "1.7.1" +coroutinesTest = "1.7.3" +datastore = "1.0.0" +espressoCore = "3.5.1" +futures = "1.1.0" +guava = "1.7.3" +hilt = "2.48" +hiltNavigationCompose = "1.1.0" +junit = "4.13.2" +kotlinPlugin = "1.8.0" +lifecycleRuntimeKtx = "2.7.0" +material = "1.8.0" +mockitoCore = "5.2.0" +navigationCompose = "2.7.6" +profileinstaller = "1.3.1" +protobuf = "3.21.12" +protobuf-plugin = "0.9.1" +robolectric = "4.11.1" +testMonitor = "1.6.1" +uiautomator = "2.2.0" +viewmodelCompose = "2.7.0" + +[libraries] +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } +android-material = { module = "com.google.android.material:material", version.ref = "material" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppCompat" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" } +androidx-core = { module = "androidx.core:core", version.ref = "androidxCore" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" } +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidJunit" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } +androidx-rules = { module = "androidx.test:rules", version.ref = "coreKtx" } +androidx-test-monitor = { module = "androidx.test:monitor", version.ref = "testMonitor" } +androidx-tracing = { module = "androidx.tracing:tracing-ktx", version.ref = "androidxTracing" } +androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "camerax" } +camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +camera-video = { module = "androidx.camera:camera-video", version.ref = "camerax" } +camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +compose-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial3" } +compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +futures-ktx = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "futures" } +hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesCore" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "guava" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" } +lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "viewmodelCompose" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" } +protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidLibrary" } +android-library = { id = "com.android.library", version.ref = "androidLibrary" } +android-test = { id = "com.android.test", version.ref = "androidLibrary" } +dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +google-protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinPlugin" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlinPlugin" }
\ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 5e4c892..b611e8d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,7 +25,7 @@ dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { maven { - setUrl("https://androidx.dev/snapshots/builds/10955671/artifacts/repository") + setUrl("https://androidx.dev/snapshots/builds/11359450/artifacts/repository") } google() mavenCentral() @@ -40,3 +40,4 @@ include(":feature:settings") include(":data:settings") include(":core:common") include(":feature:quicksettings") +include(":benchmark") |