summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Jia <davidjia@google.com>2024-05-31 22:16:38 +0000
committerDavid Jia <davidjia@google.com>2024-05-31 22:23:41 +0000
commit52a6720235ad9e773463679f1a7cda0c0ba3bfd5 (patch)
tree127e1e79dd0bb794d7688a9f844cf6ace06c81a5
parenta1661f9e655d0722ae86e370cc4e1d8052ba4b4f (diff)
downloadjetpack-camera-app-main.tar.gz
[external/jetpack-camera-app] Merge remote-tracking branch 'aosp/upstream-main' into mymergemain
Test: all tests passed Change-Id: Iebb94bd38304afa6b60f7d9424205d6363982e61
-rw-r--r--.github/workflows/PullRequestWorkflow.yaml79
-rw-r--r--.idea/deploymentTargetSelector.xml10
-rw-r--r--README.md35
-rw-r--r--app/build.gradle.kts58
-rw-r--r--app/src/androidTest/Android.bp8
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt (renamed from data/settings/src/test/java/com/google/jetpackcamera/settings/ExampleUnitTest.kt)21
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt133
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt99
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt234
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt50
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt160
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt149
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt70
-rw-r--r--app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt118
-rw-r--r--app/src/main/Android.bp2
-rw-r--r--app/src/main/AndroidManifest.xml11
-rw-r--r--app/src/main/java/com/google/jetpackcamera/BuildConfig.kt25
-rw-r--r--app/src/main/java/com/google/jetpackcamera/MainActivity.kt69
-rw-r--r--app/src/main/java/com/google/jetpackcamera/MainActivityViewModel.kt6
-rw-r--r--app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt91
-rw-r--r--app/src/main/java/com/google/jetpackcamera/ui/Routes.kt1
-rw-r--r--app/src/main/res/drawable/photo_camera.xml25
-rw-r--r--app/src/main/res/values/strings.xml4
-rw-r--r--benchmark/build.gradle.kts6
-rw-r--r--benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt2
-rw-r--r--benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt2
-rw-r--r--benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt2
-rw-r--r--benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt8
-rw-r--r--build.gradle.kts10
-rw-r--r--camera-viewfinder-compose/Android.bp23
-rw-r--r--camera-viewfinder-compose/build.gradle.kts85
-rw-r--r--camera-viewfinder-compose/proguard-rules.pro21
-rw-r--r--camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/CameraPreview.kt133
-rw-r--r--camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/CombinedSurface.kt100
-rw-r--r--camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/Surface.kt83
-rw-r--r--camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/SurfaceTransformationUtil.kt211
-rw-r--r--camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/Texture.kt202
-rw-r--r--core/common/build.gradle.kts13
-rw-r--r--core/common/src/main/java/com/google/jetpackcamera/core/common/RefCounted.kt162
-rw-r--r--core/common/src/test/java/com/google/jetpackcamera/core/common/RefCountedTest.kt129
-rw-r--r--data/settings/build.gradle.kts47
-rw-r--r--data/settings/proguard-rules.pro21
-rw-r--r--data/settings/src/androidTest/java/com/google/jetpackcamera/settings/DataStoreModuleTest.kt4
-rw-r--r--data/settings/src/androidTest/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryInstrumentedTest.kt75
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsRepository.kt41
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt7
-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/SettingsModule.kt18
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt18
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/AspectRatio.kt6
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt14
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt47
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/DynamicRange.kt45
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/LensFacing.kt51
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/Stabilization.kt2
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/model/SupportedStabilizationMode.kt2
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt9
-rw-r--r--data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt61
-rw-r--r--data/settings/src/main/proto/com/google/jetpackcamera/settings/dynamic_range.proto26
-rw-r--r--data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto27
-rw-r--r--data/settings/src/main/proto/com/google/jetpackcamera/settings/lens_facing.proto25
-rw-r--r--data/settings/src/main/proto/com/google/jetpackcamera/settings/preview_stabilization.proto2
-rw-r--r--data/settings/src/main/proto/com/google/jetpackcamera/settings/video_stabilization.proto2
-rw-r--r--data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt64
-rw-r--r--docs/images/JCA-video-capture.gifbin0 -> 881560 bytes
-rw-r--r--domain/camera/Android.bp1
-rw-r--r--domain/camera/build.gradle.kts30
-rw-r--r--domain/camera/consumer-rules.pro0
-rw-r--r--domain/camera/proguard-rules.pro21
-rw-r--r--domain/camera/src/main/AndroidManifest.xml3
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt116
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt795
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt5
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/EmptySurfaceProcessor.kt143
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt361
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt46
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt450
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/SingleSurfaceForcingEffect.kt (renamed from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/SingleSurfaceForcingEffect.kt)23
-rw-r--r--domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt128
-rw-r--r--domain/camera/src/test/Android.bp1
-rw-r--r--domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt65
-rw-r--r--feature/permissions/.gitignore (renamed from camera-viewfinder-compose/.gitignore)0
-rw-r--r--feature/permissions/Android.bp (renamed from feature/quicksettings/Android.bp)23
-rw-r--r--feature/permissions/build.gradle.kts (renamed from feature/quicksettings/build.gradle.kts)48
-rw-r--r--feature/permissions/consumer-rules.pro (renamed from camera-viewfinder-compose/consumer-rules.pro)0
-rw-r--r--feature/permissions/proguard-rules.pro (renamed from app/proguard-rules.pro)2
-rw-r--r--feature/permissions/src/main/AndroidManifest.xml (renamed from camera-viewfinder-compose/src/main/AndroidManifest.xml)4
-rw-r--r--feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsEnums.kt122
-rw-r--r--feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt105
-rw-r--r--feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsUiState.kt21
-rw-r--r--feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt101
-rw-r--r--feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/PermissionsScreenComponents.kt (renamed from app/src/main/java/com/google/jetpackcamera/ui/PermissionsUi.kt)130
-rw-r--r--feature/permissions/src/main/res/values/strings.xml30
-rw-r--r--feature/preview/Android.bp5
-rw-r--r--feature/preview/build.gradle.kts59
-rw-r--r--feature/preview/consumer-rules.pro0
-rw-r--r--feature/preview/proguard-rules.pro21
-rw-r--r--feature/preview/src/androidTest/Android.bp1
-rw-r--r--feature/preview/src/androidTest/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt2
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt39
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt470
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt47
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt442
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt2
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsEnums.kt148
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt294
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt (renamed from feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt)206
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/TestTags.kt26
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt468
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt169
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt502
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponents.kt11
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/SnackbarData.kt27
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt9
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ToastMessage.kt6
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/theme/Theme.kt50
-rw-r--r--feature/preview/src/main/res/drawable/baseline_video_stable_24.xml21
-rw-r--r--feature/preview/src/main/res/drawable/multi_stream_icon.xml21
-rw-r--r--feature/preview/src/main/res/drawable/single_stream_capture_icon.xml (renamed from feature/quicksettings/src/main/res/drawable/baseline_aspect_ratio_72.xml)6
-rw-r--r--feature/preview/src/main/res/values/dimens.xml (renamed from feature/quicksettings/src/main/res/values/dimens.xml)0
-rw-r--r--feature/preview/src/main/res/values/strings.xml40
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt90
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt112
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/rules/MainDispatcherRule.kt2
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt2
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/workaround/ComposableCaptureToImage.kt57
-rw-r--r--feature/quicksettings/.gitignore1
-rw-r--r--feature/quicksettings/proguard-rules.pro21
-rw-r--r--feature/quicksettings/src/main/AndroidManifest.xml19
-rw-r--r--feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsEnums.kt99
-rw-r--r--feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt186
-rw-r--r--feature/quicksettings/src/main/res/drawable/baseline_cameraswitch_72.xml23
-rw-r--r--feature/quicksettings/src/main/res/drawable/baseline_expand_more_72.xml21
-rw-r--r--feature/quicksettings/src/main/res/drawable/baseline_flash_auto_72.xml21
-rw-r--r--feature/quicksettings/src/main/res/drawable/baseline_flash_off_72.xml21
-rw-r--r--feature/quicksettings/src/main/res/drawable/baseline_flash_on_72.xml21
-rw-r--r--feature/quicksettings/src/main/res/drawable/baseline_timer_10_72.xml21
-rw-r--r--feature/quicksettings/src/main/res/drawable/baseline_timer_3_72.xml21
-rw-r--r--feature/quicksettings/src/main/res/drawable/baseline_timer_off_72.xml23
-rw-r--r--feature/quicksettings/src/main/res/values/strings.xml49
-rw-r--r--feature/settings/build.gradle.kts46
-rw-r--r--feature/settings/consumer-rules.pro0
-rw-r--r--feature/settings/proguard-rules.pro21
-rw-r--r--feature/settings/src/androidTest/Android.bp1
-rw-r--r--feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt76
-rw-r--r--feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt146
-rw-r--r--feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt12
-rw-r--r--feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt105
-rw-r--r--feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt225
-rw-r--r--feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt18
-rw-r--r--feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/theme/Theme.kt50
-rw-r--r--feature/settings/src/main/res/values/strings.xml22
-rw-r--r--gradle.properties13
-rw-r--r--gradle/init.gradle.kts7
-rw-r--r--gradle/libs.versions.toml136
-rw-r--r--gradle/wrapper/gradle-wrapper.properties4
-rw-r--r--settings.gradle.kts6
-rw-r--r--spotless/copyright.kt2
-rw-r--r--spotless/copyright.kts2
-rw-r--r--spotless/copyright.xml2
160 files changed, 7233 insertions, 3674 deletions
diff --git a/.github/workflows/PullRequestWorkflow.yaml b/.github/workflows/PullRequestWorkflow.yaml
index ea32c21..20e1582 100644
--- a/.github/workflows/PullRequestWorkflow.yaml
+++ b/.github/workflows/PullRequestWorkflow.yaml
@@ -17,26 +17,26 @@ jobs:
timeout-minutes: 120
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Validate Gradle Wrapper
- uses: gradle/wrapper-validation-action@v1
+ uses: gradle/wrapper-validation-action@v2
- name: Set up JDK
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v4
with:
distribution: ${{ env.DISTRIBUTION }}
java-version: ${{ env.JDK_VERSION }}
cache: gradle
- name: Setup Gradle
- uses: gradle/gradle-build-action@v2
+ uses: gradle/gradle-build-action@v3
- name: Build all build type and flavor permutations
run: ./gradlew assemble --parallel --build-cache
- name: Upload build outputs (APKs)
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: build-outputs
path: app/build/outputs
@@ -44,7 +44,7 @@ jobs:
- name: Upload build reports
if: always()
continue-on-error: true
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: build-reports
path: "*/build/reports"
@@ -55,20 +55,20 @@ jobs:
timeout-minutes: 120
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Validate Gradle Wrapper
- uses: gradle/wrapper-validation-action@v1
+ uses: gradle/wrapper-validation-action@v2
- name: Set up JDK
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v4
with:
distribution: ${{ env.DISTRIBUTION }}
java-version: ${{ env.JDK_VERSION }}
cache: gradle
- name: Setup Gradle
- uses: gradle/gradle-build-action@v2
+ uses: gradle/gradle-build-action@v3
continue-on-error: true
- name: Run local tests
@@ -76,33 +76,82 @@ jobs:
- name: Upload test reports on failure
if: failure()
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: test-reports
path: "*/build/reports/tests"
+ android-test:
+ name: Instrumentation Tests (${{ matrix.device.name }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ device:
+ - { name: pixel2Api28, img: 'system-images;android-28;google_apis;x86' }
+ - { name: pixel8Api34, img: 'system-images;android-34;aosp_atd;x86_64' }
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Enable KVM group perms
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: Set up JDK
+ uses: actions/setup-java@v4
+ with:
+ distribution: ${{ env.DISTRIBUTION }}
+ java-version: ${{ env.JDK_VERSION }}
+
+ - name: Install Emulator
+ run: |
+ yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --install "${{ matrix.device.img }}"
+
+
+ - name: Accept licenses
+ run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true
+
+ - name: Run instrumentation tests
+ uses: gradle/gradle-build-action@v3
+ with:
+ arguments: ${{ matrix.device.name }}DebugAndroidTest
+
+ - name: Upload instrumentation test reports and logs on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: instrumentation-test-reports-${{ matrix.device.name }}
+ path: |
+ */build/reports/androidTests/**/${{ matrix.device.name }}
+ */build/outputs/androidTest-results/**/${{ matrix.device.name }}
+
spotless:
name: Spotless Check
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Gradle Wrapper
- uses: gradle/wrapper-validation-action@v1
+ uses: gradle/wrapper-validation-action@v2
- name: Set up JDK
- uses: actions/setup-java@v3.9.0
+ uses: actions/setup-java@v4
with:
distribution: ${{ env.DISTRIBUTION }}
java-version: ${{ env.JDK_VERSION }}
cache: gradle
- name: Setup Gradle
- uses: gradle/gradle-build-action@v2
+ uses: gradle/gradle-build-action@v3
- name: Spotless Check
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --parallel --build-cache
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..b268ef3
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="deploymentTargetSelector">
+ <selectionStates>
+ <SelectionState runConfigName="app">
+ <option name="selectionMode" value="DROPDOWN" />
+ </SelectionState>
+ </selectionStates>
+ </component>
+</project> \ No newline at end of file
diff --git a/README.md b/README.md
index d4b9422..48f9df4 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,37 @@
-# Jetpack Camera App 📸
+![Video Capture with Jetpack Camera App](docs/images/JCA-video-capture.gif "Video Capture with Jetpack Camera App")
+# Jetpack Camera App 📸
-This repository contains Jetpack Camera App. It's a work in progress 🚧.
-
-Jetpack Camera App is (will be) a fully functional camera app, focused on features used by
-app developers, and built entirely with CameraX, Kotlin and Jetpack Compose. It follows Android
-design and development best practices and it's intended to be a useful reference for developers.
-
-This repository is currently in early development, and will go through many changes.
+Jetpack Camera App (JCA) is a camera app, focused on features used by app developers, and built
+entirely with CameraX, Kotlin and Jetpack Compose. It follows Android
+design and development best practices and it's intended to be a useful reference for developers and
+OEMs looking to validate their camera feature implementations.
# Development Environment ⚒️
This project uses the gradle build system, and can be imported directly into Android Studio.
+Currently, Jetpack Camera App is built using the Android Gradle Plugin 8.4, which is only compatible
+with Android Studio Jellyfish or newer.
+
# Architecture 📐
-TBD
+JCA is built with [modern android development (MAD)](https://developer.android.com/modern-android-development) principles in mind,
+including [architecture and testing best practices](https://developer.android.com/topic/architecture).
+
+The app is split into multiple modules, with a clear separation between the UI and data layers.
# Testing 🧪
-TBD
+Thorough testing is a key directive of JCA. We use [Compose Test](https://developer.android.com/develop/ui/compose/testing) and
+[UI Automator](https://developer.android.com/training/testing/other-components/ui-automator) to write instrumentation
+tests that run on-device.
+
+These tests can be run on a connected device via Android Studio, or can be tested on an Android
+Emulator using built-in Gradle Managed Device tasks. Currently, we include Pixel 2 (API 28) and
+Pixel 8 (API 34) emulators which can be used to run instrumentation tests with:
+
+`$ ./gradlew pixel2Api28DebugAndroidTest` and
+`$ ./gradlew pixel8Api34DebugAndroidTest`
## Source Code Headers
@@ -30,7 +43,7 @@ doesn't comply with the license.)
Apache header:
- Copyright (C) 2023 The Android Open Source Project
+ Copyright (C) 2024 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.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1c27e4d..6f57fec 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -22,29 +22,25 @@ plugins {
}
android {
+ compileSdk = libs.versions.compileSdk.get().toInt()
namespace = "com.google.jetpackcamera"
- compileSdk = 34
defaultConfig {
applicationId = "com.google.jetpackcamera"
- minSdk = 21
- targetSdk = 34
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
- versionName = "1.0"
-
+ versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables {
- useSupportLibrary = true
- }
}
buildTypes {
- release {
+ getByName("debug") {
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ getByName("release") {
isMinifyEnabled = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
create("benchmark") {
initWith(buildTypes.getByName("release"))
@@ -60,16 +56,37 @@ android {
jvmToolchain(17)
}
buildFeatures {
+ buildConfig = true
compose = true
}
composeOptions {
- kotlinCompilerExtensionVersion = "1.4.0"
+ kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
- packagingOptions {
+ packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
+ @Suppress("UnstableApiUsage")
+ testOptions {
+ managedDevices {
+ localDevices {
+ create("pixel2Api28") {
+ device = "Pixel 2"
+ apiLevel = 28
+ }
+ create("pixel8Api34") {
+ device = "Pixel 8"
+ apiLevel = 34
+ systemImageSource = "aosp_atd"
+ }
+ }
+ }
+ }
+
+ kotlinOptions {
+ freeCompilerArgs += "-Xcontext-receivers"
+ }
}
dependencies {
@@ -80,13 +97,15 @@ dependencies {
// Compose - Material Design 3
implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
// Compose - Android Studio Preview support
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
// Compose - Integration with ViewModels
- implementation(libs.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
// Compose - Integration with Activities
implementation(libs.androidx.activity.compose)
@@ -98,12 +117,12 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
-
androidTestImplementation(libs.androidx.rules)
androidTestImplementation(libs.androidx.uiautomator)
+ androidTestImplementation(libs.truth)
implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.runtime.compose)
// Hilt
implementation(libs.dagger.hilt.android)
@@ -124,6 +143,9 @@ dependencies {
// Settings Screen
implementation(project(":feature:settings"))
+ // Permissions Screen
+ implementation(project(":feature:permissions"))
+
// benchmark
implementation(libs.androidx.profileinstaller)
diff --git a/app/src/androidTest/Android.bp b/app/src/androidTest/Android.bp
index 4f5037d..cbc1bb2 100644
--- a/app/src/androidTest/Android.bp
+++ b/app/src/androidTest/Android.bp
@@ -16,10 +16,13 @@ android_test {
"androidx.test.uiautomator_uiautomator",
"androidx.test.espresso.core",
"androidx.test.ext.junit",
+ "androidx.test.ext.truth",
"androidx.test.rules",
"androidx.test.core",
"androidx.activity_activity-ktx",
"kotlinx_coroutines_test",
+ "androidx.compose.ui_ui-test-junit4",
+ "androidx.compose.ui_ui-test",
],
sdk_version: "34",
test_suites: ["general-tests"],
@@ -30,6 +33,7 @@ android_test {
data: [
":jetpack-camera-app",
],
- //TODO(b/338257246): Remove exclusion after PreviewViewModel is removed in app module
- exclude_srcs: ["java/com/google/jetpackcamera/FlashDeviceTest.kt"],
+ kotlincflags: [
+ "-Xcontext-receivers",
+ ],
}
diff --git a/data/settings/src/test/java/com/google/jetpackcamera/settings/ExampleUnitTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
index ec4b827..b68f8e6 100644
--- a/data/settings/src/test/java/com/google/jetpackcamera/settings/ExampleUnitTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -13,19 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.settings
+package com.google.jetpackcamera
-import org.junit.Assert.assertEquals
-import org.junit.Test
+import android.os.Build
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
+val APP_REQUIRED_PERMISSIONS: List<String> = buildList {
+ add(android.Manifest.permission.CAMERA)
+ add(android.Manifest.permission.RECORD_AUDIO)
+ if (Build.VERSION.SDK_INT <= 28) {
+ add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt
index 5a36f88..0cf12c1 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt
@@ -15,12 +15,25 @@
*/
package com.google.jetpackcamera
-import androidx.test.core.app.ActivityScenario
+import android.os.Build
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.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.common.truth.Truth.assertThat
+import com.google.common.truth.TruthJUnit.assume
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_CAPTURE_MODE_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_DROP_DOWN
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_1_1_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -29,55 +42,127 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BackgroundDeviceTest {
@get:Rule
- val cameraPermissionRule: GrantPermissionRule =
- GrantPermissionRule.grant(android.Manifest.permission.CAMERA)
+ val permissionsRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+
+ @get:Rule
+ val composeTestRule = createEmptyComposeRule()
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val uiDevice = UiDevice.getInstance(instrumentation)
private fun backgroundThenForegroundApp() {
uiDevice.pressHome()
- uiDevice.waitForIdle(1500)
uiDevice.pressRecentApps()
- uiDevice.waitForIdle(1500)
- uiDevice.click(uiDevice.displayWidth / 2, uiDevice.displayHeight / 2)
- uiDevice.waitForIdle(1500)
+ uiDevice.pressRecentApps()
+
+ // Wait for the app to return to the foreground
+ uiDevice.wait(
+ Until.hasObject(By.pkg("com.google.jetpackcamera")),
+ APP_START_TIMEOUT_MILLIS
+ )
}
@Before
fun setUp() {
- ActivityScenario.launch(MainActivity::class.java)
- uiDevice.waitForIdle(2000)
+ assertThat(uiDevice.isScreenOn).isTrue()
}
@Test
- fun background_foreground() {
+ fun background_foreground() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
backgroundThenForegroundApp()
}
@Test
- fun flipCamera_then_background_foreground() {
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- uiDevice.findObject(By.res("QuickSetFlipCamera")).click()
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- uiDevice.waitForIdle(2000)
+ fun flipCamera_then_background_foreground() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Navigate to quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Click the flip camera button
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Exit quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
backgroundThenForegroundApp()
}
@Test
- fun setAspectRatio_then_background_foreground() {
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- uiDevice.findObject(By.res("QuickSetAspectRatio")).click()
- uiDevice.findObject(By.res("QuickSetAspectRatio1:1")).click()
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- uiDevice.waitForIdle(2000)
+ fun setAspectRatio_then_background_foreground() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Navigate to quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Click the ratio button
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Click the 1:1 ratio button
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_1_1_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Exit quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
backgroundThenForegroundApp()
}
+ private fun assumeSupportsSingleStream() {
+ // The GMD emulators with API <=28 do not support single-stream configs.
+ assume().that(Build.HARDWARE == "ranchu" && Build.VERSION.SDK_INT <= 28).isFalse()
+ }
+
@Test
- fun toggleCaptureMode_then_background_foreground() {
- uiDevice.findObject(By.res("ToggleCaptureMode")).click()
- uiDevice.waitForIdle(2000)
+ fun toggleCaptureMode_then_background_foreground() = runScenarioTest<MainActivity> {
+ // Skip this test on devices that don't support single stream
+ assumeSupportsSingleStream()
+
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Navigate to quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Click the flip camera button
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_CAPTURE_MODE_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Exit quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
backgroundThenForegroundApp()
}
}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt b/app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt
new file mode 100644
index 0000000..1e1bca9
--- /dev/null
+++ b/app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2024 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.content.Context
+import androidx.annotation.StringRes
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.printToString
+import androidx.test.core.app.ApplicationProvider
+import org.junit.AssumptionViolatedException
+
+/**
+ * Allows use of testRule.onNodeWithText that uses an integer string resource
+ * rather than a [String] directly.
+ */
+fun SemanticsNodeInteractionsProvider.onNodeWithText(
+ @StringRes strRes: Int
+): SemanticsNodeInteraction = onNodeWithText(
+ text = getResString(strRes)
+)
+
+/**
+ * Allows use of testRule.onNodeWithContentDescription that uses an integer string resource
+ * rather than a [String] directly.
+ */
+fun SemanticsNodeInteractionsProvider.onNodeWithContentDescription(
+ @StringRes strRes: Int
+): SemanticsNodeInteraction = onNodeWithContentDescription(
+ label = getResString(strRes)
+)
+
+/**
+ * Fetch a string resources from a [SemanticsNodeInteractionsProvider] context.
+ */
+fun SemanticsNodeInteractionsProvider.getResString(@StringRes strRes: Int): String {
+ return ApplicationProvider.getApplicationContext<Context>().getString(strRes)
+}
+
+/**
+ * Assumes that the provided [matcher] is satisfied for this node.
+ *
+ * This is equivalent to [SemanticsNodeInteraction.assert()], but will skip the test rather than
+ * fail the test.
+ *
+ * @param matcher Matcher to verify.
+ * @param messagePrefixOnError Prefix to be put in front of an error that gets thrown in case this
+ * assert fails. This can be helpful in situations where this assert fails as part of a bigger
+ * operation that used this assert as a precondition check.
+ *
+ * @throws AssumptionViolatedException if the matcher does not match or the node can no
+ * longer be found
+ */
+fun SemanticsNodeInteraction.assume(
+ matcher: SemanticsMatcher,
+ messagePrefixOnError: (() -> String)? = null
+): SemanticsNodeInteraction {
+ var errorMessageOnFail = "Failed to assume the following: (${matcher.description})"
+ if (messagePrefixOnError != null) {
+ errorMessageOnFail = messagePrefixOnError() + "\n" + errorMessageOnFail
+ }
+ val node = fetchSemanticsNode(errorMessageOnFail)
+ if (!matcher.matches(node)) {
+ throw AssumptionViolatedException(
+ buildGeneralErrorMessage(errorMessageOnFail, this)
+ )
+ }
+ return this
+}
+
+internal fun buildGeneralErrorMessage(
+ errorMessage: String,
+ nodeInteraction: SemanticsNodeInteraction
+): String {
+ val sb = StringBuilder()
+
+ sb.appendLine(errorMessage)
+
+ sb.appendLine("Semantics of the node:")
+ sb.appendLine(nodeInteraction.printToString())
+
+ return sb.toString()
+}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt
index fed70d0..e33f19e 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt
@@ -15,15 +15,25 @@
*/
package com.google.jetpackcamera
-import androidx.test.core.app.ActivityScenario
+import android.os.Build
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.isEnabled
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.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 com.google.common.truth.Truth.assertThat
+import com.google.common.truth.TruthJUnit.assume
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_DROP_DOWN
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLASH_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.FLIP_CAMERA_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG
+import com.google.jetpackcamera.feature.preview.ui.SCREEN_FLASH_OVERLAY
+import com.google.jetpackcamera.settings.model.LensFacing
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -31,88 +41,182 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
internal class FlashDeviceTest {
+
+ @get:Rule
+ val permissionsRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+
@get:Rule
- val cameraPermissionRule: GrantPermissionRule =
- GrantPermissionRule.grant(android.Manifest.permission.CAMERA)
+ val composeTestRule = createEmptyComposeRule()
- private val instrumentation = InstrumentationRegistry.getInstrumentation()
- private var activityScenario: ActivityScenario<MainActivity>? = null
- private val uiDevice = UiDevice.getInstance(instrumentation)
+ private val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Before
fun setUp() {
- activityScenario = ActivityScenario.launch(MainActivity::class.java)
- uiDevice.waitForIdle(2000)
+ assertThat(uiDevice.isScreenOn).isTrue()
}
@Test
- fun set_flash_on() = runTest {
- uiDevice.waitForIdle()
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- uiDevice.findObject(By.res("QuickSetFlash")).click()
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- assert(
- UiTestUtil.getPreviewCameraAppSettings(activityScenario!!).flashMode ==
- FlashMode.ON
+ fun set_flash_on() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Navigate to quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Click the flash button to switch to ON
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLASH_BUTTON)
+ .assertExists()
+ .performClick()
+
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLASH_BUTTON)
+ .assertExists()
+ composeTestRule.onNodeWithContentDescription(
+ com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_on_description
)
}
@Test
- fun set_flash_auto() = runTest {
- uiDevice.waitForIdle()
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- uiDevice.findObject(By.res("QuickSetFlash")).click()
- uiDevice.findObject(By.res("QuickSetFlash")).click()
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- assert(
- UiTestUtil.getPreviewCameraAppSettings(activityScenario!!).flashMode ==
- FlashMode.AUTO
+ fun set_flash_auto() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Navigate to quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Click the flash button twice to switch to AUTO
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLASH_BUTTON)
+ .assertExists()
+ .performClick()
+ .performClick()
+
+ composeTestRule.onNodeWithContentDescription(
+ com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_auto_description
)
}
@Test
- fun set_flash_off() = runTest {
- uiDevice.waitForIdle()
- assert(
- UiTestUtil.getPreviewCameraAppSettings(activityScenario!!).flashMode ==
- FlashMode.OFF
+ fun set_flash_off() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ composeTestRule.onNodeWithContentDescription(
+ com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_off_description
)
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- uiDevice.findObject(By.res("QuickSetFlash")).click()
- uiDevice.findObject(By.res("QuickSetFlash")).click()
- uiDevice.findObject(By.res("QuickSetFlash")).click()
- uiDevice.findObject(By.res("QuickSettingDropDown")).click()
- assert(
- UiTestUtil.getPreviewCameraAppSettings(activityScenario!!).flashMode ==
- FlashMode.OFF
+
+ // Navigate to quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Click the flash button three times to switch to OFF
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLASH_BUTTON)
+ .assertExists()
+ .performClick()
+ .performClick()
+ .performClick()
+
+ composeTestRule.onNodeWithContentDescription(
+ com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_off_description
)
}
- @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
- )
+ private fun assumeHalStableOnImageCapture() {
+ // The GMD emulators with API <=31 will often crash the HAL when taking an image capture.
+ // See b/195122056
+ assume().that(Build.HARDWARE == "ranchu" && Build.VERSION.SDK_INT <= 31).isFalse()
}
@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
- )
+ fun set_flash_and_capture_successfully() = runScenarioTest<MainActivity> {
+ // Skip test on unstable devices
+ assumeHalStableOnImageCapture()
+
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Ensure camera has a back camera and flip to it
+ val lensFacing = composeTestRule.getCurrentLensFacing()
+ if (lensFacing != LensFacing.BACK) {
+ composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) {
+ "Device does not have a back camera to flip to."
+ }.performClick()
+ }
+
+ // Navigate to quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Click the flash button to switch to ON
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLASH_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Exit quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+ .assertExists()
+ .performClick()
+
+ composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed()
+ }
}
+
+ @Test
+ fun set_screen_flash_and_capture_with_screen_change_overlay_shown() =
+ runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Ensure camera has a front camera and flip to it
+ val lensFacing = composeTestRule.getCurrentLensFacing()
+ if (lensFacing != LensFacing.FRONT) {
+ composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) {
+ "Device does not have a front camera to flip to."
+ }.performClick()
+ }
+
+ // Navigate to quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Click the flash button to switch to ON
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLASH_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Exit quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Perform a capture to enable screen flash
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+ .assertExists()
+ .performClick()
+
+ composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(SCREEN_FLASH_OVERLAY).isDisplayed()
+ }
+ }
}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
index af70055..e9abd40 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -33,6 +33,10 @@ import androidx.test.rule.GrantPermissionRule
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
+import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_FAILURE_TAG
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG
+import kotlinx.coroutines.test.runTest
import java.io.File
import java.net.URLConnection
import org.junit.Rule
@@ -44,30 +48,43 @@ 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
- )
+ val permissionsRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activityScenario: ActivityScenario<MainActivity>? = null
private val uiDevice = UiDevice.getInstance(instrumentation)
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+
+ @Test
+ fun image_capture() = runTest {
+ val timeStamp = System.currentTimeMillis()
+ activityScenario = ActivityScenario.launch(MainActivity::class.java)
+ uiDevice.wait(
+ Until.findObject(By.res(CAPTURE_BUTTON)),
+ 5000
+ )
+ uiDevice.findObject(By.res(CAPTURE_BUTTON)).click()
+ uiDevice.wait(
+ Until.findObject(By.res(IMAGE_CAPTURE_SUCCESS_TAG)),
+ 5000
+ )
+ assert(deleteFilesInDirAfterTimestamp(timeStamp))
+ }
@Test
- fun image_capture_external() = run {
+ fun image_capture_external() = runTest {
val timeStamp = System.currentTimeMillis()
val uri = getTestUri(timeStamp)
getTestRegistry {
activityScenario = ActivityScenario.launchActivityForResult(it)
uiDevice.wait(
- Until.findObject(By.res("CaptureButton")),
+ Until.findObject(By.res(CAPTURE_BUTTON)),
5000
)
- uiDevice.findObject(By.res("CaptureButton")).click()
+ uiDevice.findObject(By.res(CAPTURE_BUTTON)).click()
uiDevice.wait(
- Until.findObject(By.res("ImageCaptureSuccessToast")),
+ Until.findObject(By.res(IMAGE_CAPTURE_SUCCESS_TAG)),
5000
)
activityScenario!!.result
@@ -85,12 +102,12 @@ internal class ImageCaptureDeviceTest {
getTestRegistry {
activityScenario = ActivityScenario.launchActivityForResult(it)
uiDevice.wait(
- Until.findObject(By.res("CaptureButton")),
+ Until.findObject(By.res(CAPTURE_BUTTON)),
5000
)
- uiDevice.findObject(By.res("CaptureButton")).click()
+ uiDevice.findObject(By.res(CAPTURE_BUTTON)).click()
uiDevice.wait(
- Until.findObject(By.res("ImageCaptureFailureToast")),
+ Until.findObject(By.res(IMAGE_CAPTURE_FAILURE_TAG)),
5000
)
uiDevice.pressBack()
@@ -109,7 +126,8 @@ internal class ImageCaptureDeviceTest {
return false
}
- private fun deleteFilesInDirAfterTimestamp(timeStamp: Long) {
+ private fun deleteFilesInDirAfterTimestamp(timeStamp: Long): Boolean {
+ var hasDeletedFile = false
for (file in File(DIR_PATH).listFiles()) {
if (file.lastModified() >= timeStamp) {
file.delete()
@@ -119,8 +137,10 @@ internal class ImageCaptureDeviceTest {
instrumentation.targetContext.applicationContext.deleteFile(file.getName())
}
}
+ hasDeletedFile = true
}
}
+ return hasDeletedFile
}
private fun getTestRegistry(
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt
new file mode 100644
index 0000000..d06904d
--- /dev/null
+++ b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2024 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 androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.isEnabled
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.GrantPermissionRule
+import androidx.test.uiautomator.UiDevice
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_DROP_DOWN
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_1_1_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.FLIP_CAMERA_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.SETTINGS_BUTTON
+import com.google.jetpackcamera.settings.ui.BACK_BUTTON
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class NavigationTest {
+ @get:Rule
+ val permissionsRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+
+ @get:Rule
+ val composeTestRule = createEmptyComposeRule()
+
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val uiDevice = UiDevice.getInstance(instrumentation)
+
+ @Test
+ fun backAfterReturnFromSettings_doesNotReturnToSettings() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Navigate to the settings screen
+ composeTestRule.onNodeWithTag(SETTINGS_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Navigate back using the button
+ composeTestRule.onNodeWithTag(BACK_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Assert we're on PreviewScreen by finding the capture button
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists()
+
+ // Press the device's back button
+ uiDevice.pressBack()
+
+ // Assert we do not see the settings screen based on the title
+ composeTestRule.onNodeWithText(
+ com.google.jetpackcamera.settings.R.string.settings_title
+ ).assertDoesNotExist()
+ }
+
+ // Test to ensure we haven't regressed to the cause of
+ // https://github.com/google/jetpack-camera-app/pull/28
+ @Test
+ fun returnFromSettings_afterFlipCamera_returnsToPreview() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // If flipping the camera is available, flip it. Otherwise skip test.
+ composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON)
+ .assume(isEnabled()) {
+ "Device does not have multiple cameras to flip between."
+ }.performClick()
+
+ // Navigate to the settings screen
+ composeTestRule.onNodeWithTag(SETTINGS_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Navigate back using the button
+ composeTestRule.onNodeWithTag(BACK_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Assert we're on PreviewScreen by finding the capture button
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists()
+ }
+
+ @Test
+ fun backFromQuickSettings_returnToPreview() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Navigate to the quick settings screen
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Wait for the quick settings to be displayed
+ composeTestRule.waitUntil {
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_BUTTON).isDisplayed()
+ }
+
+ // Press the device's back button
+ uiDevice.pressBack()
+
+ // Assert we're on PreviewScreen by finding the flip camera button
+ composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertExists()
+ }
+
+ @Test
+ fun backFromQuickSettingsExpended_returnToQuickSettings() = runScenarioTest<MainActivity> {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // Navigate to the quick settings screen
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ // Navigate to the expanded quick settings ratio screen
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_BUTTON)
+ .assertExists()
+ .performClick()
+
+ // Wait for the 1:1 ratio button to be displayed
+ composeTestRule.waitUntil {
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_1_1_BUTTON).isDisplayed()
+ }
+
+ // Press the device's back button
+ uiDevice.pressBack()
+
+ // Assert we're on quick settings by finding the ratio button
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_BUTTON).assertExists()
+ }
+}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt
new file mode 100644
index 0000000..1727db5
--- /dev/null
+++ b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2024 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 androidx.compose.ui.test.doubleClick
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.isEnabled
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.GrantPermissionRule
+import com.google.common.truth.Truth.assertThat
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_DROP_DOWN
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.FLIP_CAMERA_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.PREVIEW_DISPLAY
+import com.google.jetpackcamera.settings.model.LensFacing
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SwitchCameraTest {
+ @get:Rule
+ val permissionsRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+
+ @get:Rule
+ val composeTestRule = createEmptyComposeRule()
+
+ @Test
+ fun canFlipCamera_fromPreviewScreenButton() = runFlipCameraTest(composeTestRule) {
+ val lensFacingStates = mutableListOf<LensFacing>()
+ // Get initial lens facing
+ val initialLensFacing = composeTestRule.getCurrentLensFacing()
+ lensFacingStates.add(initialLensFacing)
+
+ // Press the flip camera button
+ composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).performClick()
+
+ // Get lens facing after first flip
+ lensFacingStates.add(composeTestRule.getCurrentLensFacing())
+
+ // Press the flip camera button again
+ composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).performClick()
+
+ // Get lens facing after second flip
+ lensFacingStates.add(composeTestRule.getCurrentLensFacing())
+
+ assertThat(lensFacingStates).containsExactly(
+ initialLensFacing,
+ initialLensFacing.flip(),
+ initialLensFacing.flip().flip()
+ ).inOrder()
+ }
+
+ @Test
+ fun canFlipCamera_fromPreviewScreenDoubleTap() = runFlipCameraTest(composeTestRule) {
+ val lensFacingStates = mutableListOf<LensFacing>()
+ // Get initial lens facing
+ val initialLensFacing = composeTestRule.getCurrentLensFacing()
+ lensFacingStates.add(initialLensFacing)
+
+ // Double click display to flip camera
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY)
+ .performTouchInput { doubleClick() }
+
+ // Get lens facing after first flip
+ lensFacingStates.add(composeTestRule.getCurrentLensFacing())
+
+ // Double click display to flip camera again
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY)
+ .performTouchInput { doubleClick() }
+
+ // Get lens facing after second flip
+ lensFacingStates.add(composeTestRule.getCurrentLensFacing())
+
+ assertThat(lensFacingStates).containsExactly(
+ initialLensFacing,
+ initialLensFacing.flip(),
+ initialLensFacing.flip().flip()
+ ).inOrder()
+ }
+
+ @Test
+ fun canFlipCamera_fromQuickSettings() = runFlipCameraTest(composeTestRule) {
+ // Navigate to quick settings
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN)
+ .assertExists()
+ .performClick()
+
+ val lensFacingStates = mutableListOf<LensFacing>()
+ // Get initial lens facing
+ val initialLensFacing = composeTestRule.getCurrentLensFacing()
+ lensFacingStates.add(initialLensFacing)
+
+ // Double click quick settings button to flip camera
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON).performClick()
+
+ // Get lens facing after first flip
+ lensFacingStates.add(composeTestRule.getCurrentLensFacing())
+
+ // Double click quick settings button to flip camera again
+ composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON).performClick()
+
+ // Get lens facing after second flip
+ lensFacingStates.add(composeTestRule.getCurrentLensFacing())
+
+ assertThat(lensFacingStates).containsExactly(
+ initialLensFacing,
+ initialLensFacing.flip(),
+ initialLensFacing.flip().flip()
+ ).inOrder()
+ }
+}
+
+inline fun runFlipCameraTest(
+ composeTestRule: ComposeTestRule,
+ crossinline block: ActivityScenario<MainActivity>.() -> Unit
+) = runScenarioTest {
+ // Wait for the preview display to be visible
+ composeTestRule.waitUntil {
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).isDisplayed()
+ }
+
+ // If flipping the camera is available, flip it. Otherwise skip test.
+ composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) {
+ "Device does not have multiple cameras to flip between."
+ }
+
+ block()
+}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt
index be57311..118bd7b 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt
@@ -15,22 +15,64 @@
*/
package com.google.jetpackcamera
+import android.app.Activity
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
-import com.google.jetpackcamera.settings.model.CameraAppSettings
-import java.util.concurrent.atomic.AtomicReference
-
-object UiTestUtil {
- private fun getActivity(activityScenario: ActivityScenario<MainActivity>): MainActivity {
- val activityRef: AtomicReference<MainActivity> = AtomicReference<MainActivity>()
- activityScenario.onActivity(activityRef::set)
- return activityRef.get()
+import com.google.jetpackcamera.feature.preview.R
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON
+import com.google.jetpackcamera.settings.model.LensFacing
+
+const val APP_START_TIMEOUT_MILLIS = 10_000L
+const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 5_000L
+
+inline fun <reified T : Activity> runScenarioTest(
+ crossinline block: ActivityScenario<T>.() -> Unit
+) {
+ ActivityScenario.launch(T::class.java).use { scenario ->
+ scenario.apply(block)
+ }
+}
+
+context(ActivityScenario<MainActivity>)
+fun ComposeTestRule.getCurrentLensFacing(): LensFacing {
+ var needReturnFromQuickSettings = false
+ onNodeWithContentDescription(R.string.quick_settings_dropdown_closed_description).apply {
+ if (isDisplayed()) {
+ performClick()
+ needReturnFromQuickSettings = true
+ }
}
- fun getPreviewCameraAppSettings(
- activityScenario: ActivityScenario<MainActivity>
- ): CameraAppSettings {
- return getActivity(
- activityScenario
- ).previewViewModel!!.previewUiState.value.currentCameraSettings
+ onNodeWithContentDescription(R.string.quick_settings_dropdown_open_description).assertExists(
+ "LensFacing can only be retrieved from PreviewScreen or QuickSettings screen"
+ )
+
+ try {
+ return onNodeWithTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON).fetchSemanticsNode(
+ "Flip camera button is not visible when expected."
+ ).let { node ->
+ node.config[SemanticsProperties.ContentDescription].any { description ->
+ when (description) {
+ getResString(R.string.quick_settings_front_camera_description) ->
+ return@let LensFacing.FRONT
+
+ getResString(R.string.quick_settings_back_camera_description) ->
+ return@let LensFacing.BACK
+
+ else -> false
+ }
+ }
+ throw AssertionError("Unable to determine lens facing from quick settings")
+ }
+ } finally {
+ if (needReturnFromQuickSettings) {
+ onNodeWithContentDescription(R.string.quick_settings_dropdown_open_description)
+ .assertExists()
+ .performClick()
+ }
}
}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
new file mode 100644
index 0000000..437f9a4
--- /dev/null
+++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2024 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 com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+import java.io.File
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+internal class VideoRecordingDeviceTest {
+ @get:Rule
+ val permissionsRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private var activityScenario: ActivityScenario<MainActivity>? = null
+ private val uiDevice = UiDevice.getInstance(instrumentation)
+
+ @Test
+ fun video_capture_external_with_image_capture_intent() = run {
+ val timeStamp = System.currentTimeMillis()
+ val uri = getTestUri(timeStamp)
+ getTestRegistry {
+ activityScenario = ActivityScenario.launchActivityForResult(it)
+ uiDevice.wait(
+ Until.findObject(By.res(CAPTURE_BUTTON)),
+ 5000
+ )
+ uiDevice.findObject(By.res(CAPTURE_BUTTON)).longClick()
+ uiDevice.wait(
+ Until.findObject(By.res(VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG)),
+ 5000
+ )
+ uiDevice.pressBack()
+ activityScenario!!.result
+ }.register("key", TEST_CONTRACT) { result ->
+ assert(!result)
+ }.launch(uri)
+ }
+
+ 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.mp4"
+ )
+ )
+ }
+
+ companion object {
+ private val TEST_CONTRACT = object : ActivityResultContracts.TakePicture() {
+ override fun createIntent(context: Context, uri: Uri): Intent {
+ return super.createIntent(context, uri).apply {
+ component = ComponentName(
+ ApplicationProvider.getApplicationContext(),
+ MainActivity::class.java
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/Android.bp b/app/src/main/Android.bp
index 1b67114..88fec06 100644
--- a/app/src/main/Android.bp
+++ b/app/src/main/Android.bp
@@ -7,6 +7,7 @@ package {
java_defaults {
name: "jetpack-camera-app-defaults",
static_libs: [
+ "accompanist-permissions",
"androidx.compose.material3_material3",
"androidx.compose.ui_ui-tooling-preview",
"androidx.compose.ui_ui-tooling",
@@ -18,6 +19,7 @@ java_defaults {
"hilt_android",
"androidx.compose.runtime_runtime",
"jetpack-camera-app_data_settings",
+ "jetpack-camera-app_feature_permissions",
"jetpack-camera-app_feature_preview",
"jetpack-camera-app_feature_settings",
],
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d91abeb..b86f484 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -24,10 +24,15 @@
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false" />
+ <uses-feature
+ android:name="android.hardware.microphone"
+ 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" />
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+ android:maxSdkVersion="28"
+ tools:ignore="ScopedStorage" />
<application
@@ -46,7 +51,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
- android:screenOrientation="portrait"
+ android:screenOrientation="nosensor"
android:theme="@style/Theme.JetpackCamera">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
diff --git a/app/src/main/java/com/google/jetpackcamera/BuildConfig.kt b/app/src/main/java/com/google/jetpackcamera/BuildConfig.kt
new file mode 100644
index 0000000..654aa3b
--- /dev/null
+++ b/app/src/main/java/com/google/jetpackcamera/BuildConfig.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2024 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
+
+object BuildConfig {
+ val DEBUG = "true".toBoolean()
+ const val APPLICATION_ID = "com.google.jetpackcamera"
+ const val BUILD_TYPE = "debug"
+ const val VERSION_CODE = 1
+ const val VERSION_NAME = "0.1.0"
+} \ No newline at end of file
diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
index 42220d1..1ac223c 100644
--- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
+++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
@@ -15,14 +15,20 @@
*/
package com.google.jetpackcamera
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.hardware.Camera
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
+import android.provider.Settings
+import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
-import androidx.annotation.VisibleForTesting
+import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -38,9 +44,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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.res.stringResource
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@@ -57,6 +66,8 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+private const val TAG = "MainActivity"
+
/**
* Activity for the JetpackCameraApp.
*/
@@ -64,9 +75,8 @@ import kotlinx.coroutines.launch
class MainActivity : Hilt_MainActivity() {
private val viewModel: MainActivityViewModel by viewModels()
- @VisibleForTesting
- var previewViewModel: PreviewViewModel? = null
-
+ @RequiresApi(Build.VERSION_CODES.M)
+ @OptIn(ExperimentalComposeUiApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
var uiState: MainActivityUiState by mutableStateOf(Loading)
@@ -102,12 +112,27 @@ class MainActivity : Hilt_MainActivity() {
dynamicColor = false
) {
Surface(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics {
+ testTagsAsResourceId = true
+ },
color = MaterialTheme.colorScheme.background
) {
JcaApp(
- onPreviewViewModel = { previewViewModel = it },
- previewMode = getPreviewMode()
+ previewMode = getPreviewMode(),
+ openAppSettings = ::openAppSettings,
+ onRequestWindowColorMode = { colorMode ->
+ // Window color mode APIs require API level 26+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Log.d(
+ TAG,
+ "Setting window color mode to:" +
+ " ${colorMode.toColorModeString()}"
+ )
+ window?.colorMode = colorMode
+ }
+ }
)
}
}
@@ -118,7 +143,13 @@ class MainActivity : Hilt_MainActivity() {
private fun getPreviewMode(): PreviewMode {
if (intent == null || MediaStore.ACTION_IMAGE_CAPTURE != intent.action) {
- return PreviewMode.StandardMode
+ return PreviewMode.StandardMode { event ->
+ if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
+ val intent = Intent(Camera.ACTION_NEW_PICTURE)
+ intent.setData(event.savedUri)
+ sendBroadcast(intent)
+ }
+ }
} else {
var uri = if (intent.extras == null ||
!intent.extras!!.containsKey(MediaStore.EXTRA_OUTPUT)
@@ -137,7 +168,7 @@ class MainActivity : Hilt_MainActivity() {
uri = intent.clipData!!.getItemAt(0).uri
}
return PreviewMode.ExternalImageCaptureMode(uri) { event ->
- if (event == PreviewViewModel.ImageCaptureEvent.ImageSaved) {
+ if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
setResult(RESULT_OK)
finish()
}
@@ -158,3 +189,23 @@ private fun isInDarkMode(uiState: MainActivityUiState): Boolean = when (uiState)
DarkMode.SYSTEM -> isSystemInDarkTheme()
}
}
+
+@RequiresApi(Build.VERSION_CODES.O)
+private fun Int.toColorModeString(): String {
+ return when (this) {
+ ActivityInfo.COLOR_MODE_DEFAULT -> "COLOR_MODE_DEFAULT"
+ ActivityInfo.COLOR_MODE_HDR -> "COLOR_MODE_HDR"
+ ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT -> "COLOR_MODE_WIDE_COLOR_GAMUT"
+ else -> "<Unknown>"
+ }
+}
+
+/**
+ * Open the app settings when necessary. I.e. to enable permissions that have been denied by a user
+ */
+private fun Activity.openAppSettings() {
+ Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", packageName, null)
+ ).also(::startActivity)
+}
diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivityViewModel.kt b/app/src/main/java/com/google/jetpackcamera/MainActivityViewModel.kt
index d18dbec..1b6cca2 100644
--- a/app/src/main/java/com/google/jetpackcamera/MainActivityViewModel.kt
+++ b/app/src/main/java/com/google/jetpackcamera/MainActivityViewModel.kt
@@ -30,9 +30,9 @@ import kotlinx.coroutines.flow.stateIn
@HiltViewModel
class MainActivityViewModel @Inject constructor(
- val settingsRepository: SettingsRepository
+ settingsRepository: SettingsRepository
) : ViewModel() {
- val uiState: StateFlow<MainActivityUiState> = settingsRepository.cameraAppSettings.map {
+ val uiState: StateFlow<MainActivityUiState> = settingsRepository.defaultCameraAppSettings.map {
Success(it)
}.stateIn(
scope = viewModelScope,
@@ -42,6 +42,6 @@ class MainActivityViewModel @Inject constructor(
}
sealed interface MainActivityUiState {
- object Loading : MainActivityUiState
+ data object Loading : MainActivityUiState
data class Success(val cameraAppSettings: CameraAppSettings) : MainActivityUiState
}
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 8193c03..1e7add4 100644
--- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
+++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
@@ -16,63 +16,98 @@
package com.google.jetpackcamera.ui
import android.Manifest
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
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.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import com.google.jetpackcamera.BuildConfig
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.permissions.PermissionsScreen
import com.google.jetpackcamera.settings.SettingsScreen
+import com.google.jetpackcamera.settings.VersionInfoHolder
+import com.google.jetpackcamera.ui.Routes.PERMISSIONS_ROUTE
import com.google.jetpackcamera.ui.Routes.PREVIEW_ROUTE
import com.google.jetpackcamera.ui.Routes.SETTINGS_ROUTE
-//@OptIn(ExperimentalPermissionsApi::class)
+@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun JcaApp(
- onPreviewViewModel: (PreviewViewModel) -> Unit,
+ openAppSettings: () -> Unit,
/*TODO(b/306236646): remove after still capture*/
- previewMode: PreviewMode
+ previewMode: PreviewMode,
+ onRequestWindowColorMode: (Int) -> Unit,
+ modifier: Modifier = Modifier
) {
-// val permissionState = Manifest.permission.CAMERA
-// rememberPermissionState(permission = Manifest.permission.CAMERA)
-
-// if (permissionState.status.isGranted) {
- JetpackCameraNavHost(
- onPreviewViewModel = onPreviewViewModel,
- previewMode = previewMode
- )
-// } else {
-// CameraPermission(
-// modifier = Modifier.fillMaxSize(),
-// cameraPermissionState = permissionState
-// )
-// }
+ JetpackCameraNavHost(
+ previewMode = previewMode,
+ onOpenAppSettings = openAppSettings,
+ onRequestWindowColorMode = onRequestWindowColorMode,
+ modifier = modifier
+ )
}
+@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun JetpackCameraNavHost(
- onPreviewViewModel: (PreviewViewModel) -> Unit,
- navController: NavHostController = rememberNavController(),
- previewMode: PreviewMode
+ modifier: Modifier = Modifier,
+ previewMode: PreviewMode,
+ onOpenAppSettings: () -> Unit,
+ onRequestWindowColorMode: (Int) -> Unit,
+ navController: NavHostController = rememberNavController()
) {
- NavHost(navController = navController, startDestination = PREVIEW_ROUTE) {
+ NavHost(
+ navController = navController,
+ startDestination = PERMISSIONS_ROUTE,
+ modifier = modifier
+ ) {
+ composable(PERMISSIONS_ROUTE) {
+ PermissionsScreen(
+ onNavigateToPreview = {
+ navController.navigate(PREVIEW_ROUTE) {
+ // cannot navigate back to permissions after leaving
+ popUpTo(0)
+ }
+ },
+ openAppSettings = onOpenAppSettings
+ )
+ }
+
composable(PREVIEW_ROUTE) {
+ val permissionStates = rememberMultiplePermissionsState(
+ permissions = listOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO
+ )
+ )
+ // Automatically navigate to permissions screen when camera permission revoked
+ LaunchedEffect(key1 = permissionStates.permissions[0].status) {
+ if (!permissionStates.permissions[0].status.isGranted) {
+ navController.navigate(PERMISSIONS_ROUTE) {
+ // cannot navigate back to preview
+ popUpTo(0)
+ }
+ }
+ }
PreviewScreen(
- onPreviewViewModel = onPreviewViewModel,
onNavigateToSettings = { navController.navigate(SETTINGS_ROUTE) },
+ onRequestWindowColorMode = onRequestWindowColorMode,
previewMode = previewMode
)
}
composable(SETTINGS_ROUTE) {
SettingsScreen(
- onNavigateToPreview = { navController.navigate(PREVIEW_ROUTE) }
+ versionInfo = VersionInfoHolder(
+ versionName = BuildConfig.VERSION_NAME,
+ buildType = BuildConfig.BUILD_TYPE
+ ),
+ onNavigateBack = { navController.popBackStack() }
)
}
}
diff --git a/app/src/main/java/com/google/jetpackcamera/ui/Routes.kt b/app/src/main/java/com/google/jetpackcamera/ui/Routes.kt
index b787c8f..1373bcc 100644
--- a/app/src/main/java/com/google/jetpackcamera/ui/Routes.kt
+++ b/app/src/main/java/com/google/jetpackcamera/ui/Routes.kt
@@ -18,4 +18,5 @@ package com.google.jetpackcamera.ui
object Routes {
const val PREVIEW_ROUTE = "preview"
const val SETTINGS_ROUTE = "settings"
+ const val PERMISSIONS_ROUTE = "permissions"
}
diff --git a/app/src/main/res/drawable/photo_camera.xml b/app/src/main/res/drawable/photo_camera.xml
deleted file mode 100644
index 7d2e784..0000000
--- a/app/src/main/res/drawable/photo_camera.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-<?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:width="48dp"
- android:height="48dp"
- android:viewportWidth="960"
- android:viewportHeight="960">
- <path
- android:fillColor="#FF000000"
- android:pathData="M479.5,693q72.5,0 121.5,-49t49,-121.5q0,-72.5 -49,-121T479.5,353q-72.5,0 -121,48.5t-48.5,121q0,72.5 48.5,121.5t121,49ZM479.5,633q-47.5,0 -78.5,-31.5t-31,-79q0,-47.5 31,-78.5t78.5,-31q47.5,0 79,31t31.5,78.5q0,47.5 -31.5,79t-79,31.5ZM140,840q-24,0 -42,-18t-18,-42v-513q0,-23 18,-41.5t42,-18.5h147l73,-87h240l73,87h147q23,0 41.5,18.5T880,267v513q0,24 -18.5,42T820,840L140,840ZM140,780h680v-513L645,267l-73,-87L388,180l-73,87L140,267v513ZM480,523Z"/>
-</vector>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e435f33..804c5df 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -16,10 +16,6 @@
-->
<resources>
<string name="app_name">Jetpack Camera</string>
- <string name="camera_permission_screen_title">Enable Camera</string>
- <string name="camera_permission_required_rationale">Please provide permission to access to the camera. It is necessary for this app to function.</string>
- <string name="camera_permission_accessibility_text">A symbol representing a camera</string>
- <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>
diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts
index 617294b..72fb9ec 100644
--- a/benchmark/build.gradle.kts
+++ b/benchmark/build.gradle.kts
@@ -21,7 +21,7 @@ plugins {
android {
namespace = "com.google.jetpackcamera.benchmark"
- compileSdk = 34
+ compileSdk = libs.versions.compileSdk.get().toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
@@ -35,7 +35,7 @@ android {
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
+ targetSdk = libs.versions.targetSdk.get().toInt()
// allows the benchmark to be run on an emulator
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] =
@@ -56,11 +56,11 @@ android {
}
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
+ experimentalProperties["android.experimental.self-instrumenting"] = true
}
dependencies {
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt
index d909bff..2096265 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
index af8d7d5..fbe4594 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt
index f8c1f71..c97a45f 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt
index 4d6f8fd..1204001 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -26,9 +26,9 @@ 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 QUICK_SETTINGS_DROP_DOWN_BUTTON = "QuickSettingsDropDown"
+const val QUICK_SETTINGS_FLASH_BUTTON = "QuickSettingsFlashButton"
+const val QUICK_SETTINGS_FLIP_CAMERA_BUTTON = "QuickSettingsFlipCameraButton"
const val IMAGE_CAPTURE_SUCCESS_TOAST = "ImageCaptureSuccessToast"
// test descriptions
diff --git a/build.gradle.kts b/build.gradle.kts
index 676fb11..982992f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -38,4 +38,14 @@ gradle.taskGraph.whenReady {
task.dependsOn(tasks["installGitHooks"])
}
}
+}
+
+// Task to print all the module paths in the project e.g. :core:data
+// Used by module graph generator script
+tasks.register("printModulePaths") {
+ subprojects {
+ if (subprojects.size == 0) {
+ println(this.path)
+ }
+ }
} \ No newline at end of file
diff --git a/camera-viewfinder-compose/Android.bp b/camera-viewfinder-compose/Android.bp
deleted file mode 100644
index 2b55a3f..0000000
--- a/camera-viewfinder-compose/Android.bp
+++ /dev/null
@@ -1,23 +0,0 @@
-package {
- default_applicable_licenses: [
- "Android-Apache-2.0",
- ],
-}
-
-android_library {
- name: "jetpack-camera-app_camera-viewfinder-compose",
- srcs: ["src/main/**/*.kt"],
- static_libs: [
- "androidx.compose.material3_material3",
- "androidx.compose.runtime_runtime",
- "androidx.compose.ui_ui-tooling-preview",
- "androidx.compose.ui_ui-tooling",
- "androidx.camera_camera-core",
- "androidx.camera_camera-viewfinder",
- "androidx.core_core",
- ],
- sdk_version: "34",
- min_sdk_version: "21",
- manifest:"src/main/AndroidManifest.xml"
-}
-
diff --git a/camera-viewfinder-compose/build.gradle.kts b/camera-viewfinder-compose/build.gradle.kts
deleted file mode 100644
index e1251c3..0000000
--- a/camera-viewfinder-compose/build.gradle.kts
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * 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.library)
- alias(libs.plugins.kotlin.android)
-}
-
-android {
- namespace = "com.google.jetpackcamera.camerax"
- compileSdk = 34
-
- defaultConfig {
- minSdk = 21
- targetSdk = 34
-
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- consumerProguardFiles("consumer-rules.pro")
- }
-
- buildTypes {
- release {
- isMinifyEnabled = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
- kotlin {
- jvmToolchain(17)
- }
- buildFeatures {
- compose = true
- }
- composeOptions {
- kotlinCompilerExtensionVersion = "1.4.0"
- }
-}
-
-dependencies {
- // Compose
- val composeBom = platform(libs.compose.bom)
- implementation(composeBom)
- androidTestImplementation(composeBom)
-
- // Compose - Material Design 3
- implementation(libs.compose.material3)
-
- // Compose - Testing
- androidTestImplementation(libs.compose.junit)
-
- // Compose - Android Studio Preview support
- implementation(libs.compose.ui.tooling.preview)
- implementation(libs.compose.ui.tooling)
-
- // Testing
- testImplementation(libs.junit)
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
-
- // CameraX
- implementation(libs.camera.core)
- implementation(libs.camera.view)
-
- // AndroidX Core
- implementation(libs.androidx.core)
-}
diff --git a/camera-viewfinder-compose/proguard-rules.pro b/camera-viewfinder-compose/proguard-rules.pro
deleted file mode 100644
index ff59496..0000000
--- a/camera-viewfinder-compose/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile \ No newline at end of file
diff --git a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/CameraPreview.kt b/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/CameraPreview.kt
deleted file mode 100644
index 97009b8..0000000
--- a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/CameraPreview.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * 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.viewfinder
-
-import android.graphics.Bitmap
-import android.util.Log
-import android.view.Surface
-import android.view.View
-import androidx.camera.core.Preview.SurfaceProvider
-import androidx.camera.core.SurfaceRequest
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.produceState
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.Modifier
-import com.google.jetpackcamera.viewfinder.surface.CombinedSurface
-import com.google.jetpackcamera.viewfinder.surface.CombinedSurfaceEvent
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.asExecutor
-import kotlinx.coroutines.flow.mapNotNull
-
-private const val TAG = "Preview"
-
-enum class ImplementationMode {
- /**
- * Use a [SurfaceView] for the preview when possible. If the device
- * doesn't support [SurfaceView], [PreviewView] will fall back to use a
- * [TextureView] instead.
- */
- PERFORMANCE,
-
- /**
- * Use a [TextureView] for the preview.
- */
- COMPATIBLE
-}
-
-@Composable
-fun CameraPreview(
- modifier: Modifier,
- implementationMode: ImplementationMode = ImplementationMode.COMPATIBLE,
- onSurfaceProviderReady: (SurfaceProvider) -> Unit = {},
- onRequestBitmapReady: (() -> Bitmap?) -> Unit,
- setSurfaceView: (View) -> Unit
-) {
- Log.d(TAG, "CameraPreview")
-
- val surfaceRequest by produceState<SurfaceRequest?>(initialValue = null) {
- onSurfaceProviderReady(
- SurfaceProvider { request ->
- value?.willNotProvideSurface()
- value = request
- }
- )
- }
-
- PreviewSurface(
- modifier = modifier,
- surfaceRequest = surfaceRequest,
- setView = setSurfaceView,
- onRequestBitmapReady = onRequestBitmapReady,
- implementationMode = implementationMode
- )
-}
-
-@Composable
-fun PreviewSurface(
- modifier: Modifier,
- surfaceRequest: SurfaceRequest?,
- onRequestBitmapReady: (() -> Bitmap?) -> Unit,
- implementationMode: ImplementationMode = ImplementationMode.COMPATIBLE,
- setView: (View) -> Unit
-) {
- Log.d(TAG, "PreviewSurface")
-
- var surface: Surface? by remember { mutableStateOf(null) }
-
- LaunchedEffect(surfaceRequest, surface) {
- Log.d(TAG, "LaunchedEffect")
- snapshotFlow {
- if (surfaceRequest == null || surface == null) {
- null
- } else {
- Pair(surfaceRequest, surface)
- }
- }.mapNotNull { it }
- .collect { (request, surface) ->
- Log.d(TAG, "Collect: Providing surface")
-
- request.provideSurface(surface!!, Dispatchers.Main.asExecutor()) {}
- }
- }
-
- when (implementationMode) {
- ImplementationMode.PERFORMANCE -> TODO()
- ImplementationMode.COMPATIBLE ->
- CombinedSurface(
- modifier = modifier,
- setView = setView,
- onSurfaceEvent = { event ->
- surface =
- when (event) {
- is CombinedSurfaceEvent.SurfaceAvailable -> {
- event.surface
- }
-
- is CombinedSurfaceEvent.SurfaceDestroyed -> {
- null
- }
- }
- },
- surfaceRequest = surfaceRequest,
- onRequestBitmapReady = onRequestBitmapReady
- )
- }
-}
diff --git a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/CombinedSurface.kt b/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/CombinedSurface.kt
deleted file mode 100644
index 0cbb14e..0000000
--- a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/CombinedSurface.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * 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.viewfinder.surface
-
-import android.graphics.Bitmap
-import android.util.Log
-import android.view.Surface
-import android.view.View
-import androidx.camera.core.SurfaceRequest
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-
-private const val TAG = "CombinedSurface"
-
-@Composable
-fun CombinedSurface(
- modifier: Modifier,
- onSurfaceEvent: (CombinedSurfaceEvent) -> Unit,
- onRequestBitmapReady: (() -> Bitmap?) -> Unit = {},
- type: SurfaceType = SurfaceType.TEXTURE_VIEW,
- setView: (View) -> Unit,
- surfaceRequest: SurfaceRequest?
-) {
- Log.d(TAG, "PreviewTexture")
-
- when (type) {
- SurfaceType.SURFACE_VIEW ->
- Surface {
- when (it) {
- is SurfaceHolderEvent.SurfaceCreated -> {
- onSurfaceEvent(CombinedSurfaceEvent.SurfaceAvailable(it.holder.surface))
- }
-
- is SurfaceHolderEvent.SurfaceDestroyed -> {
- onSurfaceEvent(CombinedSurfaceEvent.SurfaceDestroyed)
- }
-
- is SurfaceHolderEvent.SurfaceChanged -> {
- // TODO(yasith@)
- }
- }
- }
-
- SurfaceType.TEXTURE_VIEW ->
- Texture(
- modifier = modifier,
- onSurfaceTextureEvent = {
- when (it) {
- is SurfaceTextureEvent.SurfaceTextureAvailable -> {
- onSurfaceEvent(
- CombinedSurfaceEvent.SurfaceAvailable(Surface(it.surface))
- )
- }
-
- is SurfaceTextureEvent.SurfaceTextureDestroyed -> {
- onSurfaceEvent(CombinedSurfaceEvent.SurfaceDestroyed)
- }
-
- is SurfaceTextureEvent.SurfaceTextureSizeChanged -> {
- // TODO(yasith@)
- }
-
- is SurfaceTextureEvent.SurfaceTextureUpdated -> {
- // TODO(yasith@)
- }
- }
- true
- },
- onRequestBitmapReady,
- setView = setView,
- surfaceRequest = surfaceRequest
- )
- }
-}
-
-sealed interface CombinedSurfaceEvent {
- data class SurfaceAvailable(
- val surface: Surface
- ) : CombinedSurfaceEvent
-
- object SurfaceDestroyed : CombinedSurfaceEvent
-}
-
-enum class SurfaceType {
- SURFACE_VIEW,
- TEXTURE_VIEW
-}
diff --git a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/Surface.kt b/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/Surface.kt
deleted file mode 100644
index b614ea1..0000000
--- a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/Surface.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * 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.viewfinder.surface
-
-import android.util.Log
-import android.view.SurfaceHolder
-import android.view.SurfaceView
-import android.view.ViewGroup
-import android.widget.LinearLayout
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.viewinterop.AndroidView
-
-private const val TAG = "Surface"
-
-@Composable
-fun Surface(onSurfaceHolderEvent: (SurfaceHolderEvent) -> Unit = { _ -> }) {
- Log.d(TAG, "Surface")
-
- AndroidView(factory = { context ->
- SurfaceView(context).apply {
- layoutParams =
- LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
- )
- holder.addCallback(
- object : SurfaceHolder.Callback {
- override fun surfaceCreated(holder: SurfaceHolder) {
- onSurfaceHolderEvent(SurfaceHolderEvent.SurfaceCreated(holder))
- }
-
- override fun surfaceChanged(
- holder: SurfaceHolder,
- format: Int,
- width: Int,
- height: Int
- ) {
- onSurfaceHolderEvent(
- SurfaceHolderEvent.SurfaceChanged(
- holder,
- width,
- height
- )
- )
- }
-
- override fun surfaceDestroyed(holder: SurfaceHolder) {
- onSurfaceHolderEvent(SurfaceHolderEvent.SurfaceDestroyed(holder))
- }
- }
- )
- }
- })
-}
-
-sealed interface SurfaceHolderEvent {
- data class SurfaceCreated(
- val holder: SurfaceHolder
- ) : SurfaceHolderEvent
-
- data class SurfaceChanged(
- val holder: SurfaceHolder,
- val width: Int,
- val height: Int
- ) : SurfaceHolderEvent
-
- data class SurfaceDestroyed(
- val holder: SurfaceHolder
- ) : SurfaceHolderEvent
-}
diff --git a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/SurfaceTransformationUtil.kt b/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/SurfaceTransformationUtil.kt
deleted file mode 100644
index d8846ce..0000000
--- a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/SurfaceTransformationUtil.kt
+++ /dev/null
@@ -1,211 +0,0 @@
-/*
- * 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.viewfinder.surface
-
-import android.annotation.SuppressLint
-import android.graphics.Matrix
-import android.graphics.RectF
-import android.util.Size
-import androidx.camera.core.SurfaceRequest
-import androidx.camera.core.impl.utils.CameraOrientationUtil
-import androidx.camera.core.impl.utils.TransformUtils
-
-/**
- * A util class with methods that transform the input viewFinder surface so that its preview fits
- * the given aspect ratio of its parent view.
- *
- * The goal is to transform it in a way so that the entire area of
- * [SurfaceRequest.TransformationInfo.getCropRect] is 1) visible to end users, and 2)
- * displayed as large as possible.
- *
- * The inputs for the calculation are 1) the dimension of the Surface, 2) the crop rect, 3) the
- * dimension of the Viewfinder and 4) rotation degrees
- */
-object SurfaceTransformationUtil {
- @SuppressLint("RestrictedApi", "WrongConstant")
- private fun getRemainingRotationDegrees(
- transformationInfo: SurfaceRequest.TransformationInfo
- ): Int {
- return if (!transformationInfo.hasCameraTransform()) {
- // If the Surface is not connected to the camera, then the SurfaceView/TextureView will
- // not apply any transformation. In that case, we need to apply the rotation
- // calculated by CameraX.
- transformationInfo.rotationDegrees
- } else if (transformationInfo.targetRotation == -1) {
- 0
- } else {
- // If the Surface is connected to the camera, then the SurfaceView/TextureView
- // will be the one to apply the camera orientation. In that case, only the Surface
- // rotation needs to be applied.
- -CameraOrientationUtil.surfaceRotationToDegrees(transformationInfo.targetRotation)
- }
- }
-
- @SuppressLint("RestrictedApi")
- fun getTextureViewCorrectionMatrix(
- transformationInfo: SurfaceRequest.TransformationInfo,
- resolution: Size
- ): Matrix {
- val surfaceRect =
- RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
- val rotationDegrees: Int = getRemainingRotationDegrees(transformationInfo)
- return TransformUtils.getRectToRect(surfaceRect, surfaceRect, rotationDegrees)
- }
-
- @SuppressLint("RestrictedApi")
- private fun getRotatedViewportSize(
- transformationInfo: SurfaceRequest.TransformationInfo
- ): Size {
- return if (TransformUtils.is90or270(transformationInfo.rotationDegrees)) {
- Size(transformationInfo.cropRect.height(), transformationInfo.cropRect.width())
- } else {
- Size(transformationInfo.cropRect.width(), transformationInfo.cropRect.height())
- }
- }
-
- @SuppressLint("RestrictedApi")
- fun isViewportAspectRatioMatchViewFinder(
- transformationInfo: SurfaceRequest.TransformationInfo,
- viewFinderSize: Size
- ): Boolean {
- // Using viewport rect to check if the viewport is based on the view finder.
- val rotatedViewportSize: Size = getRotatedViewportSize(transformationInfo)
- return TransformUtils.isAspectRatioMatchingWithRoundingError(
- viewFinderSize,
- true,
- rotatedViewportSize,
- false
- )
- }
-
- private fun setMatrixRectToRect(matrix: Matrix, source: RectF, destination: RectF) {
- val matrixScaleType = Matrix.ScaleToFit.CENTER
- // android.graphics.Matrix doesn't support fill scale types. The workaround is
- // mapping inversely from destination to source, then invert the matrix.
- matrix.setRectToRect(destination, source, matrixScaleType)
- matrix.invert(matrix)
- }
-
- private fun getViewFinderViewportRectForMismatchedAspectRatios(
- transformationInfo: SurfaceRequest.TransformationInfo,
- viewFinderSize: Size
- ): RectF {
- val viewFinderRect =
- RectF(
- 0f,
- 0f,
- viewFinderSize.width.toFloat(),
- viewFinderSize.height.toFloat()
- )
- val rotatedViewportSize = getRotatedViewportSize(transformationInfo)
- val rotatedViewportRect =
- RectF(
- 0f,
- 0f,
- rotatedViewportSize.width.toFloat(),
- rotatedViewportSize.height.toFloat()
- )
- val matrix = Matrix()
- setMatrixRectToRect(
- matrix,
- rotatedViewportRect,
- viewFinderRect
- )
- matrix.mapRect(rotatedViewportRect)
- return rotatedViewportRect
- }
-
- @SuppressLint("RestrictedApi")
- fun getSurfaceToViewFinderMatrix(
- viewFinderSize: Size,
- transformationInfo: SurfaceRequest.TransformationInfo,
- isFrontCamera: Boolean
- ): Matrix {
- // Get the target of the mapping, the coordinates of the crop rect in view finder.
- val viewFinderCropRect: RectF =
- if (isViewportAspectRatioMatchViewFinder(transformationInfo, viewFinderSize)) {
- // If crop rect has the same aspect ratio as view finder, scale the crop rect to
- // fill the entire view finder. This happens if the scale type is FILL_* AND a
- // view-finder-based viewport is used.
- RectF(
- 0f,
- 0f,
- viewFinderSize.width.toFloat(),
- viewFinderSize.height.toFloat()
- )
- } else {
- // If the aspect ratios don't match, it could be 1) scale type is FIT_*, 2) the
- // Viewport is not based on the view finder or 3) both.
- getViewFinderViewportRectForMismatchedAspectRatios(
- transformationInfo,
- viewFinderSize
- )
- }
- val matrix =
- TransformUtils.getRectToRect(
- RectF(transformationInfo.cropRect),
- viewFinderCropRect,
- transformationInfo.rotationDegrees
- )
- if (isFrontCamera && transformationInfo.hasCameraTransform()) {
- // SurfaceView/TextureView automatically mirrors the Surface for front camera, which
- // needs to be compensated by mirroring the Surface around the upright direction of the
- // output image. This is only necessary if the stream has camera transform.
- // Otherwise, an internal GL processor would have mirrored it already.
- if (TransformUtils.is90or270(transformationInfo.rotationDegrees)) {
- // If the rotation is 90/270, the Surface should be flipped vertically.
- // +---+ 90 +---+ 270 +---+
- // | ^ | --> | < | | > |
- // +---+ +---+ +---+
- matrix.preScale(
- 1f,
- -1f,
- transformationInfo.cropRect.centerX().toFloat(),
- transformationInfo.cropRect.centerY().toFloat()
- )
- } else {
- // If the rotation is 0/180, the Surface should be flipped horizontally.
- // +---+ 0 +---+ 180 +---+
- // | ^ | --> | ^ | | v |
- // +---+ +---+ +---+
- matrix.preScale(
- -1f,
- 1f,
- transformationInfo.cropRect.centerX().toFloat(),
- transformationInfo.cropRect.centerY().toFloat()
- )
- }
- }
- return matrix
- }
-
- fun getTransformedSurfaceRect(
- resolution: Size,
- transformationInfo: SurfaceRequest.TransformationInfo,
- viewFinderSize: Size,
- isFrontCamera: Boolean
- ): RectF {
- val surfaceToViewFinder: Matrix =
- getSurfaceToViewFinderMatrix(
- viewFinderSize,
- transformationInfo,
- isFrontCamera
- )
- val rect = RectF(0f, 0f, resolution.width.toFloat(), resolution.height.toFloat())
- surfaceToViewFinder.mapRect(rect)
- return rect
- }
-}
diff --git a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/Texture.kt b/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/Texture.kt
deleted file mode 100644
index d3eaff4..0000000
--- a/camera-viewfinder-compose/src/main/java/com/google/jetpackcamera/viewfinder/surface/Texture.kt
+++ /dev/null
@@ -1,202 +0,0 @@
-/*
- * 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.viewfinder.surface
-
-import android.annotation.SuppressLint
-import android.graphics.Bitmap
-import android.graphics.RectF
-import android.graphics.SurfaceTexture
-import android.util.Log
-import android.util.Size
-import android.view.TextureView
-import android.view.View
-import android.view.ViewGroup
-import android.widget.FrameLayout
-import android.widget.LinearLayout
-import androidx.camera.core.SurfaceRequest
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.viewinterop.AndroidView
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.asExecutor
-
-private const val TAG = "Texture"
-
-@SuppressLint("RestrictedApi")
-@Composable
-fun Texture(
- modifier: Modifier,
- onSurfaceTextureEvent: (SurfaceTextureEvent) -> Boolean = { _ -> true },
- onRequestBitmapReady: (() -> Bitmap?) -> Unit,
- setView: (View) -> Unit,
- surfaceRequest: SurfaceRequest?
-) {
- Log.d(TAG, "Texture")
-
- val resolution = surfaceRequest?.resolution
- var textureView: TextureView? by remember { mutableStateOf(null) }
- var parentView: FrameLayout? by remember { mutableStateOf(null) }
- if (parentView != null && surfaceRequest != null && resolution != null) {
- surfaceRequest.setTransformationInfoListener(
- Dispatchers.Main.asExecutor()
- ) { transformationInfo ->
- val parentViewSize = Size(parentView!!.width, parentView!!.height)
- if (parentViewSize.height == 0 || parentViewSize.width == 0) {
- return@setTransformationInfoListener
- }
- val viewFinder = textureView!!
- val surfaceRectInViewFinder: RectF =
- SurfaceTransformationUtil.getTransformedSurfaceRect(
- resolution,
- transformationInfo,
- parentViewSize,
- surfaceRequest.camera.isFrontFacing
- )
- if (!transformationInfo.hasCameraTransform()) {
- viewFinder.layoutParams =
- FrameLayout.LayoutParams(
- surfaceRectInViewFinder.width().toInt(),
- surfaceRectInViewFinder.height().toInt()
- )
- } else {
- viewFinder.layoutParams =
- FrameLayout.LayoutParams(
- resolution.width,
- resolution.height
- )
- }
- // For TextureView, correct the orientation to match the target rotation.
- val correctionMatrix =
- SurfaceTransformationUtil.getTextureViewCorrectionMatrix(
- transformationInfo,
- resolution
- )
- viewFinder.setTransform(correctionMatrix)
-
- viewFinder.pivotX = 0f
- viewFinder.pivotY = 0f
- viewFinder.scaleX = surfaceRectInViewFinder.width() / resolution.width
- viewFinder.scaleY = surfaceRectInViewFinder.height() / resolution.height
- viewFinder.translationX = surfaceRectInViewFinder.left - viewFinder.left
- viewFinder.translationY = surfaceRectInViewFinder.top - viewFinder.top
- }
- }
-
- if (resolution != null) {
- AndroidView(
- modifier = modifier.clipToBounds(),
- factory = { context ->
- FrameLayout(context).apply {
- layoutParams =
- LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
- )
- addView(
- TextureView(context).apply {
- layoutParams =
- FrameLayout.LayoutParams(
- resolution.width,
- resolution.height
- )
- surfaceTextureListener =
- object : TextureView.SurfaceTextureListener {
- override fun onSurfaceTextureAvailable(
- surface: SurfaceTexture,
- width: Int,
- height: Int
- ) {
- onSurfaceTextureEvent(
- SurfaceTextureEvent.SurfaceTextureAvailable(
- surface,
- width,
- height
- )
- )
- }
-
- override fun onSurfaceTextureSizeChanged(
- surface: SurfaceTexture,
- width: Int,
- height: Int
- ) {
- onSurfaceTextureEvent(
- SurfaceTextureEvent.SurfaceTextureSizeChanged(
- surface,
- width,
- height
- )
- )
- }
-
- override fun onSurfaceTextureDestroyed(
- surface: SurfaceTexture
- ): Boolean {
- return onSurfaceTextureEvent(
- SurfaceTextureEvent.SurfaceTextureDestroyed(
- surface
- )
- )
- }
-
- override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
- onSurfaceTextureEvent(
- SurfaceTextureEvent.SurfaceTextureUpdated(
- surface
- )
- )
- }
- }
- }
- )
- }
- },
- update = {
- parentView = it
- textureView = it.getChildAt(0) as TextureView?
- setView(it)
- onRequestBitmapReady { textureView!!.bitmap }
- }
- )
- }
-}
-
-sealed interface SurfaceTextureEvent {
- data class SurfaceTextureAvailable(
- val surface: SurfaceTexture,
- val width: Int,
- val height: Int
- ) : SurfaceTextureEvent
-
- data class SurfaceTextureSizeChanged(
- val surface: SurfaceTexture,
- val width: Int,
- val height: Int
- ) : SurfaceTextureEvent
-
- data class SurfaceTextureDestroyed(
- val surface: SurfaceTexture
- ) : SurfaceTextureEvent
-
- data class SurfaceTextureUpdated(
- val surface: SurfaceTexture
- ) : SurfaceTextureEvent
-}
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index f583060..1eba073 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -23,11 +23,12 @@ plugins {
android {
namespace = "com.google.jetpackcamera.core.common"
- compileSdk = 34
+ compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
- minSdk = 21
- targetSdk = 34
+ minSdk = libs.versions.minSdk.get().toInt()
+ testOptions.targetSdk = libs.versions.targetSdk.get().toInt()
+ lint.targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
@@ -51,10 +52,16 @@ android {
}
dependencies {
+
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.android.material)
+ implementation(libs.kotlinx.atomicfu)
+
testImplementation(libs.junit)
+ testImplementation(libs.truth)
+ testImplementation(libs.robolectric)
+
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/core/common/src/main/java/com/google/jetpackcamera/core/common/RefCounted.kt b/core/common/src/main/java/com/google/jetpackcamera/core/common/RefCounted.kt
new file mode 100644
index 0000000..35c4e31
--- /dev/null
+++ b/core/common/src/main/java/com/google/jetpackcamera/core/common/RefCounted.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2024 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.core.common
+
+import android.util.Log
+import kotlinx.atomicfu.atomic
+import kotlinx.atomicfu.loop
+
+/**
+ * A thread-safe, lock-free class for managing the lifecycle of an object using ref-counting.
+ *
+ * The object that is ref-counted can be initialized late using [initialize].
+ *
+ * @param[debugRefCounts] whether to print debug log statements.
+ * @param[onRelease] a block that will be invoked once when the ref-count reaches 0.
+ */
+class RefCounted<T : Any>(
+ private val debugRefCounts: Boolean = false,
+ private val onRelease: (T) -> Unit
+) {
+ private val refCounted = atomic(uninitialized<T>())
+
+ /**
+ * Initializes the ref-count managed object with the object being managed.
+ *
+ * This also initializes the implicit ref-count to 1.
+ *
+ * All calls to this function must be paired with [release] to ensure the initial implicit
+ * ref count is decremented and the `onRelease` callback can be called.
+ *
+ */
+ fun initialize(newValue: T) {
+ val initialVal = Pair(newValue, 1)
+ check(refCounted.compareAndSet(uninitialized(), initialVal)) {
+ "Ref-count managed object has already been initialized."
+ }
+
+ if (debugRefCounts) {
+ Log.d(
+ TAG,
+ "RefCounted@${"%x".format(hashCode())}<${newValue::class.simpleName}> " +
+ "initialized: [refCount: 1, value: $newValue]",
+ Throwable()
+ )
+ }
+ }
+
+ /**
+ * Retrieves the underlying managed object, increasing the ref-count by 1.
+ *
+ * This increases the ref-count if the object has not already been released. If the object has
+ * been released, `null` is returned.
+ *
+ * All calls to this function must be paired with [release], unless `null` is returned.
+ */
+ fun acquire(): T? {
+ check(refCounted.value != uninitialized<T>()) {
+ "Ref-count managed object has not yet been initialized. Unable to acquire."
+ }
+
+ refCounted.loop { old ->
+ if (old == released<T>()) {
+ if (debugRefCounts) {
+ Log.d(
+ TAG,
+ "RefCounted@${"%x".format(hashCode())}.acquire() failure: " +
+ "[refCount: 0]",
+ Throwable()
+ )
+ }
+ return null
+ }
+
+ val (value, oldCount) = old
+ val new = Pair(value, oldCount + 1)
+ if (refCounted.compareAndSet(old, new)) {
+ if (debugRefCounts) {
+ Log.d(
+ TAG,
+ "RefCounted@${"%x".format(hashCode())}<${value::class.simpleName}>" +
+ ".acquire() success: [refCount: ${oldCount + 1}, value: $value]",
+ Throwable()
+ )
+ }
+ return value
+ }
+ }
+ }
+
+ /**
+ * Decrements the ref-count by 1 after a call to [acquire] or [initialize].
+ *
+ * This should always be called once for each [initialize] call and once for each [acquire]
+ * call that does not return `null`.
+ */
+ fun release() {
+ check(refCounted.value != uninitialized<T>()) {
+ "Ref-count managed object has not yet been initialized. Unable to release."
+ }
+
+ refCounted.loop { old ->
+ check(old != released<T>()) {
+ "Release called more times than initialize + acquire."
+ }
+
+ val (value, oldCount) = old
+ val new = if (oldCount == 1) released() else Pair(value, oldCount - 1)
+ if (refCounted.compareAndSet(old, new)) {
+ if (new == released<T>()) {
+ if (debugRefCounts) {
+ Log.d(
+ TAG,
+ "RefCounted@${"%x".format(hashCode())}<${value::class.simpleName}>" +
+ ".release() (last ref): [refCount: 0, value: $value]",
+ Throwable()
+ )
+ }
+ onRelease(value)
+ } else {
+ if (debugRefCounts) {
+ Log.d(
+ TAG,
+ "RefCounted@${"%x".format(hashCode())}<${value::class.simpleName}>" +
+ ".release(): [refCount: ${oldCount - 1}, value: $value]",
+ Throwable()
+ )
+ }
+ }
+ return
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "RefCounted"
+ private val UNINITIALIZED = Pair(Unit, -1)
+ private val RELEASED = Pair(Unit, 0)
+
+ @Suppress("UNCHECKED_CAST")
+ private fun <T> uninitialized(): Pair<T, Int> {
+ return UNINITIALIZED as Pair<T, Int>
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun <T> released(): Pair<T, Int> {
+ return RELEASED as Pair<T, Int>
+ }
+ }
+}
diff --git a/core/common/src/test/java/com/google/jetpackcamera/core/common/RefCountedTest.kt b/core/common/src/test/java/com/google/jetpackcamera/core/common/RefCountedTest.kt
new file mode 100644
index 0000000..1d15c40
--- /dev/null
+++ b/core/common/src/test/java/com/google/jetpackcamera/core/common/RefCountedTest.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2024 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.core.common
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.shadows.ShadowLog
+
+@RunWith(RobolectricTestRunner::class)
+class RefCountedTest {
+
+ @Before
+ fun setUp() {
+ ShadowLog.stream = System.out
+ }
+
+ @Test
+ fun onRelease_calledAfterRelease() {
+ var onReleaseCalled = false
+ val refCounted = RefCounted<Unit> {
+ onReleaseCalled = true
+ }.also {
+ it.initialize(Unit)
+ }
+
+ refCounted.release()
+
+ assertThat(onReleaseCalled).isTrue()
+ }
+
+ @Test
+ fun acquireBeforeInitialize_throwsException() {
+ val refCounted = RefCounted<Unit> {}
+ assertThrows(IllegalStateException::class.java) {
+ refCounted.acquire()
+ }
+ }
+
+ @Test
+ fun releaseBeforeInitialize_throwsException() {
+ val refCounted = RefCounted<Unit> {}
+ assertThrows(IllegalStateException::class.java) {
+ refCounted.release()
+ }
+ }
+
+ @Test
+ fun releaseCalledMoreTimesThanAcquire_throwsException() {
+ val refCounted = RefCounted<Unit> {}
+ refCounted.initialize(Unit)
+ refCounted.release()
+
+ assertThrows(IllegalStateException::class.java) {
+ refCounted.release()
+ }
+ }
+
+ @Test
+ fun acquireAfterRelease_returnsNull() {
+ val refCounted = RefCounted<Unit> {}
+ refCounted.initialize(Unit)
+ refCounted.release()
+
+ assertThat(refCounted.acquire()).isNull()
+ }
+
+ @Test
+ fun acquireAfterInitialize_returnsValue() {
+ val value = Object()
+ val refCounted = RefCounted<Any> {}
+ refCounted.initialize(value)
+
+ assertThat(refCounted.acquire()).isEqualTo(value)
+ }
+
+ @Test
+ fun acquiresWithMatchedRelease_callsOnRelease() = runBlocking {
+ val onReleaseCalled = atomic(false)
+ val refCounted = RefCounted<Unit> {
+ onReleaseCalled.value = true
+ }.also {
+ it.initialize(Unit)
+ }
+
+ // Run many acquire/release pairs in parallel
+ // Wrap in `coroutineScope` to ensure all children coroutines
+ // have finished before continuing
+ coroutineScope {
+ for (i in 1..1000) {
+ launch(Dispatchers.IO) {
+ refCounted.acquire()
+ delay(5)
+ refCounted.release()
+ }
+ }
+ }
+
+ val onReleaseCalledBeforeFinalRelease = onReleaseCalled.value
+
+ // Call final release to match initialize()
+ refCounted.release()
+
+ assertThat(onReleaseCalledBeforeFinalRelease).isFalse()
+ assertThat(onReleaseCalled.value).isTrue()
+ }
+}
diff --git a/data/settings/build.gradle.kts b/data/settings/build.gradle.kts
index 54cbb48..60c9aa0 100644
--- a/data/settings/build.gradle.kts
+++ b/data/settings/build.gradle.kts
@@ -24,25 +24,17 @@ plugins {
android {
namespace = "com.google.jetpackcamera.data.settings"
- compileSdk = 34
+ compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
- minSdk = 21
- targetSdk = 34
+ minSdk = libs.versions.minSdk.get().toInt()
+ testOptions.targetSdk = libs.versions.targetSdk.get().toInt()
+ lint.targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
- buildTypes {
- release {
- isMinifyEnabled = false
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -50,15 +42,28 @@ android {
kotlin {
jvmToolchain(17)
}
+
+ @Suppress("UnstableApiUsage")
+ testOptions {
+ managedDevices {
+ localDevices {
+ create("pixel2Api28") {
+ device = "Pixel 2"
+ apiLevel = 28
+ }
+ create("pixel8Api34") {
+ device = "Pixel 8"
+ apiLevel = 34
+ systemImageSource = "aosp_atd"
+ }
+ }
+ }
+ }
}
dependencies {
- // Testing
- testImplementation(libs.junit)
implementation(libs.kotlinx.coroutines.core)
- androidTestImplementation(libs.kotlinx.coroutines.test)
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
+
// Hilt
implementation(libs.dagger.hilt.android)
kapt(libs.dagger.hilt.compiler)
@@ -66,6 +71,14 @@ dependencies {
// proto datastore
implementation(libs.androidx.datastore)
implementation(libs.protobuf.kotlin.lite)
+
+ // Testing
+ testImplementation(libs.junit)
+ testImplementation(libs.truth)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.truth)
+ androidTestImplementation(libs.kotlinx.coroutines.test)
}
protobuf {
diff --git a/data/settings/proguard-rules.pro b/data/settings/proguard-rules.pro
deleted file mode 100644
index ff59496..0000000
--- a/data/settings/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile \ No newline at end of file
diff --git a/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/DataStoreModuleTest.kt b/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/DataStoreModuleTest.kt
index ab72c27..f8862b8 100644
--- a/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/DataStoreModuleTest.kt
+++ b/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/DataStoreModuleTest.kt
@@ -17,10 +17,10 @@ package com.google.jetpackcamera.settings
import androidx.datastore.core.DataStore
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
import com.google.jetpackcamera.settings.test.FakeDataStoreModule
import com.google.jetpackcamera.settings.test.FakeJcaSettingsSerializer
import java.io.File
-import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -53,6 +53,6 @@ class DataStoreModuleTest {
val datastoreValue = dataStore.data.first()
advanceUntilIdle()
- assertEquals(datastoreValue, JcaSettings.getDefaultInstance())
+ assertThat(datastoreValue).isEqualTo(JcaSettings.getDefaultInstance())
}
}
diff --git a/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryInstrumentedTest.kt b/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryInstrumentedTest.kt
index 3af226d..44846ac 100644
--- a/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryInstrumentedTest.kt
+++ b/data/settings/src/androidTest/java/com/google/jetpackcamera/settings/LocalSettingsRepositoryInstrumentedTest.kt
@@ -21,11 +21,14 @@ import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
import com.google.jetpackcamera.settings.DataStoreModule.provideDataStore
import com.google.jetpackcamera.settings.model.CameraAppSettings
import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
import com.google.jetpackcamera.settings.model.DarkMode
+import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -37,9 +40,6 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -59,7 +59,7 @@ class LocalSettingsRepositoryInstrumentedTest {
private lateinit var repository: LocalSettingsRepository
@Before
- fun setup() = runTest(StandardTestDispatcher()) {
+ fun setup() = runTest {
Dispatchers.setMain(StandardTestDispatcher())
testDataStore = provideDataStore(testContext)
datastoreScope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
@@ -85,67 +85,66 @@ class LocalSettingsRepositoryInstrumentedTest {
}
@Test
- fun repository_can_fetch_initial_datastore() = runTest(StandardTestDispatcher()) {
+ fun repository_can_fetch_initial_datastore() = runTest {
// if you've created a new setting value and this test is failing, be sure to check that
// JcaSettingsSerializer.kt defaultValue has been properly modified :)
- val cameraAppSettings: CameraAppSettings = repository.getCameraAppSettings()
+ val cameraAppSettings: CameraAppSettings = repository.getCurrentDefaultCameraAppSettings()
advanceUntilIdle()
- assertTrue(cameraAppSettings == DEFAULT_CAMERA_APP_SETTINGS)
+ assertThat(cameraAppSettings).isEqualTo(DEFAULT_CAMERA_APP_SETTINGS)
}
@Test
- fun can_update_dark_mode() = runTest(StandardTestDispatcher()) {
- val initialDarkModeStatus = repository.getCameraAppSettings().darkMode
+ fun can_update_dark_mode() = runTest {
+ val initialDarkModeStatus = repository.getCurrentDefaultCameraAppSettings().darkMode
repository.updateDarkModeStatus(DarkMode.LIGHT)
- val newDarkModeStatus = repository.getCameraAppSettings().darkMode
+ val newDarkModeStatus = repository.getCurrentDefaultCameraAppSettings().darkMode
advanceUntilIdle()
- assertFalse(initialDarkModeStatus == newDarkModeStatus)
- assertTrue(initialDarkModeStatus == DarkMode.SYSTEM)
- assertTrue(newDarkModeStatus == DarkMode.LIGHT)
+ assertThat(initialDarkModeStatus).isNotEqualTo(newDarkModeStatus)
+ assertThat(initialDarkModeStatus).isEqualTo(DarkMode.SYSTEM)
+ assertThat(newDarkModeStatus).isEqualTo(DarkMode.LIGHT)
}
@Test
- fun can_update_default_to_front_camera() = runTest(StandardTestDispatcher()) {
- // default to front camera starts false
- val initialFrontCameraDefault = repository.getCameraAppSettings().isFrontCameraFacing
- repository.updateDefaultToFrontCamera()
- // default to front camera is now true
- val frontCameraDefault = repository.getCameraAppSettings().isFrontCameraFacing
+ fun can_update_default_to_front_camera() = runTest {
+ // default lens facing starts as BACK
+ val initialDefaultLensFacing =
+ repository.getCurrentDefaultCameraAppSettings().cameraLensFacing
+ repository.updateDefaultLensFacing(LensFacing.FRONT)
+ // default lens facing is now FRONT
+ val newDefaultLensFacing = repository.getCurrentDefaultCameraAppSettings().cameraLensFacing
advanceUntilIdle()
- assertFalse(initialFrontCameraDefault)
- assertTrue(frontCameraDefault)
+ assertThat(initialDefaultLensFacing).isEqualTo(LensFacing.BACK)
+ assertThat(newDefaultLensFacing).isEqualTo(LensFacing.FRONT)
}
@Test
- fun can_update_flash_mode() = runTest(StandardTestDispatcher()) {
- // default to front camera starts false
- val initialFlashModeStatus = repository.getCameraAppSettings().flashMode
+ fun can_update_flash_mode() = runTest {
+ // default flash mode starts as OFF
+ val initialFlashModeStatus = repository.getCurrentDefaultCameraAppSettings().flashMode
repository.updateFlashModeStatus(FlashMode.ON)
- // default to front camera is now true
- val newFlashModeStatus = repository.getCameraAppSettings().flashMode
+ // default flash mode is now ON
+ val newFlashModeStatus = repository.getCurrentDefaultCameraAppSettings().flashMode
advanceUntilIdle()
- assertEquals(initialFlashModeStatus, FlashMode.OFF)
- assertEquals(newFlashModeStatus, FlashMode.ON)
+ assertThat(initialFlashModeStatus).isEqualTo(FlashMode.OFF)
+ assertThat(newFlashModeStatus).isEqualTo(FlashMode.ON)
}
@Test
- fun can_update_available_camera_lens() = runTest(StandardTestDispatcher()) {
- // available cameras start true
- val initialFrontCamera = repository.getCameraAppSettings().isFrontCameraAvailable
- val initialBackCamera = repository.getCameraAppSettings().isBackCameraAvailable
+ fun can_update_dynamic_range() = runTest {
+ val initialDynamicRange = repository.getCurrentDefaultCameraAppSettings().dynamicRange
+
+ repository.updateDynamicRange(dynamicRange = DynamicRange.HLG10)
- repository.updateAvailableCameraLens(frontLensAvailable = false, backLensAvailable = false)
- // available cameras now false
advanceUntilIdle()
- val newFrontCamera = repository.getCameraAppSettings().isFrontCameraAvailable
- val newBackCamera = repository.getCameraAppSettings().isBackCameraAvailable
- assertEquals(true, initialFrontCamera && initialBackCamera)
- assertEquals(false, newFrontCamera || newBackCamera)
+ val newDynamicRange = repository.getCurrentDefaultCameraAppSettings().dynamicRange
+
+ assertThat(initialDynamicRange).isEqualTo(DynamicRange.SDR)
+ assertThat(newDynamicRange).isEqualTo(DynamicRange.HLG10)
}
}
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsRepository.kt
new file mode 100644
index 0000000..0d150c6
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsRepository.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.settings
+
+import com.google.jetpackcamera.settings.model.SystemConstraints
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+interface ConstraintsRepository {
+ val systemConstraints: StateFlow<SystemConstraints?>
+}
+
+interface SettableConstraintsRepository : ConstraintsRepository {
+ fun updateSystemConstraints(systemConstraints: SystemConstraints)
+}
+
+class SettableConstraintsRepositoryImpl @Inject constructor() : SettableConstraintsRepository {
+
+ private val _systemConstraints = MutableStateFlow<SystemConstraints?>(null)
+ override val systemConstraints: StateFlow<SystemConstraints?>
+ get() = _systemConstraints.asStateFlow()
+
+ override fun updateSystemConstraints(systemConstraints: SystemConstraints) {
+ _systemConstraints.value = systemConstraints
+ }
+}
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 2530a2b..e0196eb 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
@@ -25,16 +25,13 @@ object JcaSettingsSerializer : Serializer<JcaSettings> {
override val defaultValue: JcaSettings = JcaSettings.newBuilder()
.setDarkModeStatus(DarkMode.DARK_MODE_SYSTEM)
- .setDefaultFrontCamera(false)
- .setBackCameraAvailable(true)
- .setFrontCameraAvailable(true)
+ .setDefaultLensFacing(LensFacing.LENS_FACING_BACK)
.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)
+ .setDynamicRangeStatus(DynamicRange.DYNAMIC_RANGE_UNSPECIFIED)
.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 f54a1a9..80d6e00 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
@@ -26,13 +26,21 @@ 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.DynamicRange
+import com.google.jetpackcamera.settings.model.DynamicRange.Companion.toProto
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.LensFacing.Companion.toProto
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
+const val TARGET_FPS_NONE = 0
+const val TARGET_FPS_15 = 15
+const val TARGET_FPS_30 = 30
+const val TARGET_FPS_60 = 60
+
/**
* Implementation of [SettingsRepository] with locally stored settings.
*/
@@ -40,10 +48,10 @@ class LocalSettingsRepository @Inject constructor(
private val jcaSettings: DataStore<JcaSettings>
) : SettingsRepository {
- override val cameraAppSettings = jcaSettings.data
+ override val defaultCameraAppSettings = jcaSettings.data
.map {
CameraAppSettings(
- isFrontCameraFacing = it.defaultFrontCamera,
+ cameraLensFacing = LensFacing.fromProto(it.defaultLensFacing),
darkMode = when (it.darkModeStatus) {
DarkModeProto.DARK_MODE_DARK -> DarkMode.DARK
DarkModeProto.DARK_MODE_LIGHT -> DarkMode.LIGHT
@@ -56,29 +64,26 @@ class LocalSettingsRepository @Inject constructor(
FlashModeProto.FLASH_MODE_OFF -> FlashMode.OFF
else -> FlashMode.OFF
},
- 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
- ),
+ targetFrameRate = it.targetFrameRate,
captureMode = when (it.captureModeStatus) {
CaptureModeProto.CAPTURE_MODE_SINGLE_STREAM -> CaptureMode.SINGLE_STREAM
CaptureModeProto.CAPTURE_MODE_MULTI_STREAM -> CaptureMode.MULTI_STREAM
else -> CaptureMode.MULTI_STREAM
- }
+ },
+ dynamicRange = DynamicRange.fromProto(it.dynamicRangeStatus)
)
}
- override suspend fun getCameraAppSettings(): CameraAppSettings = cameraAppSettings.first()
+ override suspend fun getCurrentDefaultCameraAppSettings(): CameraAppSettings =
+ defaultCameraAppSettings.first()
- override suspend fun updateDefaultToFrontCamera() {
+ override suspend fun updateDefaultLensFacing(lensFacing: LensFacing) {
jcaSettings.updateData { currentSettings ->
currentSettings.toBuilder()
- .setDefaultFrontCamera(!currentSettings.defaultFrontCamera)
+ .setDefaultLensFacing(lensFacing.toProto())
.build()
}
}
@@ -109,22 +114,10 @@ class LocalSettingsRepository @Inject constructor(
}
}
- override suspend fun updateAvailableCameraLens(
- frontLensAvailable: Boolean,
- backLensAvailable: Boolean
- ) {
- // if a front or back lens is not present, the option to change
- // the direction of the camera should be disabled
+ override suspend fun updateTargetFrameRate(targetFrameRate: Int) {
jcaSettings.updateData { currentSettings ->
- val newLensFacing = if (currentSettings.defaultFrontCamera) {
- frontLensAvailable
- } else {
- false
- }
currentSettings.toBuilder()
- .setDefaultFrontCamera(newLensFacing)
- .setFrontCameraAvailable(frontLensAvailable)
- .setBackCameraAvailable(backLensAvailable)
+ .setTargetFrameRate(targetFrameRate)
.build()
}
}
@@ -180,33 +173,11 @@ class LocalSettingsRepository @Inject constructor(
}
}
- override suspend fun updateVideoStabilizationSupported(isSupported: Boolean) {
+ override suspend fun updateDynamicRange(dynamicRange: DynamicRange) {
jcaSettings.updateData { currentSettings ->
currentSettings.toBuilder()
- .setStabilizeVideoSupported(isSupported)
+ .setDynamicRangeStatus(dynamicRange.toProto())
.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/SettingsModule.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsModule.kt
index 3c9fb71..f523b55 100644
--- a/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsModule.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsModule.kt
@@ -19,6 +19,7 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
/**
* Dagger [Module] for settings data layer.
@@ -31,4 +32,21 @@ interface SettingsModule {
fun bindsSettingsRepository(
localSettingsRepository: LocalSettingsRepository
): SettingsRepository
+
+ @Binds
+ @Singleton
+ fun bindsSettableConstraintsRepository(
+ settableConstraintsRepository: SettableConstraintsRepositoryImpl
+ ): SettableConstraintsRepository
+
+ /**
+ * ConstraintsRepository without setter.
+ *
+ * This is the same instance as the singleton SettableConstraintsRepository, but does not
+ * have the ability to update the constraints.
+ */
+ @Binds
+ fun bindsConstraintsRepository(
+ constraintsRepository: SettableConstraintsRepository
+ ): ConstraintsRepository
}
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 0f622d1..6e0fb3d 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
@@ -19,7 +19,9 @@ 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.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
import kotlinx.coroutines.flow.Flow
@@ -28,27 +30,25 @@ import kotlinx.coroutines.flow.Flow
*/
interface SettingsRepository {
- val cameraAppSettings: Flow<CameraAppSettings>
+ val defaultCameraAppSettings: Flow<CameraAppSettings>
- suspend fun updateDefaultToFrontCamera()
+ suspend fun getCurrentDefaultCameraAppSettings(): CameraAppSettings
+
+ suspend fun updateDefaultLensFacing(lensFacing: LensFacing)
suspend fun updateDarkModeStatus(darkMode: DarkMode)
suspend fun updateFlashModeStatus(flashMode: FlashMode)
- // set device values from cameraUseCase
- suspend fun updateAvailableCameraLens(frontLensAvailable: Boolean, backLensAvailable: Boolean)
-
suspend fun updateAspectRatio(aspectRatio: AspectRatio)
suspend fun updateCaptureMode(captureMode: CaptureMode)
suspend fun updatePreviewStabilization(stabilization: Stabilization)
- suspend fun updateVideoStabilization(stabilization: Stabilization)
- suspend fun updateVideoStabilizationSupported(isSupported: Boolean)
+ suspend fun updateVideoStabilization(stabilization: Stabilization)
- suspend fun updatePreviewStabilizationSupported(isSupported: Boolean)
+ suspend fun updateDynamicRange(dynamicRange: DynamicRange)
- suspend fun getCameraAppSettings(): CameraAppSettings
+ suspend fun updateTargetFrameRate(targetFrameRate: Int)
}
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/AspectRatio.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/AspectRatio.kt
index 6738154..8de6ea3 100644
--- a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/AspectRatio.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/AspectRatio.kt
@@ -28,14 +28,14 @@ enum class AspectRatio(val ratio: Rational) {
/** returns the AspectRatio enum equivalent of a provided AspectRatioProto */
fun fromProto(aspectRatioProto: AspectRatioProto): AspectRatio {
return when (aspectRatioProto) {
- AspectRatioProto.ASPECT_RATIO_NINE_SIXTEEN -> AspectRatio.NINE_SIXTEEN
- AspectRatioProto.ASPECT_RATIO_ONE_ONE -> AspectRatio.ONE_ONE
+ AspectRatioProto.ASPECT_RATIO_NINE_SIXTEEN -> NINE_SIXTEEN
+ AspectRatioProto.ASPECT_RATIO_ONE_ONE -> ONE_ONE
// defaults to 3:4 aspect ratio
AspectRatioProto.ASPECT_RATIO_THREE_FOUR,
AspectRatioProto.ASPECT_RATIO_UNDEFINED,
AspectRatioProto.UNRECOGNIZED
- -> AspectRatio.THREE_FOUR
+ -> THREE_FOUR
}
}
}
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 e135089..0bb37c2 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
@@ -14,21 +14,27 @@
* limitations under the License.
*/
package com.google.jetpackcamera.settings.model
+const val TARGET_FPS_AUTO = 0
/**
* Data layer representation for settings.
*/
data class CameraAppSettings(
- val isFrontCameraFacing: Boolean = false,
- val isFrontCameraAvailable: Boolean = true,
- val isBackCameraAvailable: Boolean = true,
+ val cameraLensFacing: LensFacing = LensFacing.BACK,
val darkMode: DarkMode = DarkMode.SYSTEM,
val flashMode: FlashMode = FlashMode.OFF,
val captureMode: CaptureMode = CaptureMode.MULTI_STREAM,
val aspectRatio: AspectRatio = AspectRatio.NINE_SIXTEEN,
val previewStabilization: Stabilization = Stabilization.UNDEFINED,
val videoCaptureStabilization: Stabilization = Stabilization.UNDEFINED,
- val supportedStabilizationModes: List<SupportedStabilizationMode> = emptyList()
+ val dynamicRange: DynamicRange = DynamicRange.SDR,
+ val defaultHdrDynamicRange: DynamicRange = DynamicRange.HLG10,
+ val zoomScale: Float = 1f,
+ val targetFrameRate: Int = TARGET_FPS_AUTO
)
+fun SystemConstraints.forCurrentLens(cameraAppSettings: CameraAppSettings): CameraConstraints? {
+ return perLensConstraints[cameraAppSettings.cameraLensFacing]
+}
+
val DEFAULT_CAMERA_APP_SETTINGS = CameraAppSettings()
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt
new file mode 100644
index 0000000..d4f7364
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2024 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
+
+data class SystemConstraints(
+ val availableLenses: List<LensFacing>,
+ val perLensConstraints: Map<LensFacing, CameraConstraints>
+)
+
+data class CameraConstraints(
+ val supportedStabilizationModes: Set<SupportedStabilizationMode>,
+ val supportedFixedFrameRates: Set<Int>,
+ val supportedDynamicRanges: Set<DynamicRange>
+)
+
+/**
+ * Useful set of constraints for testing
+ */
+val TYPICAL_SYSTEM_CONSTRAINTS =
+ SystemConstraints(
+ availableLenses = listOf(LensFacing.FRONT, LensFacing.BACK),
+ perLensConstraints = buildMap {
+ for (lensFacing in listOf(LensFacing.FRONT, LensFacing.BACK)) {
+ put(
+ lensFacing,
+ CameraConstraints(
+ supportedFixedFrameRates = setOf(15, 30),
+ supportedStabilizationModes = emptySet(),
+ supportedDynamicRanges = setOf(DynamicRange.SDR)
+ )
+ )
+ }
+ }
+ )
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/DynamicRange.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/DynamicRange.kt
new file mode 100644
index 0000000..9abef02
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/DynamicRange.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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.DynamicRange as DynamicRangeProto
+
+enum class DynamicRange {
+ SDR,
+ HLG10;
+
+ companion object {
+
+ /** returns the DynamicRangeType enum equivalent of a provided DynamicRangeTypeProto */
+ fun fromProto(dynamicRangeProto: DynamicRangeProto): DynamicRange {
+ return when (dynamicRangeProto) {
+ DynamicRangeProto.DYNAMIC_RANGE_HLG10 -> HLG10
+
+ // Treat unrecognized and unspecified as SDR as a fallback
+ DynamicRangeProto.DYNAMIC_RANGE_SDR,
+ DynamicRangeProto.DYNAMIC_RANGE_UNSPECIFIED,
+ DynamicRangeProto.UNRECOGNIZED -> SDR
+ }
+ }
+
+ fun DynamicRange.toProto(): DynamicRangeProto {
+ return when (this) {
+ SDR -> DynamicRangeProto.DYNAMIC_RANGE_SDR
+ HLG10 -> DynamicRangeProto.DYNAMIC_RANGE_HLG10
+ }
+ }
+ }
+}
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/LensFacing.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/LensFacing.kt
new file mode 100644
index 0000000..c590339
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/LensFacing.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 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.LensFacing as LensFacingProto
+
+enum class LensFacing {
+ BACK,
+ FRONT;
+
+ fun flip(): LensFacing {
+ return when (this) {
+ FRONT -> BACK
+ BACK -> FRONT
+ }
+ }
+
+ companion object {
+
+ /** returns the LensFacing enum equivalent of a provided LensFacingProto */
+ fun fromProto(lensFacingProto: LensFacingProto): LensFacing {
+ return when (lensFacingProto) {
+ LensFacingProto.LENS_FACING_BACK -> BACK
+
+ // Treat unrecognized as front as a fallback
+ LensFacingProto.LENS_FACING_FRONT,
+ LensFacingProto.UNRECOGNIZED -> FRONT
+ }
+ }
+
+ fun LensFacing.toProto(): LensFacingProto {
+ return when (this) {
+ BACK -> LensFacingProto.LENS_FACING_BACK
+ FRONT -> LensFacingProto.LENS_FACING_FRONT
+ }
+ }
+ }
+}
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
index b0b599e..e97dfc5 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
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
index 9cdc8f7..c71ca5c 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
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 c2033bd..d207a99 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
@@ -20,8 +20,10 @@ 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.DynamicRange
import com.google.jetpackcamera.settings.FlashMode
import com.google.jetpackcamera.settings.JcaSettings
+import com.google.jetpackcamera.settings.LensFacing
import com.google.jetpackcamera.settings.PreviewStabilization
import com.google.jetpackcamera.settings.VideoStabilization
import com.google.protobuf.InvalidProtocolBufferException
@@ -35,16 +37,13 @@ class FakeJcaSettingsSerializer(
override val defaultValue: JcaSettings = JcaSettings.newBuilder()
.setDarkModeStatus(DarkMode.DARK_MODE_SYSTEM)
- .setDefaultFrontCamera(false)
- .setBackCameraAvailable(true)
- .setFrontCameraAvailable(true)
+ .setDefaultLensFacing(LensFacing.LENS_FACING_BACK)
.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)
+ .setDynamicRangeStatus(DynamicRange.DYNAMIC_RANGE_SDR)
.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 2bb4295..c7ed7b7 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
@@ -21,22 +21,24 @@ import com.google.jetpackcamera.settings.model.CameraAppSettings
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.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
-import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
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
+ private var currentCameraSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS
- override val cameraAppSettings: Flow<CameraAppSettings> = flow { emit(currentCameraSettings) }
+ override val defaultCameraAppSettings: Flow<CameraAppSettings> =
+ flow { emit(currentCameraSettings) }
- override suspend fun updateDefaultToFrontCamera() {
- val newLensFacing = !currentCameraSettings.isFrontCameraFacing
- currentCameraSettings = currentCameraSettings.copy(isFrontCameraFacing = newLensFacing)
+ override suspend fun getCurrentDefaultCameraAppSettings() = defaultCameraAppSettings.first()
+
+ override suspend fun updateDefaultLensFacing(lensFacing: LensFacing) {
+ currentCameraSettings = currentCameraSettings.copy(cameraLensFacing = lensFacing)
}
override suspend fun updateDarkModeStatus(darkMode: DarkMode) {
@@ -47,20 +49,6 @@ object FakeSettingsRepository : SettingsRepository {
currentCameraSettings = currentCameraSettings.copy(flashMode = flashMode)
}
- override suspend fun getCameraAppSettings(): CameraAppSettings {
- return currentCameraSettings
- }
-
- override suspend fun updateAvailableCameraLens(
- frontLensAvailable: Boolean,
- backLensAvailable: Boolean
- ) {
- currentCameraSettings = currentCameraSettings.copy(
- isFrontCameraAvailable = frontLensAvailable,
- isBackCameraAvailable = backLensAvailable
- )
- }
-
override suspend fun updateCaptureMode(captureMode: CaptureMode) {
currentCameraSettings =
currentCameraSettings.copy(captureMode = captureMode)
@@ -76,33 +64,18 @@ object FakeSettingsRepository : SettingsRepository {
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)
- }
- }
-
+ override suspend fun updateDynamicRange(dynamicRange: DynamicRange) {
currentCameraSettings =
- currentCameraSettings.copy(supportedStabilizationModes = stabilizationModes)
+ currentCameraSettings.copy(dynamicRange = dynamicRange)
}
override suspend fun updateAspectRatio(aspectRatio: AspectRatio) {
currentCameraSettings =
currentCameraSettings.copy(aspectRatio = aspectRatio)
}
+
+ override suspend fun updateTargetFrameRate(targetFrameRate: Int) {
+ currentCameraSettings =
+ currentCameraSettings.copy(targetFrameRate = targetFrameRate)
+ }
}
diff --git a/data/settings/src/main/proto/com/google/jetpackcamera/settings/dynamic_range.proto b/data/settings/src/main/proto/com/google/jetpackcamera/settings/dynamic_range.proto
new file mode 100644
index 0000000..7ab679a
--- /dev/null
+++ b/data/settings/src/main/proto/com/google/jetpackcamera/settings/dynamic_range.proto
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 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 DynamicRange {
+ DYNAMIC_RANGE_UNSPECIFIED = 0;
+ DYNAMIC_RANGE_SDR = 1;
+ DYNAMIC_RANGE_HLG10 = 2;
+} \ No newline at end of file
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 288d501..cc87e43 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
@@ -19,26 +19,27 @@ syntax = "proto3";
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/dynamic_range.proto";
import "com/google/jetpackcamera/settings/flash_mode.proto";
+import "com/google/jetpackcamera/settings/lens_facing.proto";
import "com/google/jetpackcamera/settings/preview_stabilization.proto";
import "com/google/jetpackcamera/settings/video_stabilization.proto";
-
-
option java_package = "com.google.jetpackcamera.settings";
option java_multiple_files = true;
message JcaSettings {
- bool default_front_camera = 2;
- bool front_camera_available = 3;
- bool back_camera_available = 4;
- DarkMode dark_mode_status = 5;
- 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;
+ // Camera settings
+ LensFacing default_lens_facing = 1;
+ FlashMode flash_mode_status = 2;
+ int32 target_frame_rate = 3;
+ AspectRatio aspect_ratio_status = 4;
+ CaptureMode capture_mode_status = 5;
+ PreviewStabilization stabilize_preview = 6;
+ VideoStabilization stabilize_video = 7;
+ DynamicRange dynamic_range_status = 8;
+
+ // Non-camera app settings
+ DarkMode dark_mode_status = 9;
} \ No newline at end of file
diff --git a/data/settings/src/main/proto/com/google/jetpackcamera/settings/lens_facing.proto b/data/settings/src/main/proto/com/google/jetpackcamera/settings/lens_facing.proto
new file mode 100644
index 0000000..a90a9c7
--- /dev/null
+++ b/data/settings/src/main/proto/com/google/jetpackcamera/settings/lens_facing.proto
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2024 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 LensFacing {
+ LENS_FACING_BACK = 0;
+ LENS_FACING_FRONT = 1;
+} \ 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
index f0cf902..5d8172d 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
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
index 66e1a8b..5063b66 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
diff --git a/data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt b/data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt
new file mode 100644
index 0000000..a598530
--- /dev/null
+++ b/data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.settings
+
+import com.google.common.truth.Truth.assertThat
+import com.google.jetpackcamera.settings.DynamicRange as DynamicRangeProto
+import com.google.jetpackcamera.settings.model.DynamicRange
+import com.google.jetpackcamera.settings.model.DynamicRange.Companion.toProto
+import org.junit.Test
+
+class ProtoConversionTest {
+ @Test
+ fun dynamicRange_convertsToCorrectProto() {
+ val correctConversions = { dynamicRange: DynamicRange ->
+ when (dynamicRange) {
+ DynamicRange.SDR -> DynamicRangeProto.DYNAMIC_RANGE_SDR
+ DynamicRange.HLG10 -> DynamicRangeProto.DYNAMIC_RANGE_HLG10
+ else -> TODO(
+ "Test does not yet contain correct conversion for dynamic range " +
+ "type: ${dynamicRange.name}"
+ )
+ }
+ }
+
+ enumValues<DynamicRange>().forEach {
+ assertThat(correctConversions(it)).isEqualTo(it.toProto())
+ }
+ }
+
+ @Test
+ fun dynamicRangeProto_convertsToCorrectDynamicRange() {
+ val correctConversions = { dynamicRangeProto: DynamicRangeProto ->
+ when (dynamicRangeProto) {
+ DynamicRangeProto.DYNAMIC_RANGE_SDR,
+ DynamicRangeProto.UNRECOGNIZED,
+ DynamicRangeProto.DYNAMIC_RANGE_UNSPECIFIED
+ -> DynamicRange.SDR
+
+ DynamicRangeProto.DYNAMIC_RANGE_HLG10 -> DynamicRange.HLG10
+ else -> TODO(
+ "Test does not yet contain correct conversion for dynamic range " +
+ "proto type: ${dynamicRangeProto.name}"
+ )
+ }
+ }
+
+ enumValues<DynamicRangeProto>().forEach {
+ assertThat(correctConversions(it)).isEqualTo(DynamicRange.fromProto(it))
+ }
+ }
+}
diff --git a/docs/images/JCA-video-capture.gif b/docs/images/JCA-video-capture.gif
new file mode 100644
index 0000000..79cfa92
--- /dev/null
+++ b/docs/images/JCA-video-capture.gif
Binary files differ
diff --git a/domain/camera/Android.bp b/domain/camera/Android.bp
index e34d990..841f3c6 100644
--- a/domain/camera/Android.bp
+++ b/domain/camera/Android.bp
@@ -15,6 +15,7 @@ android_library {
"androidx.camera_camera-video",
"androidx.camera_camera-camera2",
"androidx.camera_camera-lifecycle",
+ //"androidx.graphics_graphics-core",
"jetpack-camera-app_data_settings",
"jetpack-camera-app_core_common",
],
diff --git a/domain/camera/build.gradle.kts b/domain/camera/build.gradle.kts
index 670fd8f..c79cf4b 100644
--- a/domain/camera/build.gradle.kts
+++ b/domain/camera/build.gradle.kts
@@ -23,25 +23,16 @@ plugins {
android {
namespace = "com.google.jetpackcamera.data.camera"
- compileSdk = 34
+ compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
- minSdk = 21
- targetSdk = 34
+ minSdk = libs.versions.minSdk.get().toInt()
+ testOptions.targetSdk = libs.versions.targetSdk.get().toInt()
+ lint.targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- consumerProguardFiles("consumer-rules.pro")
}
- buildTypes {
- release {
- isMinifyEnabled = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -54,10 +45,11 @@ android {
dependencies {
// Testing
testImplementation(libs.junit)
+ testImplementation(libs.truth)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.mockito.core)
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(libs.futures.ktx)
@@ -68,15 +60,15 @@ dependencies {
implementation(libs.camera.lifecycle)
implementation(libs.camera.video)
- implementation(libs.camera.view)
- implementation(libs.camera.extensions)
-
// Hilt
implementation(libs.dagger.hilt.android)
kapt(libs.dagger.hilt.compiler)
// Tracing
- implementation("androidx.tracing:tracing-ktx:1.2.0")
+ implementation(libs.androidx.tracing)
+
+ // Graphics libraries
+ implementation(libs.androidx.graphics.core)
// Project dependencies
implementation(project(":data:settings"))
diff --git a/domain/camera/consumer-rules.pro b/domain/camera/consumer-rules.pro
deleted file mode 100644
index e69de29..0000000
--- a/domain/camera/consumer-rules.pro
+++ /dev/null
diff --git a/domain/camera/proguard-rules.pro b/domain/camera/proguard-rules.pro
deleted file mode 100644
index ff59496..0000000
--- a/domain/camera/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile \ No newline at end of file
diff --git a/domain/camera/src/main/AndroidManifest.xml b/domain/camera/src/main/AndroidManifest.xml
index 2aa7dee..ea4043c 100644
--- a/domain/camera/src/main/AndroidManifest.xml
+++ b/domain/camera/src/main/AndroidManifest.xml
@@ -16,5 +16,6 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.jetpackcamera.domain.camera">
-
+ <uses-permission android:name = "android.permission.RECORD_AUDIO" />
</manifest>
+
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 1399d0f..9cf1fc4 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
@@ -17,14 +17,17 @@ 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
-import com.google.jetpackcamera.settings.model.AspectRatio as SettingsAspectRatio
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.SurfaceRequest
+import com.google.jetpackcamera.settings.model.AspectRatio
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 com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.DynamicRange
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
/**
* Data layer for camera.
@@ -35,89 +38,59 @@ interface CameraUseCase {
*
* @return list of available lenses.
*/
- suspend fun initialize(currentCameraSettings: CameraAppSettings): List<Int>
+ suspend fun initialize(disableVideoCapture: Boolean)
/**
- * Starts the camera with lensFacing with the provided [Preview.SurfaceProvider].
+ * Starts the camera.
+ *
+ * This will start to configure the camera, but frames won't stream until a [SurfaceRequest]
+ * from [getSurfaceRequest] has been fulfilled.
*
* The camera will run until the calling coroutine is cancelled.
*/
- suspend fun runCamera(
- surfaceProvider: Preview.SurfaceProvider,
- currentCameraSettings: CameraAppSettings
- )
+ suspend fun runCamera()
- suspend fun takePicture()
+ suspend fun takePicture(onCaptureStarted: (() -> Unit) = {})
- suspend fun takePicture(contentResolver: ContentResolver, imageCaptureUri: Uri?)
+ /**
+ * Takes a picture with the camera. If ignoreUri is set to true, the picture taken will be saved
+ * at the default directory for pictures on device. Otherwise, it will be saved at the uri
+ * location if the uri is not null. If it is null, an error will be thrown.
+ */
+ suspend fun takePicture(
+ onCaptureStarted: (() -> Unit) = {},
+ contentResolver: ContentResolver,
+ imageCaptureUri: Uri?,
+ ignoreUri: Boolean = false
+ ): ImageCapture.OutputFileResults
- suspend fun startVideoRecording()
+ suspend fun startVideoRecording(onVideoRecord: (OnVideoRecordEvent) -> Unit)
fun stopVideoRecording()
- fun setZoomScale(scale: Float): Float
-
- fun getScreenFlashEvents(): SharedFlow<ScreenFlashEvent>
-
- fun setFlashMode(flashMode: SettingsFlashMode, isFrontFacing: Boolean)
+ fun setZoomScale(scale: Float)
- fun isScreenFlashEnabled(): Boolean
+ fun getZoomScale(): StateFlow<Float>
- suspend fun setAspectRatio(aspectRatio: SettingsAspectRatio, isFrontFacing: Boolean)
+ fun getSurfaceRequest(): StateFlow<SurfaceRequest?>
- suspend fun flipCamera(isFrontFacing: Boolean, flashMode: SettingsFlashMode)
+ fun getScreenFlashEvents(): SharedFlow<ScreenFlashEvent>
- fun tapToFocus(display: Display, surfaceWidth: Int, surfaceHeight: Int, x: Float, y: Float)
+ fun getCurrentSettings(): StateFlow<CameraAppSettings?>
- suspend fun setCaptureMode(captureMode: SettingsCaptureMode)
+ fun setFlashMode(flashMode: FlashMode)
- companion object {
- const val INVALID_ZOOM_SCALE = -1f
- }
+ fun isScreenFlashEnabled(): Boolean
- /**
- * Data class holding information used for configuring [CameraUseCase].
- */
- data class Config(
- val lensFacing: LensFacing = LensFacing.FRONT,
- val captureMode: CaptureMode = CaptureMode.SINGLE_STREAM,
- val aspectRatio: AspectRatio = AspectRatio.ASPECT_RATIO_4_3,
- val flashMode: FlashMode = FlashMode.OFF
- )
+ suspend fun setAspectRatio(aspectRatio: AspectRatio)
- /**
- * Represents the lens used by [CameraUseCase].
- */
- enum class LensFacing {
- FRONT,
- BACK
- }
+ suspend fun setLensFacing(lensFacing: LensFacing)
- /**
- * Represents the capture mode used by [CameraUseCase].
- */
- enum class CaptureMode {
- MULTI_STREAM,
- SINGLE_STREAM
- }
+ fun tapToFocus(display: Display, surfaceWidth: Int, surfaceHeight: Int, x: Float, y: Float)
- /**
- * Represents the aspect ratio used by [CameraUseCase].
- */
- enum class AspectRatio(val rational: Rational) {
- ASPECT_RATIO_4_3(Rational(4, 3)),
- ASPECT_RATIO_16_9(Rational(16, 9)),
- ASPECT_RATIO_1_1(Rational(1, 1))
- }
+ suspend fun setCaptureMode(captureMode: CaptureMode)
- /**
- * Represents the flash mode used by [CameraUseCase].
- */
- enum class FlashMode {
- OFF,
- ON,
- AUTO
- }
+ suspend fun setDynamicRange(dynamicRange: DynamicRange)
/**
* Represents the events required for screen flash.
@@ -128,4 +101,15 @@ interface CameraUseCase {
CLEAR_UI
}
}
+
+ /**
+ * Represents the events for video recording.
+ */
+ sealed interface OnVideoRecordEvent {
+ object OnVideoRecorded : OnVideoRecordEvent
+
+ data class OnVideoRecordStatus(val audioAmplitude: Double) : OnVideoRecordEvent
+
+ object OnVideoRecordError : OnVideoRecordEvent
+ }
}
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 a640991..97dd767 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
@@ -15,62 +15,93 @@
*/
package com.google.jetpackcamera.domain.camera
+import android.Manifest
import android.app.Application
import android.content.ContentResolver
import android.content.ContentValues
+import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
+import android.util.Range
import android.view.Display
-import androidx.camera.core.Camera
+import androidx.camera.core.AspectRatio.RATIO_16_9
+import androidx.camera.core.AspectRatio.RATIO_4_3
+import androidx.camera.core.AspectRatio.RATIO_DEFAULT
+import androidx.camera.core.CameraEffect
+import androidx.camera.core.CameraInfo
import androidx.camera.core.CameraSelector
-import androidx.camera.core.CameraSelector.LensFacing
-import androidx.camera.core.DisplayOrientedMeteringPointFactory
-import androidx.camera.core.FocusMeteringAction
+import androidx.camera.core.DynamicRange as CXDynamicRange
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
+import androidx.camera.core.SurfaceRequest
import androidx.camera.core.UseCaseGroup
import androidx.camera.core.ViewPort
-import androidx.camera.core.ZoomState
+import androidx.camera.core.resolutionselector.AspectRatioStrategy
+import androidx.camera.core.resolutionselector.ResolutionSelector
+import androidx.camera.core.takePicture
import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.lifecycle.awaitInstance
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.camera.video.VideoRecordEvent
+import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE
import androidx.core.content.ContextCompat
-import com.google.jetpackcamera.domain.camera.CameraUseCase.Companion.INVALID_ZOOM_SCALE
+import androidx.core.content.ContextCompat.checkSelfPermission
import com.google.jetpackcamera.domain.camera.CameraUseCase.ScreenFlashEvent.Type
+import com.google.jetpackcamera.domain.camera.effects.SingleSurfaceForcingEffect
+import com.google.jetpackcamera.settings.SettableConstraintsRepository
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.CameraConstraints
import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
+import com.google.jetpackcamera.settings.model.SystemConstraints
import dagger.hilt.android.scopes.ViewModelScoped
import java.io.FileNotFoundException
-import java.lang.RuntimeException
+import java.lang.IllegalArgumentException
+import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
+import java.util.Locale
+import java.util.concurrent.Executor
import javax.inject.Inject
-import kotlinx.coroutines.CompletableDeferred
+import kotlin.coroutines.ContinuationInterceptor
+import kotlin.properties.Delegates
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asExecutor
-import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
private const val TAG = "CameraXCameraUseCase"
-private const val IMAGE_CAPTURE_TRACE = "JCA Image Capture"
+const val TARGET_FPS_AUTO = 0
+const val TARGET_FPS_15 = 15
+const val TARGET_FPS_30 = 30
+const val TARGET_FPS_60 = 60
/**
* CameraX based implementation for [CameraUseCase]
@@ -82,109 +113,256 @@ constructor(
private val application: Application,
private val coroutineScope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher,
- private val settingsRepository: SettingsRepository
+ private val settingsRepository: SettingsRepository,
+ private val constraintsRepository: SettableConstraintsRepository
) : CameraUseCase {
- private var camera: Camera? = null
private lateinit var cameraProvider: ProcessCameraProvider
- // TODO apply flash from settings
- private val imageCaptureUseCase = ImageCapture.Builder().build()
+ private lateinit var imageCaptureUseCase: ImageCapture
- private val recorder = Recorder.Builder().setExecutor(
- defaultDispatcher.asExecutor()
- ).build()
- private lateinit var videoCaptureUseCase: VideoCapture<Recorder>
+ private var videoCaptureUseCase: VideoCapture<Recorder>? = null
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 lateinit var systemConstraints: SystemConstraints
+ private var disableVideoCapture by Delegates.notNull<Boolean>()
private val screenFlashEvents: MutableSharedFlow<CameraUseCase.ScreenFlashEvent> =
MutableSharedFlow()
- override suspend fun initialize(currentCameraSettings: CameraAppSettings): List<Int> {
- this.aspectRatio = currentCameraSettings.aspectRatio
- this.captureMode = currentCameraSettings.captureMode
- this.stabilizePreviewMode = currentCameraSettings.previewStabilization
- this.stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization
- this.supportedStabilizationModes = currentCameraSettings.supportedStabilizationModes
- setFlashMode(currentCameraSettings.flashMode, currentCameraSettings.isFrontCameraFacing)
- cameraProvider = ProcessCameraProvider.getInstance(application).await()
+ private val currentSettings = MutableStateFlow<CameraAppSettings?>(null)
+
+ override suspend fun initialize(externalImageCapture: Boolean) {
+ this.disableVideoCapture = externalImageCapture
+ cameraProvider = ProcessCameraProvider.awaitInstance(application)
- val availableCameraLens =
+ // updates values for available cameras
+ val availableCameraLenses =
listOf(
- CameraSelector.LENS_FACING_BACK,
- CameraSelector.LENS_FACING_FRONT
- ).filter { lensFacing ->
- cameraProvider.hasCamera(cameraLensToSelector(lensFacing))
+ LensFacing.FRONT,
+ LensFacing.BACK
+ ).filter {
+ cameraProvider.hasCamera(it.toCameraSelector())
}
- // 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
- }
- override suspend fun runCamera(
- surfaceProvider: Preview.SurfaceProvider,
- currentCameraSettings: CameraAppSettings
- ) = coroutineScope {
- Log.d(TAG, "startPreview")
+ // Build and update the system constraints
+ systemConstraints = SystemConstraints(
+ availableLenses = availableCameraLenses,
+ perLensConstraints = buildMap {
+ val availableCameraInfos = cameraProvider.availableCameraInfos
+ for (lensFacing in availableCameraLenses) {
+ val selector = lensFacing.toCameraSelector()
+ selector.filter(availableCameraInfos).firstOrNull()?.let { camInfo ->
+ val supportedDynamicRanges =
+ Recorder.getVideoCapabilities(camInfo).supportedDynamicRanges
+ .mapNotNull(CXDynamicRange::toSupportedAppDynamicRange)
+ .toSet()
+
+ val supportedStabilizationModes = buildSet {
+ if (isPreviewStabilizationSupported(camInfo)) {
+ add(SupportedStabilizationMode.ON)
+ }
+
+ if (isVideoStabilizationSupported(camInfo)) {
+ add(SupportedStabilizationMode.HIGH_QUALITY)
+ }
+ }
+
+ val supportedFixedFrameRates = getSupportedFrameRates(camInfo)
- val cameraSelector =
- cameraLensToSelector(getLensFacing(currentCameraSettings.isFrontCameraFacing))
+ put(
+ lensFacing,
+ CameraConstraints(
+ supportedStabilizationModes = supportedStabilizationModes,
+ supportedFixedFrameRates = supportedFixedFrameRates,
+ supportedDynamicRanges = supportedDynamicRanges
+ )
+ )
+ }
+ }
+ }
+ )
+
+ constraintsRepository.updateSystemConstraints(systemConstraints)
- previewUseCase.setSurfaceProvider(surfaceProvider)
- this@CameraXCameraUseCase.surfaceProvider = surfaceProvider
+ currentSettings.value =
+ settingsRepository.defaultCameraAppSettings.first()
+ .tryApplyDynamicRangeConstraints()
+ .tryApplyAspectRatioForExternalCapture(externalImageCapture)
- cameraProvider.runWith(cameraSelector, useCaseGroup) {
- camera = it
- awaitCancellation()
+ imageCaptureUseCase = ImageCapture.Builder()
+ .setResolutionSelector(
+ getResolutionSelector(
+ settingsRepository.defaultCameraAppSettings.first().aspectRatio
+ )
+ ).build()
+ }
+
+ /**
+ * Returns the union of supported stabilization modes for a device's cameras
+ */
+ private fun getDeviceSupportedStabilizations(): Set<SupportedStabilizationMode> {
+ val deviceSupportedStabilizationModes = mutableSetOf<SupportedStabilizationMode>()
+
+ cameraProvider.availableCameraInfos.forEach { cameraInfo ->
+ if (isPreviewStabilizationSupported(cameraInfo)) {
+ deviceSupportedStabilizationModes.add(SupportedStabilizationMode.ON)
+ }
+ if (isVideoStabilizationSupported(cameraInfo)) {
+ deviceSupportedStabilizationModes.add(SupportedStabilizationMode.HIGH_QUALITY)
+ }
}
+ return deviceSupportedStabilizationModes
}
- override suspend fun takePicture() {
- val imageDeferred = CompletableDeferred<ImageProxy>()
+ /**
+ * Camera settings that persist as long as a camera is running.
+ *
+ * Any change in these settings will require calling [ProcessCameraProvider.runWith] with
+ * updates [CameraSelector] and/or [UseCaseGroup]
+ */
+ private data class PerpetualSessionSettings(
+ val cameraSelector: CameraSelector,
+ val aspectRatio: AspectRatio,
+ val captureMode: CaptureMode,
+ val targetFrameRate: Int,
+ val stabilizePreviewMode: Stabilization,
+ val stabilizeVideoMode: Stabilization,
+ val dynamicRange: DynamicRange
+ )
- imageCaptureUseCase.takePicture(
- defaultDispatcher.asExecutor(),
- object : ImageCapture.OnImageCapturedCallback() {
- override fun onCaptureSuccess(imageProxy: ImageProxy) {
- Log.d(TAG, "onCaptureSuccess")
- imageDeferred.complete(imageProxy)
- imageProxy.close()
+ /**
+ * Camera settings that can change while the camera is running.
+ *
+ * Any changes in these settings can be applied either directly to use cases via their
+ * setter methods or to [androidx.camera.core.CameraControl].
+ * The use cases typically will not need to be re-bound.
+ */
+ private data class TransientSessionSettings(
+ val flashMode: FlashMode,
+ val zoomScale: Float
+ )
+
+ override suspend fun runCamera() = coroutineScope {
+ Log.d(TAG, "runCamera")
+
+ val transientSettings = MutableStateFlow<TransientSessionSettings?>(null)
+ currentSettings
+ .filterNotNull()
+ .map { currentCameraSettings ->
+ transientSettings.value = TransientSessionSettings(
+ flashMode = currentCameraSettings.flashMode,
+ zoomScale = currentCameraSettings.zoomScale
+ )
+
+ val cameraSelector = when (currentCameraSettings.cameraLensFacing) {
+ LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
+ LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
}
- override fun onError(exception: ImageCaptureException) {
- super.onError(exception)
- Log.d(TAG, "takePicture onError: $exception")
- imageDeferred.completeExceptionally(exception)
+ PerpetualSessionSettings(
+ cameraSelector = cameraSelector,
+ aspectRatio = currentCameraSettings.aspectRatio,
+ captureMode = currentCameraSettings.captureMode,
+ targetFrameRate = currentCameraSettings.targetFrameRate,
+ stabilizePreviewMode = currentCameraSettings.previewStabilization,
+ stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization,
+ dynamicRange = currentCameraSettings.dynamicRange
+ )
+ }.distinctUntilChanged()
+ .collectLatest { sessionSettings ->
+ Log.d(TAG, "Starting new camera session")
+ val cameraInfo = sessionSettings.cameraSelector.filter(
+ cameraProvider.availableCameraInfos
+ ).first()
+
+ val lensFacing = sessionSettings.cameraSelector.toAppLensFacing()
+ val cameraConstraints = checkNotNull(
+ systemConstraints.perLensConstraints[lensFacing]
+ ) {
+ "Unable to retrieve CameraConstraints for $lensFacing. " +
+ "Was the use case initialized?"
+ }
+
+ val initialTransientSettings = transientSettings
+ .filterNotNull()
+ .first()
+
+ val useCaseGroup = createUseCaseGroup(
+ sessionSettings,
+ initialTransientSettings,
+ cameraConstraints.supportedStabilizationModes,
+ effect = when (sessionSettings.captureMode) {
+ CaptureMode.SINGLE_STREAM -> SingleSurfaceForcingEffect(coroutineScope)
+ CaptureMode.MULTI_STREAM -> null
+ }
+ )
+
+ var prevTransientSettings = initialTransientSettings
+ cameraProvider.runWith(sessionSettings.cameraSelector, useCaseGroup) { camera ->
+ Log.d(TAG, "Camera session started")
+ transientSettings.filterNotNull().collectLatest { newTransientSettings ->
+ // Apply camera control settings
+ if (prevTransientSettings.zoomScale != newTransientSettings.zoomScale) {
+ cameraInfo.zoomState.value?.let { zoomState ->
+ val finalScale =
+ (zoomState.zoomRatio * newTransientSettings.zoomScale).coerceIn(
+ zoomState.minZoomRatio,
+ zoomState.maxZoomRatio
+ )
+ camera.cameraControl.setZoomRatio(finalScale)
+ _zoomScale.value = finalScale
+ }
+ }
+
+ if (prevTransientSettings.flashMode != newTransientSettings.flashMode) {
+ setFlashModeInternal(
+ flashMode = newTransientSettings.flashMode,
+ isFrontFacing = sessionSettings.cameraSelector
+ == CameraSelector.DEFAULT_FRONT_CAMERA
+ )
+ }
+
+ prevTransientSettings = newTransientSettings
+ }
}
}
- )
- imageDeferred.await()
+ }
+
+ override suspend fun takePicture(onCaptureStarted: (() -> Unit)) {
+ try {
+ val imageProxy = imageCaptureUseCase.takePicture(onCaptureStarted)
+ Log.d(TAG, "onCaptureSuccess")
+ imageProxy.close()
+ } catch (exception: Exception) {
+ Log.d(TAG, "takePicture onError: $exception")
+ throw exception
+ }
}
// TODO(b/319733374): Return bitmap for external mediastore capture without URI
- override suspend fun takePicture(contentResolver: ContentResolver, imageCaptureUri: Uri?) {
- val imageDeferred = CompletableDeferred<ImageCapture.OutputFileResults>()
+ override suspend fun takePicture(
+ onCaptureStarted: (() -> Unit),
+ contentResolver: ContentResolver,
+ imageCaptureUri: Uri?,
+ ignoreUri: Boolean
+ ): ImageCapture.OutputFileResults {
val eligibleContentValues = getEligibleContentValues()
val outputFileOptions: OutputFileOptions
- if (imageCaptureUri == null) {
+ if (ignoreUri) {
+ val formatter = SimpleDateFormat(
+ "yyyy-MM-dd-HH-mm-ss-SSS",
+ Locale.US
+ )
+ val filename = "JCA-${formatter.format(Calendar.getInstance().time)}.jpg"
+ val contentValues = ContentValues()
+ contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
+ contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
+ outputFileOptions = OutputFileOptions.Builder(
+ contentResolver,
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ contentValues
+ ).build()
+ } else if (imageCaptureUri == null) {
val e = RuntimeException("Null Uri is provided.")
Log.d(TAG, "takePicture onError: $e")
throw e
@@ -206,27 +384,22 @@ constructor(
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()
+ try {
+ val outputFileResults = imageCaptureUseCase.takePicture(
+ outputFileOptions,
+ onCaptureStarted
+ )
+ 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")
+ return outputFileResults
+ } catch (exception: ImageCaptureException) {
+ Log.d(TAG, "takePicture onError: $exception")
+ throw exception
+ }
}
private fun getEligibleContentValues(): ContentValues {
@@ -243,8 +416,22 @@ constructor(
return eligibleContentValues
}
- override suspend fun startVideoRecording() {
+ override suspend fun startVideoRecording(
+ onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
+ ) {
+ if (videoCaptureUseCase == null) {
+ throw RuntimeException("Attempted video recording with null videoCapture use case")
+ }
Log.d(TAG, "recordVideo")
+ // todo(b/336886716): default setting to enable or disable audio when permission is granted
+ // todo(b/336888844): mute/unmute audio while recording is active
+ val audioEnabled = (
+ checkSelfPermission(
+ this.application.baseContext,
+ Manifest.permission.RECORD_AUDIO
+ )
+ == PackageManager.PERMISSION_GRANTED
+ )
val captureTypeString =
when (captureMode) {
CaptureMode.MULTI_STREAM -> "MultiStream"
@@ -263,12 +450,40 @@ constructor(
)
.setContentValues(contentValues)
.build()
+
+ val callbackExecutor: Executor =
+ (
+ currentCoroutineContext()[ContinuationInterceptor] as?
+ CoroutineDispatcher
+ )?.asExecutor() ?: ContextCompat.getMainExecutor(application)
recording =
- videoCaptureUseCase.output
+ videoCaptureUseCase!!.output
.prepareRecording(application, mediaStoreOutput)
- .start(ContextCompat.getMainExecutor(application)) { videoRecordEvent ->
+ .apply { if (audioEnabled) withAudioEnabled() }
+ .start(callbackExecutor) { onVideoRecordEvent ->
run {
- Log.d(TAG, videoRecordEvent.toString())
+ Log.d(TAG, onVideoRecordEvent.toString())
+ when (onVideoRecordEvent) {
+ is VideoRecordEvent.Finalize -> {
+ when (onVideoRecordEvent.error) {
+ ERROR_NONE ->
+ onVideoRecord(
+ CameraUseCase.OnVideoRecordEvent.OnVideoRecorded
+ )
+ else ->
+ onVideoRecord(
+ CameraUseCase.OnVideoRecordEvent.OnVideoRecordError
+ )
+ }
+ }
+ is VideoRecordEvent.Status -> {
+ onVideoRecord(
+ CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus(
+ onVideoRecordEvent.recordingStats.audioStats.audioAmplitude
+ )
+ )
+ }
+ }
}
}
}
@@ -278,26 +493,54 @@ constructor(
recording?.stop()
}
- override fun setZoomScale(scale: Float): Float {
- val zoomState = getZoomState() ?: return INVALID_ZOOM_SCALE
- val finalScale =
- (zoomState.zoomRatio * scale).coerceIn(
- zoomState.minZoomRatio,
- zoomState.maxZoomRatio
- )
- camera?.cameraControl?.setZoomRatio(finalScale)
- return finalScale
+ override fun setZoomScale(scale: Float) {
+ currentSettings.update { old ->
+ old?.copy(zoomScale = scale)
+ }
+ }
+
+ // Could be improved by setting initial value only when camera is initialized
+ private val _zoomScale = MutableStateFlow(1f)
+ override fun getZoomScale(): StateFlow<Float> = _zoomScale.asStateFlow()
+
+ private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
+ override fun getSurfaceRequest(): StateFlow<SurfaceRequest?> = _surfaceRequest.asStateFlow()
+
+ // Sets the camera to the designated lensFacing direction
+ override suspend fun setLensFacing(lensFacing: LensFacing) {
+ currentSettings.update { old ->
+ if (systemConstraints.availableLenses.contains(lensFacing)) {
+ old?.copy(cameraLensFacing = lensFacing)
+ ?.tryApplyDynamicRangeConstraints()
+ } else {
+ old
+ }
+ }
}
- private fun getZoomState(): ZoomState? = camera?.cameraInfo?.zoomState?.value
+ private fun CameraAppSettings.tryApplyDynamicRangeConstraints(): CameraAppSettings {
+ return systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints ->
+ with(constraints.supportedDynamicRanges) {
+ val newDynamicRange = if (contains(dynamicRange)) {
+ dynamicRange
+ } else {
+ DynamicRange.SDR
+ }
+
+ this@tryApplyDynamicRangeConstraints.copy(
+ dynamicRange = newDynamicRange
+ )
+ }
+ } ?: this
+ }
- // flips the camera to the designated lensFacing direction
- 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()
+ private fun CameraAppSettings.tryApplyAspectRatioForExternalCapture(
+ externalImageCapture: Boolean
+ ): CameraAppSettings {
+ if (externalImageCapture) {
+ return this.copy(aspectRatio = AspectRatio.THREE_FOUR)
+ }
+ return this
}
override fun tapToFocus(
@@ -307,26 +550,20 @@ constructor(
x: Float,
y: Float
) {
- if (camera != null) {
- val meteringPoint =
- DisplayOrientedMeteringPointFactory(
- display,
- camera!!.cameraInfo,
- surfaceWidth.toFloat(),
- surfaceHeight.toFloat()
- )
- .createPoint(x, y)
+ // TODO(tm):Convert API to use SurfaceOrientedMeteringPointFactory and
+ // use a Channel to get result of FocusMeteringAction
+ }
- val action = FocusMeteringAction.Builder(meteringPoint).build()
+ override fun getScreenFlashEvents() = screenFlashEvents.asSharedFlow()
+ override fun getCurrentSettings() = currentSettings.asStateFlow()
- camera!!.cameraControl.startFocusAndMetering(action)
- Log.d(TAG, "Tap to focus on: $meteringPoint")
+ override fun setFlashMode(flashMode: FlashMode) {
+ currentSettings.update { old ->
+ old?.copy(flashMode = flashMode)
}
}
- override fun getScreenFlashEvents() = screenFlashEvents.asSharedFlow()
-
- override fun setFlashMode(flashMode: FlashMode, isFrontFacing: Boolean) {
+ private fun setFlashModeInternal(flashMode: FlashMode, isFrontFacing: Boolean) {
val isScreenFlashRequired =
isFrontFacing && (flashMode == FlashMode.ON || flashMode == FlashMode.AUTO)
@@ -379,140 +616,212 @@ constructor(
imageCaptureUseCase.flashMode == ImageCapture.FLASH_MODE_SCREEN &&
imageCaptureUseCase.screenFlash != null
- override suspend fun setAspectRatio(aspectRatio: AspectRatio, isFrontFacing: Boolean) {
- this.aspectRatio = aspectRatio
- updateUseCaseGroup()
- rebindUseCases()
+ override suspend fun setAspectRatio(aspectRatio: AspectRatio) {
+ currentSettings.update { old ->
+ old?.copy(aspectRatio = aspectRatio)
+ }
}
- override suspend fun setCaptureMode(newCaptureMode: CaptureMode) {
- captureMode = newCaptureMode
- Log.d(
- TAG,
- "Changing CaptureMode: singleStreamCaptureEnabled:" +
- (captureMode == CaptureMode.SINGLE_STREAM)
- )
- updateUseCaseGroup()
- rebindUseCases()
+ override suspend fun setCaptureMode(captureMode: CaptureMode) {
+ currentSettings.update { old ->
+ old?.copy(captureMode = captureMode)
+ }
}
- private fun updateUseCaseGroup() {
- previewUseCase = createPreviewUseCase()
- if (this::surfaceProvider.isInitialized) {
- previewUseCase.setSurfaceProvider(surfaceProvider)
+ private fun createUseCaseGroup(
+ sessionSettings: PerpetualSessionSettings,
+ initialTransientSettings: TransientSessionSettings,
+ supportedStabilizationModes: Set<SupportedStabilizationMode>,
+ effect: CameraEffect? = null
+ ): UseCaseGroup {
+ val previewUseCase = createPreviewUseCase(sessionSettings, supportedStabilizationModes)
+ if (!disableVideoCapture) {
+ videoCaptureUseCase = createVideoUseCase(sessionSettings, supportedStabilizationModes)
}
- val useCaseGroupBuilder =
- UseCaseGroup.Builder()
- .setViewPort(
- ViewPort.Builder(aspectRatio.ratio, previewUseCase.targetRotation).build()
- )
- .addUseCase(previewUseCase)
- .addUseCase(imageCaptureUseCase)
- .addUseCase(videoCaptureUseCase)
+ setFlashModeInternal(
+ flashMode = initialTransientSettings.flashMode,
+ isFrontFacing = sessionSettings.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
+ )
+ imageCaptureUseCase = ImageCapture.Builder()
+ .setResolutionSelector(getResolutionSelector(sessionSettings.aspectRatio)).build()
+
+ return UseCaseGroup.Builder().apply {
+ setViewPort(
+ ViewPort.Builder(
+ sessionSettings.aspectRatio.ratio,
+ previewUseCase.targetRotation
+ ).build()
+ )
+ addUseCase(previewUseCase)
+ if (sessionSettings.dynamicRange == DynamicRange.SDR) {
+ addUseCase(imageCaptureUseCase)
+ }
+ if (videoCaptureUseCase != null) {
+ addUseCase(videoCaptureUseCase!!)
+ }
- if (captureMode == CaptureMode.SINGLE_STREAM) {
- useCaseGroupBuilder.addEffect(SingleSurfaceForcingEffect())
- }
+// effect?.let { addEffect(it) }
- useCaseGroup = useCaseGroupBuilder.build()
+ captureMode = sessionSettings.captureMode
+ }.build()
}
-
- /**
- * 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
+ override suspend fun setDynamicRange(dynamicRange: DynamicRange) {
+ currentSettings.update { old ->
+ old?.copy(dynamicRange = dynamicRange)
}
- val isVideoStabilizationSupported =
- cameraSelector.filter(availableCameraInfo).firstOrNull()?.let {
- Recorder.getVideoCapabilities(it).isStabilizationSupported
- } ?: false
-
- return isVideoStabilizationSupported
}
- private fun createVideoUseCase(): VideoCapture<Recorder> {
- val videoCaptureBuilder = VideoCapture.Builder(recorder)
+ private fun createVideoUseCase(
+ sessionSettings: PerpetualSessionSettings,
+ supportedStabilizationMode: Set<SupportedStabilizationMode>
+ ): VideoCapture<Recorder> {
+ val recorder = Recorder.Builder()
+ .setAspectRatio(getAspectRatioForUseCase(sessionSettings.aspectRatio))
+ .setExecutor(defaultDispatcher.asExecutor()).build()
+ return VideoCapture.Builder(recorder).apply {
+ // set video stabilization
+ if (shouldVideoBeStabilized(sessionSettings, supportedStabilizationMode)
+ ) {
+ setVideoStabilizationEnabled(true)
+ }
+ // set target fps
+ if (sessionSettings.targetFrameRate != TARGET_FPS_AUTO) {
+ setTargetFrameRate(
+ Range(sessionSettings.targetFrameRate, sessionSettings.targetFrameRate)
+ )
+ }
- // set video stabilization
+ setDynamicRange(sessionSettings.dynamicRange.toCXDynamicRange())
+ }.build()
+ }
- if (shouldVideoBeStabilized()) {
- val isStabilized = when (stabilizeVideoMode) {
- Stabilization.ON -> true
- Stabilization.OFF, Stabilization.UNDEFINED -> false
- }
- videoCaptureBuilder.setVideoStabilizationEnabled(isStabilized)
+ private fun getAspectRatioForUseCase(aspectRatio: AspectRatio): Int {
+ return when (aspectRatio) {
+ AspectRatio.THREE_FOUR -> RATIO_4_3
+ AspectRatio.NINE_SIXTEEN -> RATIO_16_9
+ else -> RATIO_DEFAULT
}
- 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)) &&
+ private fun shouldVideoBeStabilized(
+ sessionSettings: PerpetualSessionSettings,
+ supportedStabilizationModes: Set<SupportedStabilizationMode>
+ ): Boolean {
+ // video is on and target fps is not 60
+ return (sessionSettings.targetFrameRate != TARGET_FPS_60) &&
+ (supportedStabilizationModes.contains(SupportedStabilizationMode.HIGH_QUALITY)) &&
+ // high quality (video only) selected
(
- // high quality (video only) selected
- (
- stabilizeVideoMode == Stabilization.ON &&
- stabilizePreviewMode == Stabilization.UNDEFINED
- ) ||
- // or on is selected
- (
- stabilizePreviewMode == Stabilization.ON &&
- stabilizeVideoMode != Stabilization.OFF
- )
+ sessionSettings.stabilizeVideoMode == Stabilization.ON &&
+ sessionSettings.stabilizePreviewMode == Stabilization.UNDEFINED
)
}
- private fun createPreviewUseCase(): Preview {
+ private fun createPreviewUseCase(
+ sessionSettings: PerpetualSessionSettings,
+ supportedStabilizationModes: Set<SupportedStabilizationMode>
+ ): Preview {
val previewUseCaseBuilder = Preview.Builder()
// set preview stabilization
- if (shouldPreviewBeStabilized()) {
- val isStabilized = when (stabilizePreviewMode) {
- Stabilization.ON -> true
- else -> false
+ if (shouldPreviewBeStabilized(sessionSettings, supportedStabilizationModes)) {
+ previewUseCaseBuilder.setPreviewStabilizationEnabled(true)
+ }
+
+ previewUseCaseBuilder.setResolutionSelector(
+ getResolutionSelector(sessionSettings.aspectRatio)
+ )
+
+ return previewUseCaseBuilder.build().apply {
+ setSurfaceProvider { surfaceRequest ->
+ _surfaceRequest.value = surfaceRequest
}
- previewUseCaseBuilder.setPreviewStabilizationEnabled(isStabilized)
}
- return previewUseCaseBuilder.build()
}
- private fun shouldPreviewBeStabilized(): Boolean {
- return (
- supportedStabilizationModes.contains(SupportedStabilizationMode.ON) &&
- stabilizePreviewMode == Stabilization.ON
- )
+ private fun getResolutionSelector(aspectRatio: AspectRatio): ResolutionSelector {
+ val aspectRatioStrategy = when (aspectRatio) {
+ AspectRatio.THREE_FOUR -> AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY
+ AspectRatio.NINE_SIXTEEN -> AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
+ else -> AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
+ }
+ return ResolutionSelector.Builder().setAspectRatioStrategy(aspectRatioStrategy).build()
}
- // converts LensFacing from datastore to @LensFacing Int value
- private fun getLensFacing(isFrontFacing: Boolean): Int = when (isFrontFacing) {
- true -> CameraSelector.LENS_FACING_FRONT
- false -> CameraSelector.LENS_FACING_BACK
+ private fun shouldPreviewBeStabilized(
+ sessionSettings: PerpetualSessionSettings,
+ supportedStabilizationModes: Set<SupportedStabilizationMode>
+ ): Boolean {
+ // only supported if target fps is 30 or none
+ return (
+ when (sessionSettings.targetFrameRate) {
+ TARGET_FPS_AUTO, TARGET_FPS_30 -> true
+ else -> false
+ }
+ ) &&
+ (
+ supportedStabilizationModes.contains(SupportedStabilizationMode.ON) &&
+ sessionSettings.stabilizePreviewMode == Stabilization.ON
+ )
}
- private suspend fun rebindUseCases() {
- val cameraSelector =
- cameraLensToSelector(
- getLensFacing(isFrontFacing)
- )
- cameraProvider.unbindAll()
- cameraProvider.runWith(cameraSelector, useCaseGroup) {
- camera = it
- awaitCancellation()
+ companion object {
+ private val FIXED_FRAME_RATES = setOf(TARGET_FPS_15, TARGET_FPS_30, TARGET_FPS_60)
+
+ /**
+ * Checks if preview stabilization is supported by the device.
+ *
+ */
+ private fun isPreviewStabilizationSupported(cameraInfo: CameraInfo): Boolean {
+ return Preview.getPreviewCapabilities(cameraInfo).isStabilizationSupported
}
- }
- private fun cameraLensToSelector(@LensFacing lensFacing: Int): CameraSelector =
- when (lensFacing) {
- CameraSelector.LENS_FACING_FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
- CameraSelector.LENS_FACING_BACK -> CameraSelector.DEFAULT_BACK_CAMERA
- else -> throw IllegalArgumentException("Invalid lens facing type: $lensFacing")
+ /**
+ * Checks if video stabilization is supported by the device.
+ *
+ */
+ private fun isVideoStabilizationSupported(cameraInfo: CameraInfo): Boolean {
+ return Recorder.getVideoCapabilities(cameraInfo).isStabilizationSupported
}
+
+ private fun getSupportedFrameRates(camInfo: CameraInfo): Set<Int> {
+ return buildSet {
+ camInfo.supportedFrameRateRanges.forEach { e ->
+ if (e.upper == e.lower && FIXED_FRAME_RATES.contains(e.upper)) {
+ add(e.upper)
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun CXDynamicRange.toSupportedAppDynamicRange(): DynamicRange? {
+ return when (this) {
+ CXDynamicRange.SDR -> DynamicRange.SDR
+ CXDynamicRange.HLG_10_BIT -> DynamicRange.HLG10
+ // All other dynamic ranges unsupported. Return null.
+ else -> null
+ }
+}
+
+private fun DynamicRange.toCXDynamicRange(): CXDynamicRange {
+ return when (this) {
+ DynamicRange.SDR -> CXDynamicRange.SDR
+ DynamicRange.HLG10 -> CXDynamicRange.HLG_10_BIT
+ }
+}
+
+private fun LensFacing.toCameraSelector(): CameraSelector = when (this) {
+ LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
+ LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
+}
+
+private fun CameraSelector.toAppLensFacing(): LensFacing = when (this) {
+ CameraSelector.DEFAULT_FRONT_CAMERA -> LensFacing.FRONT
+ CameraSelector.DEFAULT_BACK_CAMERA -> LensFacing.BACK
+ else -> throw IllegalArgumentException(
+ "Unknown CameraSelector -> LensFacing mapping. [CameraSelector: $this]"
+ )
}
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt
index e55ae35..a3c6c33 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt
+++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt
@@ -56,6 +56,9 @@ private class CoroutineLifecycleOwner(coroutineContext: CoroutineContext) :
currentState = Lifecycle.State.INITIALIZED
}
+ override val lifecycle: Lifecycle
+ get() = lifecycleRegistry
+
init {
if (coroutineContext[Job]?.isActive == true) {
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
@@ -70,6 +73,4 @@ private class CoroutineLifecycleOwner(coroutineContext: CoroutineContext) :
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
}
}
-
- override public val lifecycle: Lifecycle = lifecycleRegistry
}
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/EmptySurfaceProcessor.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/EmptySurfaceProcessor.kt
deleted file mode 100644
index 9263f80..0000000
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/EmptySurfaceProcessor.kt
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- * 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
-
-import android.annotation.SuppressLint
-import android.graphics.SurfaceTexture
-import android.os.Handler
-import android.os.HandlerThread
-import android.view.Surface
-import androidx.camera.core.DynamicRange
-import androidx.camera.core.SurfaceOutput
-import androidx.camera.core.SurfaceProcessor
-import androidx.camera.core.SurfaceRequest
-import androidx.camera.core.impl.utils.executor.CameraXExecutors.newHandlerExecutor
-import androidx.camera.core.processing.OpenGlRenderer
-import androidx.camera.core.processing.ShaderProvider
-import java.util.concurrent.Executor
-
-private const val GL_THREAD_NAME = "EmptySurfaceProcessor"
-
-/**
- * This is a [SurfaceProcessor] that passes on the same content from the input
- * surface to the output surface. Used to make a copies of surfaces.
- */
-@SuppressLint("RestrictedApi")
-class EmptySurfaceProcessor : SurfaceProcessor {
- private val glThread: HandlerThread = HandlerThread(GL_THREAD_NAME)
- private var glHandler: Handler
- var glExecutor: Executor
- private set
-
- // Members below are only accessed on GL thread.
- private val glRenderer: OpenGlRenderer = OpenGlRenderer()
- private val outputSurfaces: MutableMap<SurfaceOutput, Surface> = mutableMapOf()
- private val textureTransform: FloatArray = FloatArray(16)
- private val surfaceTransform: FloatArray = FloatArray(16)
- private var isReleased = false
-
- init {
- glThread.start()
- glHandler = Handler(glThread.looper)
- glExecutor = newHandlerExecutor(glHandler)
- glExecutor.execute {
- glRenderer.init(
- DynamicRange.SDR,
- ShaderProvider.DEFAULT
- )
- }
- }
-
- override fun onInputSurface(surfaceRequest: SurfaceRequest) {
- checkGlThread()
- if (isReleased) {
- surfaceRequest.willNotProvideSurface()
- return
- }
- val surfaceTexture = SurfaceTexture(glRenderer.textureName)
- surfaceTexture.setDefaultBufferSize(
- surfaceRequest.resolution.width,
- surfaceRequest.resolution.height
- )
- val surface = Surface(surfaceTexture)
- surfaceRequest.provideSurface(surface, glExecutor) {
- surfaceTexture.setOnFrameAvailableListener(null)
- surfaceTexture.release()
- surface.release()
- }
- surfaceTexture.setOnFrameAvailableListener({
- checkGlThread()
- if (!isReleased) {
- surfaceTexture.updateTexImage()
- surfaceTexture.getTransformMatrix(textureTransform)
- outputSurfaces.forEach { (surfaceOutput, surface) ->
- run {
- surfaceOutput.updateTransformMatrix(surfaceTransform, textureTransform)
- glRenderer.render(surfaceTexture.timestamp, surfaceTransform, surface)
- }
- }
- }
- }, glHandler)
- }
-
- override fun onOutputSurface(surfaceOutput: SurfaceOutput) {
- checkGlThread()
- if (isReleased) {
- surfaceOutput.close()
- return
- }
- val surface =
- surfaceOutput.getSurface(glExecutor) {
- surfaceOutput.close()
- outputSurfaces.remove(surfaceOutput)?.let { removedSurface ->
- glRenderer.unregisterOutputSurface(removedSurface)
- }
- }
- glRenderer.registerOutputSurface(surface)
- outputSurfaces[surfaceOutput] = surface
- }
-
- /**
- * Releases associated resources.
- *
- * Closes output surfaces.
- * Releases the [OpenGlRenderer].
- * Quits the GL HandlerThread.
- */
- fun release() {
- glExecutor.execute {
- releaseInternal()
- }
- }
-
- private fun releaseInternal() {
- checkGlThread()
- if (!isReleased) {
- // Once release is called, we can stop sending frame to output surfaces.
- for (surfaceOutput in outputSurfaces.keys) {
- surfaceOutput.close()
- }
- outputSurfaces.clear()
- glRenderer.release()
- glThread.quitSafely()
- isReleased = true
- }
- }
-
- private fun checkGlThread() {
- check(GL_THREAD_NAME == Thread.currentThread().name)
- }
-}
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt
new file mode 100644
index 0000000..e5ea5ae
--- /dev/null
+++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt
@@ -0,0 +1,361 @@
+/*
+ * Copyright (C) 2024 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.effects
+
+import android.graphics.SurfaceTexture
+import android.opengl.EGL14
+import android.opengl.EGLConfig
+import android.opengl.EGLExt
+import android.opengl.EGLSurface
+import android.util.Size
+import android.view.Surface
+import androidx.camera.core.SurfaceOutput
+import androidx.camera.core.SurfaceProcessor
+import androidx.camera.core.SurfaceRequest
+//import androidx.graphics.opengl.GLRenderer
+//import androidx.graphics.opengl.egl.EGLManager
+//import androidx.graphics.opengl.egl.EGLSpec
+import com.google.jetpackcamera.core.common.RefCounted
+import kotlin.coroutines.coroutineContext
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.Runnable
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.filterNot
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private const val TIMESTAMP_UNINITIALIZED = -1L
+
+/**
+ * This is a [SurfaceProcessor] that passes on the same content from the input
+ * surface to the output surface. Used to make a copies of surfaces.
+ */
+class CopyingSurfaceProcessor(coroutineScope: CoroutineScope) : SurfaceProcessor {
+
+// private val inputSurfaceFlow = MutableStateFlow<SurfaceRequestScope?>(null)
+// private val outputSurfaceFlow = MutableStateFlow<SurfaceOutputScope?>(null)
+
+ init {
+// coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
+// inputSurfaceFlow
+// .filterNotNull()
+// .collectLatest { surfaceRequestScope ->
+// surfaceRequestScope.withSurfaceRequest { surfaceRequest ->
+//
+// val renderCallbacks = ShaderCopy(surfaceRequest.dynamicRange)
+// renderCallbacks.renderWithSurfaceRequest(surfaceRequest)
+// }
+// }
+// }
+ }
+
+// private suspend fun RenderCallbacks.renderWithSurfaceRequest(surfaceRequest: SurfaceRequest) =
+// coroutineScope inputScope@{
+// var currentTimestamp = TIMESTAMP_UNINITIALIZED
+// val surfaceTextureRef = RefCounted<SurfaceTexture> {
+// it.release()
+// }
+// val textureTransform = FloatArray(16)
+//
+// val frameUpdateFlow = MutableStateFlow(0)
+//
+// val initializeCallback = object : GLRenderer.EGLContextCallback {
+//
+// override fun onEGLContextCreated(eglManager: EGLManager) {
+// initRenderer()
+//
+// val surfaceTex = createSurfaceTexture(
+// surfaceRequest.resolution.width,
+// surfaceRequest.resolution.height
+// )
+//
+// // Initialize the reference counted surface texture
+// surfaceTextureRef.initialize(surfaceTex)
+//
+// surfaceTex.setOnFrameAvailableListener {
+// // Increment frame counter
+// frameUpdateFlow.update { it + 1 }
+// }
+//
+// val inputSurface = Surface(surfaceTex)
+// surfaceRequest.provideSurface(inputSurface, Runnable::run) { result ->
+// inputSurface.release()
+// surfaceTextureRef.release()
+// this@inputScope.cancel(
+// "Input surface no longer receiving frames: $result"
+// )
+// }
+// }
+//
+// override fun onEGLContextDestroyed(eglManager: EGLManager) {
+// // no-op
+// }
+// }
+//
+// val glRenderer = GLRenderer(
+// eglSpecFactory = provideEGLSpec,
+// eglConfigFactory = initConfig
+// )
+// glRenderer.registerEGLContextCallback(initializeCallback)
+// glRenderer.start(glThreadName)
+//
+// val inputRenderTarget = glRenderer.createRenderTarget(
+// surfaceRequest.resolution.width,
+// surfaceRequest.resolution.height,
+// object : GLRenderer.RenderCallback {
+//
+// override fun onDrawFrame(eglManager: EGLManager) {
+// surfaceTextureRef.acquire()?.also {
+// try {
+// currentTimestamp =
+// if (currentTimestamp == TIMESTAMP_UNINITIALIZED) {
+// // Don't perform any updates on first draw,
+// // we're only setting up the context.
+// 0
+// } else {
+// it.updateTexImage()
+// it.getTransformMatrix(textureTransform)
+// it.timestamp
+// }
+// } finally {
+// surfaceTextureRef.release()
+// }
+// }
+// }
+// }
+// )
+//
+// // Create the context and initialize the input. This will call RenderTarget.onDrawFrame,
+// // but we won't actually update the frame since this triggers adding the frame callback.
+// // All subsequent updates will then happen through frameUpdateFlow.
+// // This should be updated when https://issuetracker.google.com/331968279 is resolved.
+// inputRenderTarget.requestRender()
+//
+// // Connect the onConnectToInput callback with the onDisconnectFromInput
+// // Should only be called on worker thread
+// var connectedToInput = false
+//
+// // Should only be called on worker thread
+// val onConnectToInput: () -> Boolean = {
+// connectedToInput = surfaceTextureRef.acquire() != null
+// connectedToInput
+// }
+//
+// // Should only be called on worker thread
+// val onDisconnectFromInput: () -> Unit = {
+// if (connectedToInput) {
+// surfaceTextureRef.release()
+// connectedToInput = false
+// }
+// }
+//
+// // Wait for output surfaces
+// outputSurfaceFlow
+// .onCompletion {
+// glRenderer.stop(cancelPending = false)
+// glRenderer.unregisterEGLContextCallback(initializeCallback)
+// }.filterNotNull()
+// .collectLatest { surfaceOutputScope ->
+// surfaceOutputScope.withSurfaceOutput { refCountedSurface,
+// size,
+// updateTransformMatrix ->
+// // If we can't acquire the surface, then the surface output is already
+// // closed, so we'll return and wait for the next output surface.
+// val outputSurface =
+// refCountedSurface.acquire() ?: return@withSurfaceOutput
+//
+// val surfaceTransform = FloatArray(16)
+// val outputRenderTarget = glRenderer.attach(
+// outputSurface,
+// size.width,
+// size.height,
+// object : GLRenderer.RenderCallback {
+//
+// override fun onSurfaceCreated(
+// spec: EGLSpec,
+// config: EGLConfig,
+// surface: Surface,
+// width: Int,
+// height: Int
+// ): EGLSurface? {
+// return if (onConnectToInput()) {
+// createOutputSurface(spec, config, surface, width, height)
+// } else {
+// null
+// }
+// }
+//
+// override fun onDrawFrame(eglManager: EGLManager) {
+// val currentDrawSurface = eglManager.currentDrawSurface
+// if (currentDrawSurface != eglManager.defaultSurface) {
+// updateTransformMatrix(
+// surfaceTransform,
+// textureTransform
+// )
+//
+// drawFrame(
+// size.width,
+// size.height,
+// surfaceTransform
+// )
+//
+// // Set timestamp
+// val display =
+// EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
+// EGLExt.eglPresentationTimeANDROID(
+// display,
+// eglManager.currentDrawSurface,
+// currentTimestamp
+// )
+// }
+// }
+// }
+// )
+//
+// frameUpdateFlow
+// .onCompletion {
+// outputRenderTarget.detach(cancelPending = false) {
+// onDisconnectFromInput()
+// refCountedSurface.release()
+// }
+// }.filterNot { it == 0 } // Don't attempt render on frame count 0
+// .collectLatest {
+// inputRenderTarget.requestRender()
+// outputRenderTarget.requestRender()
+// }
+// }
+// }
+// }
+
+ override fun onInputSurface(surfaceRequest: SurfaceRequest) {
+// val newScope = SurfaceRequestScope(surfaceRequest)
+// inputSurfaceFlow.update { old ->
+// old?.cancel("New SurfaceRequest received.")
+// newScope
+// }
+ }
+
+ override fun onOutputSurface(surfaceOutput: SurfaceOutput) {
+// val newScope = SurfaceOutputScope(surfaceOutput)
+// outputSurfaceFlow.update { old ->
+// old?.cancel("New SurfaceOutput received.")
+// newScope
+// }
+ }
+}
+
+//interface RenderCallbacks {
+// val glThreadName: String
+// val provideEGLSpec: () -> EGLSpec
+// val initConfig: EGLManager.() -> EGLConfig
+// val initRenderer: () -> Unit
+// val createSurfaceTexture: (width: Int, height: Int) -> SurfaceTexture
+// val createOutputSurface: (
+// eglSpec: EGLSpec,
+// config: EGLConfig,
+// surface: Surface,
+// width: Int,
+// height: Int
+// ) -> EGLSurface
+// val drawFrame: (outputWidth: Int, outputHeight: Int, surfaceTransform: FloatArray) -> Unit
+//}
+//
+//private class SurfaceOutputScope(val surfaceOutput: SurfaceOutput) {
+// private val surfaceLifecycleJob = SupervisorJob()
+// private val refCountedSurface = RefCounted<Surface>(onRelease = {
+// surfaceOutput.close()
+// }).apply {
+// // Ensure we don't release until after `initialize` has completed by deferring
+// // the release.
+// val deferredRelease = CompletableDeferred<Unit>()
+// initialize(
+// surfaceOutput.getSurface(Runnable::run) {
+// deferredRelease.complete(Unit)
+// }
+// )
+// CoroutineScope(Dispatchers.Unconfined).launch {
+// deferredRelease.await()
+// surfaceLifecycleJob.cancel("SurfaceOutput close requested.")
+// this@apply.release()
+// }
+// }
+//
+// suspend fun <R> withSurfaceOutput(
+// block: suspend CoroutineScope.(
+// surface: RefCounted<Surface>,
+// surfaceSize: Size,
+// updateTransformMatrix: (updated: FloatArray, original: FloatArray) -> Unit
+// ) -> R
+// ): R {
+// return CoroutineScope(coroutineContext + Job(surfaceLifecycleJob)).async(
+// start = CoroutineStart.UNDISPATCHED
+// ) {
+// ensureActive()
+// block(
+// refCountedSurface,
+// surfaceOutput.size,
+// surfaceOutput::updateTransformMatrix
+// )
+// }.await()
+// }
+//
+// fun cancel(message: String? = null) {
+// message?.apply { surfaceLifecycleJob.cancel(message) } ?: surfaceLifecycleJob.cancel()
+// }
+//}
+//
+//private class SurfaceRequestScope(private val surfaceRequest: SurfaceRequest) {
+// private val requestLifecycleJob = SupervisorJob()
+//
+// init {
+// surfaceRequest.addRequestCancellationListener(Runnable::run) {
+// requestLifecycleJob.cancel("SurfaceRequest cancelled.")
+// }
+// }
+//
+// suspend fun <R> withSurfaceRequest(
+// block: suspend CoroutineScope.(
+// surfaceRequest: SurfaceRequest
+// ) -> R
+// ): R {
+// return CoroutineScope(coroutineContext + Job(requestLifecycleJob)).async(
+// start = CoroutineStart.UNDISPATCHED
+// ) {
+// ensureActive()
+// block(surfaceRequest)
+// }.await()
+// }
+//
+// fun cancel(message: String? = null) {
+// message?.apply { requestLifecycleJob.cancel(message) } ?: requestLifecycleJob.cancel()
+// // Attempt to tell frame producer we will not provide a surface. This may fail (silently)
+// // if surface was already provided or the producer has cancelled the request, in which
+// // case we don't have to do anything.
+// surfaceRequest.willNotProvideSurface()
+// }
+//}
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt
new file mode 100644
index 0000000..b895883
--- /dev/null
+++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt
@@ -0,0 +1,46 @@
+///*
+// * Copyright (C) 2024 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.effects
+//
+//import android.opengl.EGL14
+//import android.opengl.EGLConfig
+//import android.opengl.EGLContext
+//import androidx.graphics.opengl.egl.EGLSpec
+//
+//val EGLSpec.Companion.V14ES3: EGLSpec
+// get() = object : EGLSpec by V14 {
+//
+// private val contextAttributes = intArrayOf(
+// // GLES VERSION 3
+// EGL14.EGL_CONTEXT_CLIENT_VERSION,
+// 3,
+// // HWUI provides the ability to configure a context priority as well but that only
+// // seems to be configured on SystemUIApplication. This might be useful for
+// // front buffer rendering situations for performance.
+// EGL14.EGL_NONE
+// )
+//
+// override fun eglCreateContext(config: EGLConfig): EGLContext {
+// return EGL14.eglCreateContext(
+// EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY),
+// config,
+// // not creating from a shared context
+// EGL14.EGL_NO_CONTEXT,
+// contextAttributes,
+// 0
+// )
+// }
+// }
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt
new file mode 100644
index 0000000..8373561
--- /dev/null
+++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt
@@ -0,0 +1,450 @@
+///*
+// * Copyright (C) 2024 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.effects
+//
+//import android.graphics.SurfaceTexture
+//import android.opengl.EGL14
+//import android.opengl.EGLConfig
+//import android.opengl.EGLExt
+//import android.opengl.GLES11Ext
+//import android.opengl.GLES20
+//import android.util.Log
+//import android.view.Surface
+//import androidx.annotation.WorkerThread
+//import androidx.camera.core.DynamicRange
+//import androidx.graphics.opengl.egl.EGLConfigAttributes
+//import androidx.graphics.opengl.egl.EGLManager
+//import androidx.graphics.opengl.egl.EGLSpec
+//import java.nio.ByteBuffer
+//import java.nio.ByteOrder
+//import java.nio.FloatBuffer
+//
+//class ShaderCopy(private val dynamicRange: DynamicRange) : RenderCallbacks {
+//
+// // Called on worker thread only
+// private var externalTextureId: Int = -1
+// private var programHandle = -1
+// private var texMatrixLoc = -1
+// private var positionLoc = -1
+// private var texCoordLoc = -1
+// private val use10bitPipeline: Boolean
+// get() = dynamicRange.bitDepth == DynamicRange.BIT_DEPTH_10_BIT
+//
+// override val glThreadName: String
+// get() = TAG
+//
+// override val provideEGLSpec: () -> EGLSpec
+// get() = { if (use10bitPipeline) EGLSpec.V14ES3 else EGLSpec.V14 }
+//
+// override val initConfig: EGLManager.() -> EGLConfig
+// get() = {
+// checkNotNull(
+// loadConfig(
+// EGLConfigAttributes {
+// if (use10bitPipeline) {
+// TEN_BIT_REQUIRED_EGL_EXTENSIONS.forEach {
+// check(isExtensionSupported(it)) {
+// "Required extension for 10-bit HDR is not " +
+// "supported: $it"
+// }
+// }
+// include(EGLConfigAttributes.RGBA_1010102)
+// EGL14.EGL_RENDERABLE_TYPE to
+// EGLExt.EGL_OPENGL_ES3_BIT_KHR
+// EGL14.EGL_SURFACE_TYPE to
+// (EGL14.EGL_WINDOW_BIT or EGL14.EGL_PBUFFER_BIT)
+// } else {
+// include(EGLConfigAttributes.RGBA_8888)
+// }
+// }
+// )
+// ) {
+// "Unable to select EGLConfig"
+// }
+// }
+//
+// override val initRenderer: () -> Unit
+// get() = {
+// createProgram(
+// if (use10bitPipeline) {
+// TEN_BIT_VERTEX_SHADER
+// } else {
+// DEFAULT_VERTEX_SHADER
+// },
+// if (use10bitPipeline) {
+// TEN_BIT_FRAGMENT_SHADER
+// } else {
+// DEFAULT_FRAGMENT_SHADER
+// }
+// )
+// loadLocations()
+// createTexture()
+// useAndConfigureProgram()
+// }
+//
+// override val createSurfaceTexture
+// get() = { width: Int, height: Int ->
+// SurfaceTexture(externalTextureId).apply {
+// setDefaultBufferSize(width, height)
+// }
+// }
+//
+// override val createOutputSurface
+// get() = { eglSpec: EGLSpec,
+// config: EGLConfig,
+// surface: Surface,
+// _: Int,
+// _: Int ->
+// eglSpec.eglCreateWindowSurface(
+// config,
+// surface,
+// EGLConfigAttributes {
+// if (use10bitPipeline) {
+// EGL_GL_COLORSPACE_KHR to EGL_GL_COLORSPACE_BT2020_HLG_EXT
+// }
+// }
+// )
+// }
+//
+// override val drawFrame
+// get() = { outputWidth: Int,
+// outputHeight: Int,
+// surfaceTransform: FloatArray ->
+// GLES20.glViewport(
+// 0,
+// 0,
+// outputWidth,
+// outputHeight
+// )
+// GLES20.glScissor(
+// 0,
+// 0,
+// outputWidth,
+// outputHeight
+// )
+//
+// GLES20.glUniformMatrix4fv(
+// texMatrixLoc,
+// /*count=*/
+// 1,
+// /*transpose=*/
+// false,
+// surfaceTransform,
+// /*offset=*/
+// 0
+// )
+// checkGlErrorOrThrow("glUniformMatrix4fv")
+//
+// // Draw the rect.
+// GLES20.glDrawArrays(
+// GLES20.GL_TRIANGLE_STRIP,
+// /*firstVertex=*/
+// 0,
+// /*vertexCount=*/
+// 4
+// )
+// checkGlErrorOrThrow("glDrawArrays")
+// }
+//
+// @WorkerThread
+// fun createTexture() {
+// checkGlThread()
+// val textures = IntArray(1)
+// GLES20.glGenTextures(1, textures, 0)
+// checkGlErrorOrThrow("glGenTextures")
+// val texId = textures[0]
+// GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId)
+// checkGlErrorOrThrow("glBindTexture $texId")
+// GLES20.glTexParameterf(
+// GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
+// GLES20.GL_TEXTURE_MIN_FILTER,
+// GLES20.GL_NEAREST.toFloat()
+// )
+// GLES20.glTexParameterf(
+// GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
+// GLES20.GL_TEXTURE_MAG_FILTER,
+// GLES20.GL_LINEAR.toFloat()
+// )
+// GLES20.glTexParameteri(
+// GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
+// GLES20.GL_TEXTURE_WRAP_S,
+// GLES20.GL_CLAMP_TO_EDGE
+// )
+// GLES20.glTexParameteri(
+// GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
+// GLES20.GL_TEXTURE_WRAP_T,
+// GLES20.GL_CLAMP_TO_EDGE
+// )
+// checkGlErrorOrThrow("glTexParameter")
+// externalTextureId = texId
+// }
+//
+// @WorkerThread
+// fun useAndConfigureProgram() {
+// checkGlThread()
+// // Select the program.
+// GLES20.glUseProgram(programHandle)
+// checkGlErrorOrThrow("glUseProgram")
+//
+// // Set the texture.
+// GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
+// GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, externalTextureId)
+//
+// // Enable the "aPosition" vertex attribute.
+// GLES20.glEnableVertexAttribArray(positionLoc)
+// checkGlErrorOrThrow("glEnableVertexAttribArray")
+//
+// // Connect vertexBuffer to "aPosition".
+// val coordsPerVertex = 2
+// val vertexStride = 0
+// GLES20.glVertexAttribPointer(
+// positionLoc,
+// coordsPerVertex,
+// GLES20.GL_FLOAT,
+// /*normalized=*/
+// false,
+// vertexStride,
+// VERTEX_BUF
+// )
+// checkGlErrorOrThrow("glVertexAttribPointer")
+//
+// // Enable the "aTextureCoord" vertex attribute.
+// GLES20.glEnableVertexAttribArray(texCoordLoc)
+// checkGlErrorOrThrow("glEnableVertexAttribArray")
+//
+// // Connect texBuffer to "aTextureCoord".
+// val coordsPerTex = 2
+// val texStride = 0
+// GLES20.glVertexAttribPointer(
+// texCoordLoc,
+// coordsPerTex,
+// GLES20.GL_FLOAT,
+// /*normalized=*/
+// false,
+// texStride,
+// TEX_BUF
+// )
+// checkGlErrorOrThrow("glVertexAttribPointer")
+// }
+//
+// @WorkerThread
+// private fun createProgram(vertShader: String, fragShader: String) {
+// checkGlThread()
+// var vertexShader = -1
+// var fragmentShader = -1
+// var program = -1
+// try {
+// fragmentShader = loadShader(
+// GLES20.GL_FRAGMENT_SHADER,
+// fragShader
+// )
+// vertexShader = loadShader(
+// GLES20.GL_VERTEX_SHADER,
+// vertShader
+// )
+// program = GLES20.glCreateProgram()
+// checkGlErrorOrThrow("glCreateProgram")
+// GLES20.glAttachShader(program, vertexShader)
+// checkGlErrorOrThrow("glAttachShader")
+// GLES20.glAttachShader(program, fragmentShader)
+// checkGlErrorOrThrow("glAttachShader")
+// GLES20.glLinkProgram(program)
+// val linkStatus = IntArray(1)
+// GLES20.glGetProgramiv(
+// program,
+// GLES20.GL_LINK_STATUS,
+// linkStatus,
+// /*offset=*/
+// 0
+// )
+// check(linkStatus[0] == GLES20.GL_TRUE) {
+// "Could not link program: " + GLES20.glGetProgramInfoLog(
+// program
+// )
+// }
+// programHandle = program
+// } catch (e: Exception) {
+// if (vertexShader != -1) {
+// GLES20.glDeleteShader(vertexShader)
+// }
+// if (fragmentShader != -1) {
+// GLES20.glDeleteShader(fragmentShader)
+// }
+// if (program != -1) {
+// GLES20.glDeleteProgram(program)
+// }
+// throw e
+// }
+// }
+//
+// @WorkerThread
+// private fun loadLocations() {
+// checkGlThread()
+// positionLoc = GLES20.glGetAttribLocation(programHandle, "aPosition")
+// checkLocationOrThrow(positionLoc, "aPosition")
+// texCoordLoc = GLES20.glGetAttribLocation(programHandle, "aTextureCoord")
+// checkLocationOrThrow(texCoordLoc, "aTextureCoord")
+// texMatrixLoc = GLES20.glGetUniformLocation(programHandle, "uTexMatrix")
+// checkLocationOrThrow(texMatrixLoc, "uTexMatrix")
+// }
+//
+// @WorkerThread
+// private fun loadShader(shaderType: Int, source: String): Int {
+// checkGlThread()
+// val shader = GLES20.glCreateShader(shaderType)
+// checkGlErrorOrThrow("glCreateShader type=$shaderType")
+// GLES20.glShaderSource(shader, source)
+// GLES20.glCompileShader(shader)
+// val compiled = IntArray(1)
+// GLES20.glGetShaderiv(
+// shader,
+// GLES20.GL_COMPILE_STATUS,
+// compiled,
+// /*offset=*/
+// 0
+// )
+// check(compiled[0] == GLES20.GL_TRUE) {
+// Log.w(TAG, "Could not compile shader: $source")
+// try {
+// return@check "Could not compile shader type " +
+// "$shaderType: ${GLES20.glGetShaderInfoLog(shader)}"
+// } finally {
+// GLES20.glDeleteShader(shader)
+// }
+// }
+// return shader
+// }
+//
+// @WorkerThread
+// private fun checkGlErrorOrThrow(op: String) {
+// val error = GLES20.glGetError()
+// check(error == GLES20.GL_NO_ERROR) { op + ": GL error 0x" + Integer.toHexString(error) }
+// }
+//
+// private fun checkLocationOrThrow(location: Int, label: String) {
+// check(location >= 0) { "Unable to locate '$label' in program" }
+// }
+//
+// companion object {
+// private const val SIZEOF_FLOAT = 4
+//
+// private val VERTEX_BUF = floatArrayOf(
+// // 0 bottom left
+// -1.0f,
+// -1.0f,
+// // 1 bottom right
+// 1.0f,
+// -1.0f,
+// // 2 top left
+// -1.0f,
+// 1.0f,
+// // 3 top right
+// 1.0f,
+// 1.0f
+// ).toBuffer()
+//
+// private val TEX_BUF = floatArrayOf(
+// // 0 bottom left
+// 0.0f,
+// 0.0f,
+// // 1 bottom right
+// 1.0f,
+// 0.0f,
+// // 2 top left
+// 0.0f,
+// 1.0f,
+// // 3 top right
+// 1.0f,
+// 1.0f
+// ).toBuffer()
+//
+// private const val TAG = "ShaderCopy"
+// private const val GL_THREAD_NAME = TAG
+//
+// private const val VAR_TEXTURE_COORD = "vTextureCoord"
+// private val DEFAULT_VERTEX_SHADER =
+// """
+// uniform mat4 uTexMatrix;
+// attribute vec4 aPosition;
+// attribute vec4 aTextureCoord;
+// varying vec2 $VAR_TEXTURE_COORD;
+// void main() {
+// gl_Position = aPosition;
+// $VAR_TEXTURE_COORD = (uTexMatrix * aTextureCoord).xy;
+// }
+// """.trimIndent()
+//
+// private val TEN_BIT_VERTEX_SHADER =
+// """
+// #version 300 es
+// in vec4 aPosition;
+// in vec4 aTextureCoord;
+// uniform mat4 uTexMatrix;
+// out vec2 $VAR_TEXTURE_COORD;
+// void main() {
+// gl_Position = aPosition;
+// $VAR_TEXTURE_COORD = (uTexMatrix * aTextureCoord).xy;
+// }
+// """.trimIndent()
+//
+// private const val VAR_TEXTURE = "sTexture"
+// private val DEFAULT_FRAGMENT_SHADER =
+// """
+// #extension GL_OES_EGL_image_external : require
+// precision mediump float;
+// varying vec2 $VAR_TEXTURE_COORD;
+// uniform samplerExternalOES $VAR_TEXTURE;
+// void main() {
+// gl_FragColor = texture2D($VAR_TEXTURE, $VAR_TEXTURE_COORD);
+// }
+// """.trimIndent()
+//
+// private val TEN_BIT_FRAGMENT_SHADER =
+// """
+// #version 300 es
+// #extension GL_EXT_YUV_target : require
+// precision mediump float;
+// uniform __samplerExternal2DY2YEXT $VAR_TEXTURE;
+// in vec2 $VAR_TEXTURE_COORD;
+// layout (yuv) out vec3 outColor;
+//
+// void main() {
+// outColor = texture($VAR_TEXTURE, $VAR_TEXTURE_COORD).xyz;
+// }
+// """.trimIndent()
+//
+// private const val EGL_GL_COLORSPACE_KHR = 0x309D
+// private const val EGL_GL_COLORSPACE_BT2020_HLG_EXT = 0x3540
+//
+// private val TEN_BIT_REQUIRED_EGL_EXTENSIONS = listOf(
+// "EGL_EXT_gl_colorspace_bt2020_hlg",
+// "EGL_EXT_yuv_surface"
+// )
+//
+// private fun FloatArray.toBuffer(): FloatBuffer {
+// val bb = ByteBuffer.allocateDirect(size * SIZEOF_FLOAT)
+// bb.order(ByteOrder.nativeOrder())
+// val fb = bb.asFloatBuffer()
+// fb.put(this)
+// fb.position(0)
+// return fb
+// }
+//
+// private fun checkGlThread() {
+// check(GL_THREAD_NAME == Thread.currentThread().name)
+// }
+// }
+//}
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/SingleSurfaceForcingEffect.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/SingleSurfaceForcingEffect.kt
index 09c691d..6057b89 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/SingleSurfaceForcingEffect.kt
+++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/SingleSurfaceForcingEffect.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -13,14 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera
+package com.google.jetpackcamera.domain.camera.effects
import androidx.camera.core.CameraEffect
+import kotlinx.coroutines.CoroutineScope
private const val TARGETS =
- CameraEffect.PREVIEW or CameraEffect.VIDEO_CAPTURE or CameraEffect.IMAGE_CAPTURE
-
-private val emptySurfaceProcessor = EmptySurfaceProcessor()
+ CameraEffect.PREVIEW or CameraEffect.VIDEO_CAPTURE
/**
* [CameraEffect] that applies a no-op effect.
@@ -30,15 +29,9 @@ private val emptySurfaceProcessor = EmptySurfaceProcessor()
*
* Used as a workaround to force the above 3 use cases to use a single camera stream.
*/
-class SingleSurfaceForcingEffect : CameraEffect(
+class SingleSurfaceForcingEffect(coroutineScope: CoroutineScope) : CameraEffect(
TARGETS,
- emptySurfaceProcessor.glExecutor,
- emptySurfaceProcessor,
+ Runnable::run,
+ CopyingSurfaceProcessor(coroutineScope),
{}
-) {
- // TODO(b/304547401): Invoke this to release the processor properly
- @SuppressWarnings("unused")
- fun release() {
- emptySurfaceProcessor.release()
- }
-}
+)
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 b243301..8724bad 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,28 +15,37 @@
*/
package com.google.jetpackcamera.domain.camera.test
+import android.annotation.SuppressLint
import android.content.ContentResolver
import android.net.Uri
import android.view.Display
-import androidx.camera.core.CameraSelector
-import androidx.camera.core.Preview
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.SurfaceRequest
import com.google.jetpackcamera.domain.camera.CameraUseCase
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.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class FakeCameraUseCase(
private val coroutineScope: CoroutineScope =
- CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ CoroutineScope(SupervisorJob() + Dispatchers.Default),
+ defaultCameraSettings: CameraAppSettings = CameraAppSettings()
) : CameraUseCase {
- private val availableLenses =
- listOf(CameraSelector.LENS_FACING_FRONT, CameraSelector.LENS_FACING_BACK)
+ private val availableLenses = listOf(LensFacing.FRONT, LensFacing.BACK)
private var initialized = false
private var useCasesBinded = false
@@ -46,29 +55,18 @@ class FakeCameraUseCase(
var recordingInProgress = false
var isLensFacingFront = false
- 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> {
+ private val currentSettings = MutableStateFlow(defaultCameraSettings)
+
+ override suspend fun initialize(disableVideoCapture: Boolean) {
initialized = true
- flashMode = currentCameraSettings.flashMode
- isLensFacingFront = currentCameraSettings.isFrontCameraFacing
- aspectRatio = currentCameraSettings.aspectRatio
- return availableLenses
}
- override suspend fun runCamera(
- surfaceProvider: Preview.SurfaceProvider,
- currentCameraSettings: CameraAppSettings
- ) {
- val lensFacing =
- when (currentCameraSettings.isFrontCameraFacing) {
- true -> CameraSelector.LENS_FACING_FRONT
- false -> CameraSelector.LENS_FACING_BACK
- }
+ override suspend fun runCamera() {
+ val lensFacing = currentSettings.value.cameraLensFacing
if (!initialized) {
throw IllegalStateException("CameraProvider not initialized")
@@ -76,13 +74,28 @@ class FakeCameraUseCase(
if (!availableLenses.contains(lensFacing)) {
throw IllegalStateException("Requested lens not available")
}
- useCasesBinded = true
- previewStarted = true
+
+ currentSettings
+ .onCompletion {
+ useCasesBinded = false
+ previewStarted = false
+ recordingInProgress = false
+ }.collectLatest {
+ useCasesBinded = true
+ previewStarted = true
+
+ isLensFacingFront = it.cameraLensFacing == LensFacing.FRONT
+ isScreenFlash =
+ isLensFacingFront &&
+ (it.flashMode == FlashMode.AUTO || it.flashMode == FlashMode.ON)
+
+ _zoomScale.value = it.zoomScale
+ }
}
- override suspend fun takePicture() {
+ override suspend fun takePicture(onCaptureStarted: (() -> Unit)) {
if (!useCasesBinded) {
- throw IllegalStateException("Usecases not binded")
+ throw IllegalStateException("Usecases not bound")
}
if (isScreenFlash) {
coroutineScope.launch {
@@ -96,8 +109,16 @@ class FakeCameraUseCase(
}
numPicturesTaken += 1
}
- override suspend fun takePicture(contentResolver: ContentResolver, imageCaptureUri: Uri?) {
- takePicture()
+
+ @SuppressLint("RestrictedApi")
+ override suspend fun takePicture(
+ onCaptureStarted: (() -> Unit),
+ contentResolver: ContentResolver,
+ imageCaptureUri: Uri?,
+ ignoreUri: Boolean
+ ): ImageCapture.OutputFileResults {
+ takePicture(onCaptureStarted)
+ return ImageCapture.OutputFileResults(null)
}
fun emitScreenFlashEvent(event: CameraUseCase.ScreenFlashEvent) {
@@ -106,7 +127,12 @@ class FakeCameraUseCase(
}
}
- override suspend fun startVideoRecording() {
+ override suspend fun startVideoRecording(
+ onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
+ ) {
+ if (!useCasesBinded) {
+ throw IllegalStateException("Usecases not bound")
+ }
recordingInProgress = true
}
@@ -114,28 +140,40 @@ class FakeCameraUseCase(
recordingInProgress = false
}
- override fun setZoomScale(scale: Float): Float {
- return -1f
+ private val _zoomScale = MutableStateFlow(1f)
+ override fun setZoomScale(scale: Float) {
+ currentSettings.update { old ->
+ old.copy(zoomScale = scale)
+ }
}
+ override fun getZoomScale(): StateFlow<Float> = _zoomScale.asStateFlow()
- override fun getScreenFlashEvents() = screenFlashEvents
+ private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
+ override fun getSurfaceRequest(): StateFlow<SurfaceRequest?> = _surfaceRequest.asStateFlow()
- override fun setFlashMode(flashMode: FlashMode, isFrontFacing: Boolean) {
- this.flashMode = flashMode
- isLensFacingFront = isFrontFacing
+ override fun getScreenFlashEvents() = screenFlashEvents
+ override fun getCurrentSettings(): StateFlow<CameraAppSettings?> = currentSettings.asStateFlow()
- isScreenFlash =
- isLensFacingFront && (flashMode == FlashMode.AUTO || flashMode == FlashMode.ON)
+ override fun setFlashMode(flashMode: FlashMode) {
+ currentSettings.update { old ->
+ old.copy(flashMode = flashMode)
+ }
}
override fun isScreenFlashEnabled() = isScreenFlash
- override suspend fun setAspectRatio(aspectRatio: AspectRatio, isFrontFacing: Boolean) {
- this.aspectRatio = aspectRatio
+ fun isPreviewStarted() = previewStarted
+
+ override suspend fun setAspectRatio(aspectRatio: AspectRatio) {
+ currentSettings.update { old ->
+ old.copy(aspectRatio = aspectRatio)
+ }
}
- override suspend fun flipCamera(isFrontFacing: Boolean, flashMode: FlashMode) {
- isLensFacingFront = isFrontFacing
+ override suspend fun setLensFacing(lensFacing: LensFacing) {
+ currentSettings.update { old ->
+ old.copy(cameraLensFacing = lensFacing)
+ }
}
override fun tapToFocus(
@@ -149,6 +187,14 @@ class FakeCameraUseCase(
}
override suspend fun setCaptureMode(captureMode: CaptureMode) {
- TODO("Not yet implemented")
+ currentSettings.update { old ->
+ old.copy(captureMode = captureMode)
+ }
+ }
+
+ override suspend fun setDynamicRange(dynamicRange: DynamicRange) {
+ currentSettings.update { old ->
+ old.copy(dynamicRange = dynamicRange)
+ }
}
}
diff --git a/domain/camera/src/test/Android.bp b/domain/camera/src/test/Android.bp
index cb1318a..2fc04c2 100644
--- a/domain/camera/src/test/Android.bp
+++ b/domain/camera/src/test/Android.bp
@@ -10,6 +10,7 @@ java_test {
"androidx.test.runner",
"kotlinx_coroutines_test",
"androidx.test.ext.junit",
+ "androidx.test.ext.truth",
"mockito-core",
"jetpack-camera-app_domain_camera",
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
index 26c81d7..c6eaf59 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -15,10 +15,10 @@
*/
package com.google.jetpackcamera.domain.camera.test
-import androidx.camera.core.Preview
+import com.google.common.truth.Truth.assertThat
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 com.google.jetpackcamera.settings.model.LensFacing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
@@ -31,10 +31,8 @@ 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 {
@@ -43,11 +41,8 @@ class FakeCameraUseCaseTest {
private val cameraUseCase = FakeCameraUseCase(testScope)
- private val surfaceProvider: Preview.SurfaceProvider =
- Mockito.mock(Preview.SurfaceProvider::class.java)
-
@Before
- fun setup() = runTest(testDispatcher) {
+ fun setup() {
Dispatchers.setMain(testDispatcher)
}
@@ -58,57 +53,68 @@ class FakeCameraUseCaseTest {
@Test
fun canInitialize() = runTest(testDispatcher) {
- cameraUseCase.initialize(DEFAULT_CAMERA_APP_SETTINGS)
+ cameraUseCase.initialize(false)
}
@Test
fun canRunCamera() = runTest(testDispatcher) {
initAndRunCamera()
+ assertThat(cameraUseCase.isPreviewStarted())
}
@Test
fun screenFlashDisabled_whenFlashModeOffAndFrontCamera() = runTest(testDispatcher) {
initAndRunCamera()
- cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = false)
+ cameraUseCase.setLensFacing(lensFacing = LensFacing.FRONT)
+ cameraUseCase.setFlashMode(flashMode = FlashMode.OFF)
+ advanceUntilIdle()
- assertEquals(false, cameraUseCase.isScreenFlashEnabled())
+ assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
}
@Test
fun screenFlashDisabled_whenFlashModeOnAndNotFrontCamera() = runTest(testDispatcher) {
initAndRunCamera()
- cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = false)
+ cameraUseCase.setLensFacing(lensFacing = LensFacing.BACK)
+ cameraUseCase.setFlashMode(flashMode = FlashMode.ON)
+ advanceUntilIdle()
- assertEquals(false, cameraUseCase.isScreenFlashEnabled())
+ assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
}
@Test
fun screenFlashDisabled_whenFlashModeAutoAndNotFrontCamera() = runTest(testDispatcher) {
initAndRunCamera()
- cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = false)
+ cameraUseCase.setLensFacing(lensFacing = LensFacing.BACK)
+ cameraUseCase.setFlashMode(flashMode = FlashMode.AUTO)
+ advanceUntilIdle()
- assertEquals(false, cameraUseCase.isScreenFlashEnabled())
+ assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
}
@Test
fun screenFlashEnabled_whenFlashModeOnAndFrontCamera() = runTest(testDispatcher) {
initAndRunCamera()
- cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = true)
+ cameraUseCase.setLensFacing(lensFacing = LensFacing.FRONT)
+ cameraUseCase.setFlashMode(flashMode = FlashMode.ON)
+ advanceUntilIdle()
- assertEquals(true, cameraUseCase.isScreenFlashEnabled())
+ assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
}
@Test
fun screenFlashEnabled_whenFlashModeAutoAndFrontCamera() = runTest(testDispatcher) {
initAndRunCamera()
- cameraUseCase.setFlashMode(flashMode = FlashMode.ON, isFrontFacing = true)
+ cameraUseCase.setLensFacing(lensFacing = LensFacing.FRONT)
+ cameraUseCase.setFlashMode(flashMode = FlashMode.AUTO)
+ advanceUntilIdle()
- assertEquals(true, cameraUseCase.isScreenFlashEnabled())
+ assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
}
@Test
@@ -122,21 +128,24 @@ class FakeCameraUseCaseTest {
}
// FlashMode.ON in front facing camera automatically enables screen flash
- cameraUseCase.setFlashMode(FlashMode.ON, true)
+ cameraUseCase.setLensFacing(lensFacing = LensFacing.FRONT)
+ cameraUseCase.setFlashMode(FlashMode.ON)
+ advanceUntilIdle()
cameraUseCase.takePicture()
advanceUntilIdle()
- assertEquals(
+ assertThat(events.map { it.type }).containsExactlyElementsIn(
listOf(
CameraUseCase.ScreenFlashEvent.Type.APPLY_UI,
CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI
- ),
- events.map { it.type }
- )
+ )
+ ).inOrder()
}
- private suspend fun initAndRunCamera() {
- cameraUseCase.initialize(DEFAULT_CAMERA_APP_SETTINGS)
- cameraUseCase.runCamera(surfaceProvider, DEFAULT_CAMERA_APP_SETTINGS)
+ private fun TestScope.initAndRunCamera() {
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ cameraUseCase.initialize(false)
+ cameraUseCase.runCamera()
+ }
}
}
diff --git a/camera-viewfinder-compose/.gitignore b/feature/permissions/.gitignore
index 42afabf..42afabf 100644
--- a/camera-viewfinder-compose/.gitignore
+++ b/feature/permissions/.gitignore
diff --git a/feature/quicksettings/Android.bp b/feature/permissions/Android.bp
index 8b0e3b6..911158d 100644
--- a/feature/quicksettings/Android.bp
+++ b/feature/permissions/Android.bp
@@ -5,24 +5,23 @@ package {
}
android_library {
- name: "jetpack-camera-app_feature_quicksettings",
+ name: "jetpack-camera-app_feature_permissions",
srcs: ["src/main/**/*.kt"],
+ resource_dirs: [
+ "src/main/res",
+ ],
static_libs: [
+ "accompanist-permissions",
"androidx.compose.material3_material3",
- "androidx.compose.runtime_runtime",
+ "androidx.compose.material_material-icons-extended",
+ "androidx.compose.runtime_runtime",
"androidx.compose.ui_ui-tooling-preview",
"androidx.compose.ui_ui-tooling",
- "androidx.camera_camera-core",
- "androidx.camera_camera-viewfinder",
- "kotlinx_coroutines_guava",
- "jetpack-camera-app_data_settings",
+ "androidx.core_core",
+ "androidx.hilt_hilt-navigation-compose",
+ "hilt_android",
],
sdk_version: "34",
min_sdk_version: "21",
- manifest:"src/main/AndroidManifest.xml",
- resource_dirs: [
- "src/main/res",
- ],
+ manifest: "src/main/AndroidManifest.xml",
}
-
-
diff --git a/feature/quicksettings/build.gradle.kts b/feature/permissions/build.gradle.kts
index 58f8b5c..a14d8cc 100644
--- a/feature/quicksettings/build.gradle.kts
+++ b/feature/permissions/build.gradle.kts
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -13,46 +13,39 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
+
alias(libs.plugins.kotlin.kapt)
+ alias(libs.plugins.dagger.hilt.android)
}
android {
- namespace = "com.google.jetpackcamera.quicksettings"
- compileSdk = 34
+ namespace = "com.google.jetpackcamera.permissions"
+ compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
- minSdk = 21
- targetSdk = 34
+ minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
- buildTypes {
- release {
- isMinifyEnabled = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
+
kotlin {
jvmToolchain(17)
}
buildFeatures {
+ buildConfig = true
compose = true
}
composeOptions {
- kotlinCompilerExtensionVersion = "1.4.0"
+ kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
}
@@ -64,25 +57,34 @@ dependencies {
// Compose - Material Design 3
implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
// Compose - Android Studio Preview support
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
+ // Compose - Integration with ViewModels with Navigation and Hilt
+ implementation(libs.hilt.navigation.compose)
+
+
// Compose - Testing
androidTestImplementation(libs.compose.junit)
- // Testing
+ // Accompanist - Permissions
+ implementation(libs.accompanist.permissions)
+
+ // Hilt
+ implementation(libs.dagger.hilt.android)
+ kapt(libs.dagger.hilt.compiler)
+
+
+ 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)
-
- // Guava
- implementation(libs.kotlinx.coroutines.guava)
-
- implementation(project(":data:settings"))
}
-
// Allow references to generated code
kapt {
correctErrorTypes = true
diff --git a/camera-viewfinder-compose/consumer-rules.pro b/feature/permissions/consumer-rules.pro
index e69de29..e69de29 100644
--- a/camera-viewfinder-compose/consumer-rules.pro
+++ b/feature/permissions/consumer-rules.pro
diff --git a/app/proguard-rules.pro b/feature/permissions/proguard-rules.pro
index ff59496..481bb43 100644
--- a/app/proguard-rules.pro
+++ b/feature/permissions/proguard-rules.pro
@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
+# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
diff --git a/camera-viewfinder-compose/src/main/AndroidManifest.xml b/feature/permissions/src/main/AndroidManifest.xml
index 426a891..88a4e8b 100644
--- a/camera-viewfinder-compose/src/main/AndroidManifest.xml
+++ b/feature/permissions/src/main/AndroidManifest.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- ~ Copyright (C) 2023 The Android Open Source Project
+ ~ Copyright (C) 2024 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.
@@ -14,6 +14,6 @@
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
-<manifest package="com.google.jetpackcamera.viewfinder">
+<manifest package="com.google.jetpackcamera.permissions">
</manifest>
diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsEnums.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsEnums.kt
new file mode 100644
index 0000000..57fd0b1
--- /dev/null
+++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsEnums.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2024 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.permissions
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CameraAlt
+import androidx.compose.material.icons.outlined.Mic
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.res.painterResource
+
+const val CAMERA_PERMISSION = "android.permission.CAMERA"
+const val AUDIO_RECORD_PERMISSION = "android.permission.RECORD_AUDIO"
+
+/**
+ * Helper class storing a permission's relevant UI information
+ */
+sealed interface PermissionInfoProvider {
+ @Composable
+ fun getPainter(): Painter {
+ val iconResId = getDrawableResId()
+ val iconVector = getImageVector()
+ require((iconResId == null).xor(iconVector == null)) {
+ "UI Item should have exactly one of iconResId or iconVector set."
+ }
+ return iconResId?.let { painterResource(it) }
+ ?: iconVector?.let {
+ rememberVectorPainter(
+ it
+ )
+ }!! // !! allowed because we've checked null
+ }
+
+ /**
+ * @return the String reference for the permission
+ */
+ fun getPermission(): String
+
+ fun isOptional(): Boolean
+
+ @DrawableRes
+ fun getDrawableResId(): Int?
+
+ fun getImageVector(): ImageVector?
+
+ @StringRes
+ fun getPermissionTitleResId(): Int
+
+ @StringRes
+ fun getPermissionBodyTextResId(): Int
+
+ @StringRes
+ fun getRationaleBodyTextResId(): Int?
+
+ @StringRes
+ fun getIconAccessibilityTextResId(): Int
+}
+
+/**
+ * Implementation of [PermissionInfoProvider]
+ * Supplies the information needed for a permission's UI screen
+ */
+enum class PermissionEnum : PermissionInfoProvider {
+
+ CAMERA {
+ override fun getPermission(): String = CAMERA_PERMISSION
+
+ override fun isOptional(): Boolean = false
+
+ override fun getDrawableResId(): Int? = null
+
+ override fun getImageVector(): ImageVector = Icons.Outlined.CameraAlt
+
+ override fun getPermissionTitleResId(): Int = R.string.camera_permission_screen_title
+
+ override fun getPermissionBodyTextResId(): Int =
+ R.string.camera_permission_required_rationale
+
+ override fun getRationaleBodyTextResId(): Int =
+ R.string.camera_permission_declined_rationale
+
+ override fun getIconAccessibilityTextResId(): Int =
+ R.string.camera_permission_accessibility_text
+ },
+
+ RECORD_AUDIO {
+ override fun getPermission(): String = AUDIO_RECORD_PERMISSION
+
+ override fun isOptional(): Boolean = true
+
+ override fun getDrawableResId(): Int? = null
+
+ override fun getImageVector(): ImageVector = Icons.Outlined.Mic
+
+ override fun getPermissionTitleResId(): Int = R.string.microphone_permission_screen_title
+
+ override fun getPermissionBodyTextResId(): Int =
+ R.string.microphone_permission_required_rationale
+
+ override fun getRationaleBodyTextResId(): Int? = null
+
+ override fun getIconAccessibilityTextResId(): Int =
+ R.string.microphone_permission_accessibility_text
+ }
+}
diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt
new file mode 100644
index 0000000..69edca8
--- /dev/null
+++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2024 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.permissions
+
+import android.Manifest
+import android.util.Log
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.MultiplePermissionsState
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.jetpackcamera.permissions.ui.PermissionTemplate
+
+private const val TAG = "PermissionsScreen"
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun PermissionsScreen(onNavigateToPreview: () -> Unit, openAppSettings: () -> Unit) {
+ val permissionStates = rememberMultiplePermissionsState(
+ permissions = listOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO
+ )
+ )
+ PermissionsScreen(
+ permissionStates = permissionStates,
+ onNavigateToPreview = onNavigateToPreview,
+ openAppSettings = openAppSettings
+ )
+}
+
+/**
+ * Permission prompts screen.
+ * Camera permission will always prompt when disabled, and the app cannot be used otherwise
+ * if optional settings have not yet been declined by the user, then they will be prompted as well
+ */
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun PermissionsScreen(
+ modifier: Modifier = Modifier,
+ onNavigateToPreview: () -> Unit,
+ openAppSettings: () -> Unit,
+ permissionStates: MultiplePermissionsState,
+ viewModel: PermissionsViewModel = hiltViewModel()
+) {
+ Log.d(TAG, "PermissionsScreen")
+ viewModel.init(permissionStates)
+ val permissionsUiState: PermissionsUiState by viewModel.permissionsUiState.collectAsState()
+ if (permissionsUiState is PermissionsUiState.PermissionsNeeded) {
+ val permissionEnum =
+ (permissionsUiState as PermissionsUiState.PermissionsNeeded).currentPermission
+ val currentPermissionState =
+ rememberPermissionState(
+ permission = permissionEnum.getPermission()
+ )
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = { permissionGranted ->
+ if (permissionGranted) {
+ // remove from list
+ viewModel.dismissPermission()
+ } else if (permissionEnum.isOptional()) {
+ viewModel.dismissPermission()
+ }
+ }
+ )
+
+ PermissionTemplate(
+ modifier = modifier,
+ permissionEnum = permissionEnum,
+ permissionState = currentPermissionState,
+ onSkipPermission = when (permissionEnum) {
+ // todo: a prettier navigation to app settings.
+ PermissionEnum.CAMERA -> null
+ // todo: skip permission button functionality. currently need to go through the
+ // prompt to skip
+ else -> null // permissionsViewModel::dismissPermission
+ },
+ onRequestPermission = { permissionLauncher.launch(permissionEnum.getPermission()) },
+ onOpenAppSettings = openAppSettings
+ )
+ } else {
+ onNavigateToPreview()
+ }
+}
diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsUiState.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsUiState.kt
new file mode 100644
index 0000000..19ba97c
--- /dev/null
+++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsUiState.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 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.permissions
+
+sealed interface PermissionsUiState {
+ data class PermissionsNeeded(val currentPermission: PermissionEnum) : PermissionsUiState
+ object AllPermissionsGranted : PermissionsUiState
+}
diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt
new file mode 100644
index 0000000..9426e55
--- /dev/null
+++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2024 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.permissions
+
+import android.Manifest
+import androidx.lifecycle.ViewModel
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.MultiplePermissionsState
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.shouldShowRationale
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import javax.inject.Inject
+
+/**
+ * A [ViewModel] for [PermissionsScreen]]
+ */
+@OptIn(ExperimentalPermissionsApi::class)
+@HiltViewModel
+class PermissionsViewModel @Inject constructor(
+// @Assisted permissionStates: MultiplePermissionsState
+) : ViewModel() {
+// @AssistedFactory
+// interface Factory {
+// fun create(runtimeArg: MultiplePermissionsState): PermissionsViewModel
+// }
+
+ private val permissionQueue = mutableListOf<PermissionEnum>()
+
+ fun init(permissionStates: MultiplePermissionsState) {
+ permissionQueue.addAll(getRequestablePermissions(permissionStates))
+ }
+
+ private val _permissionsUiState: MutableStateFlow<PermissionsUiState> =
+ MutableStateFlow(getCurrentPermission())
+ val permissionsUiState: StateFlow<PermissionsUiState> = _permissionsUiState.asStateFlow()
+
+ private fun getCurrentPermission(): PermissionsUiState {
+ return if (permissionQueue.isEmpty()) {
+ PermissionsUiState.AllPermissionsGranted
+ } else {
+ PermissionsUiState.PermissionsNeeded(permissionQueue.first())
+ }
+ }
+
+ fun dismissPermission() {
+ if (permissionQueue.isNotEmpty()) {
+ permissionQueue.removeFirst()
+ }
+ _permissionsUiState.update {
+ (getCurrentPermission())
+ }
+ }
+}
+
+/**
+ *
+ * Provides a set of [PermissionEnum] representing the permissions that can still be requested.
+ * Permissions that can be requested are:
+ * - mandatory permissions that have not been granted
+ * - optional permissions that have not yet been denied by the user
+ */
+@OptIn(ExperimentalPermissionsApi::class)
+fun getRequestablePermissions(
+ permissionStates: MultiplePermissionsState
+): MutableSet<PermissionEnum> {
+ val unGrantedPermissions = mutableSetOf<PermissionEnum>()
+ for (permission in permissionStates.permissions) {
+ // camera is always required
+ if (!permission.status.isGranted && permission.permission == Manifest.permission.CAMERA) {
+ unGrantedPermissions.add(PermissionEnum.CAMERA)
+ }
+ // audio is optional
+ else if ((!permission.status.shouldShowRationale && !permission.status.isGranted) &&
+ permission.permission ==
+ Manifest.permission.RECORD_AUDIO
+ ) {
+ unGrantedPermissions.add(PermissionEnum.RECORD_AUDIO)
+ }
+ }
+ return unGrantedPermissions
+}
diff --git a/app/src/main/java/com/google/jetpackcamera/ui/PermissionsUi.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/PermissionsScreenComponents.kt
index adb56e1..d376b33 100644
--- a/app/src/main/java/com/google/jetpackcamera/ui/PermissionsUi.kt
+++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/PermissionsScreenComponents.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.ui
+package com.google.jetpackcamera.permissions.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -37,37 +37,73 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-//import com.google.accompanist.permissions.ExperimentalPermissionsApi
-//import com.google.accompanist.permissions.PermissionState
-import com.google.jetpackcamera.R
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.PermissionState
+import com.google.accompanist.permissions.shouldShowRationale
+import com.google.jetpackcamera.permissions.PermissionEnum
+import com.google.jetpackcamera.permissions.R
-//@OptIn(ExperimentalPermissionsApi::class)
+/**
+ * Template for a single page for the permissions Screen
+ *
+ * @param permissionEnum a [PermissionEnum] representing the target permission
+ * @param permissionState a [PermissionState] of the target permission
+ */
+@OptIn(ExperimentalPermissionsApi::class)
@Composable
-fun CameraPermission(modifier: Modifier = Modifier) {
+fun PermissionTemplate(
+ modifier: Modifier = Modifier,
+ permissionEnum: PermissionEnum,
+ permissionState: PermissionState,
+ onRequestPermission: () -> Unit,
+ onSkipPermission: (() -> Unit)? = null,
+ onOpenAppSettings: () -> Unit
+) {
PermissionTemplate(
modifier = modifier,
-// permissionState = cameraPermissionState,
- painter = painterResource(id = R.drawable.photo_camera),
- iconAccessibilityText = stringResource(id = R.string.camera_permission_accessibility_text),
- title = stringResource(id = R.string.camera_permission_screen_title),
- bodyText = stringResource(id = R.string.camera_permission_required_rationale),
- requestButtonText = stringResource(R.string.request_permission)
+ onRequestPermission = {
+ if (permissionState.status.shouldShowRationale) {
+ onOpenAppSettings()
+ } else {
+ onRequestPermission()
+ }
+ },
+ onSkipPermission = onSkipPermission,
+ imageVector = permissionEnum.getImageVector()!!,
+ iconAccessibilityText = stringResource(permissionEnum.getIconAccessibilityTextResId()),
+ title = stringResource(permissionEnum.getPermissionTitleResId()),
+
+ // if declined by user, must navigate to system app settings to enable permission
+ bodyText =
+ if (!permissionState.status.shouldShowRationale || permissionEnum.isOptional()) {
+ stringResource(id = permissionEnum.getPermissionBodyTextResId())
+ } else {
+ stringResource(id = permissionEnum.getRationaleBodyTextResId()!!)
+ },
+ requestButtonText =
+ if (!permissionState.status.shouldShowRationale || permissionEnum.isOptional()) {
+ stringResource(id = R.string.request_permission)
+ } else {
+ stringResource(id = R.string.navigate_to_settings)
+ }
)
}
-//@OptIn(ExperimentalPermissionsApi::class)
+/**
+ * Template for a Permission Screen page
+ */
@Composable
fun PermissionTemplate(
modifier: Modifier = Modifier,
-// permissionState: PermissionState,
+ onRequestPermission: () -> Unit,
onSkipPermission: (() -> Unit)? = null,
- painter: Painter,
+ imageVector: ImageVector,
iconAccessibilityText: String,
title: String,
bodyText: String,
@@ -82,7 +118,7 @@ fun PermissionTemplate(
modifier = Modifier
.height(IntrinsicSize.Min)
.align(Alignment.CenterHorizontally),
- painter = painter,
+ imageVector = imageVector,
accessibilityText = iconAccessibilityText
)
Spacer(modifier = Modifier.fillMaxHeight(.1f))
@@ -100,8 +136,8 @@ fun PermissionTemplate(
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.height(IntrinsicSize.Min),
-// permissionState = permissionState,
requestButtonText = requestButtonText,
+ onRequestPermission = onRequestPermission,
onSkipPermission = onSkipPermission
)
}
@@ -109,25 +145,60 @@ fun PermissionTemplate(
}
}
+/*
+Permission UI Previews
+ */
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_Camera_Permission_Page() {
+ PermissionTemplate(
+ onRequestPermission = { /*TODO*/ },
+ imageVector = PermissionEnum.CAMERA.getImageVector()!!,
+ iconAccessibilityText = "",
+ title = stringResource(id = PermissionEnum.CAMERA.getPermissionTitleResId()),
+ bodyText = stringResource(id = PermissionEnum.CAMERA.getPermissionBodyTextResId()),
+ requestButtonText = stringResource(id = R.string.request_permission)
+ )
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_Audio_Permission_Page() {
+ PermissionTemplate(
+ onRequestPermission = { /*TODO*/ },
+ imageVector = PermissionEnum.RECORD_AUDIO.getImageVector()!!,
+ iconAccessibilityText = "",
+ title = stringResource(id = PermissionEnum.RECORD_AUDIO.getPermissionTitleResId()),
+ bodyText = stringResource(id = PermissionEnum.RECORD_AUDIO.getPermissionBodyTextResId()),
+ requestButtonText = stringResource(id = R.string.request_permission)
+ )
+}
+
+/*
+Permission UI Subcomponents
+ */
@Composable
-fun PermissionImage(modifier: Modifier = Modifier, painter: Painter, accessibilityText: String) {
+fun PermissionImage(
+ modifier: Modifier = Modifier,
+ imageVector: ImageVector,
+ accessibilityText: String
+) {
Box(modifier = modifier) {
Icon(
modifier = Modifier
.size(300.dp)
.align(Alignment.BottomCenter),
- painter = painter,
+ imageVector = imageVector,
tint = MaterialTheme.colorScheme.onPrimary,
contentDescription = accessibilityText
)
}
}
-//@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionButtonSection(
modifier: Modifier = Modifier,
-// permissionState: PermissionState,
+ onRequestPermission: () -> Unit,
requestButtonText: String,
onSkipPermission: (() -> Unit)?
) {
@@ -138,7 +209,7 @@ fun PermissionButtonSection(
.align(Alignment.Center)
) {
PermissionButton(
-// permissionState = permissionState,
+ onRequestPermission = { onRequestPermission() },
requestButtonText = requestButtonText
)
Spacer(modifier = Modifier.height(20.dp))
@@ -156,11 +227,10 @@ fun PermissionButtonSection(
}
}
-//@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionButton(
modifier: Modifier = Modifier,
-// permissionState: PermissionState,
+ onRequestPermission: () -> Unit,
requestButtonText: String
) {
Button(
@@ -169,9 +239,7 @@ fun PermissionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
- onClick = {
-// permissionState.launchPermissionRequest()
- }
+ onClick = { onRequestPermission() }
) {
Text(
modifier = Modifier.padding(10.dp),
@@ -189,7 +257,7 @@ fun PermissionText(modifier: Modifier = Modifier, title: String, bodyText: Strin
.height(IntrinsicSize.Min)
) {
Column(
- modifier = modifier
+ modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
) {
diff --git a/feature/permissions/src/main/res/values/strings.xml b/feature/permissions/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2fdf2d9
--- /dev/null
+++ b/feature/permissions/src/main/res/values/strings.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2024 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.
+ -->
+<resources>
+ <string name="camera_permission_screen_title">Enable Camera</string>
+ <string name="camera_permission_required_rationale">Please provide permission to access to the camera. It is necessary for this app to function.</string>
+ <string name="camera_permission_declined_rationale">The app requires the camera to function. To use this app, go to the app\'s settings and grant the camera permission.</string>
+
+ <string name="camera_permission_accessibility_text">An icon representing a camera</string>
+ <string name="request_permission">Allow Access</string>
+ <string name="navigate_to_settings">Open App Settings</string>
+
+
+ <string name="microphone_permission_screen_title">Enable Audio Recording</string>
+ <string name="microphone_permission_required_rationale">Please provide permission to enable audio in video recording. Videos will be mute if this permission is revoked.</string>
+ <string name="microphone_permission_accessibility_text">An icon representing a microphone</string>
+</resources> \ No newline at end of file
diff --git a/feature/preview/Android.bp b/feature/preview/Android.bp
index 19485f4..b4920b1 100644
--- a/feature/preview/Android.bp
+++ b/feature/preview/Android.bp
@@ -13,6 +13,7 @@ android_library {
static_libs: [
"androidx.compose.runtime_runtime",
"androidx.compose.material3_material3",
+ "androidx.compose.material_material-icons-extended",
"androidx.compose.ui_ui-tooling-preview",
"androidx.tracing_tracing-ktx",
"hilt_android",
@@ -25,8 +26,8 @@ android_library {
"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",
+ "androidx.camera_camera-viewfinder-compose",
+ "androidx.compose.ui_ui-tooling",
],
sdk_version: "34",
diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts
index 126fe88..a5f0793 100644
--- a/feature/preview/build.gradle.kts
+++ b/feature/preview/build.gradle.kts
@@ -23,25 +23,16 @@ plugins {
android {
namespace = "com.google.jetpackcamera.feature.preview"
- compileSdk = 34
+ compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
- minSdk = 21
- targetSdk = 34
+ minSdk = libs.versions.minSdk.get().toInt()
+ testOptions.targetSdk = libs.versions.targetSdk.get().toInt()
+ lint.targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- consumerProguardFiles("consumer-rules.pro")
}
- buildTypes {
- release {
- isMinifyEnabled = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -50,16 +41,36 @@ android {
jvmToolchain(17)
}
buildFeatures {
+ buildConfig = true
compose = true
}
composeOptions {
- kotlinCompilerExtensionVersion = "1.4.0"
+ kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
+
+ @Suppress("UnstableApiUsage")
testOptions {
unitTests {
isReturnDefaultValues = true
isIncludeAndroidResources = true
}
+ managedDevices {
+ localDevices {
+ create("pixel2Api28") {
+ device = "Pixel 2"
+ apiLevel = 28
+ }
+ create("pixel8Api34") {
+ device = "Pixel 8"
+ apiLevel = 34
+ systemImageSource = "aosp_atd"
+ }
+ }
+ }
+ }
+
+ kotlinOptions {
+ freeCompilerArgs += "-Xcontext-receivers"
}
}
@@ -71,6 +82,7 @@ dependencies {
// Compose - Material Design 3
implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
// Compose - Android Studio Preview support
implementation(libs.compose.ui.tooling.preview)
@@ -79,6 +91,10 @@ dependencies {
// Compose - Integration with ViewModels with Navigation and Hilt
implementation(libs.hilt.navigation.compose)
+ // Compose - Lifecycle utilities
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+
// Compose - Testing
androidTestImplementation(libs.compose.junit)
debugImplementation(libs.compose.test.manifest)
@@ -88,20 +104,21 @@ dependencies {
// Testing
testImplementation(libs.junit)
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
+ testImplementation(libs.truth)
testImplementation(libs.mockito.core)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
debugImplementation(libs.androidx.test.monitor)
implementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
- // Guava
- implementation(libs.kotlinx.coroutines.guava)
+ // Futures
+ implementation(libs.futures.ktx)
// CameraX
implementation(libs.camera.core)
- implementation(libs.camera.view)
+ implementation(libs.camera.viewfinder.compose)
// Hilt
implementation(libs.dagger.hilt.android)
@@ -110,11 +127,11 @@ dependencies {
//Tracing
implementation(libs.androidx.tracing)
+ implementation(libs.kotlinx.atomicfu)
+
// Project dependencies
implementation(project(":data:settings"))
implementation(project(":domain:camera"))
- implementation(project(":camera-viewfinder-compose"))
- implementation(project(":feature:quicksettings"))
}
// Allow references to generated code
diff --git a/feature/preview/consumer-rules.pro b/feature/preview/consumer-rules.pro
deleted file mode 100644
index e69de29..0000000
--- a/feature/preview/consumer-rules.pro
+++ /dev/null
diff --git a/feature/preview/proguard-rules.pro b/feature/preview/proguard-rules.pro
deleted file mode 100644
index ff59496..0000000
--- a/feature/preview/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile \ No newline at end of file
diff --git a/feature/preview/src/androidTest/Android.bp b/feature/preview/src/androidTest/Android.bp
index 6bbfe3a..83e62dc 100644
--- a/feature/preview/src/androidTest/Android.bp
+++ b/feature/preview/src/androidTest/Android.bp
@@ -15,7 +15,6 @@ android_test {
"androidx.compose.ui_ui-test-junit4",
"androidx.compose.ui_ui-test-manifest",
"jetpack-camera-app_feature_preview",
-
],
instrumentation_for: "jetpack-camera-app_feature_preview",
sdk_version: "34",
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
index 7c369e3..625b907 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt
new file mode 100644
index 0000000..de1c7d8
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2024 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.net.Uri
+
+/**
+ * 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.
+ */
+ data class StandardMode(
+ val onImageCapture: (PreviewViewModel.ImageCaptureEvent) -> Unit
+ ) : 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/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
index c5cfdd2..bb005bc 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,337 +15,210 @@
*/
package com.google.jetpackcamera.feature.preview
+import android.annotation.SuppressLint
+import android.content.ContentResolver
import android.net.Uri
-import android.os.Handler
-import android.os.Looper
import android.util.Log
-import androidx.camera.core.Preview.SurfaceProvider
+import android.view.Display
+import androidx.camera.core.SurfaceRequest
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-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.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
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.compose.ui.tooling.preview.Preview
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 androidx.lifecycle.compose.LifecycleStartEffect
+import com.google.jetpackcamera.feature.preview.quicksettings.QuickSettingsScreenOverlay
+import com.google.jetpackcamera.feature.preview.ui.CameraControlsOverlay
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.QuickSettingsScreenOverlay
-import com.google.jetpackcamera.feature.quicksettings.ui.QuickSettingsIndicators
-import com.google.jetpackcamera.feature.quicksettings.ui.ToggleQuickSettingsButton
+import com.google.jetpackcamera.feature.preview.ui.TestableSnackbar
+import com.google.jetpackcamera.feature.preview.ui.TestableToast
+import com.google.jetpackcamera.settings.model.AspectRatio
import com.google.jetpackcamera.settings.model.CaptureMode
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.awaitCancellation
+import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
+import com.google.jetpackcamera.settings.model.DynamicRange
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
private const val TAG = "PreviewScreen"
-private const val ZOOM_SCALE_SHOW_TIMEOUT_MS = 3000L
/**
* Screen used for the Preview feature.
*/
-@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun PreviewScreen(
- onPreviewViewModel: (PreviewViewModel) -> Unit,
onNavigateToSettings: () -> Unit,
- viewModel: PreviewViewModel = hiltViewModel(),
- previewMode: PreviewMode
+ previewMode: PreviewMode,
+ modifier: Modifier = Modifier,
+ onRequestWindowColorMode: (Int) -> Unit = {},
+ viewModel: PreviewViewModel = hiltViewModel()
) {
Log.d(TAG, "PreviewScreen")
+ viewModel.init(previewMode)
+
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>() }
-
- val zoomScale by remember { mutableFloatStateOf(1f) }
+ val surfaceRequest: SurfaceRequest?
+ by viewModel.surfaceRequest.collectAsState()
- var zoomScaleShow by remember { mutableStateOf(false) }
+ LifecycleStartEffect(Unit) {
+ viewModel.startCamera()
+ onStopOrDispose {
+ viewModel.stopCamera()
+ }
+ }
- val zoomHandler = Handler(Looper.getMainLooper())
+ when (val currentUiState = previewUiState) {
+ is PreviewUiState.NotReady -> LoadingScreen()
+ is PreviewUiState.Ready -> ContentScreen(
+ modifier = modifier,
+ previewUiState = currentUiState,
+ screenFlashUiState = screenFlashUiState,
+ surfaceRequest = surfaceRequest,
+ onNavigateToSettings = onNavigateToSettings,
+ onClearUiScreenBrightness = viewModel.screenFlash::setClearUiScreenBrightness,
+ onSetLensFacing = viewModel::setLensFacing,
+ onTapToFocus = viewModel::tapToFocus,
+ onChangeZoomScale = viewModel::setZoomScale,
+ onChangeFlash = viewModel::setFlash,
+ onChangeAspectRatio = viewModel::setAspectRatio,
+ onChangeCaptureMode = viewModel::setCaptureMode,
+ onChangeDynamicRange = viewModel::setDynamicRange,
+ onToggleQuickSettings = viewModel::toggleQuickSettings,
+ onCaptureImage = viewModel::captureImage,
+ onCaptureImageWithUri = viewModel::captureImageWithUri,
+ onStartVideoRecording = viewModel::startVideoRecording,
+ onStopVideoRecording = viewModel::stopVideoRecording,
+ onToastShown = viewModel::onToastShown,
+ onRequestWindowColorMode = onRequestWindowColorMode,
+ onSnackBarResult = viewModel::onSnackBarResult
+ )
+ }
+}
- onPreviewViewModel(viewModel)
+@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
+@Composable
+private fun ContentScreen(
+ previewUiState: PreviewUiState.Ready,
+ screenFlashUiState: ScreenFlash.ScreenFlashUiState,
+ surfaceRequest: SurfaceRequest?,
+ modifier: Modifier = Modifier,
+ onNavigateToSettings: () -> Unit = {},
+ onClearUiScreenBrightness: (Float) -> Unit = {},
+ onSetLensFacing: (newLensFacing: LensFacing) -> Unit = {},
+ onTapToFocus: (Display, Int, Int, Float, Float) -> Unit = { _, _, _, _, _ -> },
+ onChangeZoomScale: (Float) -> Unit = {},
+ onChangeFlash: (FlashMode) -> Unit = {},
+ onChangeAspectRatio: (AspectRatio) -> Unit = {},
+ onChangeCaptureMode: (CaptureMode) -> Unit = {},
+ onChangeDynamicRange: (DynamicRange) -> Unit = {},
+ onToggleQuickSettings: () -> Unit = {},
+ onCaptureImage: () -> Unit = {},
+ onCaptureImageWithUri: (
+ ContentResolver,
+ Uri?,
+ Boolean,
+ (PreviewViewModel.ImageCaptureEvent) -> Unit
+ ) -> Unit = { _, _, _, _ -> },
+ onStartVideoRecording: () -> Unit = {},
+ onStopVideoRecording: () -> Unit = {},
+ onToastShown: () -> Unit = {},
+ onRequestWindowColorMode: (Int) -> Unit = {},
+ onSnackBarResult: (String) -> Unit = {}
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+ Scaffold(
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
+ ) {
+ val lensFacing = remember(previewUiState) {
+ previewUiState.currentCameraSettings.cameraLensFacing
+ }
- LaunchedEffect(lifecycleOwner) {
- val surfaceProvider = deferredSurfaceProvider.await()
- lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.runCamera(surfaceProvider)
- try {
- awaitCancellation()
- } finally {
- viewModel.stopCamera()
+ val onFlipCamera = remember(lensFacing) {
+ {
+ onSetLensFacing(lensFacing.flip())
}
}
- }
- if (previewUiState.cameraState == CameraState.NOT_READY) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(Color.Black),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- CircularProgressIndicator(modifier = Modifier.size(50.dp))
- Text(text = stringResource(R.string.camera_not_ready), color = Color.White)
- }
- } else if (previewUiState.cameraState == CameraState.READY) {
- Box(
- modifier = Modifier.semantics {
- testTagsAsResourceId = true
- }
- ) {
+
+ Box(modifier.fillMaxSize()) {
// 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)
- },
+ previewUiState = previewUiState,
+ onFlipCamera = onFlipCamera,
+ onTapToFocus = onTapToFocus,
+ onZoomChange = onChangeZoomScale,
aspectRatio = previewUiState.currentCameraSettings.aspectRatio,
- deferredSurfaceProvider = deferredSurfaceProvider
+ surfaceRequest = surfaceRequest,
+ onRequestWindowColorMode = onRequestWindowColorMode
)
QuickSettingsScreenOverlay(
modifier = Modifier,
+ previewUiState = previewUiState,
isOpen = previewUiState.quickSettingsIsOpen,
- toggleIsOpen = { viewModel.toggleQuickSettings() },
+ toggleIsOpen = onToggleQuickSettings,
currentCameraSettings = previewUiState.currentCameraSettings,
- onLensFaceClick = viewModel::flipCamera,
- onFlashModeClick = viewModel::setFlash,
- onAspectRatioClick = {
- viewModel.setAspectRatio(it)
- }
- // onTimerClick = {}/*TODO*/
+ systemConstraints = previewUiState.systemConstraints,
+ onLensFaceClick = onSetLensFacing,
+ onFlashModeClick = onChangeFlash,
+ onAspectRatioClick = onChangeAspectRatio,
+ onCaptureModeClick = onChangeCaptureMode,
+ onDynamicRangeClick = onChangeDynamicRange // 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
- )
-
- // 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
- )
- }
- }
- }
- }
-
- // 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
- .fillMaxWidth()
- .height(IntrinsicSize.Min)
- ) {
- when (previewUiState.videoRecordingState) {
- // hide first segment while recording in progress
- VideoRecordingState.ACTIVE -> {
- Spacer(
- modifier = Modifier
- .fillMaxHeight()
- .weight(1f)
- )
- }
- // show first segment when not recording
- VideoRecordingState.INACTIVE -> {
- Row(
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight(),
- 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() }
- val context = LocalContext.current
- CaptureButton(
- modifier = Modifier
- .testTag(CAPTURE_BUTTON),
- onClick = {
- 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()
- }
- },
- onRelease = { viewModel.stopVideoRecording() },
- videoRecordingState = previewUiState.videoRecordingState
- )
- // 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") */
- }
- }
- }
+ CameraControlsOverlay(
+ previewUiState = previewUiState,
+ onNavigateToSettings = onNavigateToSettings,
+ onFlipCamera = onFlipCamera,
+ onChangeFlash = onChangeFlash,
+ onToggleQuickSettings = onToggleQuickSettings,
+ onCaptureImage = onCaptureImage,
+ onCaptureImageWithUri = onCaptureImageWithUri,
+ onStartVideoRecording = onStartVideoRecording,
+ onStopVideoRecording = onStopVideoRecording
+ )
// 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
+ TestableToast(
+ modifier = Modifier.testTag(previewUiState.toastMessageToShow.testTag),
+ toastMessage = previewUiState.toastMessageToShow,
+ onToastShown = onToastShown
)
}
+ if (previewUiState.snackBarToShow != null) {
+ TestableSnackbar(
+ modifier = Modifier.testTag(previewUiState.snackBarToShow.testTag),
+ snackbarToShow = previewUiState.snackBarToShow,
+ snackbarHostState = snackbarHostState,
+ onSnackbarResult = onSnackBarResult
+ )
+ }
// 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)
@@ -353,27 +226,54 @@ fun PreviewScreen(
// if the relevant states are no longer changing.
ScreenFlashScreen(
screenFlashUiState = screenFlashUiState,
- onInitialBrightnessCalculated = viewModel.screenFlash::setClearUiScreenBrightness
+ onInitialBrightnessCalculated = onClearUiScreenBrightness
)
}
}
}
-/**
- * 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
+@Composable
+private fun LoadingScreen(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(50.dp))
+ Text(text = stringResource(R.string.camera_not_ready), color = Color.White)
+ }
+}
- /**
- * 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
+@Preview
+@Composable
+private fun ContentScreenPreview() {
+ MaterialTheme {
+ ContentScreen(
+ previewUiState = FAKE_PREVIEW_UI_STATE_READY,
+ screenFlashUiState = ScreenFlash.ScreenFlashUiState(),
+ surfaceRequest = null
+ )
+ }
}
+
+@Preview
+@Composable
+private fun ContentScreen_WhileRecording() {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ ContentScreen(
+ previewUiState = FAKE_PREVIEW_UI_STATE_READY.copy(
+ videoRecordingState = VideoRecordingState.ACTIVE
+ ),
+ screenFlashUiState = ScreenFlash.ScreenFlashUiState(),
+ surfaceRequest = null
+ )
+ }
+}
+
+private val FAKE_PREVIEW_UI_STATE_READY = PreviewUiState.Ready(
+ currentCameraSettings = DEFAULT_CAMERA_APP_SETTINGS,
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ previewMode = PreviewMode.StandardMode {}
+)
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 976a9f9..3bc1750 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
@@ -15,23 +15,33 @@
*/
package com.google.jetpackcamera.feature.preview
-import androidx.camera.core.CameraSelector
+import com.google.jetpackcamera.feature.preview.ui.SnackbarData
import com.google.jetpackcamera.feature.preview.ui.ToastMessage
import com.google.jetpackcamera.settings.model.CameraAppSettings
+import com.google.jetpackcamera.settings.model.SystemConstraints
/**
* Defines the current state of the [PreviewScreen].
*/
-data class PreviewUiState(
- val cameraState: CameraState = CameraState.NOT_READY,
- // "quick" settings
- val currentCameraSettings: CameraAppSettings,
- val lensFacing: Int = CameraSelector.LENS_FACING_BACK,
- val videoRecordingState: VideoRecordingState = VideoRecordingState.INACTIVE,
- val quickSettingsIsOpen: Boolean = false,
- // todo: remove after implementing post capture screen
- val toastMessageToShow: ToastMessage? = null
-)
+sealed interface PreviewUiState {
+ object NotReady : PreviewUiState
+
+ data class Ready(
+ // "quick" settings
+ val currentCameraSettings: CameraAppSettings,
+ val systemConstraints: SystemConstraints,
+ val zoomScale: Float = 1f,
+ val videoRecordingState: VideoRecordingState = VideoRecordingState.INACTIVE,
+ val quickSettingsIsOpen: Boolean = false,
+ val audioAmplitude: Double = 0.0,
+
+ // todo: remove after implementing post capture screen
+ val toastMessageToShow: ToastMessage? = null,
+ val snackBarToShow: SnackbarData? = null,
+ val lastBlinkTimeStamp: Long = 0,
+ val previewMode: PreviewMode
+ ) : PreviewUiState
+}
/**
* Defines the current state of Video Recording
@@ -47,18 +57,3 @@ enum class VideoRecordingState {
*/
ACTIVE
}
-
-/**
- * Defines the current state of the camera.
- */
-enum class CameraState {
- /**
- * Camera hasn't been initialized.
- */
- NOT_READY,
-
- /**
- * Camera is open and presenting a preview stream.
- */
- READY
-}
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 5a43e60..58c1a03 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
@@ -19,88 +19,116 @@ import android.content.ContentResolver
import android.net.Uri
import android.util.Log
import android.view.Display
-import androidx.camera.core.Preview.SurfaceProvider
+import androidx.camera.core.SurfaceRequest
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.feature.preview.ui.IMAGE_CAPTURE_FAILURE_TAG
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG
+import com.google.jetpackcamera.feature.preview.ui.SnackbarData
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+import com.google.jetpackcamera.settings.ConstraintsRepository
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.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
-import java.lang.Exception
-import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import javax.inject.Inject
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].
*/
@HiltViewModel
class PreviewViewModel @Inject constructor(
+// @Assisted previewMode: PreviewMode,
private val cameraUseCase: CameraUseCase,
- private val settingsRepository: SettingsRepository
- // only reads from settingsRepository. do not push changes to repository from here
+ private val constraintsRepository: ConstraintsRepository
+
) : ViewModel() {
+ private var previewMode: PreviewMode? = null
private val _previewUiState: MutableStateFlow<PreviewUiState> =
- MutableStateFlow(PreviewUiState(currentCameraSettings = DEFAULT_CAMERA_APP_SETTINGS))
+ MutableStateFlow(PreviewUiState.NotReady)
+
+ val previewUiState: StateFlow<PreviewUiState> =
+ _previewUiState.asStateFlow()
+
+ val surfaceRequest: StateFlow<SurfaceRequest?> = cameraUseCase.getSurfaceRequest()
- val previewUiState: StateFlow<PreviewUiState> = _previewUiState
private var runningCameraJob: Job? = null
private var recordingJob: Job? = null
val screenFlash = ScreenFlash(cameraUseCase, viewModelScope)
- init {
- viewModelScope.launch {
- settingsRepository.cameraAppSettings.collect {
- // TODO: only update settings that were actually changed
- // currently resets all "quick" settings to stored settings
- settings ->
- _previewUiState
- .emit(previewUiState.value.copy(currentCameraSettings = settings))
- }
- }
- initializeCamera()
+ private val imageCaptureCalledCount = atomic(0)
+ private val videoCaptureStartedCount = atomic(0)
+
+ // Eagerly initialize the CameraUseCase and encapsulate in a Deferred that can be
+ // used to ensure we don't start the camera before initialization is complete.
+ private var initializationDeferred: Deferred<Unit> = viewModelScope.async {
+ cameraUseCase.initialize(previewMode is PreviewMode.ExternalImageCaptureMode)
}
- private fun initializeCamera() {
- // TODO(yasith): Handle CameraUnavailableException
- Log.d(TAG, "initializeCamera")
+ fun init(previewMode: PreviewMode) {
+ this.previewMode = previewMode
viewModelScope.launch {
- cameraUseCase.initialize(previewUiState.value.currentCameraSettings)
- _previewUiState.emit(
- previewUiState.value.copy(
- cameraState = CameraState.READY
- )
- )
+ combine(
+ cameraUseCase.getCurrentSettings().filterNotNull(),
+ constraintsRepository.systemConstraints.filterNotNull(),
+ cameraUseCase.getZoomScale()
+ ) { cameraAppSettings, systemConstraints, zoomScale ->
+ _previewUiState.update { old ->
+ when (old) {
+ is PreviewUiState.Ready ->
+ old.copy(
+ currentCameraSettings = cameraAppSettings,
+ systemConstraints = systemConstraints,
+ zoomScale = zoomScale,
+ previewMode = previewMode
+ )
+
+ is PreviewUiState.NotReady ->
+ PreviewUiState.Ready(
+ currentCameraSettings = cameraAppSettings,
+ systemConstraints = systemConstraints,
+ zoomScale = zoomScale,
+ previewMode = previewMode
+ )
+ }
+ }
+ }.collect {}
}
}
- fun runCamera(surfaceProvider: SurfaceProvider) {
- Log.d(TAG, "runCamera")
+ fun startCamera() {
+ Log.d(TAG, "startCamera")
stopCamera()
runningCameraJob = viewModelScope.launch {
+ // Ensure CameraUseCase is initialized before starting camera
+ initializationDeferred.await()
// TODO(yasith): Handle Exceptions from binding use cases
- cameraUseCase.runCamera(
- surfaceProvider,
- previewUiState.value.currentCameraSettings
- )
+ cameraUseCase.runCamera()
}
}
@@ -115,181 +143,180 @@ class PreviewViewModel @Inject constructor(
fun setFlash(flashMode: FlashMode) {
viewModelScope.launch {
- _previewUiState.emit(
- previewUiState.value.copy(
- currentCameraSettings =
- previewUiState.value.currentCameraSettings.copy(
- flashMode = flashMode
- )
- )
- )
// apply to cameraUseCase
- cameraUseCase.setFlashMode(
- previewUiState.value.currentCameraSettings.flashMode,
- previewUiState.value.currentCameraSettings.isFrontCameraFacing
- )
+ cameraUseCase.setFlashMode(flashMode)
}
}
fun setAspectRatio(aspectRatio: AspectRatio) {
- stopCamera()
- runningCameraJob = viewModelScope.launch {
- _previewUiState.emit(
- previewUiState.value.copy(
- currentCameraSettings =
- previewUiState.value.currentCameraSettings.copy(
- aspectRatio = aspectRatio
- )
- )
- )
- cameraUseCase.setAspectRatio(
- aspectRatio,
- previewUiState.value
- .currentCameraSettings.isFrontCameraFacing
- )
+ viewModelScope.launch {
+ cameraUseCase.setAspectRatio(aspectRatio)
}
}
- // flips the camera opposite to its current direction
- fun flipCamera() {
- flipCamera(
- !previewUiState.value
- .currentCameraSettings.isFrontCameraFacing
- )
- }
-
- fun toggleCaptureMode() {
- val newCaptureMode = when (previewUiState.value.currentCameraSettings.captureMode) {
- CaptureMode.MULTI_STREAM -> CaptureMode.SINGLE_STREAM
- CaptureMode.SINGLE_STREAM -> CaptureMode.MULTI_STREAM
- }
-
- stopCamera()
- runningCameraJob = viewModelScope.launch {
- _previewUiState.emit(
- previewUiState.value.copy(
- currentCameraSettings =
- previewUiState.value.currentCameraSettings.copy(
- captureMode = newCaptureMode
- )
- )
- )
+ fun setCaptureMode(captureMode: CaptureMode) {
+ viewModelScope.launch {
// apply to cameraUseCase
- cameraUseCase.setCaptureMode(newCaptureMode)
+ cameraUseCase.setCaptureMode(captureMode)
}
}
- // sets the camera to a designated direction
- fun flipCamera(isFacingFront: Boolean) {
- // only flip if 2 directions are available
- if (previewUiState.value.currentCameraSettings.isBackCameraAvailable &&
- previewUiState.value.currentCameraSettings.isFrontCameraAvailable
- ) {
- stopCamera()
- runningCameraJob = viewModelScope.launch {
- _previewUiState.emit(
- previewUiState.value.copy(
- currentCameraSettings =
- previewUiState.value.currentCameraSettings.copy(
- isFrontCameraFacing = isFacingFront
- )
- )
- )
- // apply to cameraUseCase
- cameraUseCase.flipCamera(
- previewUiState.value.currentCameraSettings.isFrontCameraFacing,
- previewUiState.value.currentCameraSettings.flashMode
- )
- }
+ /** Sets the camera to a designated lens facing */
+ fun setLensFacing(newLensFacing: LensFacing) {
+ viewModelScope.launch {
+ // apply to cameraUseCase
+ cameraUseCase.setLensFacing(newLensFacing)
}
}
fun captureImage() {
Log.d(TAG, "captureImage")
viewModelScope.launch {
- 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())
+ captureImageInternal(
+ doTakePicture = {
+ cameraUseCase.takePicture {
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
+ lastBlinkTimeStamp = System.currentTimeMillis()
+ ) ?: old
+ }
+ }
}
- }
+ )
}
}
- fun captureImage(
+ fun captureImageWithUri(
contentResolver: ContentResolver,
imageCaptureUri: Uri?,
+ ignoreUri: Boolean = false,
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())
+ captureImageInternal(
+ doTakePicture = {
+ cameraUseCase.takePicture({
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
+ lastBlinkTimeStamp = System.currentTimeMillis()
+ ) ?: old
+ }
+ }, contentResolver, imageCaptureUri, ignoreUri).savedUri
+ },
+ onSuccess = { savedUri -> onImageCapture(ImageCaptureEvent.ImageSaved(savedUri)) },
+ onFailure = { exception ->
onImageCapture(ImageCaptureEvent.ImageCaptureError(exception))
}
+ )
+ }
+ }
+
+ private suspend fun <T> captureImageInternal(
+ doTakePicture: suspend () -> T,
+ onSuccess: (T) -> Unit = {},
+ onFailure: (exception: Exception) -> Unit = {}
+ ) {
+ val cookieInt = imageCaptureCalledCount.incrementAndGet()
+ val cookie = "Image-$cookieInt"
+ try {
+ traceAsync(IMAGE_CAPTURE_TRACE, cookieInt) {
+ doTakePicture()
+ }.also { result ->
+ onSuccess(result)
+ }
+ Log.d(TAG, "cameraUseCase.takePicture success")
+ SnackbarData(
+ cookie = cookie,
+ stringResource = R.string.toast_image_capture_success,
+ withDismissAction = true,
+ testTag = IMAGE_CAPTURE_SUCCESS_TAG
+ )
+ } catch (exception: Exception) {
+ onFailure(exception)
+ Log.d(TAG, "cameraUseCase.takePicture error", exception)
+ SnackbarData(
+ cookie = cookie,
+ stringResource = R.string.toast_capture_failure,
+ withDismissAction = true,
+ testTag = IMAGE_CAPTURE_FAILURE_TAG
+ )
+ }.also { snackBarData ->
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
+ // todo: remove snackBar after postcapture screen implemented
+ snackBarToShow = snackBarData
+ ) ?: old
}
}
}
fun startVideoRecording() {
+ if (previewUiState.value is PreviewUiState.Ready &&
+ (previewUiState.value as PreviewUiState.Ready).previewMode is
+ PreviewMode.ExternalImageCaptureMode
+ ) {
+ Log.d(TAG, "externalVideoRecording")
+ viewModelScope.launch {
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
+ snackBarToShow = SnackbarData(
+ cookie = "Video-ExternalImageCaptureMode",
+ stringResource = R.string.toast_video_capture_external_unsupported,
+ withDismissAction = true,
+ testTag = VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+ )
+ ) ?: old
+ }
+ }
+ return
+ }
Log.d(TAG, "startVideoRecording")
recordingJob = viewModelScope.launch {
+ val cookie = "Video-${videoCaptureStartedCount.incrementAndGet()}"
try {
- cameraUseCase.startVideoRecording()
- _previewUiState.emit(
- previewUiState.value.copy(
+ cameraUseCase.startVideoRecording {
+ var audioAmplitude = 0.0
+ var snackbarToShow: SnackbarData? = null
+ when (it) {
+ CameraUseCase.OnVideoRecordEvent.OnVideoRecorded -> {
+ snackbarToShow = SnackbarData(
+ cookie = cookie,
+ stringResource = R.string.toast_video_capture_success,
+ withDismissAction = true
+ )
+ }
+
+ CameraUseCase.OnVideoRecordEvent.OnVideoRecordError -> {
+ snackbarToShow = SnackbarData(
+ cookie = cookie,
+ stringResource = R.string.toast_video_capture_failure,
+ withDismissAction = true
+ )
+ }
+
+ is CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus -> {
+ audioAmplitude = it.audioAmplitude
+ }
+ }
+
+ viewModelScope.launch {
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
+ snackBarToShow = snackbarToShow,
+ audioAmplitude = audioAmplitude
+ ) ?: old
+ }
+ }
+ }
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
videoRecordingState = VideoRecordingState.ACTIVE
- )
- )
+ ) ?: old
+ }
Log.d(TAG, "cameraUseCase.startRecording success")
} catch (exception: IllegalStateException) {
- Log.d(TAG, "cameraUseCase.startVideoRecording error")
- Log.d(TAG, exception.toString())
+ Log.d(TAG, "cameraUseCase.startVideoRecording error", exception)
}
}
}
@@ -297,32 +324,34 @@ class PreviewViewModel @Inject constructor(
fun stopVideoRecording() {
Log.d(TAG, "stopVideoRecording")
viewModelScope.launch {
- _previewUiState.emit(
- previewUiState.value.copy(
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
videoRecordingState = VideoRecordingState.INACTIVE
- )
- )
+ ) ?: old
+ }
}
cameraUseCase.stopVideoRecording()
recordingJob?.cancel()
}
- fun setZoomScale(scale: Float): Float {
- return cameraUseCase.setZoomScale(scale = scale)
+ fun setZoomScale(scale: Float) {
+ cameraUseCase.setZoomScale(scale = scale)
}
- // modify ui values
- fun toggleQuickSettings() {
- toggleQuickSettings(!previewUiState.value.quickSettingsIsOpen)
+ fun setDynamicRange(dynamicRange: DynamicRange) {
+ viewModelScope.launch {
+ cameraUseCase.setDynamicRange(dynamicRange)
+ }
}
- private fun toggleQuickSettings(isOpen: Boolean) {
+ // modify ui values
+ fun toggleQuickSettings() {
viewModelScope.launch {
- _previewUiState.emit(
- previewUiState.value.copy(
- quickSettingsIsOpen = isOpen
- )
- )
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
+ quickSettingsIsOpen = !old.quickSettingsIsOpen
+ ) ?: old
+ }
}
}
@@ -336,20 +365,45 @@ class PreviewViewModel @Inject constructor(
)
}
+ /**
+ * Sets current value of [PreviewUiState.Ready.toastMessageToShow] to null.
+ */
fun onToastShown() {
viewModelScope.launch {
// keeps the composable up on screen longer to be detected by UiAutomator
delay(2.seconds)
- _previewUiState.emit(
- previewUiState.value.copy(
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
toastMessageToShow = null
- )
- )
+ ) ?: old
+ }
+ }
+ }
+
+ fun onSnackBarResult(cookie: String) {
+ viewModelScope.launch {
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.snackBarToShow?.let {
+ if (it.cookie == cookie) {
+ // If the latest snackbar had a result, then clear snackBarToShow
+ old.copy(snackBarToShow = null)
+ } else {
+ old
+ }
+ } ?: old
+ }
}
}
+// @AssistedFactory
+// interface Factory {
+// fun create(previewMode: PreviewMode): PreviewViewModel
+// }
+
sealed interface ImageCaptureEvent {
- object ImageSaved : ImageCaptureEvent
+ data class ImageSaved(
+ val savedUri: Uri? = null
+ ) : ImageCaptureEvent
data class ImageCaptureError(
val exception: Exception
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
index d29b4b4..e7ea585 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsEnums.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsEnums.kt
new file mode 100644
index 0000000..a569a8d
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsEnums.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2024 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.quicksettings
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AspectRatio
+import androidx.compose.material.icons.filled.Cameraswitch
+import androidx.compose.material.icons.filled.FlashAuto
+import androidx.compose.material.icons.filled.FlashOff
+import androidx.compose.material.icons.filled.FlashOn
+import androidx.compose.material.icons.filled.HdrOff
+import androidx.compose.material.icons.filled.HdrOn
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.res.painterResource
+import com.google.jetpackcamera.feature.preview.R
+
+interface QuickSettingsEnum {
+ @Composable
+ fun getPainter(): Painter {
+ val iconResId = getDrawableResId()
+ val iconVector = getImageVector()
+ require((iconResId == null).xor(iconVector == null)) {
+ "UI Item should have exactly one of iconResId or iconVector set."
+ }
+ return iconResId?.let { painterResource(it) }
+ ?: iconVector?.let {
+ rememberVectorPainter(
+ it
+ )
+ }!! // !! allowed because we've checked null
+ }
+
+ @DrawableRes
+ fun getDrawableResId(): Int?
+
+ fun getImageVector(): ImageVector?
+
+ @StringRes
+ fun getTextResId(): Int
+
+ @StringRes
+ fun getDescriptionResId(): Int
+}
+
+enum class CameraLensFace : QuickSettingsEnum {
+ FRONT {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.Cameraswitch
+ override fun getTextResId() = R.string.quick_settings_front_camera_text
+ override fun getDescriptionResId() = R.string.quick_settings_front_camera_description
+ },
+ BACK {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.Cameraswitch
+ override fun getTextResId() = R.string.quick_settings_back_camera_text
+ override fun getDescriptionResId() = R.string.quick_settings_back_camera_description
+ }
+}
+
+enum class CameraFlashMode : QuickSettingsEnum {
+ OFF {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.FlashOff
+ override fun getTextResId() = R.string.quick_settings_flash_off
+ override fun getDescriptionResId() = R.string.quick_settings_flash_off_description
+ },
+ AUTO {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.FlashAuto
+ override fun getTextResId() = R.string.quick_settings_flash_auto
+ override fun getDescriptionResId() = R.string.quick_settings_flash_auto_description
+ },
+ ON {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.FlashOn
+ override fun getTextResId() = R.string.quick_settings_flash_on
+ override fun getDescriptionResId() = R.string.quick_settings_flash_on_description
+ }
+}
+
+enum class CameraAspectRatio : QuickSettingsEnum {
+ THREE_FOUR {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.AspectRatio
+ override fun getTextResId() = R.string.quick_settings_aspect_ratio_3_4
+ override fun getDescriptionResId() = R.string.quick_settings_aspect_ratio_3_4_description
+ },
+ NINE_SIXTEEN {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.AspectRatio
+ override fun getTextResId() = R.string.quick_settings_aspect_ratio_9_16
+ override fun getDescriptionResId() = R.string.quick_settings_aspect_ratio_9_16_description
+ },
+ ONE_ONE {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.AspectRatio
+ override fun getTextResId() = R.string.quick_settings_aspect_ratio_1_1
+ override fun getDescriptionResId() = R.string.quick_settings_aspect_ratio_1_1_description
+ }
+}
+
+enum class CameraCaptureMode : QuickSettingsEnum {
+ MULTI_STREAM {
+ override fun getDrawableResId() = R.drawable.multi_stream_icon
+ override fun getImageVector() = null // this icon is not available
+ override fun getTextResId() = R.string.quick_settings_capture_mode_multi
+ override fun getDescriptionResId() = R.string.quick_settings_capture_mode_multi_description
+ },
+ SINGLE_STREAM {
+ override fun getDrawableResId() = R.drawable.single_stream_capture_icon
+ override fun getImageVector() = null // this icon is not available
+ override fun getTextResId() = R.string.quick_settings_capture_mode_single
+ override fun getDescriptionResId() = R.string.quick_settings_capture_mode_single_description
+ }
+}
+
+enum class CameraDynamicRange : QuickSettingsEnum {
+ SDR {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.HdrOff
+ override fun getTextResId() = R.string.quick_settings_dynamic_range_sdr
+ override fun getDescriptionResId() = R.string.quick_settings_dynamic_range_sdr_description
+ },
+ HLG10 {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.HdrOn
+ override fun getTextResId() = R.string.quick_settings_dynamic_range_hlg10
+ override fun getDescriptionResId() = R.string.quick_settings_dynamic_range_hlg10_description
+ }
+}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt
new file mode 100644
index 0000000..1eafa89
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright (C) 2024 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.quicksettings
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+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.platform.testTag
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.google.jetpackcamera.feature.preview.PreviewMode
+import com.google.jetpackcamera.feature.preview.PreviewUiState
+import com.google.jetpackcamera.feature.preview.R
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.ExpandedQuickSetRatio
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_CAPTURE_MODE_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLASH_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_HDR_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickFlipCamera
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetCaptureMode
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetFlash
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetHdr
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSetRatio
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSettingsGrid
+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.DynamicRange
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.SystemConstraints
+import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
+import com.google.jetpackcamera.settings.model.forCurrentLens
+
+/**
+ * The UI component for quick settings.
+ */
+@Composable
+fun QuickSettingsScreenOverlay(
+ previewUiState: PreviewUiState.Ready,
+ currentCameraSettings: CameraAppSettings,
+ systemConstraints: SystemConstraints,
+ toggleIsOpen: () -> Unit,
+ onLensFaceClick: (lensFace: LensFacing) -> Unit,
+ onFlashModeClick: (flashMode: FlashMode) -> Unit,
+ onAspectRatioClick: (aspectRation: AspectRatio) -> Unit,
+ onCaptureModeClick: (captureMode: CaptureMode) -> Unit,
+ onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit,
+ modifier: Modifier = Modifier,
+ isOpen: Boolean = false
+) {
+ var shouldShowQuickSetting by remember {
+ mutableStateOf(IsExpandedQuickSetting.NONE)
+ }
+
+ val backgroundColor =
+ animateColorAsState(
+ targetValue = Color.Black.copy(alpha = if (isOpen) 0.7f else 0f),
+ label = "backgroundColorAnimation"
+ )
+
+ val contentAlpha =
+ animateFloatAsState(
+ targetValue = if (isOpen) 1f else 0f,
+ label = "contentAlphaAnimation",
+ animationSpec = tween()
+ )
+
+ if (isOpen) {
+ val onBack = {
+ when (shouldShowQuickSetting) {
+ IsExpandedQuickSetting.NONE -> toggleIsOpen()
+ else -> shouldShowQuickSetting = IsExpandedQuickSetting.NONE
+ }
+ }
+ BackHandler(onBack = onBack)
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(color = backgroundColor.value)
+ .alpha(alpha = contentAlpha.value)
+ .clickable(onClick = onBack),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ ExpandedQuickSettingsUi(
+ previewUiState = previewUiState,
+ currentCameraSettings = currentCameraSettings,
+ systemConstraints = systemConstraints,
+ shouldShowQuickSetting = shouldShowQuickSetting,
+ setVisibleQuickSetting = { enum: IsExpandedQuickSetting ->
+ shouldShowQuickSetting = enum
+ },
+ onLensFaceClick = onLensFaceClick,
+ onFlashModeClick = onFlashModeClick,
+ onAspectRatioClick = onAspectRatioClick,
+ onCaptureModeClick = onCaptureModeClick,
+ onDynamicRangeClick = onDynamicRangeClick
+ )
+ }
+ } else {
+ shouldShowQuickSetting = IsExpandedQuickSetting.NONE
+ }
+}
+
+// enum representing which individual quick setting is currently expanded
+private enum class IsExpandedQuickSetting {
+ NONE,
+ ASPECT_RATIO
+}
+
+/**
+ * The UI component for quick settings when it is expanded.
+ */
+@Composable
+private fun ExpandedQuickSettingsUi(
+ previewUiState: PreviewUiState.Ready,
+ currentCameraSettings: CameraAppSettings,
+ systemConstraints: SystemConstraints,
+ onLensFaceClick: (newLensFace: LensFacing) -> Unit,
+ onFlashModeClick: (flashMode: FlashMode) -> Unit,
+ onAspectRatioClick: (aspectRation: AspectRatio) -> Unit,
+ onCaptureModeClick: (captureMode: CaptureMode) -> Unit,
+ shouldShowQuickSetting: IsExpandedQuickSetting,
+ setVisibleQuickSetting: (IsExpandedQuickSetting) -> Unit,
+ onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit
+) {
+ Column(
+ modifier =
+ Modifier
+ .padding(
+ horizontal = dimensionResource(
+ id = R.dimen.quick_settings_ui_horizontal_padding
+ )
+ )
+ ) {
+ // if no setting is chosen, display the grid of settings
+ // to change the order of display just move these lines of code above or below each other
+ when (shouldShowQuickSetting) {
+ IsExpandedQuickSetting.NONE -> {
+ val displayedQuickSettings: List<@Composable () -> Unit> =
+ buildList {
+ add {
+ QuickSetFlash(
+ modifier = Modifier.testTag(QUICK_SETTINGS_FLASH_BUTTON),
+ onClick = { f: FlashMode -> onFlashModeClick(f) },
+ currentFlashMode = currentCameraSettings.flashMode
+ )
+ }
+
+ add {
+ QuickFlipCamera(
+ modifier = Modifier.testTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON),
+ setLensFacing = { l: LensFacing -> onLensFaceClick(l) },
+ currentLensFacing = currentCameraSettings.cameraLensFacing
+ )
+ }
+
+ add {
+ QuickSetRatio(
+ modifier = Modifier.testTag(QUICK_SETTINGS_RATIO_BUTTON),
+ onClick = {
+ setVisibleQuickSetting(
+ IsExpandedQuickSetting.ASPECT_RATIO
+ )
+ },
+ ratio = currentCameraSettings.aspectRatio,
+ currentRatio = currentCameraSettings.aspectRatio
+ )
+ }
+
+ add {
+ QuickSetCaptureMode(
+ modifier = Modifier.testTag(QUICK_SETTINGS_CAPTURE_MODE_BUTTON),
+ setCaptureMode = { c: CaptureMode -> onCaptureModeClick(c) },
+ currentCaptureMode = currentCameraSettings.captureMode
+ )
+ }
+
+ add {
+ QuickSetHdr(
+ modifier = Modifier.testTag(QUICK_SETTINGS_HDR_BUTTON),
+ onClick = { d: DynamicRange -> onDynamicRangeClick(d) },
+ selectedDynamicRange = currentCameraSettings.dynamicRange,
+ hdrDynamicRange = currentCameraSettings.defaultHdrDynamicRange,
+ enabled = previewUiState.previewMode !is
+ PreviewMode.ExternalImageCaptureMode &&
+ previewUiState.systemConstraints.forCurrentLens(
+ currentCameraSettings
+ )
+ ?.let { it.supportedDynamicRanges.size > 1 } ?: false
+ )
+ }
+ }
+ QuickSettingsGrid(quickSettingsButtons = displayedQuickSettings)
+ }
+ // if a setting that can be expanded is selected, show it
+ IsExpandedQuickSetting.ASPECT_RATIO -> {
+ ExpandedQuickSetRatio(
+ setRatio = onAspectRatioClick,
+ currentRatio = currentCameraSettings.aspectRatio
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ExpandedQuickSettingsUiPreview() {
+ MaterialTheme {
+ ExpandedQuickSettingsUi(
+ previewUiState = PreviewUiState.Ready(
+ currentCameraSettings = CameraAppSettings(),
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ previewMode = PreviewMode.StandardMode {}
+ ),
+ currentCameraSettings = CameraAppSettings(),
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ onLensFaceClick = { },
+ onFlashModeClick = { },
+ shouldShowQuickSetting = IsExpandedQuickSetting.NONE,
+ setVisibleQuickSetting = { },
+ onAspectRatioClick = { },
+ onCaptureModeClick = { },
+ onDynamicRangeClick = { }
+ )
+ }
+}
+
+@Preview
+@Composable
+fun ExpandedQuickSettingsUiPreview_WithHdr() {
+ MaterialTheme {
+ ExpandedQuickSettingsUi(
+ previewUiState = PreviewUiState.Ready(
+ currentCameraSettings = CameraAppSettings(),
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ previewMode = PreviewMode.StandardMode {}
+ ),
+ currentCameraSettings = CameraAppSettings(dynamicRange = DynamicRange.HLG10),
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS_WITH_HDR,
+ onLensFaceClick = { },
+ onFlashModeClick = { },
+ shouldShowQuickSetting = IsExpandedQuickSetting.NONE,
+ setVisibleQuickSetting = { },
+ onAspectRatioClick = { },
+ onCaptureModeClick = { },
+ onDynamicRangeClick = { }
+ )
+ }
+}
+
+private val TYPICAL_SYSTEM_CONSTRAINTS_WITH_HDR =
+ TYPICAL_SYSTEM_CONSTRAINTS.copy(
+ perLensConstraints = TYPICAL_SYSTEM_CONSTRAINTS.perLensConstraints.entries.associate {
+ (lensFacing, constraints) ->
+ lensFacing to constraints.copy(
+ supportedDynamicRanges = setOf(DynamicRange.SDR, DynamicRange.HLG10)
+ )
+ }
+ )
diff --git a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt
index 3242f35..f7af6e0 100644
--- a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt
@@ -13,9 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.feature.quicksettings.ui
+package com.google.jetpackcamera.feature.preview.quicksettings.ui
-import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -26,39 +25,53 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalConfiguration
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import com.google.jetpackcamera.feature.quicksettings.CameraAspectRatio
-import com.google.jetpackcamera.feature.quicksettings.CameraFlashMode
-import com.google.jetpackcamera.feature.quicksettings.CameraLensFace
-import com.google.jetpackcamera.feature.quicksettings.QuickSettingsEnum
-import com.google.jetpackcamera.quicksettings.R
+import com.google.jetpackcamera.feature.preview.R
+import com.google.jetpackcamera.feature.preview.quicksettings.CameraAspectRatio
+import com.google.jetpackcamera.feature.preview.quicksettings.CameraCaptureMode
+import com.google.jetpackcamera.feature.preview.quicksettings.CameraDynamicRange
+import com.google.jetpackcamera.feature.preview.quicksettings.CameraFlashMode
+import com.google.jetpackcamera.feature.preview.quicksettings.CameraLensFace
+import com.google.jetpackcamera.feature.preview.quicksettings.QuickSettingsEnum
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.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
import kotlin.math.min
// completed components ready to go into preview screen
@Composable
-fun ExpandedQuickSetRatio(setRatio: (aspectRatio: AspectRatio) -> Unit, currentRatio: AspectRatio) {
+fun ExpandedQuickSetRatio(
+ setRatio: (aspectRatio: AspectRatio) -> Unit,
+ currentRatio: AspectRatio,
+ modifier: Modifier = Modifier
+) {
val buttons: Array<@Composable () -> Unit> =
arrayOf(
{
QuickSetRatio(
+ modifier = Modifier.testTag(QUICK_SETTINGS_RATIO_3_4_BUTTON),
onClick = { setRatio(AspectRatio.THREE_FOUR) },
ratio = AspectRatio.THREE_FOUR,
currentRatio = currentRatio,
@@ -67,6 +80,7 @@ fun ExpandedQuickSetRatio(setRatio: (aspectRatio: AspectRatio) -> Unit, currentR
},
{
QuickSetRatio(
+ modifier = Modifier.testTag(QUICK_SETTINGS_RATIO_9_16_BUTTON),
onClick = { setRatio(AspectRatio.NINE_SIXTEEN) },
ratio = AspectRatio.NINE_SIXTEEN,
currentRatio = currentRatio,
@@ -75,7 +89,7 @@ fun ExpandedQuickSetRatio(setRatio: (aspectRatio: AspectRatio) -> Unit, currentR
},
{
QuickSetRatio(
- modifier = Modifier.testTag("QuickSetAspectRatio1:1"),
+ modifier = Modifier.testTag(QUICK_SETTINGS_RATIO_1_1_BUTTON),
onClick = { setRatio(AspectRatio.ONE_ONE) },
ratio = AspectRatio.ONE_ONE,
currentRatio = currentRatio,
@@ -83,15 +97,44 @@ fun ExpandedQuickSetRatio(setRatio: (aspectRatio: AspectRatio) -> Unit, currentR
)
}
)
- ExpandedQuickSetting(quickSettingButtons = buttons)
+ ExpandedQuickSetting(modifier = modifier, quickSettingButtons = buttons)
}
@Composable
-fun QuickSetRatio(
+fun QuickSetHdr(
modifier: Modifier = Modifier,
+ onClick: (dynamicRange: DynamicRange) -> Unit,
+ selectedDynamicRange: DynamicRange,
+ hdrDynamicRange: DynamicRange,
+ enabled: Boolean = true
+) {
+ val enum =
+ when (selectedDynamicRange) {
+ DynamicRange.SDR -> CameraDynamicRange.SDR
+ DynamicRange.HLG10 -> CameraDynamicRange.HLG10
+ }
+ QuickSettingUiItem(
+ modifier = modifier,
+ enum = enum,
+ onClick = {
+ val newDynamicRange = if (selectedDynamicRange == DynamicRange.SDR) {
+ hdrDynamicRange
+ } else {
+ DynamicRange.SDR
+ }
+ onClick(newDynamicRange)
+ },
+ isHighLighted = (selectedDynamicRange != DynamicRange.SDR),
+ enabled = enabled
+ )
+}
+
+@Composable
+fun QuickSetRatio(
onClick: () -> Unit,
ratio: AspectRatio,
currentRatio: AspectRatio,
+ modifier: Modifier = Modifier,
isHighlightEnabled: Boolean = false
) {
val enum =
@@ -111,9 +154,9 @@ fun QuickSetRatio(
@Composable
fun QuickSetFlash(
- modifier: Modifier = Modifier,
onClick: (FlashMode) -> Unit,
- currentFlashMode: FlashMode
+ currentFlashMode: FlashMode,
+ modifier: Modifier = Modifier
) {
val enum = when (currentFlashMode) {
FlashMode.OFF -> CameraFlashMode.OFF
@@ -141,26 +184,42 @@ fun QuickSetFlash(
@Composable
fun QuickFlipCamera(
- modifier: Modifier = Modifier,
- flipCamera: (Boolean) -> Unit,
- currentFacingFront: Boolean
+ setLensFacing: (LensFacing) -> Unit,
+ currentLensFacing: LensFacing,
+ modifier: Modifier = Modifier
) {
val enum =
- when (currentFacingFront) {
- true -> CameraLensFace.FRONT
- false -> CameraLensFace.BACK
+ when (currentLensFacing) {
+ LensFacing.FRONT -> CameraLensFace.FRONT
+ LensFacing.BACK -> CameraLensFace.BACK
}
QuickSettingUiItem(
- modifier = modifier
- .semantics {
- contentDescription =
- when (enum) {
- CameraLensFace.FRONT -> "QUICK SETTINGS LENS FACING FRONT"
- CameraLensFace.BACK -> "QUICK SETTINGS LENS FACING BACK"
- }
- },
+ modifier = modifier,
enum = enum,
- onClick = { flipCamera(!currentFacingFront) }
+ onClick = { setLensFacing(currentLensFacing.flip()) }
+ )
+}
+
+@Composable
+fun QuickSetCaptureMode(
+ setCaptureMode: (CaptureMode) -> Unit,
+ currentCaptureMode: CaptureMode,
+ modifier: Modifier = Modifier
+) {
+ val enum: CameraCaptureMode =
+ when (currentCaptureMode) {
+ CaptureMode.MULTI_STREAM -> CameraCaptureMode.MULTI_STREAM
+ CaptureMode.SINGLE_STREAM -> CameraCaptureMode.SINGLE_STREAM
+ }
+ QuickSettingUiItem(
+ modifier = modifier,
+ enum = enum,
+ onClick = {
+ when (currentCaptureMode) {
+ CaptureMode.MULTI_STREAM -> setCaptureMode(CaptureMode.SINGLE_STREAM)
+ CaptureMode.SINGLE_STREAM -> setCaptureMode(CaptureMode.MULTI_STREAM)
+ }
+ }
)
}
@@ -169,23 +228,25 @@ fun QuickFlipCamera(
*/
@Composable
fun ToggleQuickSettingsButton(
- modifier: Modifier = Modifier,
toggleDropDown: () -> Unit,
- isOpen: Boolean
+ isOpen: Boolean,
+ modifier: Modifier = Modifier
) {
Row(
- modifier = modifier,
horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier
) {
// dropdown icon
Icon(
- painter = painterResource(R.drawable.baseline_expand_more_72),
- contentDescription = stringResource(R.string.quick_settings_dropdown_description),
- tint = Color.White,
- modifier =
- Modifier
- .testTag("QuickSettingDropDown")
+ imageVector = Icons.Filled.ExpandMore,
+ contentDescription = if (isOpen) {
+ stringResource(R.string.quick_settings_dropdown_open_description)
+ } else {
+ stringResource(R.string.quick_settings_dropdown_closed_description)
+ },
+ modifier = Modifier
+ .testTag(QUICK_SETTINGS_DROP_DOWN)
.size(72.dp)
.clickable {
toggleDropDown()
@@ -199,18 +260,20 @@ fun ToggleQuickSettingsButton(
@Composable
fun QuickSettingUiItem(
- modifier: Modifier = Modifier,
enum: QuickSettingsEnum,
onClick: () -> Unit,
- isHighLighted: Boolean = false
+ modifier: Modifier = Modifier,
+ isHighLighted: Boolean = false,
+ enabled: Boolean = true
) {
QuickSettingUiItem(
modifier = modifier,
- drawableResId = enum.getDrawableResId(),
+ painter = enum.getPainter(),
text = stringResource(id = enum.getTextResId()),
accessibilityText = stringResource(id = enum.getDescriptionResId()),
onClick = { onClick() },
- isHighLighted = isHighLighted
+ isHighLighted = isHighLighted,
+ enabled = enabled
)
}
@@ -219,35 +282,40 @@ fun QuickSettingUiItem(
*/
@Composable
fun QuickSettingUiItem(
- modifier: Modifier = Modifier,
- @DrawableRes drawableResId: Int,
text: String,
+ painter: Painter,
accessibilityText: String,
onClick: () -> Unit,
- isHighLighted: Boolean = false
+ modifier: Modifier = Modifier,
+ isHighLighted: Boolean = false,
+ enabled: Boolean = true
) {
Column(
modifier =
modifier
.wrapContentSize()
.padding(dimensionResource(id = R.dimen.quick_settings_ui_item_padding))
- .clickable {
- onClick()
- },
+ .clickable(onClick = onClick, enabled = enabled),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
- val tint = if (isHighLighted) Color.Yellow else Color.White
- Icon(
- painter = painterResource(drawableResId),
- contentDescription = accessibilityText,
- tint = tint,
- modifier =
- Modifier
- .size(dimensionResource(id = R.dimen.quick_settings_ui_item_icon_size))
- )
+ val contentColor = (if (isHighLighted) Color.Yellow else Color.White).let {
+ // When in disabled state, material3 guidelines say the element's opacity should be 38%
+ // See: https://m3.material.io/foundations/interaction/states/applying-states#3c3032e8-b07a-42ac-a508-a32f573cc7e1
+ // and: https://developer.android.com/develop/ui/compose/designsystems/material2-material3#emphasis-and
+ if (!enabled) it.copy(alpha = 0.38f) else it
+ }
+ CompositionLocalProvider(LocalContentColor provides contentColor) {
+ Icon(
+ painter = painter,
+ contentDescription = accessibilityText,
+ modifier = Modifier.size(
+ dimensionResource(id = R.dimen.quick_settings_ui_item_icon_size)
+ )
+ )
- Text(text = text, color = tint)
+ Text(text = text, textAlign = TextAlign.Center)
+ }
}
}
@@ -292,7 +360,7 @@ fun ExpandedQuickSetting(
@Composable
fun QuickSettingsGrid(
modifier: Modifier = Modifier,
- vararg quickSettingsButtons: @Composable () -> Unit
+ quickSettingsButtons: List<@Composable () -> Unit>
) {
val initialNumOfColumns =
min(
@@ -326,12 +394,11 @@ fun QuickSettingsGrid(
* The top bar indicators for quick settings items.
*/
@Composable
-fun Indicator(enum: QuickSettingsEnum, onClick: () -> Unit) {
+fun Indicator(enum: QuickSettingsEnum, onClick: () -> Unit, modifier: Modifier = Modifier) {
Icon(
- painter = painterResource(enum.getDrawableResId()),
+ painter = enum.getPainter(),
contentDescription = stringResource(id = enum.getDescriptionResId()),
- tint = Color.White,
- modifier = Modifier
+ modifier = modifier
.size(dimensionResource(id = R.dimen.quick_settings_indicator_size))
.clickable { onClick() }
)
@@ -354,11 +421,12 @@ fun FlashModeIndicator(currentFlashMode: FlashMode, onClick: (flashMode: FlashMo
@Composable
fun QuickSettingsIndicators(
- currentCameraSettings: CameraAppSettings,
- onFlashModeClick: (flashMode: FlashMode) -> Unit
+ currentFlashMode: FlashMode,
+ onFlashModeClick: (flashMode: FlashMode) -> Unit,
+ modifier: Modifier = Modifier
) {
- Row {
- FlashModeIndicator(currentCameraSettings.flashMode, onFlashModeClick)
+ Row(modifier) {
+ FlashModeIndicator(currentFlashMode, onFlashModeClick)
}
}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/TestTags.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/TestTags.kt
new file mode 100644
index 0000000..f89fb15
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/TestTags.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 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.quicksettings.ui
+
+const val QUICK_SETTINGS_CAPTURE_MODE_BUTTON = "QuickSettingsCaptureModeButton"
+const val QUICK_SETTINGS_DROP_DOWN = "QuickSettingsDropDown"
+const val QUICK_SETTINGS_HDR_BUTTON = "QuickSettingsHdrButton"
+const val QUICK_SETTINGS_FLASH_BUTTON = "QuickSettingsFlashButton"
+const val QUICK_SETTINGS_FLIP_CAMERA_BUTTON = "QuickSettingsFlipCameraButton"
+const val QUICK_SETTINGS_RATIO_3_4_BUTTON = "QuickSettingsRatio3:4Button"
+const val QUICK_SETTINGS_RATIO_9_16_BUTTON = "QuickSettingsRatio9:16Button"
+const val QUICK_SETTINGS_RATIO_1_1_BUTTON = "QuickSettingsRatio1:1Button"
+const val QUICK_SETTINGS_RATIO_BUTTON = "QuickSettingsRatioButton"
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt
new file mode 100644
index 0000000..f8e1d54
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt
@@ -0,0 +1,468 @@
+/*
+ * Copyright (C) 2024 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.content.ContentResolver
+import android.net.Uri
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.google.jetpackcamera.feature.preview.MultipleEventsCutter
+import com.google.jetpackcamera.feature.preview.PreviewMode
+import com.google.jetpackcamera.feature.preview.PreviewUiState
+import com.google.jetpackcamera.feature.preview.PreviewViewModel
+import com.google.jetpackcamera.feature.preview.VideoRecordingState
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSettingsIndicators
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.ToggleQuickSettingsButton
+import com.google.jetpackcamera.settings.model.CameraAppSettings
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.Stabilization
+import com.google.jetpackcamera.settings.model.SystemConstraints
+import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
+import kotlinx.coroutines.delay
+
+class ZoomLevelDisplayState(showInitially: Boolean = false) {
+ private var _showZoomLevel = mutableStateOf(showInitially)
+ val showZoomLevel: Boolean get() = _showZoomLevel.value
+
+ suspend fun showZoomLevel() {
+ _showZoomLevel.value = true
+ delay(3000)
+ _showZoomLevel.value = false
+ }
+}
+
+@Composable
+fun CameraControlsOverlay(
+ previewUiState: PreviewUiState.Ready,
+ modifier: Modifier = Modifier,
+ zoomLevelDisplayState: ZoomLevelDisplayState = remember { ZoomLevelDisplayState() },
+ onNavigateToSettings: () -> Unit = {},
+ onFlipCamera: () -> Unit = {},
+ onChangeFlash: (FlashMode) -> Unit = {},
+ onToggleQuickSettings: () -> Unit = {},
+ onCaptureImage: () -> Unit = {},
+ onCaptureImageWithUri: (
+ ContentResolver,
+ Uri?,
+ Boolean,
+ (PreviewViewModel.ImageCaptureEvent) -> Unit
+ ) -> Unit = { _, _, _, _ -> },
+ onStartVideoRecording: () -> Unit = {},
+ onStopVideoRecording: () -> Unit = {}
+) {
+ // Show the current zoom level for a short period of time, only when the level changes.
+ var firstRun by remember { mutableStateOf(true) }
+ LaunchedEffect(previewUiState.zoomScale) {
+ if (firstRun) {
+ firstRun = false
+ } else {
+ zoomLevelDisplayState.showZoomLevel()
+ }
+ }
+
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ Box(modifier.fillMaxSize()) {
+ if (previewUiState.videoRecordingState == VideoRecordingState.INACTIVE) {
+ ControlsTop(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.TopCenter),
+ isQuickSettingsOpen = previewUiState.quickSettingsIsOpen,
+ currentCameraSettings = previewUiState.currentCameraSettings,
+ onNavigateToSettings = onNavigateToSettings,
+ onChangeFlash = onChangeFlash,
+ onToggleQuickSettings = onToggleQuickSettings
+ )
+ }
+
+ ControlsBottom(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter),
+ previewUiState = previewUiState,
+ audioAmplitude = previewUiState.audioAmplitude,
+ zoomLevel = previewUiState.zoomScale,
+ showZoomLevel = zoomLevelDisplayState.showZoomLevel,
+ isQuickSettingsOpen = previewUiState.quickSettingsIsOpen,
+ systemConstraints = previewUiState.systemConstraints,
+ videoRecordingState = previewUiState.videoRecordingState,
+ onFlipCamera = onFlipCamera,
+ onCaptureImage = onCaptureImage,
+ onCaptureImageWithUri = onCaptureImageWithUri,
+ onToggleQuickSettings = onToggleQuickSettings,
+ onStartVideoRecording = onStartVideoRecording,
+ onStopVideoRecording = onStopVideoRecording
+ )
+ }
+ }
+}
+
+@Composable
+private fun ControlsTop(
+ isQuickSettingsOpen: Boolean,
+ currentCameraSettings: CameraAppSettings,
+ modifier: Modifier = Modifier,
+ onNavigateToSettings: () -> Unit = {},
+ onChangeFlash: (FlashMode) -> Unit = {},
+ onToggleQuickSettings: () -> Unit = {}
+) {
+ Row(modifier, verticalAlignment = Alignment.CenterVertically) {
+ Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
+ // button to open default settings page
+ SettingsNavButton(
+ modifier = Modifier
+ .padding(12.dp)
+ .testTag(SETTINGS_BUTTON),
+ onNavigateToSettings = onNavigateToSettings
+ )
+ if (!isQuickSettingsOpen) {
+ QuickSettingsIndicators(
+ currentFlashMode = currentCameraSettings.flashMode,
+ onFlashModeClick = onChangeFlash
+ )
+ }
+ }
+
+ // quick settings button
+ ToggleQuickSettingsButton(onToggleQuickSettings, isQuickSettingsOpen)
+
+ Row(
+ Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ StabilizationIcon(
+ videoStabilization = currentCameraSettings.videoCaptureStabilization,
+ previewStabilization = currentCameraSettings.previewStabilization
+ )
+ }
+ }
+}
+
+@Composable
+private fun ControlsBottom(
+ modifier: Modifier = Modifier,
+ audioAmplitude: Double,
+ previewUiState: PreviewUiState.Ready,
+ zoomLevel: Float,
+ showZoomLevel: Boolean,
+ isQuickSettingsOpen: Boolean,
+ systemConstraints: SystemConstraints,
+ videoRecordingState: VideoRecordingState,
+ onFlipCamera: () -> Unit = {},
+ onCaptureImage: () -> Unit = {},
+ onCaptureImageWithUri: (
+ ContentResolver,
+ Uri?,
+ Boolean,
+ (PreviewViewModel.ImageCaptureEvent) -> Unit
+ ) -> Unit = { _, _, _, _ -> },
+ onToggleQuickSettings: () -> Unit = {},
+ onStartVideoRecording: () -> Unit = {},
+ onStopVideoRecording: () -> Unit = {}
+) {
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ if (showZoomLevel) {
+ ZoomScaleText(zoomLevel)
+ }
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Max),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) {
+ if (!isQuickSettingsOpen && videoRecordingState == VideoRecordingState.INACTIVE) {
+ FlipCameraButton(
+ modifier = Modifier.testTag(FLIP_CAMERA_BUTTON),
+ onClick = onFlipCamera,
+ // enable only when phone has front and rear camera
+ enabledCondition = systemConstraints.availableLenses.size > 1
+ )
+ }
+ }
+ CaptureButton(
+ previewUiState = previewUiState,
+ isQuickSettingsOpen = isQuickSettingsOpen,
+ videoRecordingState = videoRecordingState,
+ onCaptureImage = onCaptureImage,
+ onCaptureImageWithUri = onCaptureImageWithUri,
+ onToggleQuickSettings = onToggleQuickSettings,
+ onStartVideoRecording = onStartVideoRecording,
+ onStopVideoRecording = onStopVideoRecording
+ )
+ Row(Modifier.weight(1f)) {
+ if (videoRecordingState == VideoRecordingState.ACTIVE) {
+ AmplitudeVisualizer(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxSize(),
+ size = 75,
+ audioAmplitude = audioAmplitude
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CaptureButton(
+ previewUiState: PreviewUiState.Ready,
+ isQuickSettingsOpen: Boolean,
+ videoRecordingState: VideoRecordingState,
+ modifier: Modifier = Modifier,
+ onCaptureImage: () -> Unit = {},
+ onCaptureImageWithUri: (
+ ContentResolver,
+ Uri?,
+ Boolean,
+ (PreviewViewModel.ImageCaptureEvent) -> Unit
+ ) -> Unit = { _, _, _, _ -> },
+ onToggleQuickSettings: () -> Unit = {},
+ onStartVideoRecording: () -> Unit = {},
+ onStopVideoRecording: () -> Unit = {}
+) {
+ val multipleEventsCutter = remember { MultipleEventsCutter() }
+ val context = LocalContext.current
+ CaptureButton(
+ modifier = modifier.testTag(CAPTURE_BUTTON),
+ onClick = {
+ multipleEventsCutter.processEvent {
+ when (previewUiState.previewMode) {
+ is PreviewMode.StandardMode -> {
+ onCaptureImageWithUri(
+ context.contentResolver,
+ null,
+ true,
+ previewUiState.previewMode.onImageCapture
+ )
+ }
+
+ is PreviewMode.ExternalImageCaptureMode -> {
+ onCaptureImageWithUri(
+ context.contentResolver,
+ previewUiState.previewMode.imageCaptureUri,
+ false,
+ previewUiState.previewMode.onImageCapture
+ )
+ }
+ }
+ }
+ if (isQuickSettingsOpen) {
+ onToggleQuickSettings()
+ }
+ },
+ onLongPress = {
+ onStartVideoRecording()
+ if (isQuickSettingsOpen) {
+ onToggleQuickSettings()
+ }
+ },
+ onRelease = { onStopVideoRecording() },
+ videoRecordingState = videoRecordingState
+ )
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsTop_QuickSettingsOpen() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsTop(
+ isQuickSettingsOpen = true,
+ currentCameraSettings = CameraAppSettings()
+ )
+ }
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsTop_QuickSettingsClosed() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsTop(
+ isQuickSettingsOpen = false,
+ currentCameraSettings = CameraAppSettings()
+ )
+ }
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsTop_FlashModeOn() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsTop(
+ isQuickSettingsOpen = false,
+ currentCameraSettings = CameraAppSettings(flashMode = FlashMode.ON)
+ )
+ }
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsTop_FlashModeAuto() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsTop(
+ isQuickSettingsOpen = false,
+ currentCameraSettings = CameraAppSettings(flashMode = FlashMode.AUTO)
+ )
+ }
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsTop_WithStabilization() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsTop(
+ isQuickSettingsOpen = false,
+ currentCameraSettings = CameraAppSettings(
+ videoCaptureStabilization = Stabilization.ON,
+ previewStabilization = Stabilization.ON
+ )
+ )
+ }
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsBottom() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsBottom(
+ previewUiState = PreviewUiState.Ready(
+ currentCameraSettings = CameraAppSettings(),
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ previewMode = PreviewMode.StandardMode {}
+ ),
+ zoomLevel = 1.3f,
+ showZoomLevel = true,
+ isQuickSettingsOpen = false,
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ videoRecordingState = VideoRecordingState.INACTIVE,
+ audioAmplitude = 0.0
+ )
+ }
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsBottom_NoZoomLevel() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsBottom(
+ previewUiState = PreviewUiState.Ready(
+ currentCameraSettings = CameraAppSettings(),
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ previewMode = PreviewMode.StandardMode {}
+ ),
+ zoomLevel = 1.3f,
+ showZoomLevel = false,
+ isQuickSettingsOpen = false,
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ videoRecordingState = VideoRecordingState.INACTIVE,
+ audioAmplitude = 0.0
+ )
+ }
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsBottom_QuickSettingsOpen() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsBottom(
+ previewUiState = PreviewUiState.Ready(
+ currentCameraSettings = CameraAppSettings(),
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ previewMode = PreviewMode.StandardMode {}
+ ),
+ zoomLevel = 1.3f,
+ showZoomLevel = true,
+ isQuickSettingsOpen = true,
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ videoRecordingState = VideoRecordingState.INACTIVE,
+ audioAmplitude = 0.0
+
+ )
+ }
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsBottom_NoFlippableCamera() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsBottom(
+ previewUiState = PreviewUiState.Ready(
+ currentCameraSettings = CameraAppSettings(),
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ previewMode = PreviewMode.StandardMode {}
+ ),
+ zoomLevel = 1.3f,
+ showZoomLevel = true,
+ isQuickSettingsOpen = false,
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS.copy(
+ availableLenses = listOf(LensFacing.FRONT),
+ perLensConstraints = mapOf(
+ LensFacing.FRONT to
+ TYPICAL_SYSTEM_CONSTRAINTS.perLensConstraints[LensFacing.FRONT]!!
+ )
+ ),
+ videoRecordingState = VideoRecordingState.INACTIVE,
+ audioAmplitude = 0.0
+
+ )
+ }
+}
+
+@Preview(backgroundColor = 0xFF000000, showBackground = true)
+@Composable
+private fun Preview_ControlsBottom_Recording() {
+ CompositionLocalProvider(LocalContentColor provides Color.White) {
+ ControlsBottom(
+ previewUiState = PreviewUiState.Ready(
+ currentCameraSettings = CameraAppSettings(),
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ previewMode = PreviewMode.StandardMode {}
+ ),
+ zoomLevel = 1.3f,
+ showZoomLevel = true,
+ isQuickSettingsOpen = false,
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
+ videoRecordingState = VideoRecordingState.ACTIVE,
+ audioAmplitude = 0.9
+ )
+ }
+}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt
new file mode 100644
index 0000000..523efdd
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2024 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.content.pm.ActivityInfo
+import android.os.Build
+import androidx.camera.core.DynamicRange
+import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.core.SurfaceRequest.TransformationInfo as CXTransformationInfo
+import androidx.camera.viewfinder.compose.Viewfinder
+import androidx.camera.viewfinder.surface.ImplementationMode
+import androidx.camera.viewfinder.surface.TransformationInfo
+import androidx.camera.viewfinder.surface.ViewfinderSurfaceRequest
+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.produceState
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Runnable
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.launch
+
+/**
+ * A composable viewfinder that adapts CameraX's [Preview.SurfaceProvider] to [Viewfinder]
+ *
+ * This adapter code will eventually be upstreamed to CameraX, but for now can be copied
+ * in its entirety to connect CameraX to [Viewfinder].
+ *
+ * @param[modifier] the modifier to be applied to the layout
+ * @param[surfaceRequest] a [SurfaceRequest] from [Preview.SurfaceProvider].
+ * @param[implementationMode] the implementation mode, either [ImplementationMode.EXTERNAL] or
+ * [ImplementationMode.EMBEDDED].
+ */
+@Composable
+fun CameraXViewfinder(
+ surfaceRequest: SurfaceRequest,
+ modifier: Modifier = Modifier,
+ implementationMode: ImplementationMode = ImplementationMode.EXTERNAL,
+ onRequestWindowColorMode: (Int) -> Unit = {}
+) {
+ val currentImplementationMode by rememberUpdatedState(implementationMode)
+ val currentOnRequestWindowColorMode by rememberUpdatedState(onRequestWindowColorMode)
+
+ val viewfinderArgs by produceState<ViewfinderArgs?>(initialValue = null, surfaceRequest) {
+ val viewfinderSurfaceRequest = ViewfinderSurfaceRequest.Builder(surfaceRequest.resolution)
+ .build()
+
+ surfaceRequest.addRequestCancellationListener(Runnable::run) {
+ viewfinderSurfaceRequest.markSurfaceSafeToRelease()
+ }
+
+ // Launch undispatched so we always reach the try/finally in this coroutine
+ launch(start = CoroutineStart.UNDISPATCHED) {
+ try {
+ val surface = viewfinderSurfaceRequest.getSurface()
+ surfaceRequest.provideSurface(surface, Runnable::run) {
+ viewfinderSurfaceRequest.markSurfaceSafeToRelease()
+ }
+ } finally {
+ // If we haven't provided the surface, such as if we're cancelled
+ // while suspending on getSurface(), this call will succeed. Otherwise
+ // it will be a no-op.
+ surfaceRequest.willNotProvideSurface()
+ }
+ }
+
+ val transformationInfos = MutableStateFlow<CXTransformationInfo?>(null)
+ surfaceRequest.setTransformationInfoListener(Runnable::run) {
+ transformationInfos.value = it
+ }
+
+ // The ImplementationMode that will be used for all TransformationInfo updates.
+ // This is locked in once we have updated ViewfinderArgs and won't change until
+ // this produceState block is cancelled and restarted
+ var snapshotImplementationMode: ImplementationMode? = null
+
+ snapshotFlow { currentImplementationMode }
+ .combine(transformationInfos.filterNotNull()) { implMode, transformInfo ->
+ Pair(implMode, transformInfo)
+ }.takeWhile { (implMode, _) ->
+ val shouldAbort =
+ snapshotImplementationMode != null && implMode != snapshotImplementationMode
+ if (shouldAbort) {
+ // Abort flow and invalidate SurfaceRequest so a new one will be sent
+ surfaceRequest.invalidate()
+ }
+ !shouldAbort
+ }.collectLatest { (implMode, transformInfo) ->
+ // We'll only ever get here with a single non-null implMode,
+ // so setting it every time is ok
+ snapshotImplementationMode = implMode
+ value = ViewfinderArgs(
+ viewfinderSurfaceRequest,
+ isSourceHdr = surfaceRequest.dynamicRange.encoding != DynamicRange.ENCODING_SDR,
+ implMode,
+ TransformationInfo(
+ sourceRotation = transformInfo.rotationDegrees,
+ cropRectLeft = transformInfo.cropRect.left,
+ cropRectTop = transformInfo.cropRect.top,
+ cropRectRight = transformInfo.cropRect.right,
+ cropRectBottom = transformInfo.cropRect.bottom,
+ shouldMirror = transformInfo.isMirroring
+ )
+ )
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ LaunchedEffect(Unit) {
+ snapshotFlow { viewfinderArgs }
+ .filterNotNull()
+ .map { args ->
+ if (args.isSourceHdr &&
+ args.implementationMode == ImplementationMode.EXTERNAL
+ ) {
+ ActivityInfo.COLOR_MODE_HDR
+ } else {
+ ActivityInfo.COLOR_MODE_DEFAULT
+ }
+ }.distinctUntilChanged()
+ .onEach { currentOnRequestWindowColorMode(it) }
+ .onCompletion { currentOnRequestWindowColorMode(ActivityInfo.COLOR_MODE_DEFAULT) }
+ .collect()
+ }
+ }
+
+ viewfinderArgs?.let { args ->
+ Viewfinder(
+ surfaceRequest = args.viewfinderSurfaceRequest,
+ implementationMode = args.implementationMode,
+ transformationInfo = args.transformationInfo,
+ modifier = modifier.fillMaxSize()
+ )
+ }
+}
+
+private data class ViewfinderArgs(
+ val viewfinderSurfaceRequest: ViewfinderSurfaceRequest,
+ val isSourceHdr: Boolean,
+ val implementationMode: ImplementationMode,
+ val transformationInfo: TransformationInfo
+)
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 01f09c8..688def4 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
@@ -15,21 +15,29 @@
*/
package com.google.jetpackcamera.feature.preview.ui
+import android.content.res.Configuration
+import android.os.Build
import android.util.Log
import android.view.Display
-import android.view.View
import android.widget.Toast
-import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.viewfinder.surface.ImplementationMode
+import androidx.compose.animation.core.EaseOutExpo
+import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
@@ -37,69 +45,188 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.FlipCameraAndroid
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.VideoStable
+import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.material.icons.outlined.CameraAlt
+import androidx.compose.material.icons.outlined.Videocam
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.SuggestionChip
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.layout
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.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.google.jetpackcamera.feature.preview.PreviewUiState
import com.google.jetpackcamera.feature.preview.R
import com.google.jetpackcamera.feature.preview.VideoRecordingState
+import com.google.jetpackcamera.feature.preview.ui.theme.PreviewPreviewTheme
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
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
private const val TAG = "PreviewScreen"
+private const val BLINK_TIME = 100L
+
+@Composable
+fun AmplitudeVisualizer(modifier: Modifier = Modifier, size: Int = 100, audioAmplitude: Double) {
+ // Tweak the multiplier to amplitude to adjust the visualizer sensitivity
+ val animatedScaling by animateFloatAsState(
+ targetValue = EaseOutExpo.transform(1 + (1.75f * audioAmplitude.toFloat())),
+ label = "AudioAnimation"
+ )
+ Box(modifier = modifier) {
+ // animated circle
+ Canvas(
+ modifier = Modifier
+ .align(Alignment.Center),
+ onDraw = {
+ drawCircle(
+ // tweak the multiplier to size to adjust the maximum size of the visualizer
+ radius = (size * animatedScaling).coerceIn(size.toFloat(), size * 1.65f),
+ alpha = .5f,
+ color = Color.White
+ )
+ }
+ )
+
+ // static circle
+ Canvas(
+ modifier = Modifier
+ .align(Alignment.Center),
+ onDraw = {
+ drawCircle(
+ radius = (size.toFloat()),
+ color = Color.White
+ )
+ }
+ )
+
+ Icon(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .size((0.5 * size).dp),
+ tint = Color.Black,
+ imageVector = if (audioAmplitude != 0.0) {
+ Icons.Filled.Mic
+ } else {
+ Icons.Filled.MicOff
+ },
+ contentDescription = stringResource(id = R.string.audio_visualizer_icon)
+ )
+ }
+}
/**
* 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,
+fun TestableToast(
toastMessage: ToastMessage,
- onToastShown: () -> Unit
+ onToastShown: () -> Unit,
+ modifier: Modifier = Modifier
) {
- 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
+ val context = LocalContext.current
+ LaunchedEffect(toastMessage) {
+ if (toastMessage.shouldShowToast) {
+ Toast.makeText(
+ context,
+ context.getText(toastMessage.stringResource),
+ toastMessage.toastLength
+ ).show()
+ }
+
onToastShown()
}
+ Log.d(
+ TAG,
+ "Toast Displayed with message: ${stringResource(id = toastMessage.stringResource)}"
+ )
+ }
+}
+
+@Composable
+fun TestableSnackbar(
+ modifier: Modifier = Modifier,
+ snackbarToShow: SnackbarData,
+ snackbarHostState: SnackbarHostState,
+ onSnackbarResult: (String) -> Unit
+) {
+ Box(
+ // box seems to need to have some size to be detected by UiAutomator
+ modifier = modifier
+ .size(20.dp)
+ .testTag(snackbarToShow.testTag)
+ ) {
+ val context = LocalContext.current
+ LaunchedEffect(snackbarToShow) {
+ val message = context.getString(snackbarToShow.stringResource)
+ Log.d(TAG, "Snackbar Displayed with message: $message")
+ try {
+ val result =
+ snackbarHostState.showSnackbar(
+ message = message,
+ duration = snackbarToShow.duration,
+ withDismissAction = snackbarToShow.withDismissAction,
+ actionLabel = if (snackbarToShow.actionLabelRes == null) {
+ null
+ } else {
+ context.getString(snackbarToShow.actionLabelRes)
+ }
+ )
+ when (result) {
+ SnackbarResult.ActionPerformed,
+ SnackbarResult.Dismissed -> onSnackbarResult(snackbarToShow.cookie)
+ }
+ } catch (e: Exception) {
+ // This is equivalent to dismissing the snackbar
+ onSnackbarResult(snackbarToShow.cookie)
+ }
+ }
}
- Log.d(TAG, "Toast Displayed with message: ${stringResource(id = toastMessage.stringResource)}")
}
/**
@@ -108,91 +235,93 @@ fun ShowTestableToast(
*/
@Composable
fun PreviewDisplay(
+ previewUiState: PreviewUiState.Ready,
onTapToFocus: (Display, Int, Int, Float, Float) -> Unit,
onFlipCamera: () -> Unit,
onZoomChange: (Float) -> Unit,
+ onRequestWindowColorMode: (Int) -> Unit,
aspectRatio: AspectRatio,
- deferredSurfaceProvider: CompletableDeferred<Preview.SurfaceProvider>
+ surfaceRequest: SurfaceRequest?,
+ modifier: Modifier = Modifier
) {
val transformableState = rememberTransformableState(
onTransformation = { zoomChange, _, _ ->
onZoomChange(zoomChange)
}
)
- val onSurfaceProviderReady: (Preview.SurfaceProvider) -> Unit = {
- Log.d(TAG, "onSurfaceProviderReady")
- deferredSurfaceProvider.complete(it)
- }
- lateinit var viewInfo: View
- BoxWithConstraints(
- Modifier
- .fillMaxSize()
- .background(Color.Black)
- .pointerInput(Unit) {
- detectTapGestures(
- onDoubleTap = { offset ->
- // double tap to flip camera
- Log.d(TAG, "onDoubleTap $offset")
- onFlipCamera()
- },
- onTap = { offset ->
- // tap to focus
- try {
- onTapToFocus(
- viewInfo.display,
- viewInfo.width,
- viewInfo.height,
- offset.x,
- offset.y
- )
- Log.d(TAG, "onTap $offset")
- } catch (e: UninitializedPropertyAccessException) {
- Log.d(TAG, "onTap $offset")
- e.printStackTrace()
- }
- }
- )
- },
+ val currentOnFlipCamera by rememberUpdatedState(onFlipCamera)
- contentAlignment = Alignment.Center
- ) {
- val maxAspectRatio: Float = maxWidth / maxHeight
- val aspectRatioFloat: Float = aspectRatio.ratio.toFloat()
- val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat
- val width = if (shouldUseMaxWidth) maxWidth else maxHeight * aspectRatioFloat
- val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat
- Box(
- modifier = Modifier
- .width(width)
- .height(height)
- .transformable(state = transformableState)
+ surfaceRequest?.let {
+ BoxWithConstraints(
+ Modifier
+ .testTag(PREVIEW_DISPLAY)
+ .fillMaxSize()
+ .background(Color.Black)
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onDoubleTap = { offset ->
+ // double tap to flip camera
+ Log.d(TAG, "onDoubleTap $offset")
+ currentOnFlipCamera()
+ }
+ )
+ },
+ contentAlignment = Alignment.Center
) {
- CameraPreview(
- modifier = Modifier
- .fillMaxSize(),
- onSurfaceProviderReady = onSurfaceProviderReady,
- onRequestBitmapReady = {
- it.invoke()
- },
- setSurfaceView = { s: View ->
- viewInfo = s
- }
+ val maxAspectRatio: Float = maxWidth / maxHeight
+ val aspectRatioFloat: Float = aspectRatio.ratio.toFloat()
+ val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat
+ val width = if (shouldUseMaxWidth) maxWidth else maxHeight * aspectRatioFloat
+ val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat
+ var imageVisible by remember { mutableStateOf(true) }
+
+ val imageAlpha: Float by animateFloatAsState(
+ targetValue = if (imageVisible) 1f else 0f,
+ animationSpec = tween(
+ durationMillis = (BLINK_TIME / 2).toInt(),
+ easing = LinearEasing
+ ),
+ label = ""
)
+
+ LaunchedEffect(previewUiState.lastBlinkTimeStamp) {
+ if (previewUiState.lastBlinkTimeStamp != 0L) {
+ imageVisible = false
+ delay(BLINK_TIME)
+ imageVisible = true
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .width(width)
+ .height(height)
+ .transformable(state = transformableState)
+ .alpha(imageAlpha)
+ ) {
+ CameraXViewfinder(
+ modifier = Modifier.fillMaxSize(),
+ surfaceRequest = it,
+ implementationMode = when {
+ Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL
+ else -> ImplementationMode.EMBEDDED
+ },
+ onRequestWindowColorMode = onRequestWindowColorMode
+ )
+ }
}
}
}
@Composable
fun StabilizationIcon(
- supportedStabilizationMode: List<SupportedStabilizationMode>,
videoStabilization: Stabilization,
- previewStabilization: Stabilization
+ previewStabilization: Stabilization,
+ modifier: Modifier = Modifier
) {
- if (supportedStabilizationMode.isNotEmpty() &&
- (videoStabilization == Stabilization.ON || previewStabilization == Stabilization.ON)
- ) {
+ if (videoStabilization == Stabilization.ON || previewStabilization == Stabilization.ON) {
val descriptionText = if (videoStabilization == Stabilization.ON) {
stringResource(id = R.string.stabilization_icon_description_preview_and_video)
} else {
@@ -200,9 +329,9 @@ fun StabilizationIcon(
stringResource(id = R.string.stabilization_icon_description_video_only)
}
Icon(
- painter = painterResource(id = R.drawable.baseline_video_stable_24),
+ imageVector = Icons.Filled.VideoStable,
contentDescription = descriptionText,
- tint = Color.White
+ modifier = modifier
)
}
}
@@ -211,7 +340,7 @@ fun StabilizationIcon(
* A temporary button that can be added to preview for quick testing purposes
*/
@Composable
-fun TestingButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) {
+fun TestingButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
SuggestionChip(
onClick = { onClick() },
modifier = modifier,
@@ -223,19 +352,17 @@ fun TestingButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: Stri
@Composable
fun FlipCameraButton(
- modifier: Modifier = Modifier,
enabledCondition: Boolean,
- onClick: () -> Unit
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
) {
IconButton(
- modifier = modifier
- .size(40.dp),
+ modifier = modifier.size(40.dp),
onClick = onClick,
enabled = enabledCondition
) {
Icon(
- imageVector = Icons.Filled.Refresh,
- tint = Color.White,
+ imageVector = Icons.Filled.FlipCameraAndroid,
contentDescription = stringResource(id = R.string.flip_camera_content_description),
modifier = Modifier.size(72.dp)
)
@@ -243,14 +370,13 @@ fun FlipCameraButton(
}
@Composable
-fun SettingsNavButton(modifier: Modifier, onNavigateToSettings: () -> Unit) {
+fun SettingsNavButton(onNavigateToSettings: () -> Unit, modifier: Modifier = Modifier) {
IconButton(
modifier = modifier,
onClick = onNavigateToSettings
) {
Icon(
imageVector = Icons.Filled.Settings,
- tint = Color.White,
contentDescription = stringResource(R.string.settings_content_description),
modifier = Modifier.size(72.dp)
)
@@ -258,7 +384,7 @@ fun SettingsNavButton(modifier: Modifier, onNavigateToSettings: () -> Unit) {
}
@Composable
-fun ZoomScaleText(zoomScale: Float) {
+fun ZoomScaleText(zoomScale: Float, modifier: Modifier = Modifier) {
val contentAlpha = animateFloatAsState(
targetValue = 10f,
label = "zoomScaleAlphaAnimation",
@@ -267,29 +393,35 @@ fun ZoomScaleText(zoomScale: Float) {
Text(
modifier = Modifier.alpha(contentAlpha.value),
text = "%.1fx".format(zoomScale),
- fontSize = 20.sp,
- color = Color.White
+ fontSize = 20.sp
)
}
@Composable
fun CaptureButton(
- modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongPress: () -> Unit,
onRelease: () -> Unit,
- videoRecordingState: VideoRecordingState
+ videoRecordingState: VideoRecordingState,
+ modifier: Modifier = Modifier
) {
+ var isPressedDown by remember {
+ mutableStateOf(false)
+ }
+ val currentColor = LocalContentColor.current
Box(
modifier = modifier
- .fillMaxHeight()
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
onLongPress()
},
+ // TODO: @kimblebee - stopVideoRecording is being called every time the capture
+ // button is pressed -- regardless of tap or long press
onPress = {
+ isPressedDown = true
awaitRelease()
+ isPressedDown = false
onRelease()
},
onTap = { onClick() }
@@ -297,16 +429,182 @@ fun CaptureButton(
}
.size(120.dp)
.padding(18.dp)
- .border(4.dp, Color.White, CircleShape)
+ .border(4.dp, currentColor, CircleShape)
) {
Canvas(modifier = Modifier.size(110.dp), onDraw = {
drawCircle(
color =
when (videoRecordingState) {
- VideoRecordingState.INACTIVE -> Color.Transparent
+ VideoRecordingState.INACTIVE -> {
+ if (isPressedDown) currentColor else Color.Transparent
+ }
+
VideoRecordingState.ACTIVE -> Color.Red
}
)
})
}
}
+
+enum class ToggleState {
+ Left,
+ Right
+}
+
+@Composable
+fun ToggleButton(
+ leftIcon: Painter,
+ rightIcon: Painter,
+ modifier: Modifier = Modifier.width(64.dp).height(32.dp),
+ initialState: ToggleState = ToggleState.Left,
+ onToggleStateChanged: (newState: ToggleState) -> Unit = {},
+ enabled: Boolean = true,
+ leftIconDescription: String = "leftIcon",
+ rightIconDescription: String = "rightIcon",
+ iconPadding: Dp = 8.dp
+) {
+ val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ val disableColor = MaterialTheme.colorScheme.onSurface
+ val iconSelectionColor = MaterialTheme.colorScheme.onPrimary
+ val iconUnSelectionColor = MaterialTheme.colorScheme.primary
+ val circleSelectionColor = MaterialTheme.colorScheme.primary
+ val circleColor = if (enabled) circleSelectionColor else disableColor.copy(alpha = 0.12f)
+ var toggleState by remember { mutableStateOf(initialState) }
+ val animatedTogglePosition by animateFloatAsState(
+ when (toggleState) {
+ ToggleState.Left -> 0f
+ ToggleState.Right -> 1f
+ },
+ label = "togglePosition"
+ )
+ val scope = rememberCoroutineScope()
+
+ Surface(
+ modifier = modifier
+ .clip(shape = RoundedCornerShape(50))
+ .then(
+ if (enabled) {
+ Modifier.clickable {
+ scope.launch {
+ toggleState = when (toggleState) {
+ ToggleState.Left -> ToggleState.Right
+ ToggleState.Right -> ToggleState.Left
+ }
+ onToggleStateChanged(toggleState)
+ }
+ }
+ } else {
+ Modifier
+ }
+ ),
+ color = backgroundColor
+ ) {
+ Box {
+ Row(
+ modifier = Modifier.matchParentSize(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ Modifier
+ .layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ layout(placeable.width, placeable.height) {
+ val xPos = animatedTogglePosition *
+ (constraints.maxWidth - placeable.width)
+ placeable.placeRelative(xPos.toInt(), 0)
+ }
+ }
+ .fillMaxHeight()
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(50))
+ .background(circleColor)
+ )
+ }
+ Row(
+ modifier = Modifier.matchParentSize().then(
+ if (enabled) Modifier else Modifier.alpha(0.38f)
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Icon(
+ painter = leftIcon,
+ contentDescription = leftIconDescription,
+ modifier = Modifier.padding(iconPadding),
+ tint = if (!enabled) {
+ disableColor
+ } else if (toggleState == ToggleState.Left) {
+ iconSelectionColor
+ } else {
+ iconUnSelectionColor
+ }
+ )
+ Icon(
+ painter = rightIcon,
+ contentDescription = rightIconDescription,
+ modifier = Modifier.padding(iconPadding),
+ tint = if (!enabled) {
+ disableColor
+ } else if (toggleState == ToggleState.Right) {
+ iconSelectionColor
+ } else {
+ iconUnSelectionColor
+ }
+ )
+ }
+ }
+ }
+}
+
+@Preview(name = "Light Mode")
+@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun Preview_ToggleButton_Selecting_Left() {
+ val initialState = ToggleState.Left
+ var toggleState by remember {
+ mutableStateOf(initialState)
+ }
+ PreviewPreviewTheme(dynamicColor = false) {
+ ToggleButton(
+ leftIcon = if (toggleState == ToggleState.Left) {
+ rememberVectorPainter(image = Icons.Filled.CameraAlt)
+ } else {
+ rememberVectorPainter(image = Icons.Outlined.CameraAlt)
+ },
+ rightIcon = if (toggleState == ToggleState.Right) {
+ rememberVectorPainter(image = Icons.Filled.Videocam)
+ } else {
+ rememberVectorPainter(image = Icons.Outlined.Videocam)
+ },
+ initialState = ToggleState.Left,
+ onToggleStateChanged = {
+ toggleState = it
+ }
+ )
+ }
+}
+
+@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun Preview_ToggleButton_Selecting_Right() {
+ PreviewPreviewTheme(dynamicColor = false) {
+ ToggleButton(
+ leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
+ rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
+ initialState = ToggleState.Right
+ )
+ }
+}
+
+@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun Preview_ToggleButton_Disabled() {
+ PreviewPreviewTheme(dynamicColor = false) {
+ ToggleButton(
+ leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
+ rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
+ initialState = ToggleState.Right,
+ enabled = false
+ )
+ }
+}
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
index 4b02195..53b14c5 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -56,7 +56,10 @@ fun ScreenFlashScreen(
}
@Composable
-fun ScreenFlashOverlay(screenFlashUiState: ScreenFlash.ScreenFlashUiState) {
+fun ScreenFlashOverlay(
+ screenFlashUiState: ScreenFlash.ScreenFlashUiState,
+ modifier: Modifier = Modifier
+) {
// Update overlay transparency gradually
val alpha by animateFloatAsState(
targetValue = if (screenFlashUiState.enabled) 1f else 0f,
@@ -65,10 +68,10 @@ fun ScreenFlashOverlay(screenFlashUiState: ScreenFlash.ScreenFlashUiState) {
finishedListener = { screenFlashUiState.onChangeComplete() }
)
Box(
- modifier = Modifier
+ modifier = modifier
.run {
if (screenFlashUiState.enabled) {
- this.testTag("ScreenFlashOverlay")
+ this.testTag(SCREEN_FLASH_OVERLAY)
} else {
this
}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/SnackbarData.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/SnackbarData.kt
new file mode 100644
index 0000000..0f158cf
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/SnackbarData.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024 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.material3.SnackbarDuration
+
+data class SnackbarData(
+ val cookie: String,
+ val stringResource: Int,
+ val duration: SnackbarDuration = SnackbarDuration.Short,
+ val actionLabelRes: Int? = null,
+ val withDismissAction: Boolean = false,
+ val testTag: String = ""
+)
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
index da4204a..974619b 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -16,5 +16,10 @@
package com.google.jetpackcamera.feature.preview.ui
const val CAPTURE_BUTTON = "CaptureButton"
+const val FLIP_CAMERA_BUTTON = "FlipCameraButton"
+const val IMAGE_CAPTURE_SUCCESS_TAG = "ImageCaptureSuccessTag"
+const val IMAGE_CAPTURE_FAILURE_TAG = "ImageCaptureFailureTag"
+const val VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG = "ImageCaptureExternalUnsupportedTag"
+const val PREVIEW_DISPLAY = "PreviewDisplay"
+const val SCREEN_FLASH_OVERLAY = "ScreenFlashOverlay"
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
index b7003da..e597598 100644
--- 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
@@ -21,11 +21,13 @@ 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.
+ * @param shouldShowToast shows a visible system toast when true.
+ * @param isLongToast determines if the visible toast's time is [Toast.LENGTH_LONG] or [Toast.LENGTH_SHORT].
+ * @property testTag the identifiable resource ID of a [TestableToast] on screen.
*/
class ToastMessage(
val stringResource: Int,
+ val shouldShowToast: Boolean = true,
isLongToast: Boolean = false,
val testTag: String = ""
) {
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/theme/Theme.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/theme/Theme.kt
new file mode 100644
index 0000000..4fd88d4
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/theme/Theme.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2024 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.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun PreviewPreviewTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme =
+ when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> darkColorScheme()
+ else -> lightColorScheme()
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content
+ )
+}
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
deleted file mode 100644
index 54f9651..0000000
--- a/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<?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/drawable/multi_stream_icon.xml b/feature/preview/src/main/res/drawable/multi_stream_icon.xml
new file mode 100644
index 0000000..834ee33
--- /dev/null
+++ b/feature/preview/src/main/res/drawable/multi_stream_icon.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2024 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 android:height="72dp" android:tint="#000000"
+ android:viewportHeight="960" android:viewportWidth="960"
+ android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="@android:color/white" android:pathData="M220,880Q162,880 121,839Q80,798 80,740Q80,682 121,641Q162,600 220,600Q238,600 255,604.5Q272,609 287,617L440,464L440,354Q396,341 368,304.5Q340,268 340,220Q340,162 381,121Q422,80 480,80Q538,80 579,121Q620,162 620,220Q620,268 592,304.5Q564,341 520,354L520,464L674,617Q689,609 705.5,604.5Q722,600 740,600Q798,600 839,641Q880,682 880,740Q880,798 839,839Q798,880 740,880Q682,880 641,839Q600,798 600,740Q600,722 604.5,705Q609,688 617,673L480,536L343,673Q351,688 355.5,705Q360,722 360,740Q360,798 319,839Q278,880 220,880ZM740,800Q765,800 782.5,782.5Q800,765 800,740Q800,715 782.5,697.5Q765,680 740,680Q715,680 697.5,697.5Q680,715 680,740Q680,765 697.5,782.5Q715,800 740,800ZM480,280Q505,280 522.5,262.5Q540,245 540,220Q540,195 522.5,177.5Q505,160 480,160Q455,160 437.5,177.5Q420,195 420,220Q420,245 437.5,262.5Q455,280 480,280ZM220,800Q245,800 262.5,782.5Q280,765 280,740Q280,715 262.5,697.5Q245,680 220,680Q195,680 177.5,697.5Q160,715 160,740Q160,765 177.5,782.5Q195,800 220,800Z"/>
+</vector>
diff --git a/feature/quicksettings/src/main/res/drawable/baseline_aspect_ratio_72.xml b/feature/preview/src/main/res/drawable/single_stream_capture_icon.xml
index abe3cd8..cc818a1 100644
--- a/feature/quicksettings/src/main/res/drawable/baseline_aspect_ratio_72.xml
+++ b/feature/preview/src/main/res/drawable/single_stream_capture_icon.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- ~ Copyright (C) 2023 The Android Open Source Project
+ ~ Copyright (C) 2024 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.
@@ -15,7 +15,7 @@
~ limitations under the License.
-->
<vector android:height="72dp" android:tint="#000000"
- android:viewportHeight="24" android:viewportWidth="24"
+ android:viewportHeight="960" android:viewportWidth="960"
android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
- <path android:fillColor="@android:color/white" android:pathData="M19,12h-2v3h-3v2h5v-5zM7,9h3L10,7L5,7v5h2L7,9zM21,3L3,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h18c1.1,0 2,-0.9 2,-2L23,5c0,-1.1 -0.9,-2 -2,-2zM21,19.01L3,19.01L3,4.99h18v14.02z"/>
+ <path android:fillColor="@android:color/white" android:pathData="M320,640Q386,640 433,593Q480,546 480,480Q480,414 433,367Q386,320 320,320Q254,320 207,367Q160,414 160,480Q160,546 207,593Q254,640 320,640ZM320,720Q220,720 150,650Q80,580 80,480Q80,380 150,310Q220,240 320,240Q410,240 476.5,297Q543,354 557,440L880,440L880,520L557,520Q543,606 476.5,663Q410,720 320,720ZM320,480Q320,480 320,480Q320,480 320,480Q320,480 320,480Q320,480 320,480Q320,480 320,480Q320,480 320,480Q320,480 320,480Q320,480 320,480Z"/>
</vector>
diff --git a/feature/quicksettings/src/main/res/values/dimens.xml b/feature/preview/src/main/res/values/dimens.xml
index 0028eac..0028eac 100644
--- a/feature/quicksettings/src/main/res/values/dimens.xml
+++ b/feature/preview/src/main/res/values/dimens.xml
diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml
index b2713f5..3bf1eec 100644
--- a/feature/preview/src/main/res/values/strings.xml
+++ b/feature/preview/src/main/res/values/strings.xml
@@ -17,13 +17,49 @@
<resources>
<string name="camera_not_ready">Camera Loading…</string>
<string name="settings_content_description">Settings</string>
- <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>
+ <string name="audio_visualizer_icon">An icon of a microphone</string>
+
<string name="toast_image_capture_success">Image Capture Success</string>
+ <string name="toast_video_capture_success">Video Capture Success</string>
+
<string name="toast_capture_failure">Image Capture Failure</string>
+ <string name="toast_video_capture_failure">Video Capture Failure</string>
+ <string name="toast_video_capture_external_unsupported">External video capture not supported</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>
+ <string name="quick_settings_front_camera_text">FRONT</string>
+ <string name="quick_settings_back_camera_text">BACK</string>
+ <string name="quick_settings_front_camera_description">Front Camera</string>
+ <string name="quick_settings_back_camera_description">Back Camera</string>
+
+ <string name="quick_settings_aspect_ratio_3_4">3:4</string>
+ <string name="quick_settings_aspect_ratio_9_16">9:16</string>
+ <string name="quick_settings_aspect_ratio_1_1">1:1</string>
+
+ <string name="quick_settings_dynamic_range_sdr">SDR</string>
+ <string name="quick_settings_dynamic_range_hlg10">HLG10</string>
+ <string name="quick_settings_dynamic_range_sdr_description">Standard dynamic range</string>
+ <string name="quick_settings_dynamic_range_hlg10_description">10-bit Hybrid Log Gamma dynamic range</string>
+
+ <string name="quick_settings_aspect_ratio_3_4_description">3 to 4 aspect ratio</string>
+ <string name="quick_settings_aspect_ratio_9_16_description">9 to 16 aspect ratio</string>
+ <string name="quick_settings_aspect_ratio_1_1_description">1 to 1 aspect ratio</string>
+
+ <string name="quick_settings_flash_off">OFF</string>
+ <string name="quick_settings_flash_auto">AUTO</string>
+ <string name="quick_settings_flash_on">ON</string>
+ <string name="quick_settings_flash_off_description">Flash off</string>
+ <string name="quick_settings_flash_auto_description">Auto flash</string>
+ <string name="quick_settings_flash_on_description">Flash on</string>
+
+ <string name="quick_settings_capture_mode_single">Single Stream</string>
+ <string name="quick_settings_capture_mode_multi">Multi Stream</string>
+ <string name="quick_settings_capture_mode_single_description">Single-stream capture mode on</string>
+ <string name="quick_settings_capture_mode_multi_description">Multi-stream capture mode on</string>
+
+ <string name="quick_settings_dropdown_open_description">Quick settings open</string>
+ <string name="quick_settings_dropdown_closed_description">Quick settings closed</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 0ecd474..1f515c6 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
@@ -16,14 +16,16 @@
package com.google.jetpackcamera.feature.preview
import android.content.ContentResolver
-import androidx.camera.core.Preview.SurfaceProvider
+import com.google.common.truth.Truth.assertThat
import com.google.jetpackcamera.domain.camera.test.FakeCameraUseCase
+import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl
import com.google.jetpackcamera.settings.model.FlashMode
-import com.google.jetpackcamera.settings.test.FakeSettingsRepository
-import junit.framework.TestCase.assertEquals
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
import kotlinx.coroutines.Dispatchers
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 kotlinx.coroutines.test.setMain
@@ -35,12 +37,19 @@ import org.mockito.Mockito.mock
class PreviewViewModelTest {
private val cameraUseCase = FakeCameraUseCase()
+ private val constraintsRepository = SettableConstraintsRepositoryImpl().apply {
+ updateSystemConstraints(TYPICAL_SYSTEM_CONSTRAINTS)
+ }
private lateinit var previewViewModel: PreviewViewModel
@Before
fun setup() = runTest(StandardTestDispatcher()) {
Dispatchers.setMain(StandardTestDispatcher())
- previewViewModel = PreviewViewModel(cameraUseCase, FakeSettingsRepository)
+ previewViewModel = PreviewViewModel(
+ PreviewMode.StandardMode {},
+ cameraUseCase,
+ constraintsRepository
+ )
advanceUntilIdle()
}
@@ -48,81 +57,90 @@ class PreviewViewModelTest {
fun getPreviewUiState() = runTest(StandardTestDispatcher()) {
advanceUntilIdle()
val uiState = previewViewModel.previewUiState.value
- assertEquals(CameraState.READY, uiState.cameraState)
+ assertThat(uiState).isInstanceOf(PreviewUiState.Ready::class.java)
}
@Test
fun runCamera() = runTest(StandardTestDispatcher()) {
- val surfaceProvider: SurfaceProvider = mock(SurfaceProvider::class.java)
- previewViewModel.runCamera(surfaceProvider)
- advanceUntilIdle()
+ previewViewModel.startCameraUntilRunning()
- assertEquals(cameraUseCase.previewStarted, true)
+ assertThat(cameraUseCase.previewStarted).isTrue()
}
@Test
fun captureImage() = runTest(StandardTestDispatcher()) {
- val surfaceProvider: SurfaceProvider = mock(SurfaceProvider::class.java)
- previewViewModel.runCamera(surfaceProvider)
+ previewViewModel.startCameraUntilRunning()
previewViewModel.captureImage()
advanceUntilIdle()
- assertEquals(cameraUseCase.numPicturesTaken, 1)
+ assertThat(cameraUseCase.numPicturesTaken).isEqualTo(1)
}
@Test
fun captureImageWithUri() = runTest(StandardTestDispatcher()) {
- val surfaceProvider: SurfaceProvider = mock(SurfaceProvider::class.java)
- val contentResolver: ContentResolver = mock(ContentResolver::class.java)
- previewViewModel.runCamera(surfaceProvider)
- previewViewModel.captureImage(contentResolver, null) {}
+ val contentResolver: ContentResolver = mock()
+ previewViewModel.startCameraUntilRunning()
+ previewViewModel.captureImageWithUri(contentResolver, null) {}
advanceUntilIdle()
- assertEquals(cameraUseCase.numPicturesTaken, 1)
+ assertThat(cameraUseCase.numPicturesTaken).isEqualTo(1)
}
@Test
fun startVideoRecording() = runTest(StandardTestDispatcher()) {
- previewViewModel.runCamera(mock(SurfaceProvider::class.java))
+ previewViewModel.startCameraUntilRunning()
previewViewModel.startVideoRecording()
advanceUntilIdle()
- assertEquals(cameraUseCase.recordingInProgress, true)
+ assertThat(cameraUseCase.recordingInProgress).isTrue()
}
@Test
fun stopVideoRecording() = runTest(StandardTestDispatcher()) {
- previewViewModel.runCamera(mock(SurfaceProvider::class.java))
+ previewViewModel.startCameraUntilRunning()
previewViewModel.startVideoRecording()
advanceUntilIdle()
previewViewModel.stopVideoRecording()
- assertEquals(cameraUseCase.recordingInProgress, false)
+ assertThat(cameraUseCase.recordingInProgress).isFalse()
}
@Test
fun setFlash() = runTest(StandardTestDispatcher()) {
- previewViewModel.runCamera(mock(SurfaceProvider::class.java))
+ previewViewModel.startCamera()
previewViewModel.setFlash(FlashMode.AUTO)
advanceUntilIdle()
- assertEquals(
- previewViewModel.previewUiState.value.currentCameraSettings.flashMode,
- FlashMode.AUTO
- )
+
+ assertIsReady(previewViewModel.previewUiState.value).also {
+ assertThat(it.currentCameraSettings.flashMode).isEqualTo(FlashMode.AUTO)
+ }
}
@Test
fun flipCamera() = runTest(StandardTestDispatcher()) {
// initial default value should be back
- previewViewModel.runCamera(mock(SurfaceProvider::class.java))
- assertEquals(
- previewViewModel.previewUiState.value.currentCameraSettings.isFrontCameraFacing,
- false
- )
- previewViewModel.flipCamera()
+ previewViewModel.startCamera()
+ assertIsReady(previewViewModel.previewUiState.value).also {
+ assertThat(it.currentCameraSettings.cameraLensFacing).isEqualTo(LensFacing.BACK)
+ }
+ previewViewModel.setLensFacing(LensFacing.FRONT)
advanceUntilIdle()
// ui state and camera should both be true now
- assertEquals(
- previewViewModel.previewUiState.value.currentCameraSettings.isFrontCameraFacing,
- true
+ assertIsReady(previewViewModel.previewUiState.value).also {
+ assertThat(it.currentCameraSettings.cameraLensFacing).isEqualTo(LensFacing.FRONT)
+ }
+ assertThat(cameraUseCase.isLensFacingFront).isTrue()
+ }
+
+ context(TestScope)
+ private fun PreviewViewModel.startCameraUntilRunning() {
+ startCamera()
+ advanceUntilIdle()
+ }
+}
+
+private fun assertIsReady(previewUiState: PreviewUiState): PreviewUiState.Ready {
+ return when (previewUiState) {
+ is PreviewUiState.Ready -> previewUiState
+ else -> throw AssertionError(
+ "PreviewUiState expected to be Ready, but was ${previewUiState::class}"
)
- assertEquals(true, cameraUseCase.isLensFacingFront)
}
}
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
index beeca8e..ea8b395 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -16,12 +16,12 @@
package com.google.jetpackcamera.feature.preview
import android.content.ContentResolver
-import androidx.camera.core.Preview
+import com.google.common.truth.Truth.assertThat
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 com.google.jetpackcamera.settings.model.LensFacing
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
@@ -30,7 +30,6 @@ 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
@@ -50,71 +49,74 @@ class ScreenFlashTest {
@Before
fun setup() = runTest(testDispatcher) {
screenFlash = ScreenFlash(cameraUseCase, testScope)
-
- val surfaceProvider: Preview.SurfaceProvider = Mockito.mock(Preview.SurfaceProvider::class.java)
- cameraUseCase.initialize(DEFAULT_CAMERA_APP_SETTINGS)
- cameraUseCase.runCamera(surfaceProvider, DEFAULT_CAMERA_APP_SETTINGS)
}
@Test
fun initialScreenFlashUiState_disabledByDefault() {
- assertEquals(false, screenFlash.screenFlashUiState.value.enabled)
+ assertThat(screenFlash.screenFlashUiState.value.enabled).isFalse()
}
@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(ContentResolver::class.java)
- cameraUseCase.takePicture(contentResolver, null)
-
- advanceUntilIdle()
- assertEquals(
- listOf(
- false,
- true,
- false
- ),
- states.map { it.enabled }
- )
+ fun captureScreenFlashImage_screenFlashUiStateChangedInCorrectSequence() = runCameraTest {
+ val states = mutableListOf<ScreenFlash.ScreenFlashUiState>()
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ screenFlash.screenFlashUiState.toList(states)
}
- @Test
- fun emitClearUiEvent_screenFlashUiStateContainsClearUiScreenBrightness() =
- runTest(testDispatcher) {
- screenFlash.setClearUiScreenBrightness(5.0f)
- cameraUseCase.emitScreenFlashEvent(
- CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
- )
+ // FlashMode.ON in front facing camera automatically enables screen flash
+ cameraUseCase.setLensFacing(lensFacing = LensFacing.FRONT)
+ cameraUseCase.setFlashMode(FlashMode.ON)
+ val contentResolver: ContentResolver = Mockito.mock()
+ cameraUseCase.takePicture({}, contentResolver, null)
- advanceUntilIdle()
- assertEquals(
- 5.0f,
- screenFlash.screenFlashUiState.value.screenBrightnessToRestore
+ advanceUntilIdle()
+ assertThat(states.map { it.enabled }).containsExactlyElementsIn(
+ listOf(
+ false,
+ true,
+ false
)
- }
+ ).inOrder()
+ }
@Test
- fun invokeOnChangeCompleteAfterClearUiEvent_screenFlashUiStateReset() =
- runTest(testDispatcher) {
- screenFlash.setClearUiScreenBrightness(5.0f)
- cameraUseCase.emitScreenFlashEvent(
- CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
- )
+ fun emitClearUiEvent_screenFlashUiStateContainsClearUiScreenBrightness() = runCameraTest {
+ screenFlash.setClearUiScreenBrightness(5.0f)
+ cameraUseCase.emitScreenFlashEvent(
+ CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
+ )
- advanceUntilIdle()
- screenFlash.screenFlashUiState.value.onChangeComplete()
+ advanceUntilIdle()
+ assertThat(screenFlash.screenFlashUiState.value.screenBrightnessToRestore)
+ .isWithin(FLOAT_TOLERANCE)
+ .of(5.0f)
+ }
- advanceUntilIdle()
- assertEquals(
- ScreenFlash.ScreenFlashUiState(),
- screenFlash.screenFlashUiState.value
- )
+ @Test
+ fun invokeOnChangeCompleteAfterClearUiEvent_screenFlashUiStateReset() = runCameraTest {
+ screenFlash.setClearUiScreenBrightness(5.0f)
+ cameraUseCase.emitScreenFlashEvent(
+ CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
+ )
+
+ advanceUntilIdle()
+ screenFlash.screenFlashUiState.value.onChangeComplete()
+
+ advanceUntilIdle()
+ assertThat(ScreenFlash.ScreenFlashUiState())
+ .isEqualTo(screenFlash.screenFlashUiState.value)
+ }
+
+ private fun runCameraTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) {
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ cameraUseCase.initialize(false)
+ cameraUseCase.runCamera()
}
+
+ testBody()
+ }
+
+ companion object {
+ const val FLOAT_TOLERANCE = 0.001f
+ }
}
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
index d1ddd15..8af03c4 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
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
index 3dae6fe..c90bde2 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
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
index 00b0bc2..d89ec02 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -13,8 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+@file:SuppressLint("UseSdkSuppress")
+
package com.google.jetpackcamera.feature.preview.workaround
+import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
@@ -52,14 +55,16 @@ import kotlin.math.roundToInt
*
* 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)
- }
+ val popupParentMaybe =
+ node.findClosestParentNode(includeSelf = true) {
+ it.config.contains(SemanticsProperties.IsPopup)
+ }
if (popupParentMaybe != null) {
return processMultiWindowScreenshot(node)
}
@@ -67,9 +72,10 @@ fun SemanticsNodeInteraction.captureToImage(): ImageBitmap {
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)
- }
+ 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) {
@@ -86,12 +92,13 @@ fun SemanticsNodeInteraction.captureToImage(): ImageBitmap {
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 nodeBoundsRect =
+ Rect(
+ nodeBounds.left.roundToInt(),
+ nodeBounds.top.roundToInt(),
+ nodeBounds.right.roundToInt(),
+ nodeBounds.bottom.roundToInt()
+ )
val locationInWindow = intArrayOf(0, 0)
view.getLocationInWindow(locationInWindow)
@@ -129,13 +136,14 @@ private fun processMultiWindowScreenshot(node: SemanticsNode): ImageBitmap {
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()
- )
+ val finalBitmap =
+ Bitmap.createBitmap(
+ combinedBitmap,
+ (nodePositionInScreen.x + nodeBoundsInRoot.left).roundToInt(),
+ (nodePositionInScreen.y + nodeBoundsInRoot.top).roundToInt(),
+ nodeBoundsInRoot.width.roundToInt(),
+ nodeBoundsInRoot.height.roundToInt()
+ )
return finalBitmap.asImageBitmap()
}
@@ -227,10 +235,11 @@ private object PixelCopyHelper {
private fun Window.generateBitmapFromPixelCopy(boundsInWindow: Rect, destBitmap: Bitmap) {
val latch = CountDownLatch(1)
var copyResult = 0
- val onCopyFinished = PixelCopy.OnPixelCopyFinishedListener { result ->
- copyResult = result
- latch.countDown()
- }
+ val onCopyFinished =
+ PixelCopy.OnPixelCopyFinishedListener { result ->
+ copyResult = result
+ latch.countDown()
+ }
PixelCopyHelper.request(
this,
boundsInWindow,
diff --git a/feature/quicksettings/.gitignore b/feature/quicksettings/.gitignore
deleted file mode 100644
index 42afabf..0000000
--- a/feature/quicksettings/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build \ No newline at end of file
diff --git a/feature/quicksettings/proguard-rules.pro b/feature/quicksettings/proguard-rules.pro
deleted file mode 100644
index ff59496..0000000
--- a/feature/quicksettings/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile \ No newline at end of file
diff --git a/feature/quicksettings/src/main/AndroidManifest.xml b/feature/quicksettings/src/main/AndroidManifest.xml
deleted file mode 100644
index 331bca7..0000000
--- a/feature/quicksettings/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<?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 package="com.google.jetpackcamera.quicksettings">
-
-</manifest>
diff --git a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsEnums.kt b/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsEnums.kt
deleted file mode 100644
index 99a9315..0000000
--- a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsEnums.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * 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.quicksettings
-
-import androidx.annotation.DrawableRes
-import androidx.annotation.StringRes
-import com.google.jetpackcamera.quicksettings.R
-
-interface QuickSettingsEnum {
- @DrawableRes
- fun getDrawableResId(): Int
-
- @StringRes
- fun getTextResId(): Int
-
- @StringRes
- fun getDescriptionResId(): Int
-}
-
-enum class CameraLensFace : QuickSettingsEnum {
- FRONT {
- override fun getDrawableResId(): Int = R.drawable.baseline_cameraswitch_72
-
- override fun getTextResId(): Int = R.string.quick_settings_front_camera_text
-
- override fun getDescriptionResId(): Int = R.string.quick_settings_front_camera_description
- },
- BACK {
- override fun getDrawableResId(): Int = R.drawable.baseline_cameraswitch_72
-
- override fun getTextResId(): Int = R.string.quick_settings_back_camera_text
-
- override fun getDescriptionResId(): Int = R.string.quick_settings_back_camera_description
- }
-}
-
-enum class CameraFlashMode : QuickSettingsEnum {
- OFF {
- override fun getDrawableResId(): Int = R.drawable.baseline_flash_off_72
-
- override fun getTextResId(): Int = R.string.quick_settings_flash_off
-
- override fun getDescriptionResId(): Int = R.string.quick_settings_flash_off_description
- },
- AUTO {
- override fun getDrawableResId(): Int = R.drawable.baseline_flash_auto_72
-
- override fun getTextResId(): Int = R.string.quick_settings_flash_auto
-
- override fun getDescriptionResId(): Int = R.string.quick_settings_flash_auto_description
- },
- ON {
- override fun getDrawableResId(): Int = R.drawable.baseline_flash_on_72
-
- override fun getTextResId(): Int = R.string.quick_settings_flash_on
-
- override fun getDescriptionResId(): Int = R.string.quick_settings_flash_on_description
- }
-}
-
-enum class CameraAspectRatio : QuickSettingsEnum {
- THREE_FOUR {
- override fun getDrawableResId(): Int = R.drawable.baseline_aspect_ratio_72
-
- override fun getTextResId(): Int = R.string.quick_settings_aspect_ratio_3_4
-
- override fun getDescriptionResId(): Int =
- R.string.quick_settings_aspect_ratio_3_4_description
- },
- NINE_SIXTEEN {
- override fun getDrawableResId(): Int = R.drawable.baseline_aspect_ratio_72
-
- override fun getTextResId(): Int = R.string.quick_settings_aspect_ratio_9_16
-
- override fun getDescriptionResId(): Int =
- R.string.quick_settings_aspect_ratio_9_16_description
- },
- ONE_ONE {
- override fun getDrawableResId(): Int = R.drawable.baseline_aspect_ratio_72
-
- override fun getTextResId(): Int = R.string.quick_settings_aspect_ratio_1_1
-
- override fun getDescriptionResId(): Int =
- R.string.quick_settings_aspect_ratio_1_1_description
- }
-}
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
deleted file mode 100644
index 8a8a60f..0000000
--- a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * 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.quicksettings
-
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-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.ExpandedQuickSetRatio
-import com.google.jetpackcamera.feature.quicksettings.ui.QuickFlipCamera
-import com.google.jetpackcamera.feature.quicksettings.ui.QuickSetFlash
-import com.google.jetpackcamera.feature.quicksettings.ui.QuickSetRatio
-import com.google.jetpackcamera.feature.quicksettings.ui.QuickSettingsGrid
-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
-
-/**
- * The UI component for quick settings.
- */
-@OptIn(ExperimentalComposeUiApi::class)
-@Composable
-fun QuickSettingsScreenOverlay(
- modifier: Modifier = Modifier,
- currentCameraSettings: CameraAppSettings,
- isOpen: Boolean = false,
- toggleIsOpen: () -> Unit,
- onLensFaceClick: (lensFace: Boolean) -> Unit,
- onFlashModeClick: (flashMode: FlashMode) -> Unit,
- onAspectRatioClick: (aspectRation: AspectRatio) -> Unit
-) {
- var shouldShowQuickSetting by remember {
- mutableStateOf(IsExpandedQuickSetting.NONE)
- }
-
- val backgroundColor =
- animateColorAsState(
- targetValue = Color.Black.copy(alpha = if (isOpen) 0.7f else 0f),
- label = "backgroundColorAnimation"
- )
-
- val contentAlpha =
- animateFloatAsState(
- targetValue = if (isOpen) 1f else 0f,
- label = "contentAlphaAnimation",
- animationSpec = tween()
- )
-
- if (isOpen) {
- Column(
- modifier =
- modifier
- .fillMaxSize()
- .background(color = backgroundColor.value)
- .alpha(alpha = contentAlpha.value)
- .clickable {
- // if a setting is expanded, click on the background to close it.
- // if no other settings are expanded, then close the popup
- when (shouldShowQuickSetting) {
- IsExpandedQuickSetting.NONE -> toggleIsOpen()
- else -> shouldShowQuickSetting = IsExpandedQuickSetting.NONE
- }
- },
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- ExpandedQuickSettingsUi(
- currentCameraSettings = currentCameraSettings,
- shouldShowQuickSetting = shouldShowQuickSetting,
- setVisibleQuickSetting = { enum: IsExpandedQuickSetting ->
- shouldShowQuickSetting = enum
- },
- onLensFaceClick = onLensFaceClick,
- onFlashModeClick = onFlashModeClick,
- onAspectRatioClick = onAspectRatioClick
- )
- }
- } else {
- shouldShowQuickSetting = IsExpandedQuickSetting.NONE
- }
-}
-
-// enum representing which individual quick setting is currently expanded
-private enum class IsExpandedQuickSetting {
- NONE,
- ASPECT_RATIO
-}
-
-/**
- * The UI component for quick settings when it is expanded.
- */
-@Composable
-private fun ExpandedQuickSettingsUi(
- currentCameraSettings: CameraAppSettings,
- onLensFaceClick: (lensFacingFront: Boolean) -> Unit,
- onFlashModeClick: (flashMode: FlashMode) -> Unit,
- shouldShowQuickSetting: IsExpandedQuickSetting,
- setVisibleQuickSetting: (IsExpandedQuickSetting) -> Unit,
- onAspectRatioClick: (aspectRation: AspectRatio) -> Unit
-) {
- Column(
- modifier =
- Modifier
- .padding(
- horizontal = dimensionResource(
- id = R.dimen.quick_settings_ui_horizontal_padding
- )
- )
- ) {
- // if no setting is chosen, display the grid of settings
- // to change the order of display just move these lines of code above or below each other
- when (shouldShowQuickSetting) {
- IsExpandedQuickSetting.NONE -> {
- val displayedQuickSettings: Array<@Composable () -> Unit> =
- arrayOf(
- {
- QuickSetFlash(
- modifier = Modifier.testTag("QuickSetFlash"),
- onClick = { f: FlashMode -> onFlashModeClick(f) },
- currentFlashMode = currentCameraSettings.flashMode
- )
- },
- {
- QuickFlipCamera(
- modifier = Modifier.testTag("QuickSetFlipCamera"),
- flipCamera = { b: Boolean -> onLensFaceClick(b) },
- currentFacingFront = currentCameraSettings.isFrontCameraFacing
- )
- },
- {
- QuickSetRatio(
- modifier = Modifier.testTag("QuickSetAspectRatio"),
- onClick = {
- setVisibleQuickSetting(
- IsExpandedQuickSetting.ASPECT_RATIO
- )
- },
- ratio = currentCameraSettings.aspectRatio,
- currentRatio = currentCameraSettings.aspectRatio
- )
- }
- )
- QuickSettingsGrid(quickSettingsButtons = displayedQuickSettings)
- }
- // if a setting that can be expanded is selected, show it
- IsExpandedQuickSetting.ASPECT_RATIO -> {
- ExpandedQuickSetRatio(
-
- setRatio = onAspectRatioClick,
- currentRatio = currentCameraSettings.aspectRatio
- )
- }
- }
- }
-}
diff --git a/feature/quicksettings/src/main/res/drawable/baseline_cameraswitch_72.xml b/feature/quicksettings/src/main/res/drawable/baseline_cameraswitch_72.xml
deleted file mode 100644
index f41a44f..0000000
--- a/feature/quicksettings/src/main/res/drawable/baseline_cameraswitch_72.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-<?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 android:height="72dp" android:tint="#000000"
- android:viewportHeight="24" android:viewportWidth="24"
- android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
- <path android:fillColor="@android:color/white" android:pathData="M16,7h-1l-1,-1h-4L9,7H8C6.9,7 6,7.9 6,9v6c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V9C18,7.9 17.1,7 16,7zM12,14c-1.1,0 -2,-0.9 -2,-2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2C14,13.1 13.1,14 12,14z"/>
- <path android:fillColor="@android:color/white" android:pathData="M8.57,0.51l4.48,4.48V2.04c4.72,0.47 8.48,4.23 8.95,8.95c0,0 2,0 2,0C23.34,3.02 15.49,-1.59 8.57,0.51z"/>
- <path android:fillColor="@android:color/white" android:pathData="M10.95,21.96C6.23,21.49 2.47,17.73 2,13.01c0,0 -2,0 -2,0c0.66,7.97 8.51,12.58 15.43,10.48l-4.48,-4.48V21.96z"/>
-</vector>
diff --git a/feature/quicksettings/src/main/res/drawable/baseline_expand_more_72.xml b/feature/quicksettings/src/main/res/drawable/baseline_expand_more_72.xml
deleted file mode 100644
index 6a0ff27..0000000
--- a/feature/quicksettings/src/main/res/drawable/baseline_expand_more_72.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<?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 android:height="72dp" android:tint="#000000"
- android:viewportHeight="24" android:viewportWidth="24"
- android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
- <path android:fillColor="@android:color/white" android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z"/>
-</vector>
diff --git a/feature/quicksettings/src/main/res/drawable/baseline_flash_auto_72.xml b/feature/quicksettings/src/main/res/drawable/baseline_flash_auto_72.xml
deleted file mode 100644
index 2a5b430..0000000
--- a/feature/quicksettings/src/main/res/drawable/baseline_flash_auto_72.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<?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 android:height="72dp" android:tint="#000000"
- android:viewportHeight="24" android:viewportWidth="24"
- android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
- <path android:fillColor="@android:color/white" android:pathData="M3,2v12h3v9l7,-12L9,11l4,-9L3,2zM19,2h-2l-3.2,9h1.9l0.7,-2h3.2l0.7,2h1.9L19,2zM16.85,7.65L18,4l1.15,3.65h-2.3z"/>
-</vector>
diff --git a/feature/quicksettings/src/main/res/drawable/baseline_flash_off_72.xml b/feature/quicksettings/src/main/res/drawable/baseline_flash_off_72.xml
deleted file mode 100644
index bf4224d..0000000
--- a/feature/quicksettings/src/main/res/drawable/baseline_flash_off_72.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<?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 android:height="72dp" android:tint="#000000"
- android:viewportHeight="24" android:viewportWidth="24"
- android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
- <path android:fillColor="@android:color/white" android:pathData="M3.27,3L2,4.27l5,5V13h3v9l3.58,-6.14L17.73,20 19,18.73 3.27,3zM17,10h-4l4,-8H7v2.18l8.46,8.46L17,10z"/>
-</vector>
diff --git a/feature/quicksettings/src/main/res/drawable/baseline_flash_on_72.xml b/feature/quicksettings/src/main/res/drawable/baseline_flash_on_72.xml
deleted file mode 100644
index 4eaf409..0000000
--- a/feature/quicksettings/src/main/res/drawable/baseline_flash_on_72.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<?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 android:height="72dp" android:tint="#000000"
- android:viewportHeight="24" android:viewportWidth="24"
- android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
- <path android:fillColor="@android:color/white" android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z"/>
-</vector>
diff --git a/feature/quicksettings/src/main/res/drawable/baseline_timer_10_72.xml b/feature/quicksettings/src/main/res/drawable/baseline_timer_10_72.xml
deleted file mode 100644
index bcaecdf..0000000
--- a/feature/quicksettings/src/main/res/drawable/baseline_timer_10_72.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<?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 android:height="72dp" android:tint="#000000"
- android:viewportHeight="24" android:viewportWidth="24"
- android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
- <path android:fillColor="@android:color/white" android:pathData="M0,7.72L0,9.4l3,-1L3,18h2L5,6h-0.25L0,7.72zM23.78,14.37c-0.14,-0.28 -0.35,-0.53 -0.63,-0.74 -0.28,-0.21 -0.61,-0.39 -1.01,-0.53s-0.85,-0.27 -1.35,-0.38c-0.35,-0.07 -0.64,-0.15 -0.87,-0.23 -0.23,-0.08 -0.41,-0.16 -0.55,-0.25 -0.14,-0.09 -0.23,-0.19 -0.28,-0.3 -0.05,-0.11 -0.08,-0.24 -0.08,-0.39 0,-0.14 0.03,-0.28 0.09,-0.41 0.06,-0.13 0.15,-0.25 0.27,-0.34 0.12,-0.1 0.27,-0.18 0.45,-0.24s0.4,-0.09 0.64,-0.09c0.25,0 0.47,0.04 0.66,0.11 0.19,0.07 0.35,0.17 0.48,0.29 0.13,0.12 0.22,0.26 0.29,0.42 0.06,0.16 0.1,0.32 0.1,0.49h1.95c0,-0.39 -0.08,-0.75 -0.24,-1.09 -0.16,-0.34 -0.39,-0.63 -0.69,-0.88 -0.3,-0.25 -0.66,-0.44 -1.09,-0.59C21.49,9.07 21,9 20.46,9c-0.51,0 -0.98,0.07 -1.39,0.21 -0.41,0.14 -0.77,0.33 -1.06,0.57 -0.29,0.24 -0.51,0.52 -0.67,0.84 -0.16,0.32 -0.23,0.65 -0.23,1.01s0.08,0.69 0.23,0.96c0.15,0.28 0.36,0.52 0.64,0.73 0.27,0.21 0.6,0.38 0.98,0.53 0.38,0.14 0.81,0.26 1.27,0.36 0.39,0.08 0.71,0.17 0.95,0.26s0.43,0.19 0.57,0.29c0.13,0.1 0.22,0.22 0.27,0.34 0.05,0.12 0.07,0.25 0.07,0.39 0,0.32 -0.13,0.57 -0.4,0.77 -0.27,0.2 -0.66,0.29 -1.17,0.29 -0.22,0 -0.43,-0.02 -0.64,-0.08 -0.21,-0.05 -0.4,-0.13 -0.56,-0.24 -0.17,-0.11 -0.3,-0.26 -0.41,-0.44 -0.11,-0.18 -0.17,-0.41 -0.18,-0.67h-1.89c0,0.36 0.08,0.71 0.24,1.05 0.16,0.34 0.39,0.65 0.7,0.93 0.31,0.27 0.69,0.49 1.15,0.66 0.46,0.17 0.98,0.25 1.58,0.25 0.53,0 1.01,-0.06 1.44,-0.19 0.43,-0.13 0.8,-0.31 1.11,-0.54 0.31,-0.23 0.54,-0.51 0.71,-0.83 0.17,-0.32 0.25,-0.67 0.25,-1.06 -0.02,-0.4 -0.09,-0.74 -0.24,-1.02zM13.82,7.05c-0.34,-0.4 -0.75,-0.7 -1.23,-0.88 -0.47,-0.18 -1.01,-0.27 -1.59,-0.27 -0.58,0 -1.11,0.09 -1.59,0.27 -0.48,0.18 -0.89,0.47 -1.23,0.88 -0.34,0.41 -0.6,0.93 -0.79,1.59 -0.18,0.65 -0.28,1.45 -0.28,2.39v1.92c0,0.94 0.09,1.74 0.28,2.39 0.19,0.66 0.45,1.19 0.8,1.6 0.34,0.41 0.75,0.71 1.23,0.89 0.48,0.18 1.01,0.28 1.59,0.28 0.59,0 1.12,-0.09 1.59,-0.28 0.48,-0.18 0.88,-0.48 1.22,-0.89 0.34,-0.41 0.6,-0.94 0.78,-1.6 0.18,-0.65 0.28,-1.45 0.28,-2.39v-1.92c0,-0.94 -0.09,-1.74 -0.28,-2.39 -0.18,-0.66 -0.44,-1.19 -0.78,-1.59zM12.9,13.22c0,0.6 -0.04,1.11 -0.12,1.53 -0.08,0.42 -0.2,0.76 -0.36,1.02 -0.16,0.26 -0.36,0.45 -0.59,0.57 -0.23,0.12 -0.51,0.18 -0.82,0.18 -0.3,0 -0.58,-0.06 -0.82,-0.18s-0.44,-0.31 -0.6,-0.57c-0.16,-0.26 -0.29,-0.6 -0.38,-1.02 -0.09,-0.42 -0.13,-0.93 -0.13,-1.53v-2.5c0,-0.6 0.04,-1.11 0.13,-1.52 0.09,-0.41 0.21,-0.74 0.38,-1 0.16,-0.25 0.36,-0.43 0.6,-0.55 0.24,-0.11 0.51,-0.17 0.81,-0.17 0.31,0 0.58,0.06 0.81,0.17 0.24,0.11 0.44,0.29 0.6,0.55 0.16,0.25 0.29,0.58 0.37,0.99 0.08,0.41 0.13,0.92 0.13,1.52v2.51z"/>
-</vector>
diff --git a/feature/quicksettings/src/main/res/drawable/baseline_timer_3_72.xml b/feature/quicksettings/src/main/res/drawable/baseline_timer_3_72.xml
deleted file mode 100644
index 0dc5cf7..0000000
--- a/feature/quicksettings/src/main/res/drawable/baseline_timer_3_72.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<?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 android:height="72dp" android:tint="#000000"
- android:viewportHeight="24" android:viewportWidth="24"
- android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
- <path android:fillColor="@android:color/white" android:pathData="M11.61,12.97c-0.16,-0.24 -0.36,-0.46 -0.62,-0.65 -0.25,-0.19 -0.56,-0.35 -0.93,-0.48 0.3,-0.14 0.57,-0.3 0.8,-0.5 0.23,-0.2 0.42,-0.41 0.57,-0.64 0.15,-0.23 0.27,-0.46 0.34,-0.71 0.08,-0.24 0.11,-0.49 0.11,-0.73 0,-0.55 -0.09,-1.04 -0.28,-1.46 -0.18,-0.42 -0.44,-0.77 -0.78,-1.06 -0.33,-0.28 -0.73,-0.5 -1.2,-0.64 -0.45,-0.13 -0.97,-0.2 -1.53,-0.2 -0.55,0 -1.06,0.08 -1.52,0.24 -0.47,0.17 -0.87,0.4 -1.2,0.69 -0.33,0.29 -0.6,0.63 -0.78,1.03 -0.2,0.39 -0.29,0.83 -0.29,1.29h1.98c0,-0.26 0.05,-0.49 0.14,-0.69 0.09,-0.2 0.22,-0.38 0.38,-0.52 0.17,-0.14 0.36,-0.25 0.58,-0.33 0.22,-0.08 0.46,-0.12 0.73,-0.12 0.61,0 1.06,0.16 1.36,0.47 0.3,0.31 0.44,0.75 0.44,1.32 0,0.27 -0.04,0.52 -0.12,0.74 -0.08,0.22 -0.21,0.41 -0.38,0.57 -0.17,0.16 -0.38,0.28 -0.63,0.37 -0.25,0.09 -0.55,0.13 -0.89,0.13L6.72,11.09v1.57L7.9,12.66c0.34,0 0.64,0.04 0.91,0.11 0.27,0.08 0.5,0.19 0.69,0.35 0.19,0.16 0.34,0.36 0.44,0.61 0.1,0.24 0.16,0.54 0.16,0.87 0,0.62 -0.18,1.09 -0.53,1.42 -0.35,0.33 -0.84,0.49 -1.45,0.49 -0.29,0 -0.56,-0.04 -0.8,-0.13 -0.24,-0.08 -0.44,-0.2 -0.61,-0.36 -0.17,-0.16 -0.3,-0.34 -0.39,-0.56 -0.09,-0.22 -0.14,-0.46 -0.14,-0.72L4.19,14.74c0,0.55 0.11,1.03 0.32,1.45 0.21,0.42 0.5,0.77 0.86,1.05s0.77,0.49 1.24,0.63 0.96,0.21 1.48,0.21c0.57,0 1.09,-0.08 1.58,-0.23 0.49,-0.15 0.91,-0.38 1.26,-0.68 0.36,-0.3 0.64,-0.66 0.84,-1.1 0.2,-0.43 0.3,-0.93 0.3,-1.48 0,-0.29 -0.04,-0.58 -0.11,-0.86 -0.08,-0.25 -0.19,-0.51 -0.35,-0.76zM20.87,14.37c-0.14,-0.28 -0.35,-0.53 -0.63,-0.74 -0.28,-0.21 -0.61,-0.39 -1.01,-0.53s-0.85,-0.27 -1.35,-0.38c-0.35,-0.07 -0.64,-0.15 -0.87,-0.23 -0.23,-0.08 -0.41,-0.16 -0.55,-0.25 -0.14,-0.09 -0.23,-0.19 -0.28,-0.3 -0.05,-0.11 -0.08,-0.24 -0.08,-0.39s0.03,-0.28 0.09,-0.41c0.06,-0.13 0.15,-0.25 0.27,-0.34 0.12,-0.1 0.27,-0.18 0.45,-0.24s0.4,-0.09 0.64,-0.09c0.25,0 0.47,0.04 0.66,0.11 0.19,0.07 0.35,0.17 0.48,0.29 0.13,0.12 0.22,0.26 0.29,0.42 0.06,0.16 0.1,0.32 0.1,0.49h1.95c0,-0.39 -0.08,-0.75 -0.24,-1.09 -0.16,-0.34 -0.39,-0.63 -0.69,-0.88 -0.3,-0.25 -0.66,-0.44 -1.09,-0.59 -0.43,-0.15 -0.92,-0.22 -1.46,-0.22 -0.51,0 -0.98,0.07 -1.39,0.21 -0.41,0.14 -0.77,0.33 -1.06,0.57 -0.29,0.24 -0.51,0.52 -0.67,0.84 -0.16,0.32 -0.23,0.65 -0.23,1.01s0.08,0.68 0.23,0.96c0.15,0.28 0.37,0.52 0.64,0.73 0.27,0.21 0.6,0.38 0.98,0.53 0.38,0.14 0.81,0.26 1.27,0.36 0.39,0.08 0.71,0.17 0.95,0.26s0.43,0.19 0.57,0.29c0.13,0.1 0.22,0.22 0.27,0.34 0.05,0.12 0.07,0.25 0.07,0.39 0,0.32 -0.13,0.57 -0.4,0.77 -0.27,0.2 -0.66,0.29 -1.17,0.29 -0.22,0 -0.43,-0.02 -0.64,-0.08 -0.21,-0.05 -0.4,-0.13 -0.56,-0.24 -0.17,-0.11 -0.3,-0.26 -0.41,-0.44 -0.11,-0.18 -0.17,-0.41 -0.18,-0.67h-1.89c0,0.36 0.08,0.71 0.24,1.05 0.16,0.34 0.39,0.65 0.7,0.93 0.31,0.27 0.69,0.49 1.15,0.66 0.46,0.17 0.98,0.25 1.58,0.25 0.53,0 1.01,-0.06 1.44,-0.19 0.43,-0.13 0.8,-0.31 1.11,-0.54 0.31,-0.23 0.54,-0.51 0.71,-0.83 0.17,-0.32 0.25,-0.67 0.25,-1.06 -0.02,-0.4 -0.09,-0.74 -0.24,-1.02z"/>
-</vector>
diff --git a/feature/quicksettings/src/main/res/drawable/baseline_timer_off_72.xml b/feature/quicksettings/src/main/res/drawable/baseline_timer_off_72.xml
deleted file mode 100644
index 8bd1087..0000000
--- a/feature/quicksettings/src/main/res/drawable/baseline_timer_off_72.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-<?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 android:height="72dp" android:tint="#000000"
- android:viewportHeight="24" android:viewportWidth="24"
- android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
- <path android:fillColor="@android:color/white" android:pathData="M9,1h6v2h-6z"/>
- <path android:fillColor="@android:color/white" android:pathData="M13,8v2.17l6.98,6.98C20.63,15.91 21,14.5 21,13c0,-2.12 -0.74,-4.07 -1.97,-5.61l1.42,-1.42c-0.43,-0.51 -0.9,-0.99 -1.41,-1.41l-1.42,1.42C16.07,4.74 14.12,4 12,4c-1.5,0 -2.91,0.37 -4.15,1.02L10.83,8H13z"/>
- <path android:fillColor="@android:color/white" android:pathData="M2.81,2.81L1.39,4.22l3.4,3.4C3.67,9.12 3,10.98 3,13c0,4.97 4.02,9 9,9c2.02,0 3.88,-0.67 5.38,-1.79l2.4,2.4l1.41,-1.41L2.81,2.81z"/>
-</vector>
diff --git a/feature/quicksettings/src/main/res/values/strings.xml b/feature/quicksettings/src/main/res/values/strings.xml
deleted file mode 100644
index e571f3e..0000000
--- a/feature/quicksettings/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,49 +0,0 @@
-<?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.
- -->
-<resources>
- <string name="quick_settings_front_camera_text">FRONT</string>
- <string name="quick_settings_back_camera_text">BACK</string>
-
- <string name="quick_settings_aspect_ratio_3_4">3:4</string>
- <string name="quick_settings_aspect_ratio_9_16">9:16</string>
- <string name="quick_settings_aspect_ratio_1_1">1:1</string>
-
- <string name="quick_settings_flash_off">OFF</string>
- <string name="quick_settings_flash_auto">AUTO</string>
- <string name="quick_settings_flash_on">ON</string>
-
- <string name="quick_settings_timer_off">OFF</string>
- <string name="quick_settings_timer_3"></string>
- <string name="quick_settings_timer_10"></string>
-
- <string name="quick_settings_dropdown_description">Quick settings drop down</string>
-
- <string name="quick_settings_front_camera_description">Front Camera</string>
- <string name="quick_settings_back_camera_description">Back Camera</string>
-
- <string name="quick_settings_aspect_ratio_3_4_description">3 to 4 aspect ratio</string>
- <string name="quick_settings_aspect_ratio_9_16_description">9 to 16 aspect ratio</string>
- <string name="quick_settings_aspect_ratio_1_1_description">1 to 1 aspect ratio</string>
-
- <string name="quick_settings_flash_off_description">Flash off</string>
- <string name="quick_settings_flash_auto_description">Auto flash</string>
- <string name="quick_settings_flash_on_description">Flash on</string>
-
- <string name="quick_settings_timer_off_description">Timer off</string>
- <string name="quick_settings_timer_3_description">3 seconds timer</string>
- <string name="quick_settings_timer_10_description">10 seconds timer</string>
-</resources> \ No newline at end of file
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 78d8f60..2bb1842 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -23,25 +23,16 @@ plugins {
android {
namespace = "com.google.jetpackcamera.settings"
- compileSdk = 34
+ compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
- minSdk = 21
- targetSdk = 34
+ minSdk = libs.versions.minSdk.get().toInt()
+ testOptions.targetSdk = libs.versions.targetSdk.get().toInt()
+ lint.targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- consumerProguardFiles("consumer-rules.pro")
}
- buildTypes {
- release {
- isMinifyEnabled = false
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -50,10 +41,28 @@ android {
jvmToolchain(17)
}
buildFeatures {
+ buildConfig = true
compose = true
}
composeOptions {
- kotlinCompilerExtensionVersion = "1.4.0"
+ kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
+ }
+
+ @Suppress("UnstableApiUsage")
+ testOptions {
+ managedDevices {
+ localDevices {
+ create("pixel2Api28") {
+ device = "Pixel 2"
+ apiLevel = 28
+ }
+ create("pixel8Api34") {
+ device = "Pixel 8"
+ apiLevel = 34
+ systemImageSource = "aosp_atd"
+ }
+ }
+ }
}
}
@@ -78,15 +87,16 @@ dependencies {
// Testing
testImplementation(libs.junit)
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
testImplementation(libs.mockito.core)
testImplementation(libs.kotlinx.coroutines.test)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.truth)
implementation(libs.androidx.core.ktx)
- // Guava
- implementation(libs.kotlinx.coroutines.guava)
+ // Futures
+ implementation(libs.futures.ktx)
// Hilt
implementation(libs.dagger.hilt.android)
diff --git a/feature/settings/consumer-rules.pro b/feature/settings/consumer-rules.pro
deleted file mode 100644
index e69de29..0000000
--- a/feature/settings/consumer-rules.pro
+++ /dev/null
diff --git a/feature/settings/proguard-rules.pro b/feature/settings/proguard-rules.pro
deleted file mode 100644
index ff59496..0000000
--- a/feature/settings/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile \ No newline at end of file
diff --git a/feature/settings/src/androidTest/Android.bp b/feature/settings/src/androidTest/Android.bp
index 94a7d7c..0ced4cc 100644
--- a/feature/settings/src/androidTest/Android.bp
+++ b/feature/settings/src/androidTest/Android.bp
@@ -10,6 +10,7 @@ android_test {
"androidx.test.runner",
"androidx.test.ext.junit",
"kotlinx_coroutines_test",
+ "androidx.test.ext.truth",
"androidx.compose.runtime_runtime",
"androidx.compose.ui_ui-test-junit4",
"jetpack-camera-app_feature_settings",
diff --git a/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt b/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
index d0cd210..ae1a0b0 100644
--- a/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
+++ b/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
@@ -21,18 +21,19 @@ import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
import com.google.jetpackcamera.settings.model.DarkMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
import java.io.File
import junit.framework.TestCase.assertEquals
-import junit.framework.TestCase.assertFalse
-import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -46,7 +47,6 @@ internal class CameraAppSettingsViewModelTest {
private val testContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var testDataStore: DataStore<JcaSettings>
private lateinit var datastoreScope: CoroutineScope
- private lateinit var repository: LocalSettingsRepository
private lateinit var settingsViewModel: SettingsViewModel
@Before
@@ -60,8 +60,11 @@ internal class CameraAppSettingsViewModelTest {
) {
testContext.dataStoreFile("test_jca_settings.pb")
}
- repository = LocalSettingsRepository(testDataStore)
- settingsViewModel = SettingsViewModel(repository)
+ val settingsRepository = LocalSettingsRepository(testDataStore)
+ val constraintsRepository = SettableConstraintsRepositoryImpl().apply {
+ updateSystemConstraints(TYPICAL_SYSTEM_CONSTRAINTS)
+ }
+ settingsViewModel = SettingsViewModel(settingsRepository, constraintsRepository)
advanceUntilIdle()
}
@@ -77,40 +80,67 @@ internal class CameraAppSettingsViewModelTest {
@Test
fun getSettingsUiState() = runTest(StandardTestDispatcher()) {
- // giving ViewModel time to call init, otherwise settings will stay disabled
- delay(100)
- val uiState = settingsViewModel.settingsUiState.value
- advanceUntilIdle()
- assertEquals(
- uiState,
- SettingsUiState(cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS, disabled = false)
+ val uiState = settingsViewModel.settingsUiState.first {
+ it is SettingsUiState.Enabled
+ }
+
+ assertThat(uiState).isEqualTo(
+ SettingsUiState.Enabled(
+ cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
+ systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS
+ )
)
}
@Test
fun setDefaultToFrontCamera() = runTest(StandardTestDispatcher()) {
- val initialFrontCameraValue =
- settingsViewModel.settingsUiState.value.cameraAppSettings.isFrontCameraFacing
- settingsViewModel.setDefaultToFrontCamera()
+ // Wait for first Enabled state
+ val initialState = settingsViewModel.settingsUiState.first {
+ it is SettingsUiState.Enabled
+ }
- advanceUntilIdle()
+ val initialCameraLensFacing = assertIsEnabled(initialState)
+ .cameraAppSettings.cameraLensFacing
+ val nextCameraLensFacing = if (initialCameraLensFacing == LensFacing.FRONT) {
+ LensFacing.BACK
+ } else {
+ LensFacing.FRONT
+ }
+ settingsViewModel.setDefaultLensFacing(nextCameraLensFacing)
- val newFrontCameraValue =
- settingsViewModel.settingsUiState.value.cameraAppSettings.isFrontCameraFacing
+ advanceUntilIdle()
- assertFalse(initialFrontCameraValue)
- assertTrue(newFrontCameraValue)
+ assertIsEnabled(settingsViewModel.settingsUiState.value).also {
+ assertThat(it.cameraAppSettings.cameraLensFacing).isEqualTo(nextCameraLensFacing)
+ }
}
@Test
fun setDarkMode() = runTest(StandardTestDispatcher()) {
- val initialDarkMode = settingsViewModel.settingsUiState.value.cameraAppSettings.darkMode
+ // Wait for first Enabled state
+ val initialState = settingsViewModel.settingsUiState.first {
+ it is SettingsUiState.Enabled
+ }
+
+ val initialDarkMode = assertIsEnabled(initialState).cameraAppSettings.darkMode
+
settingsViewModel.setDarkMode(DarkMode.DARK)
+
advanceUntilIdle()
- val newDarkMode = settingsViewModel.settingsUiState.value.cameraAppSettings.darkMode
+ val newDarkMode = assertIsEnabled(settingsViewModel.settingsUiState.value)
+ .cameraAppSettings.darkMode
assertEquals(initialDarkMode, DarkMode.SYSTEM)
assertEquals(DarkMode.DARK, newDarkMode)
}
}
+
+private fun assertIsEnabled(settingsUiState: SettingsUiState): SettingsUiState.Enabled {
+ return when (settingsUiState) {
+ is SettingsUiState.Enabled -> settingsUiState
+ else -> throw AssertionError(
+ "SettingsUiState expected to be Enabled, but was ${settingsUiState::class}"
+ )
+ }
+}
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 fd8b32c..d5ebc0e 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
@@ -15,15 +15,27 @@
*/
package com.google.jetpackcamera.settings
+import android.content.res.Configuration
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
+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.DarkMode
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.Stabilization
+import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
import com.google.jetpackcamera.settings.ui.AspectRatioSetting
import com.google.jetpackcamera.settings.ui.CaptureModeSetting
import com.google.jetpackcamera.settings.ui.DarkModeSetting
@@ -32,67 +44,171 @@ 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
+import com.google.jetpackcamera.settings.ui.TargetFpsSetting
+import com.google.jetpackcamera.settings.ui.VersionInfo
+import com.google.jetpackcamera.settings.ui.theme.SettingsPreviewTheme
/**
* Screen used for the Settings feature.
*/
@Composable
fun SettingsScreen(
+ versionInfo: VersionInfoHolder,
viewModel: SettingsViewModel = hiltViewModel(),
- onNavigateToPreview: () -> Unit
+ onNavigateBack: () -> Unit
) {
val settingsUiState by viewModel.settingsUiState.collectAsState()
+ SettingsScreen(
+ uiState = settingsUiState,
+ versionInfo = versionInfo,
+ onNavigateBack = onNavigateBack,
+ setDefaultLensFacing = viewModel::setDefaultLensFacing,
+ setFlashMode = viewModel::setFlashMode,
+ setTargetFrameRate = viewModel::setTargetFrameRate,
+ setAspectRatio = viewModel::setAspectRatio,
+ setCaptureMode = viewModel::setCaptureMode,
+ setVideoStabilization = viewModel::setVideoStabilization,
+ setPreviewStabilization = viewModel::setPreviewStabilization,
+ setDarkMode = viewModel::setDarkMode
+ )
+}
+
+@Composable
+private fun SettingsScreen(
+ uiState: SettingsUiState,
+ versionInfo: VersionInfoHolder,
+ onNavigateBack: () -> Unit = {},
+ setDefaultLensFacing: (LensFacing) -> Unit = {},
+ setFlashMode: (FlashMode) -> Unit = {},
+ setTargetFrameRate: (Int) -> Unit = {},
+ setAspectRatio: (AspectRatio) -> Unit = {},
+ setCaptureMode: (CaptureMode) -> Unit = {},
+ setVideoStabilization: (Stabilization) -> Unit = {},
+ setPreviewStabilization: (Stabilization) -> Unit = {},
+ setDarkMode: (DarkMode) -> Unit = {}
+) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
+ .background(color = MaterialTheme.colorScheme.background)
) {
SettingsPageHeader(
title = stringResource(id = R.string.settings_title),
- navBack = onNavigateToPreview
+ navBack = onNavigateBack
)
- SettingsList(uiState = settingsUiState, viewModel = viewModel)
+ if (uiState is SettingsUiState.Enabled) {
+ SettingsList(
+ uiState = uiState,
+ versionInfo = versionInfo,
+ setDefaultLensFacing = setDefaultLensFacing,
+ setFlashMode = setFlashMode,
+ setTargetFrameRate = setTargetFrameRate,
+ setAspectRatio = setAspectRatio,
+ setCaptureMode = setCaptureMode,
+ setVideoStabilization = setVideoStabilization,
+ setPreviewStabilization = setPreviewStabilization,
+ setDarkMode = setDarkMode
+ )
+ }
}
}
@Composable
-fun SettingsList(uiState: SettingsUiState, viewModel: SettingsViewModel) {
+fun SettingsList(
+ uiState: SettingsUiState.Enabled,
+ versionInfo: VersionInfoHolder,
+ setDefaultLensFacing: (LensFacing) -> Unit = {},
+ setFlashMode: (FlashMode) -> Unit = {},
+ setTargetFrameRate: (Int) -> Unit = {},
+ setAspectRatio: (AspectRatio) -> Unit = {},
+ setCaptureMode: (CaptureMode) -> Unit = {},
+ setVideoStabilization: (Stabilization) -> Unit = {},
+ setPreviewStabilization: (Stabilization) -> Unit = {},
+ setDarkMode: (DarkMode) -> Unit = {}
+) {
SectionHeader(title = stringResource(id = R.string.section_title_camera_settings))
DefaultCameraFacing(
- cameraAppSettings = uiState.cameraAppSettings,
- onClick = viewModel::setDefaultToFrontCamera
+ settingValue = (uiState.cameraAppSettings.cameraLensFacing == LensFacing.FRONT),
+ enabled = with(uiState.systemConstraints.availableLenses) {
+ size > 1 && contains(LensFacing.FRONT)
+ },
+ setDefaultLensFacing = setDefaultLensFacing
)
FlashModeSetting(
currentFlashMode = uiState.cameraAppSettings.flashMode,
- setFlashMode = viewModel::setFlashMode
+ setFlashMode = setFlashMode
+ )
+
+ TargetFpsSetting(
+ currentTargetFps = uiState.cameraAppSettings.targetFrameRate,
+ supportedFps = uiState.systemConstraints.perLensConstraints.values.fold(emptySet()) {
+ union, constraints ->
+ union + constraints.supportedFixedFrameRates
+ },
+ setTargetFps = setTargetFrameRate
)
AspectRatioSetting(
currentAspectRatio = uiState.cameraAppSettings.aspectRatio,
- setAspectRatio = viewModel::setAspectRatio
+ setAspectRatio = setAspectRatio
)
CaptureModeSetting(
currentCaptureMode = uiState.cameraAppSettings.captureMode,
- setCaptureMode = viewModel::setCaptureMode
+ setCaptureMode = 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
+ currentTargetFps = uiState.cameraAppSettings.targetFrameRate,
+ supportedStabilizationMode = uiState.systemConstraints.perLensConstraints.values.fold(
+ emptySet()
+ ) {
+ union, constraints ->
+ union + constraints.supportedStabilizationModes
+ },
+ setVideoStabilization = setVideoStabilization,
+ setPreviewStabilization = setPreviewStabilization
)
SectionHeader(title = stringResource(id = R.string.section_title_app_settings))
DarkModeSetting(
currentDarkMode = uiState.cameraAppSettings.darkMode,
- setDarkMode = viewModel::setDarkMode
+ setDarkMode = setDarkMode
)
+
+ SectionHeader(title = stringResource(id = R.string.section_title_software_info))
+
+ VersionInfo(
+ versionName = versionInfo.versionName,
+ buildType = versionInfo.buildType
+ )
+}
+
+data class VersionInfoHolder(
+ val versionName: String,
+ val buildType: String
+)
+
+@Preview(name = "Light Mode")
+@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun Preview_SettingsScreen() {
+ SettingsPreviewTheme {
+ SettingsScreen(
+ uiState = SettingsUiState.Enabled(
+ DEFAULT_CAMERA_APP_SETTINGS,
+ TYPICAL_SYSTEM_CONSTRAINTS
+ ),
+ versionInfo = VersionInfoHolder(
+ versionName = "1.0.0",
+ buildType = "release"
+ )
+ )
+ }
}
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt
index 85901e2..13cf5f0 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt
@@ -16,11 +16,15 @@
package com.google.jetpackcamera.settings
import com.google.jetpackcamera.settings.model.CameraAppSettings
+import com.google.jetpackcamera.settings.model.SystemConstraints
/**
* Defines the current state of the [SettingsScreen].
*/
-data class SettingsUiState(
- val cameraAppSettings: CameraAppSettings,
- var disabled: Boolean = false
-)
+sealed interface SettingsUiState {
+ object Disabled : SettingsUiState
+ data class Enabled(
+ val cameraAppSettings: CameraAppSettings,
+ val systemConstraints: SystemConstraints
+ ) : SettingsUiState
+}
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 ca55517..ea8caf7 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
@@ -20,14 +20,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.DarkMode
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
-import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
private const val TAG = "SettingsViewModel"
@@ -37,116 +40,78 @@ private const val TAG = "SettingsViewModel"
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
- private val settingsRepository: SettingsRepository
+ private val settingsRepository: SettingsRepository,
+ constraintsRepository: ConstraintsRepository
) : ViewModel() {
- private val _settingsUiState: MutableStateFlow<SettingsUiState> =
- MutableStateFlow(
- SettingsUiState(
- DEFAULT_CAMERA_APP_SETTINGS,
- disabled = true
+ val settingsUiState: StateFlow<SettingsUiState> =
+ combine(
+ settingsRepository.defaultCameraAppSettings,
+ constraintsRepository.systemConstraints.filterNotNull()
+ ) { updatedSettings, constraints ->
+ SettingsUiState.Enabled(
+ cameraAppSettings = updatedSettings,
+ systemConstraints = constraints
)
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = SettingsUiState.Disabled
)
- val settingsUiState: StateFlow<SettingsUiState> = _settingsUiState
- init {
- // updates our view model as soon as datastore is updated
+ fun setDefaultLensFacing(lensFacing: LensFacing) {
viewModelScope.launch {
- settingsRepository.cameraAppSettings.collect { updatedSettings ->
- _settingsUiState.emit(
- settingsUiState.value.copy(
- cameraAppSettings = updatedSettings,
- disabled = false
- )
- )
-
- Log.d(
- TAG,
- "updated setting ${settingsRepository.getCameraAppSettings().captureMode}"
- )
- }
- }
- viewModelScope.launch {
- _settingsUiState.emit(
- settingsUiState.value.copy(
- disabled = false
- )
- )
- }
- }
-
- fun setDefaultToFrontCamera() {
- // true means default is front
- viewModelScope.launch {
- settingsRepository.updateDefaultToFrontCamera()
- Log.d(
- TAG,
- "set camera default facing: " +
- "${settingsRepository.getCameraAppSettings().isFrontCameraFacing}"
- )
+ settingsRepository.updateDefaultLensFacing(lensFacing)
+ Log.d(TAG, "set camera default facing: $lensFacing")
}
}
fun setDarkMode(darkMode: DarkMode) {
viewModelScope.launch {
settingsRepository.updateDarkModeStatus(darkMode)
- Log.d(
- TAG,
- "set dark mode theme: ${settingsRepository.getCameraAppSettings().darkMode}"
- )
+ Log.d(TAG, "set dark mode theme: $darkMode")
}
}
fun setFlashMode(flashMode: FlashMode) {
viewModelScope.launch {
settingsRepository.updateFlashModeStatus(flashMode)
+ Log.d(TAG, "set flash mode: $flashMode")
+ }
+ }
+
+ fun setTargetFrameRate(targetFrameRate: Int) {
+ viewModelScope.launch {
+ settingsRepository.updateTargetFrameRate(targetFrameRate)
+ Log.d(TAG, "set target frame rate: $targetFrameRate")
}
}
fun setAspectRatio(aspectRatio: AspectRatio) {
viewModelScope.launch {
settingsRepository.updateAspectRatio(aspectRatio)
- Log.d(
- TAG,
- "set aspect ratio: " +
- "${settingsRepository.getCameraAppSettings().aspectRatio}"
- )
+ Log.d(TAG, "set aspect ratio: $aspectRatio")
}
}
fun setCaptureMode(captureMode: CaptureMode) {
viewModelScope.launch {
settingsRepository.updateCaptureMode(captureMode)
-
- Log.d(
- TAG,
- "set default capture mode: " +
- "${settingsRepository.getCameraAppSettings().captureMode}"
- )
+ Log.d(TAG, "set default capture mode: $captureMode")
}
}
fun setPreviewStabilization(stabilization: Stabilization) {
viewModelScope.launch {
settingsRepository.updatePreviewStabilization(stabilization)
-
- Log.d(
- TAG,
- "set preview stabilization: " +
- "${settingsRepository.getCameraAppSettings().previewStabilization}"
- )
+ Log.d(TAG, "set preview stabilization: $stabilization")
}
}
fun setVideoStabilization(stabilization: Stabilization) {
viewModelScope.launch {
settingsRepository.updateVideoStabilization(stabilization)
-
- Log.d(
- TAG,
- "set video stabilization: " +
- "${settingsRepository.getCameraAppSettings().previewStabilization}"
- )
+ Log.d(TAG, "set video stabilization: $stabilization")
}
}
}
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 01ea1a4..559f24f 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
@@ -15,12 +15,11 @@
*/
package com.google.jetpackcamera.settings.ui
+import android.content.res.Configuration
import androidx.compose.foundation.clickable
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.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
@@ -39,22 +38,34 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.text.toUpperCase
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.jetpackcamera.settings.R
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.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
+import com.google.jetpackcamera.settings.ui.theme.SettingsPreviewTheme
+
+const val FPS_AUTO = 0
+const val FPS_15 = 15
+const val FPS_30 = 30
+const val FPS_60 = 60
/**
* MAJOR SETTING UI COMPONENTS
@@ -63,22 +74,28 @@ import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SettingsPageHeader(modifier: Modifier = Modifier, title: String, navBack: () -> Unit) {
+fun SettingsPageHeader(title: String, navBack: () -> Unit, modifier: Modifier = Modifier) {
TopAppBar(
modifier = modifier,
title = {
Text(title)
},
navigationIcon = {
- IconButton(onClick = { navBack() }) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(id = R.string.nav_back_accessibility))
+ IconButton(
+ modifier = Modifier.testTag(BACK_BUTTON),
+ onClick = { navBack() }
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ stringResource(id = R.string.nav_back_accessibility)
+ )
}
}
)
}
@Composable
-fun SectionHeader(modifier: Modifier = Modifier, title: String) {
+fun SectionHeader(title: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier
.padding(start = 20.dp, top = 10.dp),
@@ -90,27 +107,29 @@ fun SectionHeader(modifier: Modifier = Modifier, title: String) {
@Composable
fun DefaultCameraFacing(
- modifier: Modifier = Modifier,
- cameraAppSettings: CameraAppSettings,
- onClick: () -> Unit
+ settingValue: Boolean,
+ enabled: Boolean,
+ setDefaultLensFacing: (LensFacing) -> Unit,
+ modifier: Modifier = Modifier
) {
SwitchSettingUI(
modifier = modifier,
title = stringResource(id = R.string.default_facing_camera_title),
description = null,
leadingIcon = null,
- onClick = { onClick() },
- settingValue = cameraAppSettings.isFrontCameraFacing,
- enabled = cameraAppSettings.isBackCameraAvailable &&
- cameraAppSettings.isFrontCameraAvailable
+ onSwitchChanged = { on ->
+ setDefaultLensFacing(if (on) LensFacing.FRONT else LensFacing.BACK)
+ },
+ settingValue = settingValue,
+ enabled = enabled
)
}
@Composable
fun DarkModeSetting(
- modifier: Modifier = Modifier,
currentDarkMode: DarkMode,
- setDarkMode: (DarkMode) -> Unit
+ setDarkMode: (DarkMode) -> Unit,
+ modifier: Modifier = Modifier
) {
BasicPopupSetting(
modifier = modifier,
@@ -145,9 +164,9 @@ fun DarkModeSetting(
@Composable
fun FlashModeSetting(
- modifier: Modifier = Modifier,
currentFlashMode: FlashMode,
- setFlashMode: (FlashMode) -> Unit
+ setFlashMode: (FlashMode) -> Unit,
+ modifier: Modifier = Modifier
) {
BasicPopupSetting(
modifier = modifier,
@@ -181,8 +200,13 @@ fun FlashModeSetting(
}
@Composable
-fun AspectRatioSetting(currentAspectRatio: AspectRatio, setAspectRatio: (AspectRatio) -> Unit) {
+fun AspectRatioSetting(
+ currentAspectRatio: AspectRatio,
+ setAspectRatio: (AspectRatio) -> Unit,
+ modifier: Modifier = Modifier
+) {
BasicPopupSetting(
+ modifier = modifier,
title = stringResource(id = R.string.aspect_ratio_title),
leadingIcon = null,
description = when (currentAspectRatio) {
@@ -213,8 +237,13 @@ fun AspectRatioSetting(currentAspectRatio: AspectRatio, setAspectRatio: (AspectR
}
@Composable
-fun CaptureModeSetting(currentCaptureMode: CaptureMode, setCaptureMode: (CaptureMode) -> Unit) {
+fun CaptureModeSetting(
+ currentCaptureMode: CaptureMode,
+ setCaptureMode: (CaptureMode) -> Unit,
+ modifier: Modifier = Modifier
+) {
BasicPopupSetting(
+ modifier = modifier,
title = stringResource(R.string.capture_mode_title),
leadingIcon = null,
description = when (currentCaptureMode) {
@@ -243,6 +272,56 @@ fun CaptureModeSetting(currentCaptureMode: CaptureMode, setCaptureMode: (Capture
)
}
+@Composable
+fun TargetFpsSetting(
+ currentTargetFps: Int,
+ supportedFps: Set<Int>,
+ setTargetFps: (Int) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ BasicPopupSetting(
+ modifier = modifier,
+ title = stringResource(id = R.string.fps_title),
+ enabled = supportedFps.isNotEmpty(),
+ leadingIcon = null,
+ description = if (supportedFps.isEmpty()) {
+ stringResource(id = R.string.fps_description_unavailable)
+ } else {
+ when (currentTargetFps) {
+ FPS_15 -> stringResource(id = R.string.fps_description, FPS_15)
+ FPS_30 -> stringResource(id = R.string.fps_description, FPS_30)
+ FPS_60 -> stringResource(id = R.string.fps_description, FPS_60)
+ else -> stringResource(
+ id = R.string.fps_description_auto
+ )
+ }
+ },
+ popupContents = {
+ Column(Modifier.selectableGroup()) {
+ Text(
+ text = stringResource(id = R.string.fps_stabilization_disclaimer),
+ fontStyle = FontStyle.Italic,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+
+ SingleChoiceSelector(
+ text = stringResource(id = R.string.fps_selector_auto),
+ selected = currentTargetFps == FPS_AUTO,
+ onClick = { setTargetFps(FPS_AUTO) }
+ )
+ listOf(FPS_15, FPS_30, FPS_60).forEach { fpsOption ->
+ SingleChoiceSelector(
+ text = "%d".format(fpsOption),
+ selected = currentTargetFps == fpsOption,
+ onClick = { setTargetFps(fpsOption) },
+ enabled = supportedFps.contains(fpsOption)
+ )
+ }
+ }
+ }
+ )
+}
+
/**
* Returns the description text depending on the preview/video stabilization configuration.
* On - preview is on and video is NOT off.
@@ -279,16 +358,35 @@ private fun getStabilizationStringRes(
fun StabilizationSetting(
currentPreviewStabilization: Stabilization,
currentVideoStabilization: Stabilization,
- supportedStabilizationMode: List<SupportedStabilizationMode>,
+ currentTargetFps: Int,
+ supportedStabilizationMode: Set<SupportedStabilizationMode>,
setVideoStabilization: (Stabilization) -> Unit,
- setPreviewStabilization: (Stabilization) -> Unit
+ setPreviewStabilization: (Stabilization) -> Unit,
+ modifier: Modifier = Modifier
) {
+ // if the preview stabilization was left ON and the target frame rate was set to 15,
+ // this setting needs to be reset to OFF
+ LaunchedEffect(key1 = currentTargetFps, key2 = currentPreviewStabilization) {
+ if (currentTargetFps == FPS_15 &&
+ currentPreviewStabilization == Stabilization.ON
+ ) {
+ setPreviewStabilization(Stabilization.UNDEFINED)
+ }
+ }
+ // entire setting disabled when no available fps or target fps = 60
+ // stabilization is unsupported >30 fps
BasicPopupSetting(
+ modifier = modifier,
title = stringResource(R.string.video_stabilization_title),
leadingIcon = null,
- enabled = supportedStabilizationMode.isNotEmpty(),
+ enabled = (
+ supportedStabilizationMode.isNotEmpty() &&
+ currentTargetFps != FPS_60
+ ),
description = if (supportedStabilizationMode.isEmpty()) {
- stringResource(id = R.string.stabilization_description_unsupported)
+ stringResource(id = R.string.stabilization_description_unsupported_device)
+ } else if (currentTargetFps == FPS_60) {
+ stringResource(id = R.string.stabilization_description_unsupported_fps)
} else {
stringResource(
id = getStabilizationStringRes(
@@ -299,13 +397,26 @@ fun StabilizationSetting(
},
popupContents = {
Column(Modifier.selectableGroup()) {
- Spacer(modifier = Modifier.height(10.dp))
+ Text(
+ text = stringResource(id = R.string.lens_stabilization_disclaimer),
+ fontStyle = FontStyle.Italic,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
- // on selector
+ // on (preview) selector
+ // disabled if target fps != (30 or off)
+ // TODO(b/328223562): device always resolves to 30fps when using preview stabilization
SingleChoiceSelector(
text = stringResource(id = R.string.stabilization_selector_on),
secondaryText = stringResource(id = R.string.stabilization_selector_on_info),
- enabled = supportedStabilizationMode.contains(SupportedStabilizationMode.ON),
+ enabled =
+ (
+ when (currentTargetFps) {
+ FPS_AUTO, FPS_30 -> true
+ else -> false
+ }
+ ) &&
+ supportedStabilizationMode.contains(SupportedStabilizationMode.ON),
selected = (currentPreviewStabilization == Stabilization.ON) &&
(currentVideoStabilization != Stabilization.OFF),
onClick = {
@@ -315,14 +426,16 @@ fun StabilizationSetting(
)
// high quality selector
+ // disabled if target fps = 60 (see VideoCapabilities.isStabilizationSupported)
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
- ),
+ enabled = (currentTargetFps != FPS_60) &&
+ supportedStabilizationMode.contains(
+ SupportedStabilizationMode.HIGH_QUALITY
+ ),
selected = (currentPreviewStabilization == Stabilization.UNDEFINED) &&
(currentVideoStabilization == Stabilization.ON),
@@ -347,6 +460,23 @@ fun StabilizationSetting(
)
}
+@Composable
+fun VersionInfo(versionName: String, modifier: Modifier = Modifier, buildType: String = "") {
+ SettingUI(
+ modifier = modifier,
+ title = stringResource(id = R.string.version_info_title),
+ leadingIcon = null
+ ) {
+ val versionString = versionName +
+ if (buildType.isNotEmpty()) {
+ "/${buildType.toUpperCase(Locale.current)}"
+ } else {
+ ""
+ }
+ Text(text = versionString)
+ }
+}
+
/*
* Setting UI sub-Components
* small and whimsical :)
@@ -357,12 +487,12 @@ fun StabilizationSetting(
@Composable
fun BasicPopupSetting(
- modifier: Modifier = Modifier,
title: String,
description: String?,
- enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)?,
- popupContents: @Composable () -> Unit
+ popupContents: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
) {
val popupStatus = remember { mutableStateOf(false) }
SettingUI(
@@ -396,20 +526,20 @@ fun BasicPopupSetting(
*/
@Composable
fun SwitchSettingUI(
- modifier: Modifier = Modifier,
title: String,
description: String?,
leadingIcon: @Composable (() -> Unit)?,
- onClick: () -> Unit,
+ onSwitchChanged: (Boolean) -> Unit,
settingValue: Boolean,
- enabled: Boolean
+ enabled: Boolean,
+ modifier: Modifier = Modifier
) {
SettingUI(
modifier = modifier.toggleable(
enabled = enabled,
role = Role.Switch,
value = settingValue,
- onValueChange = { _ -> onClick() }
+ onValueChange = { value -> onSwitchChanged(value) }
),
enabled = enabled,
title = title,
@@ -419,8 +549,8 @@ fun SwitchSettingUI(
Switch(
enabled = enabled,
checked = settingValue,
- onCheckedChange = {
- onClick()
+ onCheckedChange = { value ->
+ onSwitchChanged(value)
}
)
}
@@ -432,11 +562,11 @@ fun SwitchSettingUI(
*/
@Composable
fun SettingUI(
- modifier: Modifier = Modifier,
title: String,
+ leadingIcon: @Composable (() -> Unit)?,
+ modifier: Modifier = Modifier,
enabled: Boolean = true,
description: String? = null,
- leadingIcon: @Composable (() -> Unit)?,
trailingContent: @Composable (() -> Unit)?
) {
ListItem(
@@ -470,11 +600,11 @@ fun SettingUI(
*/
@Composable
fun SingleChoiceSelector(
- modifier: Modifier = Modifier,
text: String,
- secondaryText: String? = null,
selected: Boolean,
onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ secondaryText: String? = null,
enabled: Boolean = true
) {
Row(
@@ -503,3 +633,12 @@ fun SingleChoiceSelector(
)
}
}
+
+@Preview(name = "Light Mode")
+@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun Preview_VersionInfo() {
+ SettingsPreviewTheme {
+ VersionInfo(versionName = "0.1.0", buildType = "debug")
+ }
+}
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt
new file mode 100644
index 0000000..ef11b24
--- /dev/null
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (C) 2024 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.ui
+
+const val BACK_BUTTON = "BackButton"
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/theme/Theme.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/theme/Theme.kt
new file mode 100644
index 0000000..80aa90f
--- /dev/null
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/theme/Theme.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2024 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.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun SettingsPreviewTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme =
+ when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> darkColorScheme()
+ else -> lightColorScheme()
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content
+ )
+}
diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml
index 44a0bbc..ad8aff6 100644
--- a/feature/settings/src/main/res/values/strings.xml
+++ b/feature/settings/src/main/res/values/strings.xml
@@ -23,6 +23,7 @@
<string name="section_title_camera_settings">Default Camera Settings</string>
<string name="section_title_app_settings">App Settings</string>
+ <string name="section_title_software_info">Software Information</string>
<string name="default_facing_camera_title">Default to Front Camera</string>
<string name="default_facing_camera_description">Default Front</string>
@@ -83,5 +84,24 @@
<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>
+ <string name="stabilization_description_unsupported_device">Stabilization unsupported by device</string>
+ <string name="stabilization_description_unsupported_fps">Stabilization unsupported due to frame rate</string>
+
+ <!-- Target Fps setting strings -->
+ <string name="fps_title">Set Frame Rate</string>
+
+ <string name="fps_description_unavailable">No fixed frame rates supported</string>
+ <string name="fps_description_auto">Auto Frame Rate</string>
+ <string name="fps_description">%d fps</string>
+
+
+ <string name="fps_selector_auto">Auto</string>
+ <string name="fps_selector_value">%d</string>
+
+ <string name="fps_stabilization_disclaimer">*Available stabilization modes may change due to selected frame rate.</string>
+ <string name="lens_stabilization_disclaimer">*Some devices may not support stabilization on both lens.</string>
+
+
+ <!-- Version info strings -->
+ <string name="version_info_title">Version</string>
</resources> \ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index a2e90d8..9e52ff1 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -15,11 +15,20 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
+# Suppress warnings for experimental AGP properties
+android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,\
+ android.experimental.testOptions.managedDevices.maxConcurrentDevices,\
+ android.experimental.testOptions.managedDevices.setupTimeoutMinutes,\
+ android.testoptions.manageddevices.emulator.gpu
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
-android.defaults.buildfeatures.buildconfig=true
-android.nonFinalResIds=false \ No newline at end of file
+android.nonFinalResIds=false
+# Properties to make gradle managed devices testing more stable (see https://issuetracker.google.com/287312019#comment41)
+android.experimental.testOptions.managedDevices.maxConcurrentDevices=1
+android.experimental.testOptions.managedDevices.setupTimeoutMinutes=180
+# Ensure we can run managed devices on servers that don't support hardware rendering
+android.testoptions.manageddevices.emulator.gpu=swiftshader_indirect \ No newline at end of file
diff --git a/gradle/init.gradle.kts b/gradle/init.gradle.kts
index 38989df..8b5cab4 100644
--- a/gradle/init.gradle.kts
+++ b/gradle/init.gradle.kts
@@ -14,10 +14,10 @@
* limitations under the License.
*/
-val ktlintVersion = "1.0.0"
+val ktlintVersion = "1.2.1"
initscript {
- val spotlessVersion = "6.22.0"
+ val spotlessVersion = "6.25.0"
repositories {
mavenCentral()
@@ -40,18 +40,21 @@ rootProject {
ktlint(ktlintVersion)
.setEditorConfigPath(rootProject.file(".editorconfig"))
licenseHeaderFile(rootProject.file("spotless/copyright.kt"))
+ .updateYearWithLatest(false)
}
format("kts") {
target("**/*.kts")
targetExclude("**/build/**/*.kts")
// Look for the first line that doesn't have a block comment (assumed to be the license)
licenseHeaderFile(rootProject.file("spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)")
+ .updateYearWithLatest(false)
}
format("xml") {
target("**/*.xml")
targetExclude("**/build/**/*.xml")
// Look for the first XML tag that isn't a comment (<!--) or the xml declaration (<?xml)
licenseHeaderFile(rootProject.file("spotless/copyright.xml"), "(<[^!?])")
+ .updateYearWithLatest(false)
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e867155..a650cf0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,88 +1,100 @@
[versions]
-accompanistPermissions = "0.26.5-rc"
-androidJunit = "1.1.5"
-androidLibrary = "8.1.4"
+# Used directly in build.gradle.kts files
+compileSdk = "34"
+minSdk = "21"
+targetSdk = "34"
+composeCompiler = "1.5.10"
+
+# Used below in dependency definitions
+# Compose and Accompanist versions are linked
+# See https://github.com/google/accompanist?tab=readme-ov-file#compose-versions
+composeBom = "2024.04.00"
+accompanist = "0.34.0"
+# kotlinPlugin and composeCompiler are linked
+# See https://developer.android.com/jetpack/androidx/releases/compose-kotlin
+kotlinPlugin = "1.9.22"
+androidGradlePlugin = "8.4.0-rc01"
+protobufPlugin = "0.9.4"
+
androidxActivityCompose = "1.8.2"
androidxAppCompat = "1.6.1"
-androidxCore = "1.12.0"
+androidxBenchmark = "1.2.3"
+androidxCamera = "1.4.0-SNAPSHOT"
+androidxCameraViewfinder = "1.0.0-SNAPSHOT"
+androidxConcurrentFutures = "1.1.0"
androidxCoreKtx = "1.12.0"
+androidxDatastore = "1.0.0"
+androidxGraphicsCore = "1.0.0-beta01"
+androidxHiltNavigationCompose = "1.2.0"
+androidxLifecycle = "2.7.0"
+androidxNavigationCompose = "2.7.7"
+androidxProfileinstaller = "1.3.1"
+androidxTestEspresso = "3.5.1"
+androidxTestJunit = "1.1.5"
+androidxTestMonitor = "1.6.1"
+androidxTestRules = "1.5.0"
+androidxTestUiautomator = "2.3.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"
+kotlinxAtomicfu = "0.23.2"
+kotlinxCoroutines = "1.8.0"
+hilt = "2.51"
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"
+material = "1.11.0"
+mockitoCore = "5.6.0"
+protobuf = "3.25.2"
robolectric = "4.11.1"
-testMonitor = "1.6.1"
-uiautomator = "2.2.0"
-viewmodelCompose = "2.7.0"
+truth = "1.4.2"
[libraries]
-accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
+accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
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-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidxBenchmark" }
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-datastore = { module = "androidx.datastore:datastore", version.ref = "androidxDatastore" }
+androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }
+androidx-graphics-core = { module = "androidx.graphics:graphics-core", version.ref = "androidxGraphicsCore" }
+androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" }
+androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
+androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigationCompose" }
+androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "androidxProfileinstaller" }
+androidx-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" }
+androidx-test-monitor = { module = "androidx.test:monitor", version.ref = "androidxTestMonitor" }
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" }
+androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidxTestUiautomator" }
+camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" }
+camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" }
+camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" }
+camera-video = { module = "androidx.camera:camera-video", version.ref = "androidxCamera" }
+camera-viewfinder-compose = { module = "androidx.camera:camera-viewfinder-compose", version.ref = "androidxCameraViewfinder" }
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" }
+compose-junit = { module = "androidx.compose.ui:ui-test-junit4" }
+compose-material3 = { module = "androidx.compose.material3:material3" }
+compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
+compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
+compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
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" }
+futures-ktx = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "androidxConcurrentFutures" }
+hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
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" }
+kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
+
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" }
+truth = { module = "com.google.truth:truth", version.ref = "truth" }
[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" }
+android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
+android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
+android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" }
dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
-google-protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" }
+google-protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
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/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index b19396a..84d16f4 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Wed Jan 25 00:53:54 EST 2023
+#Tue Mar 12 23:44:57 PDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
index b611e8d..dd158c4 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,11 +21,12 @@ pluginManagement {
gradlePluginPortal()
}
}
+@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven {
- setUrl("https://androidx.dev/snapshots/builds/11359450/artifacts/repository")
+ setUrl("https://androidx.dev/snapshots/builds/11790852/artifacts/repository")
}
google()
mavenCentral()
@@ -35,9 +36,8 @@ rootProject.name = "Jetpack Camera"
include(":app")
include(":feature:preview")
include(":domain:camera")
-include(":camera-viewfinder-compose")
include(":feature:settings")
include(":data:settings")
include(":core:common")
-include(":feature:quicksettings")
include(":benchmark")
+include(":feature:permissions")
diff --git a/spotless/copyright.kt b/spotless/copyright.kt
index eeed4ac..d705563 100644
--- a/spotless/copyright.kt
+++ b/spotless/copyright.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) $YEAR 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.
diff --git a/spotless/copyright.kts b/spotless/copyright.kts
index eeed4ac..d705563 100644
--- a/spotless/copyright.kts
+++ b/spotless/copyright.kts
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * Copyright (C) $YEAR 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.
diff --git a/spotless/copyright.xml b/spotless/copyright.xml
index 121891e..bd67457 100644
--- a/spotless/copyright.xml
+++ b/spotless/copyright.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- ~ Copyright (C) 2023 The Android Open Source Project
+ ~ Copyright (C) $YEAR 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.