summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Jia <davidjia@google.com>2024-02-05 23:58:14 +0000
committerDavid Jia <davidjia@google.com>2024-02-08 21:31:25 +0000
commit1fbcf3a6c408393401786a876009fe9d18844c31 (patch)
tree222c6eb4bf6594f9fa9cbaf4184e503f2beb2269
parent1280ae1bb0718dbd855c28e9065fe71d448dd057 (diff)
parentaa8801977aa9586d9c7693d09c410201d5a1743e (diff)
downloadjetpack-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
-rw-r--r--.github/workflows/MergeToMainWorkflow.yaml53
-rw-r--r--.github/workflows/PullRequestWorkflow.yaml (renamed from .github/workflows/PushWorkflow.yaml)2
-rw-r--r--.idea/androidTestResultsUserPreferences.xml39
-rw-r--r--.idea/gradle.xml4
-rw-r--r--app/build.gradle.kts55
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt29
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt174
-rw-r--r--app/src/main/AndroidManifest.xml5
-rw-r--r--app/src/main/java/com/google/jetpackcamera/MainActivity.kt38
-rw-r--r--app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt15
-rw-r--r--app/src/main/res/values/strings.xml1
-rw-r--r--benchmark/.gitignore1
-rw-r--r--benchmark/build.gradle.kts75
-rw-r--r--benchmark/src/main/AndroidManifest.xml17
-rw-r--r--benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt106
-rw-r--r--benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt26
-rw-r--r--benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt83
-rw-r--r--benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt152
-rw-r--r--build.gradle.kts11
-rw-r--r--camera-viewfinder-compose/build.gradle.kts28
-rw-r--r--core/common/build.gradle.kts25
-rw-r--r--core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt10
-rw-r--r--data/settings/build.gradle.kts35
-rw-r--r--data/settings/consumer-rules.pro1
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt4
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt73
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt8
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt5
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/Stabilization.kt48
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/SupportedStabilizationMode.kt25
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt14
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt46
-rw-r--r--data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto8
-rw-r--r--data/settings/src/main/proto/com/google/jetpackcamera/settings/preview_stabilization.proto26
-rw-r--r--data/settings/src/main/proto/com/google/jetpackcamera/settings/video_stabilization.proto26
-rw-r--r--domain/camera/Android.bp9
-rw-r--r--domain/camera/build.gradle.kts38
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt4
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt23
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt277
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt48
-rw-r--r--domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt141
-rw-r--r--feature/preview/Android.bp14
-rw-r--r--feature/preview/build.gradle.kts52
-rw-r--r--feature/preview/src/androidTest/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt114
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt293
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt5
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt118
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt87
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt98
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponents.kt123
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt20
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ToastMessage.kt36
-rw-r--r--feature/preview/src/main/res/drawable/baseline_video_stable_24.xml21
-rw-r--r--feature/preview/src/main/res/values/strings.xml8
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt11
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt120
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/rules/MainDispatcherRule.kt39
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt133
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/workaround/ComposableCaptureToImage.kt248
-rw-r--r--feature/quicksettings/build.gradle.kts24
-rw-r--r--feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt12
-rw-r--r--feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt85
-rw-r--r--feature/quicksettings/src/main/res/values/dimens.xml1
-rw-r--r--feature/settings/build.gradle.kts43
-rw-r--r--feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt11
-rw-r--r--feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt39
-rw-r--r--feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt159
-rw-r--r--feature/settings/src/main/res/values/strings.xml16
-rw-r--r--gradle/libs.versions.toml88
-rw-r--r--settings.gradle.kts3
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")