Merge remote-tracking branch 'aosp/upstream-main' into main am: cd16728b23
Original change: https://android-review.googlesource.com/c/platform/external/jetpack-camera-app/+/3177250
Change-Id: Idbc38ddd973d397058c99201d8aa546ff197a458
Signed-off-by: Automerger Merge Worker <[email protected]>
diff --git a/.github/workflows/MergeToMainWorkflow.yaml b/.github/workflows/MergeToMainWorkflow.yaml
index ad4954e..9aa6590 100644
--- a/.github/workflows/MergeToMainWorkflow.yaml
+++ b/.github/workflows/MergeToMainWorkflow.yaml
@@ -16,30 +16,30 @@
jobs:
build:
name: Build
- runs-on: ubuntu-latest
+ runs-on: ${{ vars.RUNNER }}
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/actions/wrapper-validation@v3
- 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/actions/setup-gradle@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
@@ -47,7 +47,7 @@
- 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"
diff --git a/.github/workflows/PullRequestWorkflow.yaml b/.github/workflows/PullRequestWorkflow.yaml
index 20e1582..ed83887 100644
--- a/.github/workflows/PullRequestWorkflow.yaml
+++ b/.github/workflows/PullRequestWorkflow.yaml
@@ -13,14 +13,14 @@
jobs:
build:
name: Build
- runs-on: ubuntu-latest
+ runs-on: ${{ vars.RUNNER }}
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
- uses: gradle/wrapper-validation-action@v2
+ uses: gradle/actions/wrapper-validation@v3
- name: Set up JDK
uses: actions/setup-java@v4
@@ -30,10 +30,10 @@
cache: gradle
- name: Setup Gradle
- uses: gradle/gradle-build-action@v3
+ uses: gradle/actions/setup-gradle@v3
- - name: Build all build type and flavor permutations
- run: ./gradlew assemble --parallel --build-cache
+ - name: Build stable debug gradle target
+ run: ./gradlew assembleStableDebug --parallel --build-cache
- name: Upload build outputs (APKs)
uses: actions/upload-artifact@v4
@@ -51,14 +51,14 @@
test:
name: Unit Tests
- runs-on: ubuntu-latest
+ runs-on: ${{ vars.RUNNER }}
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
- uses: gradle/wrapper-validation-action@v2
+ uses: gradle/actions/wrapper-validation@v3
- name: Set up JDK
uses: actions/setup-java@v4
@@ -68,7 +68,7 @@
cache: gradle
- name: Setup Gradle
- uses: gradle/gradle-build-action@v3
+ uses: gradle/actions/setup-gradle@v3
continue-on-error: true
- name: Run local tests
@@ -83,7 +83,7 @@
android-test:
name: Instrumentation Tests (${{ matrix.device.name }})
- runs-on: ubuntu-latest
+ runs-on: ${{ vars.RUNNER }}
timeout-minutes: 30
strategy:
fail-fast: false
@@ -117,9 +117,9 @@
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true
- name: Run instrumentation tests
- uses: gradle/gradle-build-action@v3
+ uses: gradle/actions/setup-gradle@v3
with:
- arguments: ${{ matrix.device.name }}DebugAndroidTest
+ arguments: ${{ matrix.device.name }}StableDebugAndroidTest
- name: Upload instrumentation test reports and logs on failure
if: failure()
@@ -132,7 +132,7 @@
spotless:
name: Spotless Check
- runs-on: ubuntu-latest
+ runs-on: ${{ vars.RUNNER }}
timeout-minutes: 60
steps:
- name: Checkout
@@ -141,7 +141,7 @@
fetch-depth: 0
- name: Validate Gradle Wrapper
- uses: gradle/wrapper-validation-action@v2
+ uses: gradle/actions/wrapper-validation@v3
- name: Set up JDK
uses: actions/setup-java@v4
@@ -151,7 +151,7 @@
cache: gradle
- name: Setup Gradle
- uses: gradle/gradle-build-action@v3
+ uses: gradle/actions/setup-gradle@v3
- name: Spotless Check
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --parallel --build-cache
diff --git a/.gitignore b/.gitignore
index 8b3881d..4e44987 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,4 @@
local.properties
.idea/deploymentTargetDropDown.xml
.idea/gradle.xml
+.idea/deploymentTargetSelector.xml
\ No newline at end of file
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
index 24b6073..c64c910 100644
--- a/.idea/androidTestResultsUserPreferences.xml
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -3,65 +3,14 @@
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
- <entry key="-1168588695">
+ <entry key="811462001">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_7_Pro_API_34" value="120" />
- <entry key="Tests" value="360" />
- </map>
- </option>
- </AndroidTestResultsTableState>
- </value>
- </entry>
- <entry key="401594821">
- <value>
- <AndroidTestResultsTableState>
- <option name="preferredColumnWidths">
- <map>
- <entry key="Duration" value="90" />
- <entry key="Pixel_6_Pro_API_30" value="120" />
- <entry key="Tests" value="360" />
- </map>
- </option>
- </AndroidTestResultsTableState>
- </value>
- </entry>
- <entry key="571770275">
- <value>
- <AndroidTestResultsTableState>
- <option name="preferredColumnWidths">
- <map>
- <entry key="Duration" value="90" />
- <entry key="Pixel_7_Pro_API_34" value="120" />
- <entry key="Tests" value="360" />
- </map>
- </option>
- </AndroidTestResultsTableState>
- </value>
- </entry>
- <entry key="632950842">
- <value>
- <AndroidTestResultsTableState>
- <option name="preferredColumnWidths">
- <map>
- <entry key="Duration" value="90" />
- <entry key="Tests" value="360" />
- <entry key="samsung SM-G990U1" value="120" />
- </map>
- </option>
- </AndroidTestResultsTableState>
- </value>
- </entry>
- <entry key="2043991187">
- <value>
- <AndroidTestResultsTableState>
- <option name="preferredColumnWidths">
- <map>
- <entry key="Duration" value="90" />
- <entry key="Pixel_6_Pro_API_30" value="120" />
+ <entry key="Pixel_C_API_34" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 37f7544..ce85930 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,25 +4,21 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
- <option name="testRunner" value="GRADLE" />
- <option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
- <option name="gradleJvm" value="Android Studio default JDK" />
+ <option name="gradleJvm" value="jbr-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/benchmark" />
- <option value="$PROJECT_DIR$/camera-viewfinder-compose" />
<option value="$PROJECT_DIR$/core" />
+ <option value="$PROJECT_DIR$/core/camera" />
<option value="$PROJECT_DIR$/core/common" />
<option value="$PROJECT_DIR$/data" />
<option value="$PROJECT_DIR$/data/settings" />
- <option value="$PROJECT_DIR$/domain" />
- <option value="$PROJECT_DIR$/domain/camera" />
<option value="$PROJECT_DIR$/feature" />
+ <option value="$PROJECT_DIR$/feature/permissions" />
<option value="$PROJECT_DIR$/feature/preview" />
- <option value="$PROJECT_DIR$/feature/quicksettings" />
<option value="$PROJECT_DIR$/feature/settings" />
</set>
</option>
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 2b8a50f..8d81632 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
- <option name="version" value="1.8.0" />
+ <option name="version" value="1.9.22" />
</component>
</project>
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index e67ad2b..0ff99b3 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,3 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="NullableNotNullManager">
@@ -5,7 +6,7 @@
<option name="myDefaultNotNull" value="androidx.annotation.NonNull" />
<option name="myNullables">
<value>
- <list size="17">
+ <list size="18">
<item index="0" class="java.lang.String" itemvalue="com.android.annotations.Nullable" />
<item index="1" class="java.lang.String" itemvalue="org.jspecify.nullness.Nullable" />
<item index="2" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNullable" />
@@ -23,12 +24,13 @@
<item index="14" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableDecl" />
<item index="15" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableType" />
<item index="16" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.Nullable" />
+ <item index="17" class="java.lang.String" itemvalue="jakarta.annotation.Nullable" />
</list>
</value>
</option>
<option name="myNotNulls">
<value>
- <list size="16">
+ <list size="17">
<item index="0" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNonNull" />
<item index="1" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.NonNull" />
<item index="2" class="java.lang.String" itemvalue="org.jspecify.nullness.NonNull" />
@@ -45,11 +47,12 @@
<item index="13" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullDecl" />
<item index="14" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullType" />
<item index="15" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.NonNull" />
+ <item index="16" class="java.lang.String" itemvalue="jakarta.annotation.Nonnull" />
</list>
</value>
</option>
</component>
- <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
+ <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6f57fec..8686bda 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,6 +23,8 @@
android {
compileSdk = libs.versions.compileSdk.get().toInt()
+ compileSdkPreview = libs.versions.compileSdkPreview.get()
+
namespace = "com.google.jetpackcamera"
defaultConfig {
@@ -48,6 +50,20 @@
matchingFallbacks += listOf("release")
}
}
+
+ flavorDimensions += "flavor"
+ productFlavors {
+ create("stable") {
+ dimension = "flavor"
+ isDefault = true
+ }
+
+ create("preview") {
+ dimension = "flavor"
+ targetSdkPreview = libs.versions.targetSdkPreview.get()
+ }
+ }
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -90,6 +106,8 @@
}
dependencies {
+ implementation(libs.androidx.tracing)
+ implementation(project(":core:common"))
// Compose
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt
index 0cf12c1..747650f 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt
@@ -34,6 +34,9 @@
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.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.runScenarioTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt
index e33f19e..0e57a00 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt
@@ -27,6 +27,7 @@
import androidx.test.uiautomator.UiDevice
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.TruthJUnit.assume
+import com.google.jetpackcamera.feature.preview.R
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
@@ -34,6 +35,13 @@
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 com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.assume
+import com.google.jetpackcamera.utils.getCurrentLensFacing
+import com.google.jetpackcamera.utils.onNodeWithContentDescription
+import com.google.jetpackcamera.utils.runScenarioTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -76,7 +84,7 @@
composeTestRule.onNodeWithTag(QUICK_SETTINGS_FLASH_BUTTON)
.assertExists()
composeTestRule.onNodeWithContentDescription(
- com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_on_description
+ R.string.quick_settings_flash_on_description
)
}
@@ -99,7 +107,7 @@
.performClick()
composeTestRule.onNodeWithContentDescription(
- com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_auto_description
+ R.string.quick_settings_flash_auto_description
)
}
@@ -111,7 +119,7 @@
}
composeTestRule.onNodeWithContentDescription(
- com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_off_description
+ R.string.quick_settings_flash_off_description
)
// Navigate to quick settings
@@ -127,7 +135,7 @@
.performClick()
composeTestRule.onNodeWithContentDescription(
- com.google.jetpackcamera.feature.preview.R.string.quick_settings_flash_off_description
+ R.string.quick_settings_flash_off_description
)
}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
index e9abd40..3d86388 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
@@ -15,28 +15,28 @@
*/
package com.google.jetpackcamera
-import android.app.Instrumentation
+import android.app.Activity
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 android.provider.MediaStore
+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.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 com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.runScenarioTest
+import com.google.jetpackcamera.utils.runScenarioTestForResult
import java.io.File
import java.net.URLConnection
import org.junit.Rule
@@ -51,70 +51,70 @@
val permissionsRule: GrantPermissionRule =
GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+ @get:Rule
+ val composeTestRule = createEmptyComposeRule()
+
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 {
+ fun image_capture() = runScenarioTest<MainActivity> {
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))
- }
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
- @Test
- fun image_capture_external() = runTest {
- 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)).click()
- uiDevice.wait(
- Until.findObject(By.res(IMAGE_CAPTURE_SUCCESS_TAG)),
- 5000
- )
- activityScenario!!.result
- }.register("key", TEST_CONTRACT) { result ->
- assert(result)
- assert(doesImageFileExist(uri))
- }.launch(uri)
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+ .assertExists()
+ .performClick()
+ composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed()
+ }
+ assert(File(DIR_PATH).lastModified() > timeStamp)
deleteFilesInDirAfterTimestamp(timeStamp)
}
@Test
- fun image_capture_external_illegal_uri() = run {
+ fun image_capture_external() {
val timeStamp = System.currentTimeMillis()
- val inputUri = Uri.parse("asdfasdf")
- getTestRegistry {
- activityScenario = ActivityScenario.launchActivityForResult(it)
- uiDevice.wait(
- Until.findObject(By.res(CAPTURE_BUTTON)),
- 5000
- )
- uiDevice.findObject(By.res(CAPTURE_BUTTON)).click()
- uiDevice.wait(
- Until.findObject(By.res(IMAGE_CAPTURE_FAILURE_TAG)),
- 5000
- )
- uiDevice.pressBack()
- activityScenario!!.result
- }.register("key_illegal_uri", TEST_CONTRACT) { result ->
- assert(!result)
- }.launch(inputUri)
+ val uri = getTestUri(timeStamp)
+ val result =
+ runScenarioTestForResult<MainActivity>(getIntent(uri)) {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+ .assertExists()
+ .performClick()
+ }
+ assert(result?.resultCode == Activity.RESULT_OK)
+ assert(doesImageFileExist(uri))
+ deleteFilesInDirAfterTimestamp(timeStamp)
+ }
+
+ @Test
+ fun image_capture_external_illegal_uri() {
+ val uri = Uri.parse("asdfasdf")
+ val result =
+ runScenarioTestForResult<MainActivity>(getIntent(uri)) {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+ .assertExists()
+ .performClick()
+ composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(IMAGE_CAPTURE_FAILURE_TAG).isDisplayed()
+ }
+ uiDevice.pressBack()
+ }
+ assert(result?.resultCode == Activity.RESULT_CANCELED)
+ assert(!doesImageFileExist(uri))
}
private fun doesImageFileExist(uri: Uri): Boolean {
@@ -143,28 +143,6 @@
return hasDeletedFile
}
- 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(
@@ -174,19 +152,20 @@
)
}
+ private fun getIntent(uri: Uri): Intent {
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ intent.setComponent(
+ ComponentName(
+ "com.google.jetpackcamera",
+ "com.google.jetpackcamera.MainActivity"
+ )
+ )
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
+ return intent
+ }
+
companion object {
val DIR_PATH: String =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).path
-
- val TEST_CONTRACT = object : ActivityResultContracts.TakePicture() {
- override fun createIntent(context: Context, uri: Uri): Intent {
- return super.createIntent(context, uri).apply {
- component = ComponentName(
- ApplicationProvider.getApplicationContext(),
- MainActivity::class.java
- )
- }
- }
- }
}
}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt
index d06904d..b3e82ab 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt
@@ -30,7 +30,13 @@
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.R
import com.google.jetpackcamera.settings.ui.BACK_BUTTON
+import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.assume
+import com.google.jetpackcamera.utils.onNodeWithText
+import com.google.jetpackcamera.utils.runScenarioTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -72,7 +78,7 @@
// Assert we do not see the settings screen based on the title
composeTestRule.onNodeWithText(
- com.google.jetpackcamera.settings.R.string.settings_title
+ R.string.settings_title
).assertDoesNotExist()
}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt
index 1727db5..5d732a9 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt
@@ -32,6 +32,10 @@
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 com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.assume
+import com.google.jetpackcamera.utils.getCurrentLensFacing
+import com.google.jetpackcamera.utils.runScenarioTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt
new file mode 100644
index 0000000..0cfbd73
--- /dev/null
+++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.RequiresDevice
+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.jetpackcamera.feature.preview.ui.AMPLITUDE_HOT_TAG
+import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
+import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.runScenarioTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@RequiresDevice
+class VideoAudioTest {
+ @get:Rule
+ val permissionsRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+
+ @get:Rule
+ val composeTestRule = createEmptyComposeRule()
+
+ private val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ @Before
+ fun setUp() {
+ assertThat(uiDevice.isScreenOn).isTrue()
+ }
+
+ @Test
+ fun audioIncomingWhenEnabled() {
+ runScenarioTest<MainActivity> {
+ // check audio visualizer composable for muted/unmuted icon.
+ // icon will only be unmuted if audio is nonzero
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+
+ // record video
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+ .assertExists().performTouchInput { longClick(durationMillis = 5000) }
+
+ // assert hot amplitude tag visible
+ uiDevice.wait(
+ Until.findObject(By.res(AMPLITUDE_HOT_TAG)),
+ 5000
+ )
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
index 437f9a4..ee81021 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
@@ -35,6 +35,7 @@
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 com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS
import java.io.File
import org.junit.Rule
import org.junit.Test
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt
similarity index 95%
rename from app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
rename to app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt
index b68f8e6..0d1e8d6 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera
+package com.google.jetpackcamera.utils
import android.os.Build
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt
similarity index 98%
rename from app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt
rename to app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt
index 1e1bca9..e3be80e 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/ComposeTestRuleExt.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera
+package com.google.jetpackcamera.utils
import android.content.Context
import androidx.annotation.StringRes
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
similarity index 84%
rename from app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt
rename to app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
index 118bd7b..af2c512 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/UiTestUtil.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.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,15 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera
+package com.google.jetpackcamera.utils
import android.app.Activity
+import android.app.Instrumentation
+import android.content.Intent
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.MainActivity
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
@@ -37,6 +40,16 @@
}
}
+inline fun <reified T : Activity> runScenarioTestForResult(
+ intent: Intent,
+ crossinline block: ActivityScenario<T>.() -> Unit
+): Instrumentation.ActivityResult? {
+ ActivityScenario.launchActivityForResult<T>(intent).use { scenario ->
+ scenario.apply(block)
+ return scenario.result
+ }
+}
+
context(ActivityScenario<MainActivity>)
fun ComposeTestRule.getCurrentLensFacing(): LensFacing {
var needReturnFromQuickSettings = false
diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
index 1ac223c..f00aee6 100644
--- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
+++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
@@ -54,14 +54,17 @@
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
+import androidx.tracing.Trace
import com.google.jetpackcamera.MainActivityUiState.Loading
import com.google.jetpackcamera.MainActivityUiState.Success
+import com.google.jetpackcamera.core.common.traceFirstFrameMainActivity
import com.google.jetpackcamera.feature.preview.PreviewMode
import com.google.jetpackcamera.feature.preview.PreviewViewModel
import com.google.jetpackcamera.settings.model.DarkMode
import com.google.jetpackcamera.ui.JcaApp
import com.google.jetpackcamera.ui.theme.JetpackCameraTheme
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -90,6 +93,18 @@
.collect()
}
}
+
+ var firstFrameComplete: CompletableDeferred<Unit>? = null
+ if (Trace.isEnabled()) {
+ firstFrameComplete = CompletableDeferred()
+ // start trace between app starting and the earliest possible completed capture
+ lifecycleScope.launch {
+ traceFirstFrameMainActivity(cookie = 0) {
+ firstFrameComplete.await()
+ }
+ }
+ }
+
setContent {
when (uiState) {
Loading -> {
@@ -132,6 +147,9 @@
)
window?.colorMode = colorMode
}
+ },
+ onFirstFrameCaptureCompleted = {
+ firstFrameComplete?.complete(Unit)
}
)
}
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 1e7add4..2f2ea31 100644
--- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
+++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
@@ -36,19 +36,20 @@
import com.google.jetpackcamera.ui.Routes.PREVIEW_ROUTE
import com.google.jetpackcamera.ui.Routes.SETTINGS_ROUTE
-@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun JcaApp(
openAppSettings: () -> Unit,
/*TODO(b/306236646): remove after still capture*/
previewMode: PreviewMode,
+ modifier: Modifier = Modifier,
onRequestWindowColorMode: (Int) -> Unit,
- modifier: Modifier = Modifier
+ onFirstFrameCaptureCompleted: () -> Unit
) {
JetpackCameraNavHost(
previewMode = previewMode,
onOpenAppSettings = openAppSettings,
onRequestWindowColorMode = onRequestWindowColorMode,
+ onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted,
modifier = modifier
)
}
@@ -60,6 +61,7 @@
previewMode: PreviewMode,
onOpenAppSettings: () -> Unit,
onRequestWindowColorMode: (Int) -> Unit,
+ onFirstFrameCaptureCompleted: () -> Unit,
navController: NavHostController = rememberNavController()
) {
NavHost(
@@ -69,6 +71,7 @@
) {
composable(PERMISSIONS_ROUTE) {
PermissionsScreen(
+ shouldRequestAudioPermission = previewMode is PreviewMode.StandardMode,
onNavigateToPreview = {
navController.navigate(PREVIEW_ROUTE) {
// cannot navigate back to permissions after leaving
@@ -98,6 +101,7 @@
PreviewScreen(
onNavigateToSettings = { navController.navigate(SETTINGS_ROUTE) },
onRequestWindowColorMode = onRequestWindowColorMode,
+ onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted,
previewMode = previewMode
)
}
@@ -112,4 +116,3 @@
}
}
}
-
diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts
index 72fb9ec..a923cc4 100644
--- a/benchmark/build.gradle.kts
+++ b/benchmark/build.gradle.kts
@@ -22,6 +22,7 @@
android {
namespace = "com.google.jetpackcamera.benchmark"
compileSdk = libs.versions.compileSdk.get().toInt()
+ compileSdkPreview = libs.versions.compileSdkPreview.get()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
@@ -55,6 +56,18 @@
}
}
+ flavorDimensions += "flavor"
+ productFlavors {
+ create("stable") {
+ dimension = "flavor"
+ }
+
+ create("preview") {
+ dimension = "flavor"
+ targetSdkPreview = libs.versions.targetSdkPreview.get()
+ }
+ }
+
targetProjectPath = ":app"
// required for benchmark:
// self instrumentation required for the tests to be able to compile, start, or kill the app
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/FirstFrameBenchmark.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/FirstFrameBenchmark.kt
new file mode 100644
index 0000000..3ed5ae4
--- /dev/null
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/FirstFrameBenchmark.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.benchmark
+
+import android.content.Intent
+import androidx.benchmark.macro.ExperimentalMetricApi
+import androidx.benchmark.macro.MacrobenchmarkScope
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.TraceSectionMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.jetpackcamera.benchmark.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.benchmark.utils.DEFAULT_TEST_ITERATIONS
+import com.google.jetpackcamera.benchmark.utils.FIRST_FRAME_TRACE_MAIN_ACTIVITY
+import com.google.jetpackcamera.benchmark.utils.FIRST_FRAME_TRACE_PREVIEW
+import com.google.jetpackcamera.benchmark.utils.IMAGE_CAPTURE_SUCCESS_TAG
+import com.google.jetpackcamera.benchmark.utils.JCA_PACKAGE_NAME
+import com.google.jetpackcamera.benchmark.utils.allowAllRequiredPerms
+import com.google.jetpackcamera.benchmark.utils.clickCaptureButton
+import com.google.jetpackcamera.benchmark.utils.findObjectByRes
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FirstFrameBenchmark {
+ @get:Rule
+ val benchmarkRule = MacrobenchmarkRule()
+
+ @Test
+ fun timeToFirstFrameDefaultSettingsColdStartup() {
+ benchmarkFirstFrame(setupBlock = {
+ allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray())
+ })
+ }
+
+ @Test
+ fun timeToFirstFrameDefaultSettingsHotStartup() {
+ benchmarkFirstFrame(startupMode = StartupMode.HOT, setupBlock = {
+ allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray())
+ })
+ }
+
+ /**
+ * The benchmark for first frame tracks the amount of time it takes from preview loading on the
+ * screen to when the use case is able to start capturing frames.
+ *
+ * Note that the trace this benchmark tracks is the earliest point in which a frame is captured
+ * and sent to a surface. This does not necessarily mean the frame is visible on screen.
+ *
+ * @param startupMode the designated startup mode, either [StartupMode.COLD] or [StartupMode.HOT]
+ * @param timeout option to change the default timeout length after clicking the Image Capture
+ * button.
+ *
+ */
+ @OptIn(ExperimentalMetricApi::class)
+ private fun benchmarkFirstFrame(
+ startupMode: StartupMode? = StartupMode.COLD,
+ iterations: Int = DEFAULT_TEST_ITERATIONS,
+ timeout: Long = 15000,
+ intent: Intent? = null,
+ setupBlock: MacrobenchmarkScope.() -> Unit = {}
+ ) {
+ benchmarkRule.measureRepeated(
+ packageName = JCA_PACKAGE_NAME,
+ metrics = buildList {
+ add(StartupTimingMetric())
+ if (startupMode == StartupMode.COLD) {
+ add(
+ TraceSectionMetric(
+ sectionName = FIRST_FRAME_TRACE_MAIN_ACTIVITY,
+ targetPackageOnly = false,
+ mode = TraceSectionMetric.Mode.First
+ )
+ )
+ }
+ add(
+ TraceSectionMetric(
+ sectionName = FIRST_FRAME_TRACE_PREVIEW,
+ targetPackageOnly = false,
+ mode = TraceSectionMetric.Mode.First
+ )
+ )
+ },
+ iterations = iterations,
+ startupMode = startupMode,
+ setupBlock = setupBlock
+ ) {
+ pressHome()
+ if (intent == null) startActivityAndWait() else startActivityAndWait(intent)
+ device.waitForIdle()
+
+ clickCaptureButton(device)
+
+ // ensure trace is closed
+ findObjectByRes(
+ device = device,
+ testTag = IMAGE_CAPTURE_SUCCESS_TAG,
+ timeout = timeout,
+ shouldFailIfNotFound = true
+ )
+ }
+ }
+}
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 2096265..787e16f 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/ImageCaptureLatencyBenchmark.kt
@@ -20,6 +20,17 @@
import androidx.benchmark.macro.TraceSectionMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.jetpackcamera.benchmark.utils.DEFAULT_TEST_ITERATIONS
+import com.google.jetpackcamera.benchmark.utils.FlashMode
+import com.google.jetpackcamera.benchmark.utils.IMAGE_CAPTURE_SUCCESS_TAG
+import com.google.jetpackcamera.benchmark.utils.IMAGE_CAPTURE_TRACE
+import com.google.jetpackcamera.benchmark.utils.JCA_PACKAGE_NAME
+import com.google.jetpackcamera.benchmark.utils.allowCamera
+import com.google.jetpackcamera.benchmark.utils.clickCaptureButton
+import com.google.jetpackcamera.benchmark.utils.findObjectByRes
+import com.google.jetpackcamera.benchmark.utils.setQuickFrontFacingCamera
+import com.google.jetpackcamera.benchmark.utils.setQuickSetFlash
+import com.google.jetpackcamera.benchmark.utils.toggleQuickSettings
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -97,7 +108,7 @@
// ensure trace is closed
findObjectByRes(
device = device,
- testTag = IMAGE_CAPTURE_SUCCESS_TOAST,
+ testTag = IMAGE_CAPTURE_SUCCESS_TAG,
timeout = timeout,
shouldFailIfNotFound = true
)
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 c97a45f..3c1275c 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/StartupBenchmark.kt
@@ -20,6 +20,10 @@
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.jetpackcamera.benchmark.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.benchmark.utils.DEFAULT_TEST_ITERATIONS
+import com.google.jetpackcamera.benchmark.utils.JCA_PACKAGE_NAME
+import com.google.jetpackcamera.benchmark.utils.allowAllRequiredPerms
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -34,33 +38,33 @@
val benchmarkRule = MacrobenchmarkRule()
@Test
- fun startupColdWithoutCameraPermission() {
+ fun startupColdWithPermissionRequest() {
benchmarkStartup()
}
@Test
- fun startupCold() {
+ fun startupColdNoPermissionRequest() {
benchmarkStartup(
setupBlock =
- { allowCamera() }
+ { allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray()) }
)
}
@Test
- fun startupWarm() {
+ fun startupWarmNoPermissionRequest() {
benchmarkStartup(
startupMode = StartupMode.WARM,
setupBlock =
- { allowCamera() }
+ { allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray()) }
)
}
@Test
- fun startupHot() {
+ fun startupHotNoPermissionRequest() {
benchmarkStartup(
startupMode = StartupMode.HOT,
setupBlock =
- { allowCamera() }
+ { allowAllRequiredPerms(perms = APP_REQUIRED_PERMISSIONS.toTypedArray()) }
)
}
@@ -74,7 +78,6 @@
iterations = DEFAULT_TEST_ITERATIONS,
startupMode = startupMode,
setupBlock = setupBlock
-
) {
pressHome()
startActivityAndWait()
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Permissions.kt
similarity index 61%
copy from benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
copy to benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Permissions.kt
index fbe4594..af0455c 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Permissions.kt
@@ -13,14 +13,25 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.benchmark
+package com.google.jetpackcamera.benchmark.utils
import android.Manifest.permission
+import android.os.Build
import androidx.benchmark.macro.MacrobenchmarkScope
-import org.junit.Assert
+val APP_REQUIRED_PERMISSIONS: List<String> = buildList {
+ add(permission.CAMERA)
+ add(permission.RECORD_AUDIO)
+ if (Build.VERSION.SDK_INT <= 28) {
+ add(permission.WRITE_EXTERNAL_STORAGE)
+ }
+}
fun MacrobenchmarkScope.allowCamera() {
val command = "pm grant $packageName ${permission.CAMERA}"
- val output = device.executeShellCommand(command)
- Assert.assertEquals("", output)
+ device.executeShellCommand(command)
+}
+
+fun MacrobenchmarkScope.allowAllRequiredPerms(vararg perms: String) {
+ val command = "pm grant $packageName"
+ perms.forEach { perm -> device.executeShellCommand("$command $perm") }
}
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Utils.kt
similarity index 94%
rename from benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt
rename to benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Utils.kt
index 1204001..10b030e 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Utils.kt
+++ b/benchmark/src/main/java/com/google/jetpackcamera/benchmark/utils/Utils.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.benchmark
+package com.google.jetpackcamera.benchmark.utils
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
@@ -29,7 +29,7 @@
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"
+const val IMAGE_CAPTURE_SUCCESS_TAG = "ImageCaptureSuccessTag"
// test descriptions
const val QUICK_SETTINGS_FLASH_OFF = "QUICK SETTINGS FLASH IS OFF"
@@ -40,6 +40,9 @@
// trace tags
const val IMAGE_CAPTURE_TRACE = "JCA Image Capture"
+const val FIRST_FRAME_TRACE_PREVIEW = "firstFrameTracePreview"
+const val FIRST_FRAME_TRACE_MAIN_ACTIVITY = "firstFrameTraceMainActivity"
+
// enums
enum class FlashMode {
ON,
diff --git a/domain/camera/.gitignore b/core/camera/.gitignore
similarity index 100%
rename from domain/camera/.gitignore
rename to core/camera/.gitignore
diff --git a/domain/camera/Android.bp b/core/camera/Android.bp
similarity index 87%
rename from domain/camera/Android.bp
rename to core/camera/Android.bp
index 0872d4f..fc9c829 100644
--- a/domain/camera/Android.bp
+++ b/core/camera/Android.bp
@@ -5,13 +5,12 @@
}
android_library {
- name: "jetpack-camera-app_domain_camera",
+ name: "jetpack-camera-app_core_camera",
srcs: ["src/main/**/*.kt"],
static_libs: [
"androidx.concurrent_concurrent-futures-ktx",
"hilt_android",
"androidx.camera_camera-core",
- "androidx.camera_camera-viewfinder",
"androidx.camera_camera-video",
"androidx.camera_camera-camera2",
"androidx.camera_camera-lifecycle",
diff --git a/domain/camera/build.gradle.kts b/core/camera/build.gradle.kts
similarity index 61%
rename from domain/camera/build.gradle.kts
rename to core/camera/build.gradle.kts
index c79cf4b..4fb048a 100644
--- a/domain/camera/build.gradle.kts
+++ b/core/camera/build.gradle.kts
@@ -22,8 +22,9 @@
}
android {
- namespace = "com.google.jetpackcamera.data.camera"
+ namespace = "com.google.jetpackcamera.core.camera"
compileSdk = libs.versions.compileSdk.get().toInt()
+ compileSdkPreview = libs.versions.compileSdkPreview.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
@@ -31,6 +32,49 @@
lint.targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ @Suppress("UnstableApiUsage")
+ externalNativeBuild {
+ val versionScript = file("src/main/cpp/jni.lds").absolutePath
+ cmake {
+ cppFlags += listOf(
+ "-std=c++17",
+ "-O3",
+ "-flto",
+ "-fPIC",
+ "-fno-exceptions",
+ "-fno-rtti",
+ "-fomit-frame-pointer",
+ "-fdata-sections",
+ "-ffunction-sections"
+ )
+ arguments += listOf(
+ "-DCMAKE_VERBOSE_MAKEFILE=ON",
+ "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,--gc-sections " +
+ "-Wl,--version-script=${versionScript}"
+ )
+ }
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ version = libs.versions.cmake.get()
+ path = file("src/main/cpp/CMakeLists.txt")
+ }
+ }
+
+ flavorDimensions += "flavor"
+ productFlavors {
+ create("stable") {
+ dimension = "flavor"
+ isDefault = true
+ }
+
+ create("preview") {
+ dimension = "flavor"
+ targetSdkPreview = libs.versions.targetSdkPreview.get()
+ }
}
compileOptions {
@@ -66,6 +110,7 @@
// Tracing
implementation(libs.androidx.tracing)
+ implementation(libs.kotlinx.atomicfu)
// Graphics libraries
implementation(libs.androidx.graphics.core)
diff --git a/domain/camera/src/main/AndroidManifest.xml b/core/camera/src/main/AndroidManifest.xml
similarity index 87%
rename from domain/camera/src/main/AndroidManifest.xml
rename to core/camera/src/main/AndroidManifest.xml
index ea4043c..39bd3ea 100644
--- a/domain/camera/src/main/AndroidManifest.xml
+++ b/core/camera/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.
@@ -15,7 +15,6 @@
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
- package="com.google.jetpackcamera.domain.camera">
+ package="com.google.jetpackcamera.core.camera">
<uses-permission android:name = "android.permission.RECORD_AUDIO" />
</manifest>
-
diff --git a/core/camera/src/main/cpp/CMakeLists.txt b/core/camera/src/main/cpp/CMakeLists.txt
new file mode 100644
index 0000000..008a8a2
--- /dev/null
+++ b/core/camera/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,34 @@
+#
+# 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.
+#
+cmake_minimum_required(VERSION 3.22.1)
+
+project(core_camera_jni)
+
+add_library(
+ opengl_debug_lib
+ SHARED
+ opengl_debug_jni.cpp
+ jni_hooks.cpp
+)
+
+find_library(log-lib log)
+find_library(opengles3-lib GLESv3)
+target_link_libraries(opengl_debug_lib PRIVATE ${log-lib} ${opengles3-lib})
+target_link_options(
+ opengl_debug_lib
+ PRIVATE
+ "-Wl,-z,max-page-size=16384"
+)
diff --git a/core/camera/src/main/cpp/jni.lds b/core/camera/src/main/cpp/jni.lds
new file mode 100644
index 0000000..c619e88
--- /dev/null
+++ b/core/camera/src/main/cpp/jni.lds
@@ -0,0 +1,10 @@
+VERS_1.0 {
+ # Export JNI symbols.
+ global:
+ Java_*;
+ JNI_OnLoad;
+
+ # Hide everything else.
+ local:
+ *;
+};
diff --git a/core/camera/src/main/cpp/jni_hooks.cpp b/core/camera/src/main/cpp/jni_hooks.cpp
new file mode 100644
index 0000000..ee1cc2c
--- /dev/null
+++ b/core/camera/src/main/cpp/jni_hooks.cpp
@@ -0,0 +1,23 @@
+/*
+ * Copyright 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.
+ */
+
+#include <jni.h>
+
+extern "C" {
+JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *jvm, void *reserved) {
+ return JNI_VERSION_1_6;
+}
+}
diff --git a/core/camera/src/main/cpp/opengl_debug_jni.cpp b/core/camera/src/main/cpp/opengl_debug_jni.cpp
new file mode 100644
index 0000000..58e5a86
--- /dev/null
+++ b/core/camera/src/main/cpp/opengl_debug_jni.cpp
@@ -0,0 +1,44 @@
+/*
+ * Copyright 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.
+ */
+
+#include <android/log.h>
+
+#define GL_GLEXT_PROTOTYPES
+#include <GLES2/gl2.h>
+#include <GLES2/gl2ext.h>
+#include <jni.h>
+
+namespace {
+ auto constexpr LOG_TAG = "OpenGLDebugLib";
+
+ void gl_debug_cb(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length,
+ const GLchar* message, const void* userParam) {
+ if (type == GL_DEBUG_TYPE_ERROR_KHR) {
+ __android_log_print(ANDROID_LOG_ERROR, LOG_TAG,
+ "GL ERROR:\n %s.",
+ message);
+ }
+ }
+} // namespace
+
+extern "C" {
+JNIEXPORT void JNICALL
+Java_com_google_jetpackcamera_core_camera_effects_GLDebug_enableES3DebugErrorLogging(
+ JNIEnv *env, jobject clazz) {
+ glDebugMessageCallbackKHR(gl_debug_cb, nullptr);
+ glEnable(GL_DEBUG_OUTPUT_KHR);
+}
+}
diff --git a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
similarity index 63%
rename from benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
index fbe4594..27ea15f 100644
--- a/benchmark/src/main/java/com/google/jetpackcamera/benchmark/Permissions.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
@@ -13,14 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.benchmark
+package com.google.jetpackcamera.core.camera
-import android.Manifest.permission
-import androidx.benchmark.macro.MacrobenchmarkScope
-import org.junit.Assert
+import androidx.camera.core.MeteringPoint
-fun MacrobenchmarkScope.allowCamera() {
- val command = "pm grant $packageName ${permission.CAMERA}"
- val output = device.executeShellCommand(command)
- Assert.assertEquals("", output)
+/**
+ * An event that can be sent to the camera coroutine.
+ */
+sealed interface CameraEvent {
+
+ /**
+ * Represents a focus metering event, that the camera can act on.
+ */
+ class FocusMeteringEvent(val meteringPoint: MeteringPoint) : CameraEvent
}
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraModule.kt
similarity index 94%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraModule.kt
index a2348ac..db2e167 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraModule.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraModule.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera
+package com.google.jetpackcamera.core.camera
import dagger.Binds
import dagger.Module
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
similarity index 83%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
index 9cf1fc4..4c8fbc8 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
@@ -13,19 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera
+package com.google.jetpackcamera.core.camera
import android.content.ContentResolver
import android.net.Uri
-import android.view.Display
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
+import com.google.jetpackcamera.settings.model.DeviceRotation
import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.LowLightBoost
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
@@ -70,7 +72,7 @@
fun setZoomScale(scale: Float)
- fun getZoomScale(): StateFlow<Float>
+ fun getCurrentCameraState(): StateFlow<CameraState>
fun getSurfaceRequest(): StateFlow<SurfaceRequest?>
@@ -86,12 +88,20 @@
suspend fun setLensFacing(lensFacing: LensFacing)
- fun tapToFocus(display: Display, surfaceWidth: Int, surfaceHeight: Int, x: Float, y: Float)
+ suspend fun tapToFocus(x: Float, y: Float)
suspend fun setCaptureMode(captureMode: CaptureMode)
suspend fun setDynamicRange(dynamicRange: DynamicRange)
+ fun setDeviceRotation(deviceRotation: DeviceRotation)
+
+ suspend fun setLowLightBoost(lowLightBoost: LowLightBoost)
+
+ suspend fun setImageFormat(imageFormat: ImageOutputFormat)
+
+ suspend fun setAudioMuted(isAudioMuted: Boolean)
+
/**
* Represents the events required for screen flash.
*/
@@ -113,3 +123,8 @@
object OnVideoRecordError : OnVideoRecordEvent
}
}
+
+data class CameraState(
+ val zoomScale: Float = 1f,
+ val sessionFirstFrameTimestamp: Long = 0L
+)
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
similarity index 69%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
index a20c4a0..7561056 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
@@ -13,31 +13,41 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera
+package com.google.jetpackcamera.core.camera
import android.Manifest
import android.app.Application
import android.content.ContentResolver
import android.content.ContentValues
import android.content.pm.PackageManager
+import android.hardware.camera2.CameraCaptureSession
+import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.CaptureRequest
+import android.hardware.camera2.TotalCaptureResult
import android.net.Uri
import android.os.Environment
+import android.os.SystemClock
import android.provider.MediaStore
import android.util.Log
import android.util.Range
-import android.view.Display
+import androidx.annotation.OptIn
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.camera2.interop.Camera2Interop
+import androidx.camera.camera2.interop.ExperimentalCamera2Interop
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.DynamicRange as CXDynamicRange
+import androidx.camera.core.ExperimentalImageCaptureOutputFormat
+import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCapture.OutputFileOptions
import androidx.camera.core.ImageCapture.ScreenFlash
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceOrientedMeteringPointFactory
import androidx.camera.core.SurfaceRequest
import androidx.camera.core.UseCaseGroup
import androidx.camera.core.ViewPort
@@ -54,23 +64,25 @@
import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE
import androidx.core.content.ContextCompat
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.core.camera.CameraUseCase.ScreenFlashEvent.Type
+import com.google.jetpackcamera.core.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.DeviceRotation
import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.LowLightBoost
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.IllegalArgumentException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
@@ -78,10 +90,13 @@
import java.util.concurrent.Executor
import javax.inject.Inject
import kotlin.coroutines.ContinuationInterceptor
+import kotlin.math.abs
import kotlin.properties.Delegates
+import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -90,6 +105,7 @@
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
@@ -120,6 +136,39 @@
private lateinit var imageCaptureUseCase: ImageCapture
+ /**
+ * Applies a CaptureCallback to the provided image capture builder
+ */
+ @OptIn(ExperimentalCamera2Interop::class)
+ private fun setOnCaptureCompletedCallback(previewBuilder: Preview.Builder) {
+ val isFirstFrameTimestampUpdated = atomic(false)
+ val captureCallback = object : CameraCaptureSession.CaptureCallback() {
+ override fun onCaptureCompleted(
+ session: CameraCaptureSession,
+ request: CaptureRequest,
+ result: TotalCaptureResult
+ ) {
+ super.onCaptureCompleted(session, request, result)
+ try {
+ if (!isFirstFrameTimestampUpdated.value) {
+ _currentCameraState.update { old ->
+ old.copy(
+ sessionFirstFrameTimestamp = SystemClock.elapsedRealtimeNanos()
+ )
+ }
+ isFirstFrameTimestampUpdated.value = true
+ }
+ } catch (_: Exception) {}
+ }
+ }
+
+ // Create an Extender to attach Camera2 options
+ val imageCaptureExtender = Camera2Interop.Extender(previewBuilder)
+
+ // Attach the Camera2 CaptureCallback
+ imageCaptureExtender.setSessionCaptureCallback(captureCallback)
+ }
+
private var videoCaptureUseCase: VideoCapture<Recorder>? = null
private var recording: Recording? = null
private lateinit var captureMode: CaptureMode
@@ -128,6 +177,8 @@
private val screenFlashEvents: MutableSharedFlow<CameraUseCase.ScreenFlashEvent> =
MutableSharedFlow()
+ private val focusMeteringEvents =
+ Channel<CameraEvent.FocusMeteringEvent>(capacity = Channel.CONFLATED)
private val currentSettings = MutableStateFlow<CameraAppSettings?>(null)
@@ -168,13 +219,21 @@
}
val supportedFixedFrameRates = getSupportedFrameRates(camInfo)
+ val supportedImageFormats = getSupportedImageFormats(camInfo)
put(
lensFacing,
CameraConstraints(
supportedStabilizationModes = supportedStabilizationModes,
supportedFixedFrameRates = supportedFixedFrameRates,
- supportedDynamicRanges = supportedDynamicRanges
+ supportedDynamicRanges = supportedDynamicRanges,
+ supportedImageFormatsMap = mapOf(
+ // Only JPEG is supported in single-stream mode, since
+ // single-stream mode uses CameraEffect, which does not support
+ // Ultra HDR now.
+ Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)),
+ Pair(CaptureMode.MULTI_STREAM, supportedImageFormats)
+ )
)
)
}
@@ -188,30 +247,7 @@
settingsRepository.defaultCameraAppSettings.first()
.tryApplyDynamicRangeConstraints()
.tryApplyAspectRatioForExternalCapture(externalImageCapture)
-
- 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
+ .tryApplyImageFormatConstraints()
}
/**
@@ -227,7 +263,8 @@
val targetFrameRate: Int,
val stabilizePreviewMode: Stabilization,
val stabilizeVideoMode: Stabilization,
- val dynamicRange: DynamicRange
+ val dynamicRange: DynamicRange,
+ val imageFormat: ImageOutputFormat
)
/**
@@ -238,6 +275,8 @@
* The use cases typically will not need to be re-bound.
*/
private data class TransientSessionSettings(
+ val audioMuted: Boolean,
+ val deviceRotation: DeviceRotation,
val flashMode: FlashMode,
val zoomScale: Float
)
@@ -250,6 +289,8 @@
.filterNotNull()
.map { currentCameraSettings ->
transientSettings.value = TransientSessionSettings(
+ audioMuted = currentCameraSettings.audioMuted,
+ deviceRotation = currentCameraSettings.deviceRotation,
flashMode = currentCameraSettings.flashMode,
zoomScale = currentCameraSettings.zoomScale
)
@@ -266,7 +307,8 @@
targetFrameRate = currentCameraSettings.targetFrameRate,
stabilizePreviewMode = currentCameraSettings.previewStabilization,
stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization,
- dynamicRange = currentCameraSettings.dynamicRange
+ dynamicRange = currentCameraSettings.dynamicRange,
+ imageFormat = currentCameraSettings.imageFormat
)
}.distinctUntilChanged()
.collectLatest { sessionSettings ->
@@ -288,6 +330,7 @@
.first()
val useCaseGroup = createUseCaseGroup(
+ cameraInfo,
sessionSettings,
initialTransientSettings,
cameraConstraints.supportedStabilizationModes,
@@ -300,6 +343,16 @@
var prevTransientSettings = initialTransientSettings
cameraProvider.runWith(sessionSettings.cameraSelector, useCaseGroup) { camera ->
Log.d(TAG, "Camera session started")
+
+ launch {
+ focusMeteringEvents.consumeAsFlow().collect {
+ val focusMeteringAction =
+ FocusMeteringAction.Builder(it.meteringPoint).build()
+ Log.d(TAG, "Starting focus and metering")
+ camera.cameraControl.startFocusAndMetering(focusMeteringAction)
+ }
+ }
+
transientSettings.filterNotNull().collectLatest { newTransientSettings ->
// Apply camera control settings
if (prevTransientSettings.zoomScale != newTransientSettings.zoomScale) {
@@ -310,7 +363,9 @@
zoomState.maxZoomRatio
)
camera.cameraControl.setZoomRatio(finalScale)
- _zoomScale.value = finalScale
+ _currentCameraState.update { old ->
+ old.copy(zoomScale = finalScale)
+ }
}
}
@@ -322,6 +377,36 @@
)
}
+ if (prevTransientSettings.deviceRotation
+ != newTransientSettings.deviceRotation
+ ) {
+ Log.d(
+ TAG,
+ "Updating device rotation from " +
+ "${prevTransientSettings.deviceRotation} -> " +
+ "${newTransientSettings.deviceRotation}"
+ )
+ val targetRotation =
+ newTransientSettings.deviceRotation.toUiSurfaceRotation()
+ useCaseGroup.useCases.forEach {
+ when (it) {
+ is Preview -> {
+ // Preview rotation should always be natural orientation
+ // in order to support seamless handling of orientation
+ // configuration changes in UI
+ }
+
+ is ImageCapture -> {
+ it.targetRotation = targetRotation
+ }
+
+ is VideoCapture<*> -> {
+ it.targetRotation = targetRotation
+ }
+ }
+ }
+ }
+
prevTransientSettings = newTransientSettings
}
}
@@ -424,7 +509,12 @@
}
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
+
+ // ok. there is a difference between MUTING and ENABLING audio
+ // audio must be enabled in order to be muted
+ // if the video recording isnt started with audio enabled, you will not be able to unmute it
+ // the toggle should only affect whether or not the audio is muted.
+ // the permission will determine whether or not the audio is enabled.
val audioEnabled = (
checkSelfPermission(
this.application.baseContext,
@@ -442,7 +532,6 @@
ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, name)
}
-
val mediaStoreOutput =
MediaStoreOutputOptions.Builder(
application.contentResolver,
@@ -459,7 +548,11 @@
recording =
videoCaptureUseCase!!.output
.prepareRecording(application, mediaStoreOutput)
- .apply { if (audioEnabled) withAudioEnabled() }
+ .apply {
+ if (audioEnabled) {
+ withAudioEnabled()
+ }
+ }
.start(callbackExecutor) { onVideoRecordEvent ->
run {
Log.d(TAG, onVideoRecordEvent.toString())
@@ -486,6 +579,7 @@
}
}
}
+ currentSettings.value?.audioMuted?.let { recording?.mute(it) }
}
override fun stopVideoRecording() {
@@ -500,8 +594,8 @@
}
// 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 _currentCameraState = MutableStateFlow(CameraState())
+ override fun getCurrentCameraState(): StateFlow<CameraState> = _currentCameraState.asStateFlow()
private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
override fun getSurfaceRequest(): StateFlow<SurfaceRequest?> = _surfaceRequest.asStateFlow()
@@ -512,6 +606,7 @@
if (systemConstraints.availableLenses.contains(lensFacing)) {
old?.copy(cameraLensFacing = lensFacing)
?.tryApplyDynamicRangeConstraints()
+ ?.tryApplyImageFormatConstraints()
} else {
old
}
@@ -543,15 +638,34 @@
return this
}
- override fun tapToFocus(
- display: Display,
- surfaceWidth: Int,
- surfaceHeight: Int,
- x: Float,
- y: Float
- ) {
- // TODO(tm):Convert API to use SurfaceOrientedMeteringPointFactory and
- // use a Channel to get result of FocusMeteringAction
+ private fun CameraAppSettings.tryApplyImageFormatConstraints(): CameraAppSettings {
+ return systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints ->
+ with(constraints.supportedImageFormatsMap[captureMode]) {
+ val newImageFormat = if (this != null && contains(imageFormat)) {
+ imageFormat
+ } else {
+ ImageOutputFormat.JPEG
+ }
+
+ [email protected](
+ imageFormat = newImageFormat
+ )
+ }
+ } ?: this
+ }
+
+ override suspend fun tapToFocus(x: Float, y: Float) {
+ Log.d(TAG, "tapToFocus, sending FocusMeteringEvent")
+
+ getSurfaceRequest().filterNotNull().map { surfaceRequest ->
+ SurfaceOrientedMeteringPointFactory(
+ surfaceRequest.resolution.width.toFloat(),
+ surfaceRequest.resolution.height.toFloat()
+ )
+ }.collectLatest { meteringPointFactory ->
+ val meteringPoint = meteringPointFactory.createPoint(x, y)
+ focusMeteringEvents.send(CameraEvent.FocusMeteringEvent(meteringPoint))
+ }
}
override fun getScreenFlashEvents() = screenFlashEvents.asSharedFlow()
@@ -624,40 +738,51 @@
override suspend fun setCaptureMode(captureMode: CaptureMode) {
currentSettings.update { old ->
- old?.copy(captureMode = captureMode)
+ old?.copy(captureMode = captureMode)?.tryApplyImageFormatConstraints()
}
}
private fun createUseCaseGroup(
+ cameraInfo: CameraInfo,
sessionSettings: PerpetualSessionSettings,
initialTransientSettings: TransientSessionSettings,
supportedStabilizationModes: Set<SupportedStabilizationMode>,
effect: CameraEffect? = null
): UseCaseGroup {
- val previewUseCase = createPreviewUseCase(sessionSettings, supportedStabilizationModes)
+ val previewUseCase =
+ createPreviewUseCase(cameraInfo, sessionSettings, supportedStabilizationModes)
+ imageCaptureUseCase = createImageUseCase(cameraInfo, sessionSettings)
if (!disableVideoCapture) {
- videoCaptureUseCase = createVideoUseCase(sessionSettings, supportedStabilizationModes)
+ videoCaptureUseCase =
+ createVideoUseCase(cameraInfo, sessionSettings, supportedStabilizationModes)
}
setFlashModeInternal(
flashMode = initialTransientSettings.flashMode,
isFrontFacing = sessionSettings.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
)
- imageCaptureUseCase = ImageCapture.Builder()
- .setResolutionSelector(getResolutionSelector(sessionSettings.aspectRatio)).build()
return UseCaseGroup.Builder().apply {
+ Log.d(
+ TAG,
+ "Setting initial device rotation to ${initialTransientSettings.deviceRotation}"
+ )
setViewPort(
ViewPort.Builder(
sessionSettings.aspectRatio.ratio,
- previewUseCase.targetRotation
+ initialTransientSettings.deviceRotation.toUiSurfaceRotation()
).build()
)
addUseCase(previewUseCase)
- if (sessionSettings.dynamicRange == DynamicRange.SDR) {
+ if (sessionSettings.dynamicRange == DynamicRange.SDR ||
+ sessionSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
+ ) {
addUseCase(imageCaptureUseCase)
}
- if (videoCaptureUseCase != null) {
+ // Not to bind VideoCapture when Ultra HDR is enabled to keep the app design simple.
+ if (videoCaptureUseCase != null &&
+ sessionSettings.imageFormat == ImageOutputFormat.JPEG
+ ) {
addUseCase(videoCaptureUseCase!!)
}
@@ -672,12 +797,68 @@
}
}
+ override fun setDeviceRotation(deviceRotation: DeviceRotation) {
+ currentSettings.update { old ->
+ old?.copy(deviceRotation = deviceRotation)
+ }
+ }
+
+ override suspend fun setImageFormat(imageFormat: ImageOutputFormat) {
+ currentSettings.update { old ->
+ old?.copy(imageFormat = imageFormat)
+ }
+ }
+
+ @OptIn(ExperimentalImageCaptureOutputFormat::class)
+ private fun getSupportedImageFormats(cameraInfo: CameraInfo): Set<ImageOutputFormat> {
+ return ImageCapture.getImageCaptureCapabilities(cameraInfo).supportedOutputFormats
+ .mapNotNull(Int::toAppImageFormat)
+ .toSet()
+ }
+
+ @OptIn(ExperimentalImageCaptureOutputFormat::class)
+ private fun createImageUseCase(
+ cameraInfo: CameraInfo,
+ sessionSettings: PerpetualSessionSettings,
+ onCloseTrace: () -> Unit = {}
+ ): ImageCapture {
+ val builder = ImageCapture.Builder()
+ builder.setResolutionSelector(
+ getResolutionSelector(cameraInfo.sensorLandscapeRatio, sessionSettings.aspectRatio)
+ )
+ if (sessionSettings.dynamicRange != DynamicRange.SDR &&
+ sessionSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
+ ) {
+ builder.setOutputFormat(ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR)
+ }
+ return builder.build()
+ }
+
+ override suspend fun setLowLightBoost(lowLightBoost: LowLightBoost) {
+ currentSettings.update { old ->
+ old?.copy(lowLightBoost = lowLightBoost)
+ }
+ }
+
+ override suspend fun setAudioMuted(isAudioMuted: Boolean) {
+ // toggle mute for current in progress recording
+ recording?.mute(!isAudioMuted)
+
+ currentSettings.update { old ->
+ old?.copy(audioMuted = isAudioMuted)
+ }
+ }
+
private fun createVideoUseCase(
+ cameraInfo: CameraInfo,
sessionSettings: PerpetualSessionSettings,
supportedStabilizationMode: Set<SupportedStabilizationMode>
): VideoCapture<Recorder> {
+ val sensorLandscapeRatio = cameraInfo.sensorLandscapeRatio
val recorder = Recorder.Builder()
- .setAspectRatio(getAspectRatioForUseCase(sessionSettings.aspectRatio))
+ .setAspectRatio(
+ getAspectRatioForUseCase(sensorLandscapeRatio, sessionSettings.aspectRatio)
+ )
.setExecutor(defaultDispatcher.asExecutor()).build()
return VideoCapture.Builder(recorder).apply {
// set video stabilization
@@ -696,11 +877,24 @@
}.build()
}
- private fun getAspectRatioForUseCase(aspectRatio: AspectRatio): Int {
+ private fun getAspectRatioForUseCase(
+ sensorLandscapeRatio: Float,
+ aspectRatio: AspectRatio
+ ): Int {
return when (aspectRatio) {
AspectRatio.THREE_FOUR -> RATIO_4_3
AspectRatio.NINE_SIXTEEN -> RATIO_16_9
- else -> RATIO_DEFAULT
+ else -> {
+ // Choose the aspect ratio which maximizes FOV by being closest to the sensor ratio
+ if (
+ abs(sensorLandscapeRatio - AspectRatio.NINE_SIXTEEN.landscapeRatio.toFloat()) <
+ abs(sensorLandscapeRatio - AspectRatio.THREE_FOUR.landscapeRatio.toFloat())
+ ) {
+ RATIO_16_9
+ } else {
+ RATIO_4_3
+ }
+ }
}
}
@@ -719,17 +913,23 @@
}
private fun createPreviewUseCase(
+ cameraInfo: CameraInfo,
sessionSettings: PerpetualSessionSettings,
supportedStabilizationModes: Set<SupportedStabilizationMode>
): Preview {
- val previewUseCaseBuilder = Preview.Builder()
+ val previewUseCaseBuilder = Preview.Builder().apply {
+ setTargetRotation(DeviceRotation.Natural.toUiSurfaceRotation())
+ }
+
+ setOnCaptureCompletedCallback(previewUseCaseBuilder)
+
// set preview stabilization
if (shouldPreviewBeStabilized(sessionSettings, supportedStabilizationModes)) {
previewUseCaseBuilder.setPreviewStabilizationEnabled(true)
}
previewUseCaseBuilder.setResolutionSelector(
- getResolutionSelector(sessionSettings.aspectRatio)
+ getResolutionSelector(cameraInfo.sensorLandscapeRatio, sessionSettings.aspectRatio)
)
return previewUseCaseBuilder.build().apply {
@@ -739,11 +939,25 @@
}
}
- private fun getResolutionSelector(aspectRatio: AspectRatio): ResolutionSelector {
+ private fun getResolutionSelector(
+ sensorLandscapeRatio: Float,
+ 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
+ else -> {
+ // Choose the resolution selector strategy which maximizes FOV by being closest
+ // to the sensor aspect ratio
+ if (
+ abs(sensorLandscapeRatio - AspectRatio.NINE_SIXTEEN.landscapeRatio.toFloat()) <
+ abs(sensorLandscapeRatio - AspectRatio.THREE_FOUR.landscapeRatio.toFloat())
+ ) {
+ AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
+ } else {
+ AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY
+ }
+ }
}
return ResolutionSelector.Builder().setAspectRatioStrategy(aspectRatioStrategy).build()
}
@@ -825,3 +1039,24 @@
)
}
+private val CameraInfo.sensorLandscapeRatio: Float
+ @OptIn(ExperimentalCamera2Interop::class)
+ get() = Camera2CameraInfo.from(this)
+ .getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)
+ ?.let { sensorRect ->
+ if (sensorRect.width() > sensorRect.height()) {
+ sensorRect.width().toFloat() / sensorRect.height()
+ } else {
+ sensorRect.height().toFloat() / sensorRect.width()
+ }
+ } ?: Float.NaN
+
+@OptIn(ExperimentalImageCaptureOutputFormat::class)
+private fun Int.toAppImageFormat(): ImageOutputFormat? {
+ return when (this) {
+ ImageCapture.OUTPUT_FORMAT_JPEG -> ImageOutputFormat.JPEG
+ ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR -> ImageOutputFormat.JPEG_ULTRA_HDR
+ // All other output formats unsupported. Return null.
+ else -> null
+ }
+}
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
similarity index 97%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
index a3c6c33..e6f98ea 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CoroutineCameraProvider.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera
+package com.google.jetpackcamera.core.camera
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/CopyingSurfaceProcessor.kt
similarity index 99%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/CopyingSurfaceProcessor.kt
index 6f626c1..8f1fd6c 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/CopyingSurfaceProcessor.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/CopyingSurfaceProcessor.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera.effects
+package com.google.jetpackcamera.core.camera.effects
import android.graphics.SurfaceTexture
import android.opengl.EGL14
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/EGLSpecV14ES3.kt
similarity index 96%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/EGLSpecV14ES3.kt
index 97918ed..bf6e3ca 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/EGLSpecV14ES3.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/EGLSpecV14ES3.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera.effects
+package com.google.jetpackcamera.core.camera.effects
import android.opengl.EGL14
import android.opengl.EGLConfig
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/GLDebug.kt
similarity index 66%
copy from app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
copy to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/GLDebug.kt
index b68f8e6..d9e3fe1 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/GLDebug.kt
@@ -13,14 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera
+package com.google.jetpackcamera.core.camera.effects
-import android.os.Build
-
-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)
+object GLDebug {
+ init {
+ System.loadLibrary("opengl_debug_lib")
}
+
+ external fun enableES3DebugErrorLogging()
}
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
similarity index 86%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
index e3aff12..fc32ecd 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/ShaderCopy.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera.effects
+package com.google.jetpackcamera.core.camera.effects
import android.graphics.SurfaceTexture
import android.opengl.EGL14
@@ -21,6 +21,7 @@
import android.opengl.EGLExt
import android.opengl.GLES11Ext
import android.opengl.GLES20
+import android.opengl.GLES30
import android.util.Log
import android.view.Surface
import androidx.annotation.WorkerThread
@@ -38,8 +39,17 @@
private var externalTextureId: Int = -1
private var programHandle = -1
private var texMatrixLoc = -1
+ private var samplerLoc = -1
private var positionLoc = -1
private var texCoordLoc = -1
+ private val glExtensions: Set<String> by lazy {
+ checkGlThread()
+ buildSet {
+ GLES20.glGetString(GLES20.GL_EXTENSIONS)?.split(" ")?.also {
+ addAll(it)
+ }
+ }
+ }
private val use10bitPipeline: Boolean
get() = dynamicRange.bitDepth == DynamicRange.BIT_DEPTH_10_BIT
@@ -78,6 +88,10 @@
override val initRenderer: () -> Unit
get() = {
+ if (use10bitPipeline && glExtensions.contains("GL_KHR_debug")) {
+ GLDebug.enableES3DebugErrorLogging()
+ }
+
createProgram(
if (use10bitPipeline) {
TEN_BIT_VERTEX_SHADER
@@ -123,6 +137,7 @@
get() = { outputWidth: Int,
outputHeight: Int,
surfaceTransform: FloatArray ->
+ checkGlThread()
GLES20.glViewport(
0,
0,
@@ -202,14 +217,36 @@
// Set the texture.
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, externalTextureId)
+ GLES20.glUniform1i(samplerLoc, 0)
+
+ if (use10bitPipeline) {
+ val vaos = IntArray(1)
+ GLES30.glGenVertexArrays(1, vaos, 0)
+ GLES30.glBindVertexArray(vaos[0])
+ checkGlErrorOrThrow("glBindVertexArray")
+ }
+
+ val vbos = IntArray(2)
+ GLES20.glGenBuffers(2, vbos, 0)
+ checkGlErrorOrThrow("glGenBuffers")
+
+ // Connect vertexBuffer to "aPosition".
+ val coordsPerVertex = 2
+ val vertexStride = 0
+ GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbos[0])
+ checkGlErrorOrThrow("glBindBuffer")
+ GLES20.glBufferData(
+ GLES20.GL_ARRAY_BUFFER,
+ VERTEX_BUF.capacity() * SIZEOF_FLOAT,
+ VERTEX_BUF,
+ GLES20.GL_STATIC_DRAW
+ )
+ checkGlErrorOrThrow("glBufferData")
// Enable the "aPosition" vertex attribute.
GLES20.glEnableVertexAttribArray(positionLoc)
checkGlErrorOrThrow("glEnableVertexAttribArray")
- // Connect vertexBuffer to "aPosition".
- val coordsPerVertex = 2
- val vertexStride = 0
GLES20.glVertexAttribPointer(
positionLoc,
coordsPerVertex,
@@ -217,17 +254,28 @@
/*normalized=*/
false,
vertexStride,
- VERTEX_BUF
+ 0
)
checkGlErrorOrThrow("glVertexAttribPointer")
+ // Connect texBuffer to "aTextureCoord".
+ val coordsPerTex = 2
+ val texStride = 0
+ GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbos[1])
+ checkGlErrorOrThrow("glBindBuffer")
+
+ GLES20.glBufferData(
+ GLES20.GL_ARRAY_BUFFER,
+ TEX_BUF.capacity() * SIZEOF_FLOAT,
+ TEX_BUF,
+ GLES20.GL_STATIC_DRAW
+ )
+ checkGlErrorOrThrow("glBufferData")
+
// Enable the "aTextureCoord" vertex attribute.
GLES20.glEnableVertexAttribArray(texCoordLoc)
checkGlErrorOrThrow("glEnableVertexAttribArray")
- // Connect texBuffer to "aTextureCoord".
- val coordsPerTex = 2
- val texStride = 0
GLES20.glVertexAttribPointer(
texCoordLoc,
coordsPerTex,
@@ -235,7 +283,7 @@
/*normalized=*/
false,
texStride,
- TEX_BUF
+ 0
)
checkGlErrorOrThrow("glVertexAttribPointer")
}
@@ -299,6 +347,8 @@
checkLocationOrThrow(texCoordLoc, "aTextureCoord")
texMatrixLoc = GLES20.glGetUniformLocation(programHandle, "uTexMatrix")
checkLocationOrThrow(texMatrixLoc, "uTexMatrix")
+ samplerLoc = GLES20.glGetUniformLocation(programHandle, VAR_TEXTURE)
+ checkLocationOrThrow(samplerLoc, VAR_TEXTURE)
}
@WorkerThread
@@ -419,10 +469,20 @@
precision mediump float;
uniform __samplerExternal2DY2YEXT $VAR_TEXTURE;
in vec2 $VAR_TEXTURE_COORD;
- layout (yuv) out vec3 outColor;
-
+ out vec3 outColor;
+
+ vec3 yuvToRgb(vec3 yuv) {
+ const vec3 yuvOffset = vec3(0.0625, 0.5, 0.5);
+ const mat3 yuvToRgbColorTransform = mat3(
+ 1.1689f, 1.1689f, 1.1689f,
+ 0.0000f, -0.1881f, 2.1502f,
+ 1.6853f, -0.6530f, 0.0000f
+ );
+ return clamp(yuvToRgbColorTransform * (yuv - yuvOffset), 0.0, 1.0);
+ }
+
void main() {
- outColor = texture($VAR_TEXTURE, $VAR_TEXTURE_COORD).xyz;
+ outColor = yuvToRgb(texture($VAR_TEXTURE, $VAR_TEXTURE_COORD).xyz);
}
""".trimIndent()
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/SingleSurfaceForcingEffect.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/SingleSurfaceForcingEffect.kt
similarity index 95%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/SingleSurfaceForcingEffect.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/SingleSurfaceForcingEffect.kt
index 6057b89..7748719 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/effects/SingleSurfaceForcingEffect.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/SingleSurfaceForcingEffect.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera.effects
+package com.google.jetpackcamera.core.camera.effects
import androidx.camera.core.CameraEffect
import kotlinx.coroutines.CoroutineScope
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
similarity index 81%
rename from domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt
rename to core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
index 8724bad..4245f59 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
@@ -13,21 +13,24 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera.test
+package com.google.jetpackcamera.core.camera.test
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.net.Uri
-import android.view.Display
import androidx.camera.core.ImageCapture
import androidx.camera.core.SurfaceRequest
-import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.jetpackcamera.core.camera.CameraState
+import com.google.jetpackcamera.core.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.DeviceRotation
import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.LowLightBoost
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -89,7 +92,9 @@
isLensFacingFront &&
(it.flashMode == FlashMode.AUTO || it.flashMode == FlashMode.ON)
- _zoomScale.value = it.zoomScale
+ _currentCameraState.update { old ->
+ old.copy(zoomScale = it.zoomScale)
+ }
}
}
@@ -140,13 +145,13 @@
recordingInProgress = false
}
- private val _zoomScale = MutableStateFlow(1f)
+ private val _currentCameraState = MutableStateFlow(CameraState())
override fun setZoomScale(scale: Float) {
currentSettings.update { old ->
old.copy(zoomScale = scale)
}
}
- override fun getZoomScale(): StateFlow<Float> = _zoomScale.asStateFlow()
+ override fun getCurrentCameraState(): StateFlow<CameraState> = _currentCameraState.asStateFlow()
private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
override fun getSurfaceRequest(): StateFlow<SurfaceRequest?> = _surfaceRequest.asStateFlow()
@@ -176,13 +181,7 @@
}
}
- override fun tapToFocus(
- display: Display,
- surfaceWidth: Int,
- surfaceHeight: Int,
- x: Float,
- y: Float
- ) {
+ override suspend fun tapToFocus(x: Float, y: Float) {
TODO("Not yet implemented")
}
@@ -197,4 +196,28 @@
old.copy(dynamicRange = dynamicRange)
}
}
+
+ override fun setDeviceRotation(deviceRotation: DeviceRotation) {
+ currentSettings.update { old ->
+ old.copy(deviceRotation = deviceRotation)
+ }
+ }
+
+ override suspend fun setImageFormat(imageFormat: ImageOutputFormat) {
+ currentSettings.update { old ->
+ old.copy(imageFormat = imageFormat)
+ }
+ }
+
+ override suspend fun setLowLightBoost(lowLightBoost: LowLightBoost) {
+ currentSettings.update { old ->
+ old.copy(lowLightBoost = lowLightBoost)
+ }
+ }
+
+ override suspend fun setAudioMuted(isAudioMuted: Boolean) {
+ currentSettings.update { old ->
+ old.copy(audioMuted = isAudioMuted)
+ }
+ }
}
diff --git a/domain/camera/src/test/Android.bp b/core/camera/src/test/Android.bp
similarity index 78%
rename from domain/camera/src/test/Android.bp
rename to core/camera/src/test/Android.bp
index 2fc04c2..b969779 100644
--- a/domain/camera/src/test/Android.bp
+++ b/core/camera/src/test/Android.bp
@@ -3,7 +3,7 @@
}
java_test {
- name: "jetpack-camera-app_domain_camera-tests",
+ name: "jetpack-camera-app_core_camera-tests",
team: "trendy_team_camerax",
srcs: ["java/**/*.kt"],
static_libs: [
@@ -12,8 +12,7 @@
"androidx.test.ext.junit",
"androidx.test.ext.truth",
"mockito-core",
- "jetpack-camera-app_domain_camera",
-
+ "jetpack-camera-app_core_camera",
],
min_sdk_version: "21",
}
diff --git a/domain/camera/src/test/AndroidManifest.xml b/core/camera/src/test/AndroidManifest.xml
similarity index 78%
rename from domain/camera/src/test/AndroidManifest.xml
rename to core/camera/src/test/AndroidManifest.xml
index e84b7b1..91b7229 100644
--- a/domain/camera/src/test/AndroidManifest.xml
+++ b/core/camera/src/test/AndroidManifest.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- ~ Copyright (C) 2015 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,15 +16,15 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
- package="com.google.jetpackcamera.domain.camera.test" >
+ package="com.google.jetpackcamera.core.camera.test" >
<instrumentation
android:name="androidx.test.runner.AndroidJUnitRunner"
- android:label="Domain Camera Unit Tests"
- android:targetPackage="com.google.jetpackcamera.domain.camera" />
+ android:label="Core Camera Unit Tests"
+ android:targetPackage="com.google.jetpackcamera.core.camera" />
<application>
<uses-library android:name="android.test.runner" />
</application>
-</manifest>
+</manifest>
\ No newline at end of file
diff --git a/domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
similarity index 86%
rename from domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt
rename to core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
index c6eaf59..ad47e41 100644
--- a/domain/camera/src/test/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCaseTest.kt
+++ b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
@@ -13,10 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera.domain.camera.test
+package com.google.jetpackcamera.core.camera.test
-import com.google.common.truth.Truth.assertThat
-import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.common.truth.Truth
+import com.google.jetpackcamera.core.camera.CameraUseCase
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.LensFacing
import kotlinx.coroutines.Dispatchers
@@ -59,7 +59,7 @@
@Test
fun canRunCamera() = runTest(testDispatcher) {
initAndRunCamera()
- assertThat(cameraUseCase.isPreviewStarted())
+ Truth.assertThat(cameraUseCase.isPreviewStarted())
}
@Test
@@ -70,7 +70,7 @@
cameraUseCase.setFlashMode(flashMode = FlashMode.OFF)
advanceUntilIdle()
- assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
+ Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
}
@Test
@@ -81,7 +81,7 @@
cameraUseCase.setFlashMode(flashMode = FlashMode.ON)
advanceUntilIdle()
- assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
+ Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
}
@Test
@@ -92,7 +92,7 @@
cameraUseCase.setFlashMode(flashMode = FlashMode.AUTO)
advanceUntilIdle()
- assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
+ Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isFalse()
}
@Test
@@ -103,7 +103,7 @@
cameraUseCase.setFlashMode(flashMode = FlashMode.ON)
advanceUntilIdle()
- assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
+ Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
}
@Test
@@ -114,7 +114,7 @@
cameraUseCase.setFlashMode(flashMode = FlashMode.AUTO)
advanceUntilIdle()
- assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
+ Truth.assertThat(cameraUseCase.isScreenFlashEnabled()).isTrue()
}
@Test
@@ -134,7 +134,7 @@
cameraUseCase.takePicture()
advanceUntilIdle()
- assertThat(events.map { it.type }).containsExactlyElementsIn(
+ Truth.assertThat(events.map { it.type }).containsExactlyElementsIn(
listOf(
CameraUseCase.ScreenFlashEvent.Type.APPLY_UI,
CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI
diff --git a/core/common/Android.bp b/core/common/Android.bp
index 552b888..b5ccf9b 100644
--- a/core/common/Android.bp
+++ b/core/common/Android.bp
@@ -9,11 +9,12 @@
srcs: ["src/main/**/*.kt"],
static_libs: [
"androidx.core_core-ktx",
- "hilt_android",
+ "androidx.tracing_tracing-ktx",
+ "hilt_android",
"androidx.appcompat_appcompat",
- "com.google.android.material_material",
+ "com.google.android.material_material",
],
sdk_version: "34",
min_sdk_version: "21",
- manifest:"src/main/AndroidManifest.xml"
+ manifest: "src/main/AndroidManifest.xml",
}
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 1eba073..2a81639 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -24,6 +24,7 @@
android {
namespace = "com.google.jetpackcamera.core.common"
compileSdk = libs.versions.compileSdk.get().toInt()
+ compileSdkPreview = libs.versions.compileSdkPreview.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
@@ -41,7 +42,24 @@
"proguard-rules.pro"
)
}
+ create("benchmark") {
+ initWith(buildTypes.getByName("release"))
+ }
}
+
+ flavorDimensions += "flavor"
+ productFlavors {
+ create("stable") {
+ dimension = "flavor"
+ isDefault = true
+ }
+
+ create("preview") {
+ dimension = "flavor"
+ targetSdkPreview = libs.versions.targetSdkPreview.get()
+ }
+ }
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -57,6 +75,7 @@
implementation(libs.androidx.appcompat)
implementation(libs.android.material)
implementation(libs.kotlinx.atomicfu)
+ implementation(libs.androidx.tracing)
testImplementation(libs.junit)
testImplementation(libs.truth)
diff --git a/core/common/src/main/java/com/google/jetpackcamera/core/common/TraceManager.kt b/core/common/src/main/java/com/google/jetpackcamera/core/common/TraceManager.kt
new file mode 100644
index 0000000..6079362
--- /dev/null
+++ b/core/common/src/main/java/com/google/jetpackcamera/core/common/TraceManager.kt
@@ -0,0 +1,32 @@
+/*
+ * 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 androidx.tracing.traceAsync
+
+const val FIRST_FRAME_TRACE_PREVIEW = "firstFrameTracePreview"
+const val FIRST_FRAME_TRACE_MAIN_ACTIVITY = "firstFrameTraceMainActivity"
+
+suspend inline fun traceFirstFramePreview(cookie: Int, crossinline block: suspend () -> Unit) {
+ traceAsync(FIRST_FRAME_TRACE_PREVIEW, cookie) {
+ block()
+ }
+}
+suspend inline fun traceFirstFrameMainActivity(cookie: Int, crossinline block: suspend () -> Unit) {
+ traceAsync(FIRST_FRAME_TRACE_MAIN_ACTIVITY, cookie) {
+ block()
+ }
+}
diff --git a/data/settings/build.gradle.kts b/data/settings/build.gradle.kts
index 60c9aa0..e0edd40 100644
--- a/data/settings/build.gradle.kts
+++ b/data/settings/build.gradle.kts
@@ -25,6 +25,7 @@
android {
namespace = "com.google.jetpackcamera.data.settings"
compileSdk = libs.versions.compileSdk.get().toInt()
+ compileSdkPreview = libs.versions.compileSdkPreview.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
@@ -35,6 +36,19 @@
consumerProguardFiles("consumer-rules.pro")
}
+ flavorDimensions += "flavor"
+ productFlavors {
+ create("stable") {
+ dimension = "flavor"
+ isDefault = true
+ }
+
+ create("preview") {
+ dimension = "flavor"
+ targetSdkPreview = libs.versions.targetSdkPreview.get()
+ }
+ }
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
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 44846ac..2c67114 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
@@ -28,6 +28,7 @@
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.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
import java.io.File
import kotlinx.coroutines.CoroutineScope
@@ -147,4 +148,18 @@
assertThat(initialDynamicRange).isEqualTo(DynamicRange.SDR)
assertThat(newDynamicRange).isEqualTo(DynamicRange.HLG10)
}
+
+ @Test
+ fun can_update_image_format() = runTest {
+ val initialImageFormat = repository.getCurrentDefaultCameraAppSettings().imageFormat
+
+ repository.updateImageFormat(imageFormat = ImageOutputFormat.JPEG_ULTRA_HDR)
+
+ advanceUntilIdle()
+
+ val newImageFormat = repository.getCurrentDefaultCameraAppSettings().imageFormat
+
+ assertThat(initialImageFormat).isEqualTo(ImageOutputFormat.JPEG)
+ assertThat(newImageFormat).isEqualTo(ImageOutputFormat.JPEG_ULTRA_HDR)
+ }
}
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 e0196eb..1a07af2 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
@@ -32,6 +32,7 @@
.setStabilizePreview(PreviewStabilization.PREVIEW_STABILIZATION_UNDEFINED)
.setStabilizeVideo(VideoStabilization.VIDEO_STABILIZATION_UNDEFINED)
.setDynamicRangeStatus(DynamicRange.DYNAMIC_RANGE_UNSPECIFIED)
+ .setImageFormatStatus(ImageOutputFormat.IMAGE_OUTPUT_FORMAT_JPEG)
.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 80d6e00..8183542 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
@@ -29,6 +29,8 @@
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.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.ImageOutputFormat.Companion.toProto
import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.LensFacing.Companion.toProto
import com.google.jetpackcamera.settings.model.Stabilization
@@ -73,7 +75,8 @@
CaptureModeProto.CAPTURE_MODE_MULTI_STREAM -> CaptureMode.MULTI_STREAM
else -> CaptureMode.MULTI_STREAM
},
- dynamicRange = DynamicRange.fromProto(it.dynamicRangeStatus)
+ dynamicRange = DynamicRange.fromProto(it.dynamicRangeStatus),
+ imageFormat = ImageOutputFormat.fromProto(it.imageFormatStatus)
)
}
@@ -180,4 +183,12 @@
.build()
}
}
+
+ override suspend fun updateImageFormat(imageFormat: ImageOutputFormat) {
+ jcaSettings.updateData { currentSettings ->
+ currentSettings.toBuilder()
+ .setImageFormatStatus(imageFormat.toProto())
+ .build()
+ }
+ }
}
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 6e0fb3d..2631d7f 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
@@ -21,6 +21,7 @@
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.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
import kotlinx.coroutines.flow.Flow
@@ -51,4 +52,6 @@
suspend fun updateDynamicRange(dynamicRange: DynamicRange)
suspend fun updateTargetFrameRate(targetFrameRate: Int)
+
+ suspend fun updateImageFormat(imageFormat: ImageOutputFormat)
}
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 8de6ea3..2725bf5 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
@@ -23,6 +23,10 @@
NINE_SIXTEEN(Rational(9, 16)),
ONE_ONE(Rational(1, 1));
+ val landscapeRatio: Rational by lazy {
+ Rational(ratio.denominator, ratio.numerator)
+ }
+
companion object {
/** returns the AspectRatio enum equivalent of a provided AspectRatioProto */
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 0bb37c2..712a0cc 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
@@ -29,8 +29,13 @@
val videoCaptureStabilization: Stabilization = Stabilization.UNDEFINED,
val dynamicRange: DynamicRange = DynamicRange.SDR,
val defaultHdrDynamicRange: DynamicRange = DynamicRange.HLG10,
+ val defaultHdrImageOutputFormat: ImageOutputFormat = ImageOutputFormat.JPEG_ULTRA_HDR,
+ val lowLightBoost: LowLightBoost = LowLightBoost.DISABLED,
val zoomScale: Float = 1f,
- val targetFrameRate: Int = TARGET_FPS_AUTO
+ val targetFrameRate: Int = TARGET_FPS_AUTO,
+ val imageFormat: ImageOutputFormat = ImageOutputFormat.JPEG,
+ val audioMuted: Boolean = false,
+ val deviceRotation: DeviceRotation = DeviceRotation.Natural
)
fun SystemConstraints.forCurrentLens(cameraAppSettings: CameraAppSettings): CameraConstraints? {
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
index d4f7364..51a1eb4 100644
--- 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
@@ -23,7 +23,8 @@
data class CameraConstraints(
val supportedStabilizationModes: Set<SupportedStabilizationMode>,
val supportedFixedFrameRates: Set<Int>,
- val supportedDynamicRanges: Set<DynamicRange>
+ val supportedDynamicRanges: Set<DynamicRange>,
+ val supportedImageFormatsMap: Map<CaptureMode, Set<ImageOutputFormat>>
)
/**
@@ -39,7 +40,11 @@
CameraConstraints(
supportedFixedFrameRates = setOf(15, 30),
supportedStabilizationModes = emptySet(),
- supportedDynamicRanges = setOf(DynamicRange.SDR)
+ supportedDynamicRanges = setOf(DynamicRange.SDR),
+ supportedImageFormatsMap = mapOf(
+ Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)),
+ Pair(CaptureMode.MULTI_STREAM, setOf(ImageOutputFormat.JPEG))
+ )
)
)
}
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/DeviceRotation.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/DeviceRotation.kt
new file mode 100644
index 0000000..95e3c06
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/DeviceRotation.kt
@@ -0,0 +1,70 @@
+/*
+ * 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 android.view.Surface
+
+enum class DeviceRotation {
+ Natural,
+ Rotated90,
+ Rotated180,
+ Rotated270;
+
+ /**
+ * Returns the rotation of the UI, expressed as a [Surface] rotation constant, needed to
+ * compensate for device rotation.
+ *
+ * These values do not match up with the device rotation angle. When the device is rotated,
+ * the UI must rotate in the opposite direction to compensate, so the angles 90 and 270 will
+ * be swapped in UI rotation compared to device rotation.
+ */
+ fun toUiSurfaceRotation(): Int {
+ return when (this) {
+ Natural -> Surface.ROTATION_0
+ Rotated90 -> Surface.ROTATION_270
+ Rotated180 -> Surface.ROTATION_180
+ Rotated270 -> Surface.ROTATION_90
+ }
+ }
+
+ fun toClockwiseRotationDegrees(): Int {
+ return when (this) {
+ Natural -> 0
+ Rotated90 -> 90
+ Rotated180 -> 180
+ Rotated270 -> 270
+ }
+ }
+
+ companion object {
+ fun snapFrom(degrees: Int): DeviceRotation {
+ check(degrees in 0..359) {
+ "Degrees must be in the range [0, 360)"
+ }
+
+ return when (val snappedDegrees = ((degrees + 45) / 90 * 90) % 360) {
+ 0 -> Natural
+ 90 -> Rotated90
+ 180 -> Rotated180
+ 270 -> Rotated270
+ else -> throw IllegalStateException(
+ "Unexpected snapped degrees: $snappedDegrees" +
+ ". Should be one of 0, 90, 180 or 270."
+ )
+ }
+ }
+ }
+}
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ImageOutputFormat.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ImageOutputFormat.kt
new file mode 100644
index 0000000..9f9cf46
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ImageOutputFormat.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.ImageOutputFormat as ImageOutputFormatProto
+
+enum class ImageOutputFormat {
+ JPEG,
+ JPEG_ULTRA_HDR;
+
+ companion object {
+
+ /** returns the DynamicRangeType enum equivalent of a provided DynamicRangeTypeProto */
+ fun fromProto(imageOutputFormatProto: ImageOutputFormatProto): ImageOutputFormat {
+ return when (imageOutputFormatProto) {
+ ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR -> JPEG_ULTRA_HDR
+
+ // Treat unrecognized as JPEG as a fallback
+ ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG,
+ ImageOutputFormatProto.UNRECOGNIZED -> JPEG
+ }
+ }
+
+ fun ImageOutputFormat.toProto(): ImageOutputFormatProto {
+ return when (this) {
+ JPEG -> ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG
+ JPEG_ULTRA_HDR -> ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/LowLightBoost.kt
similarity index 65%
copy from app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
copy to data/settings/src/main/java/com/google/jetpackcamera/settings/model/LowLightBoost.kt
index b68f8e6..8fd7221 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/LowLightBoost.kt
@@ -13,14 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera
+package com.google.jetpackcamera.settings.model
-import android.os.Build
-
-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)
- }
+enum class LowLightBoost {
+ DISABLED,
+ ENABLED
}
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 c7ed7b7..fce599f 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
@@ -23,6 +23,7 @@
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.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
import kotlinx.coroutines.flow.Flow
@@ -78,4 +79,8 @@
currentCameraSettings =
currentCameraSettings.copy(targetFrameRate = targetFrameRate)
}
+
+ override suspend fun updateImageFormat(imageFormat: ImageOutputFormat) {
+ currentCameraSettings = currentCameraSettings.copy(imageFormat = imageFormat)
+ }
}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt b/data/settings/src/main/proto/com/google/jetpackcamera/settings/image_output_format.proto
similarity index 65%
copy from app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
copy to data/settings/src/main/proto/com/google/jetpackcamera/settings/image_output_format.proto
index b68f8e6..dedeb4f 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/AppTestUtil.kt
+++ b/data/settings/src/main/proto/com/google/jetpackcamera/settings/image_output_format.proto
@@ -13,14 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.jetpackcamera
-import android.os.Build
+syntax = "proto3";
-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)
- }
-}
+option java_package = "com.google.jetpackcamera.settings";
+option java_multiple_files = true;
+
+enum ImageOutputFormat {
+ IMAGE_OUTPUT_FORMAT_JPEG = 0;
+ IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR = 1;
+}
\ 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 cc87e43..03aeb6d 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
@@ -21,6 +21,7 @@
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/image_output_format.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";
@@ -39,6 +40,7 @@
PreviewStabilization stabilize_preview = 6;
VideoStabilization stabilize_video = 7;
DynamicRange dynamic_range_status = 8;
+ ImageOutputFormat image_format_status = 10;
// Non-camera app settings
DarkMode dark_mode_status = 9;
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
index a598530..95503bc 100644
--- a/data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt
+++ b/data/settings/src/test/java/com/google/jetpackcamera/settings/ProtoConversionTest.kt
@@ -17,8 +17,11 @@
import com.google.common.truth.Truth.assertThat
import com.google.jetpackcamera.settings.DynamicRange as DynamicRangeProto
+import com.google.jetpackcamera.settings.ImageOutputFormat as ImageOutputFormatProto
import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.DynamicRange.Companion.toProto
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.ImageOutputFormat.Companion.toProto
import org.junit.Test
class ProtoConversionTest {
@@ -61,4 +64,44 @@
assertThat(correctConversions(it)).isEqualTo(DynamicRange.fromProto(it))
}
}
+
+ @Test
+ fun imageOutputFormat_convertsToCorrectProto() {
+ val correctConversions = { imageOutputFormat: ImageOutputFormat ->
+ when (imageOutputFormat) {
+ ImageOutputFormat.JPEG -> ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG
+ ImageOutputFormat.JPEG_ULTRA_HDR
+ -> ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR
+ else -> TODO(
+ "Test does not yet contain correct conversion for image output format " +
+ "type: ${imageOutputFormat.name}"
+ )
+ }
+ }
+
+ enumValues<ImageOutputFormat>().forEach {
+ assertThat(correctConversions(it)).isEqualTo(it.toProto())
+ }
+ }
+
+ @Test
+ fun imageOutputFormatProto_convertsToCorrectImageOutputFormat() {
+ val correctConversions = { imageOutputFormatProto: ImageOutputFormatProto ->
+ when (imageOutputFormatProto) {
+ ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG,
+ ImageOutputFormatProto.UNRECOGNIZED
+ -> ImageOutputFormat.JPEG
+ ImageOutputFormatProto.IMAGE_OUTPUT_FORMAT_JPEG_ULTRA_HDR
+ -> ImageOutputFormat.JPEG_ULTRA_HDR
+ else -> TODO(
+ "Test does not yet contain correct conversion for image output format " +
+ "proto type: ${imageOutputFormatProto.name}"
+ )
+ }
+ }
+
+ enumValues<ImageOutputFormatProto>().forEach {
+ assertThat(correctConversions(it)).isEqualTo(ImageOutputFormat.fromProto(it))
+ }
+ }
}
diff --git a/feature/permissions/build.gradle.kts b/feature/permissions/build.gradle.kts
index a14d8cc..66fadf3 100644
--- a/feature/permissions/build.gradle.kts
+++ b/feature/permissions/build.gradle.kts
@@ -24,6 +24,7 @@
android {
namespace = "com.google.jetpackcamera.permissions"
compileSdk = libs.versions.compileSdk.get().toInt()
+ compileSdkPreview = libs.versions.compileSdkPreview.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
diff --git a/feature/permissions/src/main/AndroidManifest.xml b/feature/permissions/src/main/AndroidManifest.xml
index 88a4e8b..926ca9b 100644
--- a/feature/permissions/src/main/AndroidManifest.xml
+++ b/feature/permissions/src/main/AndroidManifest.xml
@@ -17,3 +17,4 @@
<manifest package="com.google.jetpackcamera.permissions">
</manifest>
+
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
index af17e30..ad67fda 100644
--- a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt
+++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsScreen.kt
@@ -34,12 +34,22 @@
@OptIn(ExperimentalPermissionsApi::class)
@Composable
-fun PermissionsScreen(onNavigateToPreview: () -> Unit, openAppSettings: () -> Unit) {
+fun PermissionsScreen(
+ shouldRequestAudioPermission: Boolean,
+ onNavigateToPreview: () -> Unit,
+ openAppSettings: () -> Unit
+) {
val permissionStates = rememberMultiplePermissionsState(
- permissions = listOf(
- Manifest.permission.CAMERA,
- Manifest.permission.RECORD_AUDIO
- )
+ permissions = if (shouldRequestAudioPermission) {
+ listOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO
+ )
+ } else {
+ listOf(
+ Manifest.permission.CAMERA
+ )
+ }
)
PermissionsScreen(
permissionStates = permissionStates,
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
index 047442f..ac538aa 100644
--- a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt
+++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsViewModel.kt
@@ -25,6 +25,7 @@
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlin.collections.removeFirst as ktRemoveFirst // alias must be used now. see https://issuetracker.google.com/348683480
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -63,7 +64,7 @@
fun dismissPermission() {
if (permissionQueue.isNotEmpty()) {
- permissionQueue.removeFirst()
+ permissionQueue.ktRemoveFirst()
}
_permissionsUiState.update {
(getCurrentPermission())
diff --git a/feature/preview/Android.bp b/feature/preview/Android.bp
index b4920b1..a146711 100644
--- a/feature/preview/Android.bp
+++ b/feature/preview/Android.bp
@@ -25,7 +25,8 @@
"androidx.camera_camera-core",
"androidx.camera_camera-viewfinder",
"jetpack-camera-app_data_settings",
- "jetpack-camera-app_domain_camera",
+ "jetpack-camera-app_core_camera",
+ "jetpack-camera-app_core_common",
"androidx.camera_camera-viewfinder-compose",
"androidx.compose.ui_ui-tooling",
diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts
index a5f0793..81a3ddb 100644
--- a/feature/preview/build.gradle.kts
+++ b/feature/preview/build.gradle.kts
@@ -24,6 +24,7 @@
android {
namespace = "com.google.jetpackcamera.feature.preview"
compileSdk = libs.versions.compileSdk.get().toInt()
+ compileSdkPreview = libs.versions.compileSdkPreview.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
@@ -33,6 +34,19 @@
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+ flavorDimensions += "flavor"
+ productFlavors {
+ create("stable") {
+ dimension = "flavor"
+ isDefault = true
+ }
+
+ create("preview") {
+ dimension = "flavor"
+ targetSdkPreview = libs.versions.targetSdkPreview.get()
+ }
+ }
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -131,7 +145,9 @@
// Project dependencies
implementation(project(":data:settings"))
- implementation(project(":domain:camera"))
+ implementation(project(":core:camera"))
+ implementation(project(":core:common"))
+ testImplementation(project(":core:common"))
}
// Allow references to generated code
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt
new file mode 100644
index 0000000..86e084f
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt
@@ -0,0 +1,76 @@
+/*
+ * 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 com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_DEVICE_TAG
+import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_LENS_TAG
+import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM_TAG
+import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG
+import com.google.jetpackcamera.feature.preview.ui.HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG
+import com.google.jetpackcamera.feature.preview.ui.HDR_VIDEO_UNSUPPORTED_ON_LENS_TAG
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+
+sealed interface CaptureModeToggleUiState {
+
+ data object Invisible : CaptureModeToggleUiState
+
+ sealed interface Visible : CaptureModeToggleUiState {
+ val currentMode: ToggleMode
+ }
+
+ data class Enabled(override val currentMode: ToggleMode) : Visible
+
+ data class Disabled(
+ override val currentMode: ToggleMode,
+ val disabledReason: DisabledReason
+ ) : Visible
+
+ enum class DisabledReason(val testTag: String, val reasonTextResId: Int) {
+ VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED(
+ VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG,
+ R.string.toast_video_capture_external_unsupported
+ ),
+ HDR_VIDEO_UNSUPPORTED_ON_DEVICE(
+ HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG,
+ R.string.toast_hdr_video_unsupported_on_device
+ ),
+ HDR_VIDEO_UNSUPPORTED_ON_LENS(
+ HDR_VIDEO_UNSUPPORTED_ON_LENS_TAG,
+ R.string.toast_hdr_video_unsupported_on_lens
+ ),
+ HDR_IMAGE_UNSUPPORTED_ON_DEVICE(
+ HDR_IMAGE_UNSUPPORTED_ON_DEVICE_TAG,
+ R.string.toast_hdr_photo_unsupported_on_device
+ ),
+ HDR_IMAGE_UNSUPPORTED_ON_LENS(
+ HDR_IMAGE_UNSUPPORTED_ON_LENS_TAG,
+ R.string.toast_hdr_photo_unsupported_on_lens
+ ),
+ HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM(
+ HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG,
+ R.string.toast_hdr_photo_unsupported_on_lens_single_stream
+ ),
+ HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM(
+ HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM_TAG,
+ R.string.toast_hdr_photo_unsupported_on_lens_multi_stream
+ )
+ }
+
+ enum class ToggleMode {
+ CAPTURE_TOGGLE_IMAGE,
+ CAPTURE_TOGGLE_VIDEO
+ }
+}
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 90eb918..f4c7e34 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
@@ -19,7 +19,6 @@
import android.content.ContentResolver
import android.net.Uri
import android.util.Log
-import android.view.Display
import androidx.camera.core.SurfaceRequest
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -35,31 +34,39 @@
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.remember
+import androidx.compose.runtime.snapshotFlow
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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
+import androidx.tracing.Trace
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.TestableSnackbar
import com.google.jetpackcamera.feature.preview.ui.TestableToast
+import com.google.jetpackcamera.feature.preview.ui.debouncedOrientationFlow
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.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.LowLightBoost
import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
+import kotlinx.coroutines.flow.transformWhile
private const val TAG = "PreviewScreen"
@@ -72,6 +79,7 @@
previewMode: PreviewMode,
modifier: Modifier = Modifier,
onRequestWindowColorMode: (Int) -> Unit = {},
+ onFirstFrameCaptureCompleted: () -> Unit = {},
viewModel: PreviewViewModel = hiltViewModel<PreviewViewModel, PreviewViewModel.Factory>
{ factory -> factory.create(previewMode) }
) {
@@ -92,31 +100,60 @@
}
}
+ if (Trace.isEnabled()) {
+ LaunchedEffect(onFirstFrameCaptureCompleted) {
+ snapshotFlow { previewUiState }
+ .transformWhile {
+ var continueCollecting = true
+ (it as? PreviewUiState.Ready)?.let { ready ->
+ if (ready.sessionFirstFrameTimestamp > 0) {
+ emit(Unit)
+ continueCollecting = false
+ }
+ }
+ continueCollecting
+ }.collect {
+ onFirstFrameCaptureCompleted()
+ }
+ }
+ }
+
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
- )
+ is PreviewUiState.Ready -> {
+ val context = LocalContext.current
+ LaunchedEffect(Unit) {
+ debouncedOrientationFlow(context).collect(viewModel::setDisplayRotation)
+ }
+
+ 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,
+ onLowLightBoost = viewModel::setLowLightBoost,
+ onChangeImageFormat = viewModel::setImageFormat,
+ onToggleWhenDisabled = viewModel::showSnackBarForDisabledHdrToggle,
+ onToggleQuickSettings = viewModel::toggleQuickSettings,
+ onMuteAudio = viewModel::setAudioMuted,
+ onCaptureImage = viewModel::captureImage,
+ onCaptureImageWithUri = viewModel::captureImageWithUri,
+ onStartVideoRecording = viewModel::startVideoRecording,
+ onStopVideoRecording = viewModel::stopVideoRecording,
+ onToastShown = viewModel::onToastShown,
+ onRequestWindowColorMode = onRequestWindowColorMode,
+ onSnackBarResult = viewModel::onSnackBarResult
+ )
+ }
}
}
@@ -130,13 +167,17 @@
onNavigateToSettings: () -> Unit = {},
onClearUiScreenBrightness: (Float) -> Unit = {},
onSetLensFacing: (newLensFacing: LensFacing) -> Unit = {},
- onTapToFocus: (Display, Int, Int, Float, Float) -> Unit = { _, _, _, _, _ -> },
+ onTapToFocus: (x: Float, y: Float) -> Unit = { _, _ -> },
onChangeZoomScale: (Float) -> Unit = {},
onChangeFlash: (FlashMode) -> Unit = {},
onChangeAspectRatio: (AspectRatio) -> Unit = {},
onChangeCaptureMode: (CaptureMode) -> Unit = {},
onChangeDynamicRange: (DynamicRange) -> Unit = {},
+ onLowLightBoost: (LowLightBoost) -> Unit = {},
+ onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
+ onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
onToggleQuickSettings: () -> Unit = {},
+ onMuteAudio: (Boolean) -> Unit = {},
onCaptureImage: () -> Unit = {},
onCaptureImageWithUri: (
ContentResolver,
@@ -164,6 +205,15 @@
}
}
+ val isMuted = remember(previewUiState) {
+ previewUiState.currentCameraSettings.audioMuted
+ }
+ val onToggleMuteAudio = remember(isMuted) {
+ {
+ onMuteAudio(!isMuted)
+ }
+ }
+
Box(modifier.fillMaxSize()) {
// display camera feed. this stays behind everything else
PreviewDisplay(
@@ -187,7 +237,9 @@
onFlashModeClick = onChangeFlash,
onAspectRatioClick = onChangeAspectRatio,
onCaptureModeClick = onChangeCaptureMode,
- onDynamicRangeClick = onChangeDynamicRange // onTimerClick = {}/*TODO*/
+ onDynamicRangeClick = onChangeDynamicRange,
+ onImageOutputFormatClick = onChangeImageFormat,
+ onLowLightBoostClick = onLowLightBoost
)
// relative-grid style overlay on top of preview display
CameraControlsOverlay(
@@ -195,7 +247,10 @@
onNavigateToSettings = onNavigateToSettings,
onFlipCamera = onFlipCamera,
onChangeFlash = onChangeFlash,
+ onMuteAudio = onToggleMuteAudio,
onToggleQuickSettings = onToggleQuickSettings,
+ onChangeImageFormat = onChangeImageFormat,
+ onToggleWhenDisabled = onToggleWhenDisabled,
onCaptureImage = onCaptureImage,
onCaptureImageWithUri = onCaptureImageWithUri,
onStartVideoRecording = onStartVideoRecording,
@@ -274,5 +329,6 @@
private val FAKE_PREVIEW_UI_STATE_READY = PreviewUiState.Ready(
currentCameraSettings = DEFAULT_CAMERA_APP_SETTINGS,
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
- previewMode = PreviewMode.StandardMode {}
+ previewMode = PreviewMode.StandardMode {},
+ captureModeToggleUiState = CaptureModeToggleUiState.Invisible
)
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 3bc1750..dfd20d1 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
@@ -24,7 +24,7 @@
* Defines the current state of the [PreviewScreen].
*/
sealed interface PreviewUiState {
- object NotReady : PreviewUiState
+ data object NotReady : PreviewUiState
data class Ready(
// "quick" settings
@@ -34,12 +34,15 @@
val videoRecordingState: VideoRecordingState = VideoRecordingState.INACTIVE,
val quickSettingsIsOpen: Boolean = false,
val audioAmplitude: Double = 0.0,
+ val audioMuted: Boolean = false,
// todo: remove after implementing post capture screen
val toastMessageToShow: ToastMessage? = null,
val snackBarToShow: SnackbarData? = null,
val lastBlinkTimeStamp: Long = 0,
- val previewMode: PreviewMode
+ val previewMode: PreviewMode,
+ val captureModeToggleUiState: CaptureModeToggleUiState,
+ val sessionFirstFrameTimestamp: Long = 0L
) : PreviewUiState
}
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 ca138a8..7142a1a 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
@@ -17,29 +17,39 @@
import android.content.ContentResolver
import android.net.Uri
+import android.os.SystemClock
import android.util.Log
-import android.view.Display
import androidx.camera.core.SurfaceRequest
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import androidx.tracing.Trace
import androidx.tracing.traceAsync
-import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.jetpackcamera.core.camera.CameraUseCase
+import com.google.jetpackcamera.core.common.traceFirstFramePreview
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.CameraAppSettings
+import com.google.jetpackcamera.settings.model.CameraConstraints
import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.DeviceRotation
import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.LowLightBoost
+import com.google.jetpackcamera.settings.model.SystemConstraints
+import com.google.jetpackcamera.settings.model.forCurrentLens
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlin.time.Duration.Companion.seconds
import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
@@ -49,6 +59,7 @@
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -60,10 +71,9 @@
*/
@HiltViewModel(assistedFactory = PreviewViewModel.Factory::class)
class PreviewViewModel @AssistedInject constructor(
- @Assisted previewMode: PreviewMode,
+ @Assisted val previewMode: PreviewMode,
private val cameraUseCase: CameraUseCase,
private val constraintsRepository: ConstraintsRepository
-
) : ViewModel() {
private val _previewUiState: MutableStateFlow<PreviewUiState> =
MutableStateFlow(PreviewUiState.NotReady)
@@ -79,7 +89,7 @@
val screenFlash = ScreenFlash(cameraUseCase, viewModelScope)
- private val imageCaptureCalledCount = atomic(0)
+ private val snackBarCount = atomic(0)
private val videoCaptureStartedCount = atomic(0)
// Eagerly initialize the CameraUseCase and encapsulate in a Deferred that can be
@@ -93,24 +103,33 @@
combine(
cameraUseCase.getCurrentSettings().filterNotNull(),
constraintsRepository.systemConstraints.filterNotNull(),
- cameraUseCase.getZoomScale()
- ) { cameraAppSettings, systemConstraints, zoomScale ->
+ cameraUseCase.getCurrentCameraState()
+ ) { cameraAppSettings, systemConstraints, cameraState ->
_previewUiState.update { old ->
when (old) {
is PreviewUiState.Ready ->
old.copy(
currentCameraSettings = cameraAppSettings,
systemConstraints = systemConstraints,
- zoomScale = zoomScale,
- previewMode = previewMode
+ zoomScale = cameraState.zoomScale,
+ sessionFirstFrameTimestamp = cameraState.sessionFirstFrameTimestamp,
+ captureModeToggleUiState = getCaptureToggleUiState(
+ systemConstraints,
+ cameraAppSettings
+ )
)
is PreviewUiState.NotReady ->
PreviewUiState.Ready(
currentCameraSettings = cameraAppSettings,
systemConstraints = systemConstraints,
- zoomScale = zoomScale,
- previewMode = previewMode
+ zoomScale = cameraState.zoomScale,
+ sessionFirstFrameTimestamp = cameraState.sessionFirstFrameTimestamp,
+ previewMode = previewMode,
+ captureModeToggleUiState = getCaptureToggleUiState(
+ systemConstraints,
+ cameraAppSettings
+ )
)
}
}
@@ -118,10 +137,137 @@
}
}
+ private fun getCaptureToggleUiState(
+ systemConstraints: SystemConstraints,
+ cameraAppSettings: CameraAppSettings
+ ): CaptureModeToggleUiState {
+ val cameraConstraints: CameraConstraints? = systemConstraints.forCurrentLens(
+ cameraAppSettings
+ )
+ val hdrDynamicRangeSupported = cameraConstraints?.let {
+ it.supportedDynamicRanges.size > 1
+ } ?: false
+ val hdrImageFormatSupported =
+ cameraConstraints?.supportedImageFormatsMap?.get(cameraAppSettings.captureMode)?.let {
+ it.size > 1
+ } ?: false
+ val isShown = previewMode is PreviewMode.ExternalImageCaptureMode ||
+ cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR ||
+ cameraAppSettings.dynamicRange == DynamicRange.HLG10
+ val enabled = previewMode !is PreviewMode.ExternalImageCaptureMode &&
+ hdrDynamicRangeSupported && hdrImageFormatSupported
+ return if (isShown) {
+ val currentMode = if (previewMode is PreviewMode.ExternalImageCaptureMode ||
+ cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
+ ) {
+ CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE
+ } else {
+ CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO
+ }
+ if (enabled) {
+ CaptureModeToggleUiState.Enabled(currentMode)
+ } else {
+ CaptureModeToggleUiState.Disabled(
+ currentMode,
+ getCaptureToggleUiStateDisabledReason(
+ hdrDynamicRangeSupported,
+ hdrImageFormatSupported,
+ systemConstraints,
+ cameraAppSettings.cameraLensFacing,
+ cameraAppSettings.captureMode
+ )
+ )
+ }
+ } else {
+ CaptureModeToggleUiState.Invisible
+ }
+ }
+
+ private fun getCaptureToggleUiStateDisabledReason(
+ hdrDynamicRangeSupported: Boolean,
+ hdrImageFormatSupported: Boolean,
+ systemConstraints: SystemConstraints,
+ currentLensFacing: LensFacing,
+ currentCaptureMode: CaptureMode
+ ): CaptureModeToggleUiState.DisabledReason {
+ if (previewMode is PreviewMode.ExternalImageCaptureMode) {
+ return CaptureModeToggleUiState.DisabledReason.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED
+ }
+ if (!hdrImageFormatSupported) {
+ // First assume HDR image is only unsupported on this capture mode
+ var disabledReason = when (currentCaptureMode) {
+ CaptureMode.MULTI_STREAM ->
+ CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM
+ CaptureMode.SINGLE_STREAM ->
+ CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM
+ }
+ // Check if other capture modes supports HDR image on this lens
+ systemConstraints
+ .perLensConstraints[currentLensFacing]
+ ?.supportedImageFormatsMap
+ ?.filterKeys { it != currentCaptureMode }
+ ?.values
+ ?.forEach { supportedFormats ->
+ if (supportedFormats.size > 1) {
+ // Found another capture mode that supports HDR image,
+ // return previously discovered disabledReason
+ return disabledReason
+ }
+ }
+ // HDR image is not supported by this lens
+ disabledReason = CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_LENS
+ // Check if any other lens supports HDR image
+ systemConstraints
+ .perLensConstraints
+ .filterKeys { it != currentLensFacing }
+ .values
+ .forEach { constraints ->
+ constraints.supportedImageFormatsMap.values.forEach { supportedFormats ->
+ if (supportedFormats.size > 1) {
+ // Found another lens that supports HDR image,
+ // return previously discovered disabledReason
+ return disabledReason
+ }
+ }
+ }
+ // No lenses support HDR image on device
+ return CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_DEVICE
+ } else if (!hdrDynamicRangeSupported) {
+ systemConstraints.perLensConstraints.forEach { entry ->
+ if (entry.key != currentLensFacing) {
+ val cameraConstraints = systemConstraints.perLensConstraints[entry.key]
+ if (cameraConstraints?.let { it.supportedDynamicRanges.size > 1 } == true) {
+ return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_LENS
+ }
+ }
+ }
+ return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_DEVICE
+ } else {
+ throw RuntimeException("Unknown CaptureModeUnsupportedReason.")
+ }
+ }
+
fun startCamera() {
Log.d(TAG, "startCamera")
stopCamera()
runningCameraJob = viewModelScope.launch {
+ if (Trace.isEnabled()) {
+ launch(start = CoroutineStart.UNDISPATCHED) {
+ val startTraceTimestamp: Long = SystemClock.elapsedRealtimeNanos()
+ traceFirstFramePreview(cookie = 1) {
+ _previewUiState.transformWhile {
+ var continueCollecting = true
+ (it as? PreviewUiState.Ready)?.let { uiState ->
+ if (uiState.sessionFirstFrameTimestamp > startTraceTimestamp) {
+ emit(Unit)
+ continueCollecting = false
+ }
+ }
+ continueCollecting
+ }.collect {}
+ }
+ }
+ }
// Ensure CameraUseCase is initialized before starting camera
initializationDeferred.await()
// TODO(yasith): Handle Exceptions from binding use cases
@@ -153,7 +299,6 @@
fun setCaptureMode(captureMode: CaptureMode) {
viewModelScope.launch {
- // apply to cameraUseCase
cameraUseCase.setCaptureMode(captureMode)
}
}
@@ -166,6 +311,20 @@
}
}
+ fun setAudioMuted(shouldMuteAudio: Boolean) {
+ viewModelScope.launch {
+ cameraUseCase.setAudioMuted(shouldMuteAudio)
+ }
+
+ Log.d(
+ TAG,
+ "Toggle Audio ${
+ (previewUiState.value as PreviewUiState.Ready)
+ .currentCameraSettings.audioMuted
+ }"
+ )
+ }
+
fun captureImage() {
Log.d(TAG, "captureImage")
viewModelScope.launch {
@@ -214,7 +373,7 @@
onSuccess: (T) -> Unit = {},
onFailure: (exception: Exception) -> Unit = {}
) {
- val cookieInt = imageCaptureCalledCount.incrementAndGet()
+ val cookieInt = snackBarCount.incrementAndGet()
val cookie = "Image-$cookieInt"
try {
traceAsync(IMAGE_CAPTURE_TRACE, cookieInt) {
@@ -248,6 +407,23 @@
}
}
+ fun showSnackBarForDisabledHdrToggle(disabledReason: CaptureModeToggleUiState.DisabledReason) {
+ val cookieInt = snackBarCount.incrementAndGet()
+ val cookie = "DisabledHdrToggle-$cookieInt"
+ viewModelScope.launch {
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
+ snackBarToShow = SnackbarData(
+ cookie = cookie,
+ stringResource = disabledReason.reasonTextResId,
+ withDismissAction = true,
+ testTag = disabledReason.testTag
+ )
+ ) ?: old
+ }
+ }
+ }
+
fun startVideoRecording() {
if (previewUiState.value is PreviewUiState.Ready &&
(previewUiState.value as PreviewUiState.Ready).previewMode is
@@ -341,6 +517,18 @@
}
}
+ fun setLowLightBoost(lowLightBoost: LowLightBoost) {
+ viewModelScope.launch {
+ cameraUseCase.setLowLightBoost(lowLightBoost)
+ }
+ }
+
+ fun setImageFormat(imageFormat: ImageOutputFormat) {
+ viewModelScope.launch {
+ cameraUseCase.setImageFormat(imageFormat)
+ }
+ }
+
// modify ui values
fun toggleQuickSettings() {
viewModelScope.launch {
@@ -352,14 +540,11 @@
}
}
- fun tapToFocus(display: Display, surfaceWidth: Int, surfaceHeight: Int, x: Float, y: Float) {
- cameraUseCase.tapToFocus(
- display = display,
- surfaceWidth = surfaceWidth,
- surfaceHeight = surfaceHeight,
- x = x,
- y = y
- )
+ fun tapToFocus(x: Float, y: Float) {
+ Log.d(TAG, "tapToFocus")
+ viewModelScope.launch {
+ cameraUseCase.tapToFocus(x, y)
+ }
}
/**
@@ -392,6 +577,12 @@
}
}
+ fun setDisplayRotation(deviceRotation: DeviceRotation) {
+ viewModelScope.launch {
+ cameraUseCase.setDeviceRotation(deviceRotation)
+ }
+ }
+
@AssistedFactory
interface Factory {
fun create(previewMode: PreviewMode): PreviewViewModel
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 e7ea585..7cd1a4f 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
@@ -15,7 +15,7 @@
*/
package com.google.jetpackcamera.feature.preview
-import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.jetpackcamera.core.camera.CameraUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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
index a569a8d..db13cd2 100644
--- 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
@@ -25,6 +25,8 @@
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material.icons.filled.HdrOff
import androidx.compose.material.icons.filled.HdrOn
+import androidx.compose.material.icons.filled.Nightlight
+import androidx.compose.material.icons.outlined.Nightlight
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
@@ -139,10 +141,29 @@
override fun getTextResId() = R.string.quick_settings_dynamic_range_sdr
override fun getDescriptionResId() = R.string.quick_settings_dynamic_range_sdr_description
},
- HLG10 {
+ HDR {
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
+ override fun getTextResId() = R.string.quick_settings_dynamic_range_hdr
+ override fun getDescriptionResId() = R.string.quick_settings_dynamic_range_hdr_description
+ }
+}
+
+enum class CameraLowLightBoost : QuickSettingsEnum {
+
+ ENABLED {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.Nightlight
+ override fun getTextResId() = R.string.quick_settings_lowlightboost_enabled
+ override fun getDescriptionResId() =
+ R.string.quick_settings_lowlightboost_enabled_description
+ },
+
+ DISABLED {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Outlined.Nightlight
+ override fun getTextResId() = R.string.quick_settings_lowlightboost_disabled
+ override fun getDescriptionResId() =
+ R.string.quick_settings_lowlightboost_disabled_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
index 1eafa89..59e97bc 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2024 The Android Open Source Project
+ * 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.
@@ -38,6 +38,7 @@
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.CaptureModeToggleUiState
import com.google.jetpackcamera.feature.preview.PreviewMode
import com.google.jetpackcamera.feature.preview.PreviewUiState
import com.google.jetpackcamera.feature.preview.R
@@ -46,11 +47,13 @@
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_LOW_LIGHT_BOOST_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.QuickSetLowLightBoost
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
@@ -58,7 +61,9 @@
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.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.LowLightBoost
import com.google.jetpackcamera.settings.model.SystemConstraints
import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
import com.google.jetpackcamera.settings.model.forCurrentLens
@@ -77,6 +82,8 @@
onAspectRatioClick: (aspectRation: AspectRatio) -> Unit,
onCaptureModeClick: (captureMode: CaptureMode) -> Unit,
onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit,
+ onImageOutputFormatClick: (imageOutputFormat: ImageOutputFormat) -> Unit,
+ onLowLightBoostClick: (lowLightBoost: LowLightBoost) -> Unit,
modifier: Modifier = Modifier,
isOpen: Boolean = false
) {
@@ -127,7 +134,9 @@
onFlashModeClick = onFlashModeClick,
onAspectRatioClick = onAspectRatioClick,
onCaptureModeClick = onCaptureModeClick,
- onDynamicRangeClick = onDynamicRangeClick
+ onDynamicRangeClick = onDynamicRangeClick,
+ onImageOutputFormatClick = onImageOutputFormatClick,
+ onLowLightBoostClick = onLowLightBoostClick
)
}
} else {
@@ -155,7 +164,9 @@
onCaptureModeClick: (captureMode: CaptureMode) -> Unit,
shouldShowQuickSetting: IsExpandedQuickSetting,
setVisibleQuickSetting: (IsExpandedQuickSetting) -> Unit,
- onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit
+ onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit,
+ onImageOutputFormatClick: (imageOutputFormat: ImageOutputFormat) -> Unit,
+ onLowLightBoostClick: (lowLightBoost: LowLightBoost) -> Unit
) {
Column(
modifier =
@@ -209,18 +220,38 @@
)
}
+ val cameraConstraints = previewUiState.systemConstraints.forCurrentLens(
+ currentCameraSettings
+ )
add {
QuickSetHdr(
modifier = Modifier.testTag(QUICK_SETTINGS_HDR_BUTTON),
- onClick = { d: DynamicRange -> onDynamicRangeClick(d) },
+ onClick = { d: DynamicRange, i: ImageOutputFormat ->
+ onDynamicRangeClick(d)
+ onImageOutputFormatClick(i)
+ },
selectedDynamicRange = currentCameraSettings.dynamicRange,
+ selectedImageOutputFormat = currentCameraSettings.imageFormat,
hdrDynamicRange = currentCameraSettings.defaultHdrDynamicRange,
- enabled = previewUiState.previewMode !is
- PreviewMode.ExternalImageCaptureMode &&
- previewUiState.systemConstraints.forCurrentLens(
- currentCameraSettings
- )
- ?.let { it.supportedDynamicRanges.size > 1 } ?: false
+ hdrImageFormat = currentCameraSettings.defaultHdrImageOutputFormat,
+ hdrDynamicRangeSupported = cameraConstraints?.let
+ { it.supportedDynamicRanges.size > 1 } ?: false,
+ hdrImageFormatSupported =
+ cameraConstraints?.supportedImageFormatsMap?.get(
+ currentCameraSettings.captureMode
+ )?.let { it.size > 1 } ?: false,
+ previewMode = previewUiState.previewMode
+ )
+ }
+
+ add {
+ QuickSetLowLightBoost(
+ modifier = Modifier.testTag(QUICK_SETTINGS_LOW_LIGHT_BOOST_BUTTON),
+ onClick = {
+ l: LowLightBoost ->
+ onLowLightBoostClick(l)
+ },
+ selectedLowLightBoost = currentCameraSettings.lowLightBoost
)
}
}
@@ -245,7 +276,8 @@
previewUiState = PreviewUiState.Ready(
currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
- previewMode = PreviewMode.StandardMode {}
+ previewMode = PreviewMode.StandardMode {},
+ captureModeToggleUiState = CaptureModeToggleUiState.Invisible
),
currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
@@ -255,7 +287,9 @@
setVisibleQuickSetting = { },
onAspectRatioClick = { },
onCaptureModeClick = { },
- onDynamicRangeClick = { }
+ onDynamicRangeClick = { },
+ onImageOutputFormatClick = { },
+ onLowLightBoostClick = { }
)
}
}
@@ -268,7 +302,8 @@
previewUiState = PreviewUiState.Ready(
currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
- previewMode = PreviewMode.StandardMode {}
+ previewMode = PreviewMode.StandardMode {},
+ captureModeToggleUiState = CaptureModeToggleUiState.Invisible
),
currentCameraSettings = CameraAppSettings(dynamicRange = DynamicRange.HLG10),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS_WITH_HDR,
@@ -278,7 +313,9 @@
setVisibleQuickSetting = { },
onAspectRatioClick = { },
onCaptureModeClick = { },
- onDynamicRangeClick = { }
+ onDynamicRangeClick = { },
+ onImageOutputFormatClick = { },
+ onLowLightBoostClick = { }
)
}
}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt
index f7af6e0..0e6b85f 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/ui/QuickSettingsComponents.kt
@@ -45,18 +45,22 @@
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import com.google.jetpackcamera.feature.preview.PreviewMode
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.CameraLowLightBoost
import com.google.jetpackcamera.feature.preview.quicksettings.QuickSettingsEnum
import com.google.jetpackcamera.settings.model.AspectRatio
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.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.LowLightBoost
import kotlin.math.min
// completed components ready to go into preview screen
@@ -103,29 +107,71 @@
@Composable
fun QuickSetHdr(
modifier: Modifier = Modifier,
- onClick: (dynamicRange: DynamicRange) -> Unit,
+ onClick: (dynamicRange: DynamicRange, imageOutputFormat: ImageOutputFormat) -> Unit,
selectedDynamicRange: DynamicRange,
+ selectedImageOutputFormat: ImageOutputFormat,
hdrDynamicRange: DynamicRange,
- enabled: Boolean = true
+ hdrImageFormat: ImageOutputFormat,
+ hdrDynamicRangeSupported: Boolean,
+ hdrImageFormatSupported: Boolean,
+ previewMode: PreviewMode
) {
val enum =
- when (selectedDynamicRange) {
- DynamicRange.SDR -> CameraDynamicRange.SDR
- DynamicRange.HLG10 -> CameraDynamicRange.HLG10
+ if (selectedDynamicRange == hdrDynamicRange ||
+ selectedImageOutputFormat == hdrImageFormat
+ ) {
+ CameraDynamicRange.HDR
+ } else {
+ CameraDynamicRange.SDR
}
+
QuickSettingUiItem(
modifier = modifier,
enum = enum,
onClick = {
- val newDynamicRange = if (selectedDynamicRange == DynamicRange.SDR) {
- hdrDynamicRange
- } else {
- DynamicRange.SDR
- }
- onClick(newDynamicRange)
+ val newDynamicRange =
+ if (selectedDynamicRange == DynamicRange.SDR && hdrDynamicRangeSupported) {
+ hdrDynamicRange
+ } else {
+ DynamicRange.SDR
+ }
+ val newImageOutputFormat =
+ if (!hdrDynamicRangeSupported ||
+ previewMode is PreviewMode.ExternalImageCaptureMode
+ ) {
+ hdrImageFormat
+ } else {
+ ImageOutputFormat.JPEG
+ }
+ onClick(newDynamicRange, newImageOutputFormat)
},
isHighLighted = (selectedDynamicRange != DynamicRange.SDR),
- enabled = enabled
+ enabled = (hdrDynamicRangeSupported && previewMode is PreviewMode.StandardMode) ||
+ hdrImageFormatSupported
+ )
+}
+
+@Composable
+fun QuickSetLowLightBoost(
+ modifier: Modifier = Modifier,
+ onClick: (lowLightBoost: LowLightBoost) -> Unit,
+ selectedLowLightBoost: LowLightBoost
+) {
+ val enum = when (selectedLowLightBoost) {
+ LowLightBoost.DISABLED -> CameraLowLightBoost.DISABLED
+ LowLightBoost.ENABLED -> CameraLowLightBoost.ENABLED
+ }
+
+ QuickSettingUiItem(
+ modifier = modifier,
+ enum = enum,
+ onClick = {
+ when (selectedLowLightBoost) {
+ LowLightBoost.DISABLED -> onClick(LowLightBoost.ENABLED)
+ LowLightBoost.ENABLED -> onClick(LowLightBoost.DISABLED)
+ }
+ },
+ isHighLighted = false
)
}
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
index f89fb15..7334407 100644
--- 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
@@ -19,6 +19,7 @@
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_LOW_LIGHT_BOOST_BUTTON = "QuickSettingsLowLightBoostButton"
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"
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
index f8e1d54..a8c8a3b 100644
--- 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
@@ -15,6 +15,7 @@
*/
package com.google.jetpackcamera.feature.preview.ui
+import android.annotation.SuppressLint
import android.content.ContentResolver
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
@@ -26,6 +27,11 @@
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CameraAlt
+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.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -37,10 +43,13 @@
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
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 androidx.core.util.Preconditions
+import com.google.jetpackcamera.feature.preview.CaptureModeToggleUiState
import com.google.jetpackcamera.feature.preview.MultipleEventsCutter
import com.google.jetpackcamera.feature.preview.PreviewMode
import com.google.jetpackcamera.feature.preview.PreviewUiState
@@ -50,6 +59,7 @@
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.ImageOutputFormat
import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
import com.google.jetpackcamera.settings.model.SystemConstraints
@@ -75,7 +85,10 @@
onNavigateToSettings: () -> Unit = {},
onFlipCamera: () -> Unit = {},
onChangeFlash: (FlashMode) -> Unit = {},
+ onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
+ onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
onToggleQuickSettings: () -> Unit = {},
+ onMuteAudio: () -> Unit = {},
onCaptureImage: () -> Unit = {},
onCaptureImageWithUri: (
ContentResolver,
@@ -120,12 +133,16 @@
zoomLevel = previewUiState.zoomScale,
showZoomLevel = zoomLevelDisplayState.showZoomLevel,
isQuickSettingsOpen = previewUiState.quickSettingsIsOpen,
+ currentCameraSettings = previewUiState.currentCameraSettings,
systemConstraints = previewUiState.systemConstraints,
videoRecordingState = previewUiState.videoRecordingState,
onFlipCamera = onFlipCamera,
onCaptureImage = onCaptureImage,
onCaptureImageWithUri = onCaptureImageWithUri,
onToggleQuickSettings = onToggleQuickSettings,
+ onToggleAudioMuted = onMuteAudio,
+ onChangeImageFormat = onChangeImageFormat,
+ onToggleWhenDisabled = onToggleWhenDisabled,
onStartVideoRecording = onStartVideoRecording,
onStopVideoRecording = onStopVideoRecording
)
@@ -171,6 +188,9 @@
videoStabilization = currentCameraSettings.videoCaptureStabilization,
previewStabilization = currentCameraSettings.previewStabilization
)
+ LowLightBoostIcon(
+ lowLightBoost = currentCameraSettings.lowLightBoost
+ )
}
}
}
@@ -183,6 +203,7 @@
zoomLevel: Float,
showZoomLevel: Boolean,
isQuickSettingsOpen: Boolean,
+ currentCameraSettings: CameraAppSettings,
systemConstraints: SystemConstraints,
videoRecordingState: VideoRecordingState,
onFlipCamera: () -> Unit = {},
@@ -194,6 +215,9 @@
(PreviewViewModel.ImageCaptureEvent) -> Unit
) -> Unit = { _, _, _, _ -> },
onToggleQuickSettings: () -> Unit = {},
+ onToggleAudioMuted: () -> Unit = {},
+ onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
+ onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
onStartVideoRecording: () -> Unit = {},
onStopVideoRecording: () -> Unit = {}
) {
@@ -228,15 +252,26 @@
onStartVideoRecording = onStartVideoRecording,
onStopVideoRecording = onStopVideoRecording
)
- Row(Modifier.weight(1f)) {
+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) {
if (videoRecordingState == VideoRecordingState.ACTIVE) {
AmplitudeVisualizer(
modifier = Modifier
.weight(1f)
.fillMaxSize(),
+ onToggleMute = onToggleAudioMuted,
size = 75,
audioAmplitude = audioAmplitude
)
+ } else {
+ if (!isQuickSettingsOpen &&
+ previewUiState.captureModeToggleUiState is CaptureModeToggleUiState.Visible
+ ) {
+ CaptureModeToggleButton(
+ uiState = previewUiState.captureModeToggleUiState,
+ onChangeImageFormat = onChangeImageFormat,
+ onToggleWhenDisabled = onToggleWhenDisabled
+ )
+ }
}
}
}
@@ -301,6 +336,50 @@
)
}
+@SuppressLint("RestrictedApi")
+@Composable
+private fun CaptureModeToggleButton(
+ uiState: CaptureModeToggleUiState.Visible,
+ onChangeImageFormat: (ImageOutputFormat) -> Unit,
+ onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit
+) {
+ // Captures hdr image (left) when output format is UltraHdr, else captures hdr video (right).
+ val initialState =
+ when (uiState.currentMode) {
+ CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE -> ToggleState.Left
+ CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO -> ToggleState.Right
+ }
+ ToggleButton(
+ leftIcon = if (uiState.currentMode ==
+ CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE
+ ) {
+ rememberVectorPainter(image = Icons.Filled.CameraAlt)
+ } else {
+ rememberVectorPainter(image = Icons.Outlined.CameraAlt)
+ },
+ rightIcon = if (uiState.currentMode ==
+ CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO
+ ) {
+ rememberVectorPainter(image = Icons.Filled.Videocam)
+ } else {
+ rememberVectorPainter(image = Icons.Outlined.Videocam)
+ },
+ initialState = initialState,
+ onToggleStateChanged = {
+ val imageFormat = when (it) {
+ ToggleState.Left -> ImageOutputFormat.JPEG_ULTRA_HDR
+ ToggleState.Right -> ImageOutputFormat.JPEG
+ }
+ onChangeImageFormat(imageFormat)
+ },
+ onToggleWhenDisabled = {
+ Preconditions.checkArgument(uiState is CaptureModeToggleUiState.Disabled)
+ onToggleWhenDisabled((uiState as CaptureModeToggleUiState.Disabled).disabledReason)
+ },
+ enabled = uiState is CaptureModeToggleUiState.Enabled
+ )
+}
+
@Preview(backgroundColor = 0xFF000000, showBackground = true)
@Composable
private fun Preview_ControlsTop_QuickSettingsOpen() {
@@ -367,11 +446,13 @@
previewUiState = PreviewUiState.Ready(
currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
- previewMode = PreviewMode.StandardMode {}
+ previewMode = PreviewMode.StandardMode {},
+ captureModeToggleUiState = CaptureModeToggleUiState.Invisible
),
zoomLevel = 1.3f,
showZoomLevel = true,
isQuickSettingsOpen = false,
+ currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
videoRecordingState = VideoRecordingState.INACTIVE,
audioAmplitude = 0.0
@@ -387,11 +468,13 @@
previewUiState = PreviewUiState.Ready(
currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
- previewMode = PreviewMode.StandardMode {}
+ previewMode = PreviewMode.StandardMode {},
+ captureModeToggleUiState = CaptureModeToggleUiState.Invisible
),
zoomLevel = 1.3f,
showZoomLevel = false,
isQuickSettingsOpen = false,
+ currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
videoRecordingState = VideoRecordingState.INACTIVE,
audioAmplitude = 0.0
@@ -407,15 +490,16 @@
previewUiState = PreviewUiState.Ready(
currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
- previewMode = PreviewMode.StandardMode {}
+ previewMode = PreviewMode.StandardMode {},
+ captureModeToggleUiState = CaptureModeToggleUiState.Invisible
),
zoomLevel = 1.3f,
showZoomLevel = true,
isQuickSettingsOpen = true,
+ currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
videoRecordingState = VideoRecordingState.INACTIVE,
audioAmplitude = 0.0
-
)
}
}
@@ -428,11 +512,13 @@
previewUiState = PreviewUiState.Ready(
currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
- previewMode = PreviewMode.StandardMode {}
+ previewMode = PreviewMode.StandardMode {},
+ captureModeToggleUiState = CaptureModeToggleUiState.Invisible
),
zoomLevel = 1.3f,
showZoomLevel = true,
isQuickSettingsOpen = false,
+ currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS.copy(
availableLenses = listOf(LensFacing.FRONT),
perLensConstraints = mapOf(
@@ -442,7 +528,6 @@
),
videoRecordingState = VideoRecordingState.INACTIVE,
audioAmplitude = 0.0
-
)
}
}
@@ -455,11 +540,13 @@
previewUiState = PreviewUiState.Ready(
currentCameraSettings = CameraAppSettings(),
systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
- previewMode = PreviewMode.StandardMode {}
+ previewMode = PreviewMode.StandardMode {},
+ captureModeToggleUiState = CaptureModeToggleUiState.Invisible
),
zoomLevel = 1.3f,
showZoomLevel = true,
isQuickSettingsOpen = false,
+ currentCameraSettings = CameraAppSettings(),
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
index 523efdd..2cf49ad 100644
--- 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
@@ -17,14 +17,17 @@
import android.content.pm.ActivityInfo
import android.os.Build
+import android.util.Log
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.MutableCoordinateTransformer
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.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -33,6 +36,7 @@
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,6 +51,8 @@
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
+private const val TAG = "CameraXViewfinder"
+
/**
* A composable viewfinder that adapts CameraX's [Preview.SurfaceProvider] to [Viewfinder]
*
@@ -63,7 +69,8 @@
surfaceRequest: SurfaceRequest,
modifier: Modifier = Modifier,
implementationMode: ImplementationMode = ImplementationMode.EXTERNAL,
- onRequestWindowColorMode: (Int) -> Unit = {}
+ onRequestWindowColorMode: (Int) -> Unit = {},
+ onTap: (x: Float, y: Float) -> Unit = { _, _ -> }
) {
val currentImplementationMode by rememberUpdatedState(implementationMode)
val currentOnRequestWindowColorMode by rememberUpdatedState(onRequestWindowColorMode)
@@ -151,12 +158,23 @@
}
}
+ val coordinateTransformer = MutableCoordinateTransformer()
+
viewfinderArgs?.let { args ->
Viewfinder(
surfaceRequest = args.viewfinderSurfaceRequest,
implementationMode = args.implementationMode,
transformationInfo = args.transformationInfo,
- modifier = modifier.fillMaxSize()
+ modifier = modifier.fillMaxSize().pointerInput(Unit) {
+ detectTapGestures {
+ with(coordinateTransformer) {
+ val tapOffset = it.transform()
+ Log.d(TAG, "onTap: $tapOffset")
+ onTap(tapOffset.x, tapOffset.y)
+ }
+ }
+ },
+ coordinateTransformer = coordinateTransformer
)
}
}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/DebouncedOrientationFlow.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/DebouncedOrientationFlow.kt
new file mode 100644
index 0000000..7add7b2
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/DebouncedOrientationFlow.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.Context
+import android.view.OrientationEventListener
+import android.view.OrientationEventListener.ORIENTATION_UNKNOWN
+import com.google.jetpackcamera.settings.model.DeviceRotation
+import kotlin.math.abs
+import kotlin.math.min
+import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.buffer
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.runningFold
+
+/** Orientation hysteresis amount used in rounding, in degrees. */
+private const val ORIENTATION_HYSTERESIS = 5
+
+fun debouncedOrientationFlow(context: Context) = callbackFlow {
+ val orientationListener = object : OrientationEventListener(context) {
+ override fun onOrientationChanged(orientation: Int) {
+ trySend(orientation)
+ }
+ }
+
+ orientationListener.enable()
+
+ awaitClose {
+ orientationListener.disable()
+ }
+}.buffer(capacity = CONFLATED)
+ .runningFold(initial = DeviceRotation.Natural) { prevSnap, newDegrees ->
+ if (
+ newDegrees != ORIENTATION_UNKNOWN &&
+ abs(prevSnap.toClockwiseRotationDegrees() - newDegrees).let { min(it, 360 - it) } >=
+ 45 + ORIENTATION_HYSTERESIS
+ ) {
+ DeviceRotation.snapFrom(newDegrees)
+ } else {
+ prevSnap
+ }
+ }.distinctUntilChanged()
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 688def4..4995e97 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt
@@ -18,7 +18,6 @@
import android.content.res.Configuration
import android.os.Build
import android.util.Log
-import android.view.Display
import android.widget.Toast
import androidx.camera.core.SurfaceRequest
import androidx.camera.viewfinder.surface.ImplementationMode
@@ -51,10 +50,12 @@
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.Nightlight
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.Nightlight
import androidx.compose.material.icons.outlined.Videocam
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -94,6 +95,7 @@
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.LowLightBoost
import com.google.jetpackcamera.settings.model.Stabilization
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -102,13 +104,18 @@
private const val BLINK_TIME = 100L
@Composable
-fun AmplitudeVisualizer(modifier: Modifier = Modifier, size: Int = 100, audioAmplitude: Double) {
+fun AmplitudeVisualizer(
+ modifier: Modifier = Modifier,
+ size: Int = 100,
+ audioAmplitude: Double,
+ onToggleMute: () -> Unit
+) {
// 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) {
+ Box(modifier = modifier.clickable { onToggleMute() }) {
// animated circle
Canvas(
modifier = Modifier
@@ -138,7 +145,14 @@
Icon(
modifier = Modifier
.align(Alignment.Center)
- .size((0.5 * size).dp),
+ .size((0.5 * size).dp)
+ .apply {
+ if (audioAmplitude != 0.0) {
+ testTag(AMPLITUDE_HOT_TAG)
+ } else {
+ testTag(AMPLITUDE_NONE_TAG)
+ }
+ },
tint = Color.Black,
imageVector = if (audioAmplitude != 0.0) {
Icons.Filled.Mic
@@ -236,7 +250,7 @@
@Composable
fun PreviewDisplay(
previewUiState: PreviewUiState.Ready,
- onTapToFocus: (Display, Int, Int, Float, Float) -> Unit,
+ onTapToFocus: (x: Float, y: Float) -> Unit,
onFlipCamera: () -> Unit,
onZoomChange: (Float) -> Unit,
onRequestWindowColorMode: (Int) -> Unit,
@@ -300,6 +314,7 @@
.height(height)
.transformable(state = transformableState)
.alpha(imageAlpha)
+ .clip(RoundedCornerShape(16.dp))
) {
CameraXViewfinder(
modifier = Modifier.fillMaxSize(),
@@ -308,7 +323,8 @@
Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL
else -> ImplementationMode.EMBEDDED
},
- onRequestWindowColorMode = onRequestWindowColorMode
+ onRequestWindowColorMode = onRequestWindowColorMode,
+ onTap = { x, y -> onTapToFocus(x, y) }
)
}
}
@@ -337,6 +353,28 @@
}
/**
+ * LowLightBoostIcon has 3 states
+ * - disabled: hidden
+ * - enabled and inactive: outline
+ * - enabled and active: filled
+ */
+@Composable
+fun LowLightBoostIcon(lowLightBoost: LowLightBoost, modifier: Modifier = Modifier) {
+ when (lowLightBoost) {
+ LowLightBoost.ENABLED -> {
+ Icon(
+ imageVector = Icons.Outlined.Nightlight,
+ contentDescription =
+ stringResource(id = R.string.quick_settings_lowlightboost_enabled),
+ modifier = modifier.alpha(0.5f)
+ )
+ }
+ LowLightBoost.DISABLED -> {
+ }
+ }
+}
+
+/**
* A temporary button that can be added to preview for quick testing purposes
*/
@Composable
@@ -455,9 +493,12 @@
fun ToggleButton(
leftIcon: Painter,
rightIcon: Painter,
- modifier: Modifier = Modifier.width(64.dp).height(32.dp),
+ modifier: Modifier = Modifier
+ .width(64.dp)
+ .height(32.dp),
initialState: ToggleState = ToggleState.Left,
onToggleStateChanged: (newState: ToggleState) -> Unit = {},
+ onToggleWhenDisabled: () -> Unit = {},
enabled: Boolean = true,
leftIconDescription: String = "leftIcon",
rightIconDescription: String = "rightIcon",
@@ -483,18 +524,18 @@
modifier = modifier
.clip(shape = RoundedCornerShape(50))
.then(
- if (enabled) {
- Modifier.clickable {
- scope.launch {
+ Modifier.clickable {
+ scope.launch {
+ if (enabled) {
toggleState = when (toggleState) {
ToggleState.Left -> ToggleState.Right
ToggleState.Right -> ToggleState.Left
}
onToggleStateChanged(toggleState)
+ } else {
+ onToggleWhenDisabled()
}
}
- } else {
- Modifier
}
),
color = backgroundColor
@@ -521,9 +562,11 @@
)
}
Row(
- modifier = Modifier.matchParentSize().then(
- if (enabled) Modifier else Modifier.alpha(0.38f)
- ),
+ modifier = Modifier
+ .matchParentSize()
+ .then(
+ if (enabled) Modifier else Modifier.alpha(0.38f)
+ ),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
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 974619b..5391125 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
@@ -23,3 +23,11 @@
const val PREVIEW_DISPLAY = "PreviewDisplay"
const val SCREEN_FLASH_OVERLAY = "ScreenFlashOverlay"
const val SETTINGS_BUTTON = "SettingsButton"
+const val AMPLITUDE_NONE_TAG = "AmplitudeNoneTag"
+const val AMPLITUDE_HOT_TAG = "AmplitudeHotTag"
+const val HDR_IMAGE_UNSUPPORTED_ON_DEVICE_TAG = "HdrImageUnsupportedOnDeviceTag"
+const val HDR_IMAGE_UNSUPPORTED_ON_LENS_TAG = "HdrImageUnsupportedOnLensTag"
+const val HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG = "HdrImageUnsupportedOnSingleStreamTag"
+const val HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM_TAG = "HdrImageUnsupportedOnMultiStreamTag"
+const val HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG = "HdrVideoUnsupportedOnDeviceTag"
+const val HDR_VIDEO_UNSUPPORTED_ON_LENS_TAG = "HdrVideoUnsupportedOnDeviceTag"
diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml
index 3bf1eec..fc81069 100644
--- a/feature/preview/src/main/res/values/strings.xml
+++ b/feature/preview/src/main/res/values/strings.xml
@@ -26,9 +26,16 @@
<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="toast_video_capture_external_unsupported">Video not supported while app is in image-only capture mode</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="toast_hdr_photo_unsupported_on_device">Ultra HDR photos not supported on this device</string>
+ <string name="toast_hdr_photo_unsupported_on_lens">Ultra HDR photos not supported by current lens</string>
+ <string name="toast_hdr_photo_unsupported_on_lens_single_stream">Single-stream mode does not support UltraHDR photo capture for current lens</string>
+ <string name="toast_hdr_photo_unsupported_on_lens_multi_stream">Multi-stream mode does not support UltraHDR photo capture for current lens</string>
+ <string name="toast_hdr_video_unsupported_on_device">HDR video not supported on this device</string>
+ <string name="toast_hdr_video_unsupported_on_lens">HDR video not supported by current lens</string>
+
<string name="quick_settings_front_camera_text">FRONT</string>
<string name="quick_settings_back_camera_text">BACK</string>
@@ -40,9 +47,9 @@
<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_hdr">HDR</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_dynamic_range_hdr_description">High 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>
@@ -62,4 +69,9 @@
<string name="quick_settings_dropdown_open_description">Quick settings open</string>
<string name="quick_settings_dropdown_closed_description">Quick settings closed</string>
+
+ <string name="quick_settings_lowlightboost_enabled">Low light boost on</string>
+ <string name="quick_settings_lowlightboost_disabled">Low light boost off</string>
+ <string name="quick_settings_lowlightboost_enabled_description">Low light boost on</string>
+ <string name="quick_settings_lowlightboost_disabled_description">Low light boost off</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 1f515c6..ee4bc9e 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
@@ -17,7 +17,7 @@
import android.content.ContentResolver
import com.google.common.truth.Truth.assertThat
-import com.google.jetpackcamera.domain.camera.test.FakeCameraUseCase
+import com.google.jetpackcamera.core.camera.test.FakeCameraUseCase
import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.LensFacing
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 ea8b395..9dd08cc 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
@@ -17,8 +17,8 @@
import android.content.ContentResolver
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.core.camera.CameraUseCase
+import com.google.jetpackcamera.core.camera.test.FakeCameraUseCase
import com.google.jetpackcamera.feature.preview.rules.MainDispatcherRule
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.LensFacing
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 2bb1842..0be4f1b 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -24,6 +24,7 @@
android {
namespace = "com.google.jetpackcamera.settings"
compileSdk = libs.versions.compileSdk.get().toInt()
+ compileSdkPreview = libs.versions.compileSdkPreview.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
@@ -33,6 +34,19 @@
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+ flavorDimensions += "flavor"
+ productFlavors {
+ create("stable") {
+ dimension = "flavor"
+ isDefault = true
+ }
+
+ create("preview") {
+ dimension = "flavor"
+ targetSdkPreview = libs.versions.targetSdkPreview.get()
+ }
+ }
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a650cf0..930f838 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,8 +1,10 @@
[versions]
# Used directly in build.gradle.kts files
compileSdk = "34"
+compileSdkPreview = "VanillaIceCream"
minSdk = "21"
targetSdk = "34"
+targetSdkPreview = "VanillaIceCream"
composeCompiler = "1.5.10"
# Used below in dependency definitions
@@ -13,7 +15,7 @@
# kotlinPlugin and composeCompiler are linked
# See https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinPlugin = "1.9.22"
-androidGradlePlugin = "8.4.0-rc01"
+androidGradlePlugin = "8.4.2"
protobufPlugin = "0.9.4"
androidxActivityCompose = "1.8.2"
@@ -35,6 +37,7 @@
androidxTestRules = "1.5.0"
androidxTestUiautomator = "2.3.0"
androidxTracing = "1.2.0"
+cmake = "3.22.1"
kotlinxAtomicfu = "0.23.2"
kotlinxCoroutines = "1.8.0"
hilt = "2.51"
diff --git a/settings.gradle.kts b/settings.gradle.kts
index dd158c4..b021ba1 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -26,7 +26,7 @@
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven {
- setUrl("https://androidx.dev/snapshots/builds/11790852/artifacts/repository")
+ setUrl("https://androidx.dev/snapshots/builds/11932573/artifacts/repository")
}
google()
mavenCentral()
@@ -35,7 +35,7 @@
rootProject.name = "Jetpack Camera"
include(":app")
include(":feature:preview")
-include(":domain:camera")
+include(":core:camera")
include(":feature:settings")
include(":data:settings")
include(":core:common")