[external/jetpack-camera-app] Upstream merge am: 4919941812 am: 310a14dd9e
Original change: https://android-review.googlesource.com/c/platform/external/jetpack-camera-app/+/3263806
Change-Id: I5a11c0c61dc54c3d7c026892d6fb144e1a8602e2
Signed-off-by: Automerger Merge Worker <[email protected]>
diff --git a/.gitignore b/.gitignore
index 4e44987..7b74e6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,5 @@
local.properties
.idea/deploymentTargetDropDown.xml
.idea/gradle.xml
-.idea/deploymentTargetSelector.xml
\ No newline at end of file
+.idea/deploymentTargetSelector.xml
+.idea/androidTestResultsUserPreferences.xml
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
index 3d86388..7edae9a 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt
@@ -16,29 +16,35 @@
package com.google.jetpackcamera
import android.app.Activity
-import android.content.ComponentName
-import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
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.performClick
+import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import androidx.test.uiautomator.UiDevice
+import com.google.common.truth.Truth
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 com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
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.VIDEO_CAPTURE_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.deleteFilesInDirAfterTimestamp
+import com.google.jetpackcamera.utils.doesImageFileExist
+import com.google.jetpackcamera.utils.getIntent
+import com.google.jetpackcamera.utils.getTestUri
import com.google.jetpackcamera.utils.runScenarioTest
import com.google.jetpackcamera.utils.runScenarioTestForResult
import java.io.File
-import java.net.URLConnection
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -71,16 +77,18 @@
composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) {
composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed()
}
- assert(File(DIR_PATH).lastModified() > timeStamp)
- deleteFilesInDirAfterTimestamp(timeStamp)
+ Truth.assertThat(File(DIR_PATH).lastModified() > timeStamp).isTrue()
+ deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
}
@Test
fun image_capture_external() {
val timeStamp = System.currentTimeMillis()
- val uri = getTestUri(timeStamp)
+ val uri = getTestUri(DIR_PATH, timeStamp, "jpg")
val result =
- runScenarioTestForResult<MainActivity>(getIntent(uri)) {
+ runScenarioTestForResult<MainActivity>(
+ getIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
+ ) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
@@ -90,16 +98,18 @@
.assertExists()
.performClick()
}
- assert(result?.resultCode == Activity.RESULT_OK)
- assert(doesImageFileExist(uri))
- deleteFilesInDirAfterTimestamp(timeStamp)
+ Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_OK)
+ Truth.assertThat(doesImageFileExist(uri, "image")).isTrue()
+ deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
}
@Test
fun image_capture_external_illegal_uri() {
val uri = Uri.parse("asdfasdf")
val result =
- runScenarioTestForResult<MainActivity>(getIntent(uri)) {
+ runScenarioTestForResult<MainActivity>(
+ getIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
+ ) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
@@ -113,55 +123,34 @@
}
uiDevice.pressBack()
}
- assert(result?.resultCode == Activity.RESULT_CANCELED)
- assert(!doesImageFileExist(uri))
+ Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+ Truth.assertThat(doesImageFileExist(uri, "image")).isFalse()
}
- private fun doesImageFileExist(uri: Uri): Boolean {
- val file = File(uri.path)
- if (file.exists()) {
- val mimeType = URLConnection.guessContentTypeFromName(uri.path)
- return mimeType != null && mimeType.startsWith("image")
- }
- return false
- }
-
- private fun deleteFilesInDirAfterTimestamp(timeStamp: Long): Boolean {
- var hasDeletedFile = false
- for (file in File(DIR_PATH).listFiles()) {
- if (file.lastModified() >= timeStamp) {
- file.delete()
- if (file.exists()) {
- file.getCanonicalFile().delete()
- if (file.exists()) {
- instrumentation.targetContext.applicationContext.deleteFile(file.getName())
- }
+ @Test
+ fun video_capture_during_image_capture_external() {
+ val timeStamp = System.currentTimeMillis()
+ val uri = getTestUri(DIR_PATH, timeStamp, "mp4")
+ val result =
+ runScenarioTestForResult<MainActivity>(
+ getIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
+ ) {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
}
- hasDeletedFile = true
+
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+ .assertExists()
+ .performTouchInput { longClick() }
+ composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG)
+ .isDisplayed()
+ }
+ uiDevice.pressBack()
}
- }
- return hasDeletedFile
- }
-
- private fun getTestUri(timeStamp: Long): Uri {
- return Uri.fromFile(
- File(
- Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
- "$timeStamp.jpg"
- )
- )
- }
-
- 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
+ Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+ Truth.assertThat(doesImageFileExist(uri, "video")).isFalse()
}
companion object {
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
index ee81021..545b406 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt
@@ -15,27 +15,36 @@
*/
package com.google.jetpackcamera
-import android.app.Instrumentation
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
+import android.app.Activity
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.ComposeTimeoutException
+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.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
-import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
-import androidx.test.uiautomator.Until
+import com.google.common.truth.Truth
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.feature.preview.ui.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_FAILURE_TAG
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_SUCCESS_TAG
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.VIDEO_CAPTURE_TIMEOUT_MILLIS
+import com.google.jetpackcamera.utils.VIDEO_DURATION_MILLIS
+import com.google.jetpackcamera.utils.deleteFilesInDirAfterTimestamp
+import com.google.jetpackcamera.utils.doesImageFileExist
+import com.google.jetpackcamera.utils.getIntent
+import com.google.jetpackcamera.utils.getTestUri
+import com.google.jetpackcamera.utils.runScenarioTest
+import com.google.jetpackcamera.utils.runScenarioTestForResult
import java.io.File
import org.junit.Rule
import org.junit.Test
@@ -47,73 +56,120 @@
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)
@Test
- fun video_capture_external_with_image_capture_intent() = run {
+ fun video_capture() = runScenarioTest<MainActivity> {
val timeStamp = System.currentTimeMillis()
- val uri = getTestUri(timeStamp)
- getTestRegistry {
- activityScenario = ActivityScenario.launchActivityForResult(it)
- uiDevice.wait(
- Until.findObject(By.res(CAPTURE_BUTTON)),
- 5000
- )
- uiDevice.findObject(By.res(CAPTURE_BUTTON)).longClick()
- uiDevice.wait(
- Until.findObject(By.res(VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG)),
- 5000
- )
- uiDevice.pressBack()
- activityScenario!!.result
- }.register("key", TEST_CONTRACT) { result ->
- assert(!result)
- }.launch(uri)
- }
-
- private fun getTestRegistry(
- launch: (Intent) -> Instrumentation.ActivityResult
- ): ActivityResultRegistry {
- val testRegistry = object : ActivityResultRegistry() {
- override fun <I, O> onLaunch(
- requestCode: Int,
- contract: ActivityResultContract<I, O>,
- input: I,
- options: ActivityOptionsCompat?
- ) {
- // contract.create
- val launchIntent = contract.createIntent(
- ApplicationProvider.getApplicationContext(),
- input
- )
- val result: Instrumentation.ActivityResult = launch(launchIntent)
- dispatchResult(requestCode, result.resultCode, result.resultData)
- }
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
}
- return testRegistry
+ longClickForVideoRecording()
+ composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed()
+ }
+ Truth.assertThat(File(DIR_PATH).lastModified() > timeStamp).isTrue()
+ deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
}
- private fun getTestUri(timeStamp: Long): Uri {
- return Uri.fromFile(
- File(
- Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
- "$timeStamp.mp4"
- )
- )
+ @Test
+ fun video_capture_external_intent() {
+ val timeStamp = System.currentTimeMillis()
+ val uri = getTestUri(DIR_PATH, timeStamp, "mp4")
+ val result =
+ runScenarioTestForResult<MainActivity>(
+ getIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
+ ) {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+ longClickForVideoRecording()
+ }
+ Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_OK)
+ Truth.assertThat(doesImageFileExist(uri, "video")).isTrue()
+ deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
+ }
+
+ @Test
+ fun video_capture_external_illegal_uri() {
+ val uri = Uri.parse("asdfasdf")
+ val result =
+ runScenarioTestForResult<MainActivity>(
+ getIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
+ ) {
+ // Wait for the capture button to be displayed
+ composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
+ }
+ longClickForVideoRecording()
+ composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) {
+ composeTestRule.onNodeWithTag(VIDEO_CAPTURE_FAILURE_TAG).isDisplayed()
+ }
+ uiDevice.pressBack()
+ }
+ Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+ Truth.assertThat(doesImageFileExist(uri, "video")).isFalse()
+ }
+
+ @Test
+ fun image_capture_during_video_capture_external() {
+ val timeStamp = System.currentTimeMillis()
+ val uri = getTestUri(ImageCaptureDeviceTest.DIR_PATH, timeStamp, "mp4")
+ val result =
+ runScenarioTestForResult<MainActivity>(
+ getIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
+ ) {
+ // 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_EXTERNAL_UNSUPPORTED_TAG
+ ).isDisplayed()
+ }
+ uiDevice.pressBack()
+ }
+ Truth.assertThat(result?.resultCode).isEqualTo(Activity.RESULT_CANCELED)
+ Truth.assertThat(doesImageFileExist(uri, "image")).isFalse()
+ }
+
+ private fun longClickForVideoRecording() {
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+ .assertExists()
+ .performTouchInput {
+ down(center)
+ }
+ idleForVideoDuration()
+ composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
+ .assertExists()
+ .performTouchInput {
+ up()
+ }
+ }
+
+ private fun idleForVideoDuration() {
+ // TODO: replace with a check for the timestamp UI of the video duration
+ try {
+ composeTestRule.waitUntil(timeoutMillis = VIDEO_DURATION_MILLIS) {
+ composeTestRule.onNodeWithTag("dummyTagForLongPress").isDisplayed()
+ }
+ } catch (e: ComposeTimeoutException) {
+ }
}
companion object {
- private val TEST_CONTRACT = object : ActivityResultContracts.TakePicture() {
- override fun createIntent(context: Context, uri: Uri): Intent {
- return super.createIntent(context, uri).apply {
- component = ComponentName(
- ApplicationProvider.getApplicationContext(),
- MainActivity::class.java
- )
- }
- }
- }
+ val DIR_PATH: String =
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES).path
}
}
diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
index af2c512..782ede2 100644
--- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
+++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt
@@ -17,7 +17,10 @@
import android.app.Activity
import android.app.Instrumentation
+import android.content.ComponentName
import android.content.Intent
+import android.net.Uri
+import android.provider.MediaStore
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.junit4.ComposeTestRule
@@ -28,9 +31,13 @@
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
+import java.io.File
+import java.net.URLConnection
const val APP_START_TIMEOUT_MILLIS = 10_000L
const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 5_000L
+const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 5_000L
+const val VIDEO_DURATION_MILLIS = 2_000L
inline fun <reified T : Activity> runScenarioTest(
crossinline block: ActivityScenario<T>.() -> Unit
@@ -89,3 +96,54 @@
}
}
}
+
+fun getTestUri(directoryPath: String, timeStamp: Long, suffix: String): Uri {
+ return Uri.fromFile(
+ File(
+ directoryPath,
+ "$timeStamp.$suffix"
+ )
+ )
+}
+
+fun deleteFilesInDirAfterTimestamp(
+ directoryPath: String,
+ instrumentation: Instrumentation,
+ timeStamp: Long
+): Boolean {
+ var hasDeletedFile = false
+ for (file in File(directoryPath).listFiles()) {
+ if (file.lastModified() >= timeStamp) {
+ file.delete()
+ if (file.exists()) {
+ file.getCanonicalFile().delete()
+ if (file.exists()) {
+ instrumentation.targetContext.applicationContext.deleteFile(file.getName())
+ }
+ }
+ hasDeletedFile = true
+ }
+ }
+ return hasDeletedFile
+}
+
+fun doesImageFileExist(uri: Uri, prefix: String): Boolean {
+ val file = File(uri.path)
+ if (file.exists()) {
+ val mimeType = URLConnection.guessContentTypeFromName(uri.path)
+ return mimeType != null && mimeType.startsWith(prefix)
+ }
+ return false
+}
+
+fun getIntent(uri: Uri, action: String): Intent {
+ val intent = Intent(action)
+ intent.setComponent(
+ ComponentName(
+ "com.google.jetpackcamera",
+ "com.google.jetpackcamera.MainActivity"
+ )
+ )
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
+ return intent
+}
diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
index f00aee6..04dfaf9 100644
--- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
+++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
@@ -51,6 +51,7 @@
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
+import androidx.core.content.IntentCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -70,6 +71,7 @@
import kotlinx.coroutines.launch
private const val TAG = "MainActivity"
+private const val KEY_DEBUG_MODE = "KEY_DEBUG_MODE"
/**
* Activity for the JetpackCameraApp.
@@ -136,6 +138,7 @@
) {
JcaApp(
previewMode = getPreviewMode(),
+ isDebugMode = isDebugMode,
openAppSettings = ::openAppSettings,
onRequestWindowColorMode = { colorMode ->
// Window color mode APIs require API level 26+
@@ -159,40 +162,61 @@
}
}
- private fun getPreviewMode(): PreviewMode {
- if (intent == null || MediaStore.ACTION_IMAGE_CAPTURE != intent.action) {
- return PreviewMode.StandardMode { event ->
- if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
- val intent = Intent(Camera.ACTION_NEW_PICTURE)
- intent.setData(event.savedUri)
- sendBroadcast(intent)
- }
- }
- } else {
- var uri = if (intent.extras == null ||
- !intent.extras!!.containsKey(MediaStore.EXTRA_OUTPUT)
- ) {
- null
- } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.extras!!.getParcelable(
- MediaStore.EXTRA_OUTPUT,
- Uri::class.java
- )
- } else {
- @Suppress("DEPRECATION")
- intent.extras!!.getParcelable(MediaStore.EXTRA_OUTPUT)
- }
- if (uri == null && intent.clipData != null && intent.clipData!!.itemCount != 0) {
- uri = intent.clipData!!.getItemAt(0).uri
- }
- return PreviewMode.ExternalImageCaptureMode(uri) { event ->
- if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
- setResult(RESULT_OK)
- finish()
- }
+ private val isDebugMode: Boolean
+ get() = intent?.getBooleanExtra(KEY_DEBUG_MODE, false) ?: false
+
+ private fun getStandardMode(): PreviewMode.StandardMode {
+ return PreviewMode.StandardMode { event ->
+ if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
+ val intent = Intent(Camera.ACTION_NEW_PICTURE)
+ intent.setData(event.savedUri)
+ sendBroadcast(intent)
}
}
}
+
+ private fun getExternalCaptureUri(): Uri? {
+ return IntentCompat.getParcelableExtra(
+ intent,
+ MediaStore.EXTRA_OUTPUT,
+ Uri::class.java
+ ) ?: intent?.clipData?.getItemAt(0)?.uri
+ }
+
+ private fun getPreviewMode(): PreviewMode {
+ return intent?.action?.let { action ->
+ when (action) {
+ MediaStore.ACTION_IMAGE_CAPTURE ->
+ PreviewMode.ExternalImageCaptureMode(getExternalCaptureUri()) { event ->
+ Log.d(TAG, "onImageCapture, event: $event")
+ if (event is PreviewViewModel.ImageCaptureEvent.ImageSaved) {
+ val resultIntent = Intent()
+ resultIntent.putExtra(MediaStore.EXTRA_OUTPUT, event.savedUri)
+ setResult(RESULT_OK, resultIntent)
+ Log.d(TAG, "onImageCapture, finish()")
+ finish()
+ }
+ }
+
+ MediaStore.ACTION_VIDEO_CAPTURE ->
+ PreviewMode.ExternalVideoCaptureMode(getExternalCaptureUri()) { event ->
+ Log.d(TAG, "onVideoCapture, event: $event")
+ if (event is PreviewViewModel.VideoCaptureEvent.VideoSaved) {
+ val resultIntent = Intent()
+ resultIntent.putExtra(MediaStore.EXTRA_OUTPUT, event.savedUri)
+ setResult(RESULT_OK, resultIntent)
+ Log.d(TAG, "onVideoCapture, finish()")
+ finish()
+ }
+ }
+
+ else -> {
+ Log.w(TAG, "Ignoring external intent with unknown action.")
+ getStandardMode()
+ }
+ }
+ } ?: getStandardMode()
+ }
}
/**
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 2f2ea31..1e16d5b 100644
--- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
+++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
@@ -42,11 +42,13 @@
/*TODO(b/306236646): remove after still capture*/
previewMode: PreviewMode,
modifier: Modifier = Modifier,
+ isDebugMode: Boolean,
onRequestWindowColorMode: (Int) -> Unit,
onFirstFrameCaptureCompleted: () -> Unit
) {
JetpackCameraNavHost(
previewMode = previewMode,
+ isDebugMode = isDebugMode,
onOpenAppSettings = openAppSettings,
onRequestWindowColorMode = onRequestWindowColorMode,
onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted,
@@ -59,6 +61,7 @@
private fun JetpackCameraNavHost(
modifier: Modifier = Modifier,
previewMode: PreviewMode,
+ isDebugMode: Boolean,
onOpenAppSettings: () -> Unit,
onRequestWindowColorMode: (Int) -> Unit,
onFirstFrameCaptureCompleted: () -> Unit,
@@ -102,7 +105,8 @@
onNavigateToSettings = { navController.navigate(SETTINGS_ROUTE) },
onRequestWindowColorMode = onRequestWindowColorMode,
onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted,
- previewMode = previewMode
+ previewMode = previewMode,
+ isDebugMode = isDebugMode
)
}
composable(SETTINGS_ROUTE) {
diff --git a/core/camera/Android.bp b/core/camera/Android.bp
index fc9c829..b5a8c62 100644
--- a/core/camera/Android.bp
+++ b/core/camera/Android.bp
@@ -21,4 +21,7 @@
sdk_version: "34",
min_sdk_version: "21",
manifest: "src/main/AndroidManifest.xml",
+ kotlincflags: [
+ "-Xcontext-receivers",
+ ],
}
diff --git a/core/camera/build.gradle.kts b/core/camera/build.gradle.kts
index 4fb048a..cc471c3 100644
--- a/core/camera/build.gradle.kts
+++ b/core/camera/build.gradle.kts
@@ -84,6 +84,10 @@
kotlin {
jvmToolchain(17)
}
+
+ kotlinOptions {
+ freeCompilerArgs += "-Xcontext-receivers"
+ }
}
dependencies {
@@ -94,10 +98,16 @@
testImplementation(libs.mockito.core)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.kotlinx.coroutines.test)
+ androidTestImplementation(libs.rules)
+ androidTestImplementation(libs.truth)
// Futures
implementation(libs.futures.ktx)
+ // LiveData
+ implementation(libs.androidx.lifecycle.livedata)
+
// CameraX
implementation(libs.camera.core)
implementation(libs.camera.camera2)
diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt
new file mode 100644
index 0000000..5cd9d75
--- /dev/null
+++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt
@@ -0,0 +1,245 @@
+/*
+ * 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.camera
+
+import android.app.Application
+import android.content.ContentResolver
+import android.graphics.SurfaceTexture
+import android.net.Uri
+import android.view.Surface
+import androidx.concurrent.futures.DirectExecutor
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.GrantPermissionRule
+import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecordError
+import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus
+import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecorded
+import com.google.jetpackcamera.core.camera.utils.APP_REQUIRED_PERMISSIONS
+import com.google.jetpackcamera.settings.ConstraintsRepository
+import com.google.jetpackcamera.settings.SettableConstraintsRepository
+import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl
+import com.google.jetpackcamera.settings.model.CameraAppSettings
+import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import java.io.File
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.produceIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import org.junit.After
+import org.junit.Assert.fail
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class CameraXCameraUseCaseTest {
+
+ companion object {
+ private const val STATUS_VERIFY_COUNT = 5
+ private const val GENERAL_TIMEOUT_MS = 3_000L
+ private const val STATUS_VERIFY_TIMEOUT_MS = 10_000L
+ }
+
+ @get:Rule
+ val permissionsRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())
+
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context = instrumentation.context
+ private val application = context.applicationContext as Application
+ private val videosToDelete = mutableSetOf<Uri>()
+ private lateinit var useCaseScope: CoroutineScope
+
+ @Before
+ fun setup() {
+ useCaseScope = CoroutineScope(Dispatchers.Default)
+ }
+
+ @After
+ fun tearDown() {
+ useCaseScope.cancel()
+ deleteVideos()
+ }
+
+ @Test
+ fun canRecordVideo(): Unit = runBlocking {
+ // Arrange.
+ val cameraUseCase = createAndInitCameraXUseCase()
+ cameraUseCase.runCameraOnMain()
+
+ // Act.
+ val recordEvent = cameraUseCase.startRecordingAndGetEvents()
+
+ // Assert.
+ recordEvent.onRecordStatus.await(STATUS_VERIFY_TIMEOUT_MS)
+
+ // Act.
+ cameraUseCase.stopVideoRecording()
+
+ // Assert.
+ recordEvent.onRecorded.await()
+ }
+
+ @Test
+ fun recordVideoWithFlashModeOn_shouldEnableTorch(): Unit = runBlocking {
+ // Arrange.
+ val lensFacing = LensFacing.BACK
+ val constraintsRepository = SettableConstraintsRepositoryImpl()
+ val cameraUseCase = createAndInitCameraXUseCase(
+ constraintsRepository = constraintsRepository
+ )
+ assumeTrue("No flash unit, skip the test.", constraintsRepository.hasFlashUnit(lensFacing))
+ cameraUseCase.runCameraOnMain()
+
+ // Arrange: Create a ReceiveChannel to observe the torch enabled state.
+ val torchEnabled: ReceiveChannel<Boolean> = cameraUseCase.getCurrentCameraState()
+ .map { it.torchEnabled }
+ .produceIn(this)
+
+ // Assert: The initial torch enabled should be false.
+ torchEnabled.awaitValue(false)
+
+ // Act: Start recording with FlashMode.ON
+ cameraUseCase.setFlashMode(FlashMode.ON)
+ val recordEvent = cameraUseCase.startRecordingAndGetEvents()
+
+ // Assert: Torch enabled transitions to true.
+ torchEnabled.awaitValue(true)
+
+ // Act: Ensure enough data is received and stop recording.
+ recordEvent.onRecordStatus.await(STATUS_VERIFY_TIMEOUT_MS)
+ cameraUseCase.stopVideoRecording()
+
+ // Assert: Torch enabled transitions to false.
+ torchEnabled.awaitValue(false)
+
+ // Clean-up.
+ torchEnabled.cancel()
+ }
+
+ private suspend fun createAndInitCameraXUseCase(
+ appSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
+ constraintsRepository: SettableConstraintsRepository = SettableConstraintsRepositoryImpl()
+ ) = CameraXCameraUseCase(
+ application,
+ useCaseScope,
+ Dispatchers.Default,
+ constraintsRepository
+ ).apply {
+ initialize(appSettings, CameraUseCase.UseCaseMode.STANDARD)
+ providePreviewSurface()
+ }
+
+ private data class RecordEvents(
+ val onRecorded: CompletableDeferred<Unit>,
+ val onRecordStatus: CompletableDeferred<Unit>
+ )
+
+ private suspend fun CompletableDeferred<*>.await(timeoutMs: Long = GENERAL_TIMEOUT_MS) =
+ withTimeoutOrNull(timeoutMs) {
+ await()
+ Unit
+ } ?: fail("Timeout while waiting for the Deferred to complete")
+
+ private suspend fun <T> ReceiveChannel<T>.awaitValue(
+ expectedValue: T,
+ timeoutMs: Long = GENERAL_TIMEOUT_MS
+ ) = withTimeoutOrNull(timeoutMs) {
+ for (value in this@awaitValue) {
+ if (value == expectedValue) return@withTimeoutOrNull
+ }
+ } ?: fail("Timeout while waiting for expected value: $expectedValue")
+
+ private suspend fun CameraXCameraUseCase.startRecordingAndGetEvents(
+ statusVerifyCount: Int = STATUS_VERIFY_COUNT
+ ): RecordEvents {
+ val onRecorded = CompletableDeferred<Unit>()
+ val onRecordStatus = CompletableDeferred<Unit>()
+ var statusCount = 0
+ startVideoRecording(null, false) {
+ when (it) {
+ is OnVideoRecorded -> {
+ val videoUri = it.savedUri
+ if (videoUri != Uri.EMPTY) {
+ videosToDelete.add(videoUri)
+ }
+ onRecorded.complete(Unit)
+ }
+ is OnVideoRecordError -> onRecorded.complete(Unit)
+ is OnVideoRecordStatus -> {
+ statusCount++
+ if (statusCount == statusVerifyCount) {
+ onRecordStatus.complete(Unit)
+ }
+ }
+ }
+ }
+ return RecordEvents(onRecorded, onRecordStatus)
+ }
+
+ private fun CameraXCameraUseCase.providePreviewSurface() {
+ useCaseScope.launch {
+ getSurfaceRequest().filterNotNull().first().let { surfaceRequest ->
+ val surfaceTexture = SurfaceTexture(0)
+ surfaceTexture.setDefaultBufferSize(640, 480)
+ val surface = Surface(surfaceTexture)
+ surfaceRequest.provideSurface(surface, DirectExecutor.INSTANCE) {
+ surface.release()
+ surfaceTexture.release()
+ }
+ }
+ }
+ }
+
+ private suspend fun CameraXCameraUseCase.runCameraOnMain() {
+ useCaseScope.launch(Dispatchers.Main) { runCamera() }
+ instrumentation.waitForIdleSync()
+ }
+
+ private suspend fun ConstraintsRepository.hasFlashUnit(lensFacing: LensFacing): Boolean =
+ systemConstraints.first()!!.perLensConstraints[lensFacing]!!.hasFlashUnit
+
+ private fun deleteVideos() {
+ for (uri in videosToDelete) {
+ when (uri.scheme) {
+ ContentResolver.SCHEME_CONTENT -> {
+ try {
+ context.contentResolver.delete(uri, null, null)
+ } catch (e: RuntimeException) {
+ // Ignore any exception.
+ }
+ }
+ ContentResolver.SCHEME_FILE -> {
+ File(uri.path!!).delete()
+ }
+ }
+ }
+ }
+}
diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/AppTestUtil.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/AppTestUtil.kt
new file mode 100644
index 0000000..509029e
--- /dev/null
+++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/AppTestUtil.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.core.camera.utils
+
+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)
+ }
+}
diff --git a/core/camera/src/main/AndroidManifest.xml b/core/camera/src/main/AndroidManifest.xml
index 39bd3ea..150f8d8 100644
--- a/core/camera/src/main/AndroidManifest.xml
+++ b/core/camera/src/main/AndroidManifest.xml
@@ -15,6 +15,11 @@
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
package="com.google.jetpackcamera.core.camera">
- <uses-permission android:name = "android.permission.RECORD_AUDIO" />
+ <uses-permission android:name="android.permission.CAMERA" />
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+ android:maxSdkVersion="28"
+ tools:ignore="ScopedStorage" />
</manifest>
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
index 27ea15f..0fe8cc6 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraEvent.kt
@@ -15,8 +15,6 @@
*/
package com.google.jetpackcamera.core.camera
-import androidx.camera.core.MeteringPoint
-
/**
* An event that can be sent to the camera coroutine.
*/
@@ -25,5 +23,5 @@
/**
* Represents a focus metering event, that the camera can act on.
*/
- class FocusMeteringEvent(val meteringPoint: MeteringPoint) : CameraEvent
+ data class FocusMeteringEvent(val x: Float, val y: Float) : CameraEvent
}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraExt.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraExt.kt
new file mode 100644
index 0000000..df24af5
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraExt.kt
@@ -0,0 +1,134 @@
+/*
+ * 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.camera
+
+import android.annotation.SuppressLint
+import android.hardware.camera2.CameraCharacteristics
+import androidx.annotation.OptIn
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.camera2.interop.ExperimentalCamera2Interop
+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.ImageCapture
+import androidx.camera.core.Preview
+import androidx.camera.core.UseCase
+import androidx.camera.core.UseCaseGroup
+import androidx.camera.video.Recorder
+import androidx.camera.video.VideoCapture
+import com.google.jetpackcamera.settings.model.DynamicRange
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.LensFacing
+
+val CameraInfo.appLensFacing: LensFacing
+ get() = when (this.lensFacing) {
+ CameraSelector.LENS_FACING_FRONT -> LensFacing.FRONT
+ CameraSelector.LENS_FACING_BACK -> LensFacing.BACK
+ else -> throw IllegalArgumentException(
+ "Unknown CameraSelector.LensFacing -> LensFacing mapping. " +
+ "[CameraSelector.LensFacing: ${this.lensFacing}]"
+ )
+ }
+
+fun CXDynamicRange.toSupportedAppDynamicRange(): DynamicRange? {
+ return when (this) {
+ CXDynamicRange.SDR -> DynamicRange.SDR
+ CXDynamicRange.HLG_10_BIT -> DynamicRange.HLG10
+ // All other dynamic ranges unsupported. Return null.
+ else -> null
+ }
+}
+
+fun DynamicRange.toCXDynamicRange(): CXDynamicRange {
+ return when (this) {
+ com.google.jetpackcamera.settings.model.DynamicRange.SDR -> CXDynamicRange.SDR
+ com.google.jetpackcamera.settings.model.DynamicRange.HLG10 -> CXDynamicRange.HLG_10_BIT
+ }
+}
+
+fun LensFacing.toCameraSelector(): CameraSelector = when (this) {
+ LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
+ LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
+}
+
+@SuppressLint("RestrictedApi")
+fun CameraSelector.toAppLensFacing(): LensFacing = when (this.lensFacing) {
+ CameraSelector.LENS_FACING_FRONT -> LensFacing.FRONT
+ CameraSelector.LENS_FACING_BACK -> LensFacing.BACK
+ else -> throw IllegalArgumentException(
+ "Unknown CameraSelector -> LensFacing mapping. [CameraSelector: $this]"
+ )
+}
+
+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)
+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
+ }
+}
+
+/**
+ * Checks if preview stabilization is supported by the device.
+ *
+ */
+val CameraInfo.isPreviewStabilizationSupported: Boolean
+ get() = Preview.getPreviewCapabilities(this).isStabilizationSupported
+
+/**
+ * Checks if video stabilization is supported by the device.
+ *
+ */
+val CameraInfo.isVideoStabilizationSupported: Boolean
+ get() = Recorder.getVideoCapabilities(this).isStabilizationSupported
+
+fun CameraInfo.filterSupportedFixedFrameRates(desired: Set<Int>): Set<Int> {
+ return buildSet {
+ this@filterSupportedFixedFrameRates.supportedFrameRateRanges.forEach { e ->
+ if (e.upper == e.lower && desired.contains(e.upper)) {
+ add(e.upper)
+ }
+ }
+ }
+}
+
+val CameraInfo.supportedImageFormats: Set<ImageOutputFormat>
+ @OptIn(ExperimentalImageCaptureOutputFormat::class)
+ get() = ImageCapture.getImageCaptureCapabilities(this).supportedOutputFormats
+ .mapNotNull(Int::toAppImageFormat)
+ .toSet()
+
+fun UseCaseGroup.getVideoCapture() = getUseCaseOrNull<VideoCapture<Recorder>>()
+fun UseCaseGroup.getImageCapture() = getUseCaseOrNull<ImageCapture>()
+
+private inline fun <reified T : UseCase> UseCaseGroup.getUseCaseOrNull(): T? {
+ return useCases.filterIsInstance<T>().singleOrNull()
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt
new file mode 100644
index 0000000..fbed566
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt
@@ -0,0 +1,728 @@
+/*
+ * 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.camera
+
+import android.Manifest
+import android.content.ContentValues
+import android.content.Context
+import android.content.pm.PackageManager
+import android.hardware.camera2.CameraCaptureSession
+import android.hardware.camera2.CaptureRequest
+import android.hardware.camera2.CaptureResult
+import android.hardware.camera2.TotalCaptureResult
+import android.net.Uri
+import android.os.Build
+import android.os.SystemClock
+import android.provider.MediaStore
+import android.util.Log
+import android.util.Range
+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.Camera
+import androidx.camera.core.CameraControl
+import androidx.camera.core.CameraEffect
+import androidx.camera.core.CameraInfo
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ExperimentalImageCaptureOutputFormat
+import androidx.camera.core.FocusMeteringAction
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceOrientedMeteringPointFactory
+import androidx.camera.core.TorchState
+import androidx.camera.core.UseCaseGroup
+import androidx.camera.core.ViewPort
+import androidx.camera.core.resolutionselector.AspectRatioStrategy
+import androidx.camera.core.resolutionselector.ResolutionSelector
+import androidx.camera.video.FileOutputOptions
+import androidx.camera.video.MediaStoreOutputOptions
+import androidx.camera.video.Recorder
+import androidx.camera.video.Recording
+import androidx.camera.video.VideoCapture
+import androidx.camera.video.VideoRecordEvent
+import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE
+import androidx.concurrent.futures.await
+import androidx.core.content.ContextCompat
+import androidx.core.content.ContextCompat.checkSelfPermission
+import androidx.lifecycle.asFlow
+import com.google.jetpackcamera.core.camera.effects.SingleSurfaceForcingEffect
+import com.google.jetpackcamera.settings.model.AspectRatio
+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.Stabilization
+import java.io.File
+import java.util.Date
+import java.util.concurrent.Executor
+import kotlin.coroutines.ContinuationInterceptor
+import kotlin.math.abs
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private const val TAG = "CameraSession"
+
+context(CameraSessionContext)
+internal suspend fun runSingleCameraSession(
+ sessionSettings: PerpetualSessionSettings.SingleCamera,
+ useCaseMode: CameraUseCase.UseCaseMode,
+ // TODO(tm): ImageCapture should go through an event channel like VideoCapture
+ onImageCaptureCreated: (ImageCapture) -> Unit = {}
+) = coroutineScope {
+ val lensFacing = sessionSettings.cameraInfo.appLensFacing
+ Log.d(TAG, "Starting new single camera session for $lensFacing")
+
+ val initialTransientSettings = transientSettings
+ .filterNotNull()
+ .first()
+
+ val useCaseGroup = createUseCaseGroup(
+ cameraInfo = sessionSettings.cameraInfo,
+ initialTransientSettings = initialTransientSettings,
+ stabilizePreviewMode = sessionSettings.stabilizePreviewMode,
+ stabilizeVideoMode = sessionSettings.stabilizeVideoMode,
+ aspectRatio = sessionSettings.aspectRatio,
+ targetFrameRate = sessionSettings.targetFrameRate,
+ dynamicRange = sessionSettings.dynamicRange,
+ imageFormat = sessionSettings.imageFormat,
+ useCaseMode = useCaseMode,
+ effect = when (sessionSettings.captureMode) {
+ CaptureMode.SINGLE_STREAM -> SingleSurfaceForcingEffect(this@coroutineScope)
+ CaptureMode.MULTI_STREAM -> null
+ }
+ ).apply {
+ getImageCapture()?.let(onImageCaptureCreated)
+ }
+
+ cameraProvider.runWith(sessionSettings.cameraInfo.cameraSelector, useCaseGroup) { camera ->
+ Log.d(TAG, "Camera session started")
+
+ launch {
+ processFocusMeteringEvents(camera.cameraControl)
+ }
+
+ launch {
+ processVideoControlEvents(
+ camera,
+ useCaseGroup.getVideoCapture(),
+ captureTypeSuffix = when (sessionSettings.captureMode) {
+ CaptureMode.MULTI_STREAM -> "MultiStream"
+ CaptureMode.SINGLE_STREAM -> "SingleStream"
+ }
+ )
+ }
+
+ launch {
+ camera.cameraInfo.torchState.asFlow().collectLatest { torchState ->
+ currentCameraState.update { old ->
+ old.copy(torchEnabled = torchState == TorchState.ON)
+ }
+ }
+ }
+
+ applyDeviceRotation(initialTransientSettings.deviceRotation, useCaseGroup)
+ processTransientSettingEvents(
+ camera,
+ useCaseGroup,
+ initialTransientSettings,
+ transientSettings
+ )
+ }
+}
+
+context(CameraSessionContext)
+internal suspend fun processTransientSettingEvents(
+ camera: Camera,
+ useCaseGroup: UseCaseGroup,
+ initialTransientSettings: TransientSessionSettings,
+ transientSettings: StateFlow<TransientSessionSettings?>
+) {
+ var prevTransientSettings = initialTransientSettings
+ transientSettings.filterNotNull().collectLatest { newTransientSettings ->
+ // Apply camera control settings
+ if (prevTransientSettings.zoomScale != newTransientSettings.zoomScale) {
+ camera.cameraInfo.zoomState.value?.let { zoomState ->
+ val finalScale =
+ (zoomState.zoomRatio * newTransientSettings.zoomScale).coerceIn(
+ zoomState.minZoomRatio,
+ zoomState.maxZoomRatio
+ )
+ camera.cameraControl.setZoomRatio(finalScale)
+ currentCameraState.update { old ->
+ old.copy(zoomScale = finalScale)
+ }
+ }
+ }
+
+ useCaseGroup.getImageCapture()?.let { imageCapture ->
+ if (prevTransientSettings.flashMode != newTransientSettings.flashMode) {
+ setFlashModeInternal(
+ imageCapture = imageCapture,
+ flashMode = newTransientSettings.flashMode,
+ isFrontFacing = camera.cameraInfo.appLensFacing == LensFacing.FRONT
+ )
+ }
+ }
+
+ if (prevTransientSettings.deviceRotation
+ != newTransientSettings.deviceRotation
+ ) {
+ Log.d(
+ TAG,
+ "Updating device rotation from " +
+ "${prevTransientSettings.deviceRotation} -> " +
+ "${newTransientSettings.deviceRotation}"
+ )
+ applyDeviceRotation(newTransientSettings.deviceRotation, useCaseGroup)
+ }
+
+ prevTransientSettings = newTransientSettings
+ }
+}
+
+internal fun applyDeviceRotation(deviceRotation: DeviceRotation, useCaseGroup: UseCaseGroup) {
+ val targetRotation = deviceRotation.toUiSurfaceRotation()
+ useCaseGroup.useCases.forEach {
+ when (it) {
+ is Preview -> {
+ // Preview's target rotation should not be updated with device rotation.
+ // Instead, preview rotation should match the display rotation.
+ // When Preview is created, it is initialized with the display rotation.
+ // This will need to be updated separately if the display rotation is not
+ // locked. Currently the app is locked to portrait orientation.
+ }
+
+ is ImageCapture -> {
+ it.targetRotation = targetRotation
+ }
+
+ is VideoCapture<*> -> {
+ it.targetRotation = targetRotation
+ }
+ }
+ }
+}
+
+context(CameraSessionContext)
+internal fun createUseCaseGroup(
+ cameraInfo: CameraInfo,
+ initialTransientSettings: TransientSessionSettings,
+ stabilizePreviewMode: Stabilization,
+ stabilizeVideoMode: Stabilization,
+ aspectRatio: AspectRatio,
+ targetFrameRate: Int,
+ dynamicRange: DynamicRange,
+ imageFormat: ImageOutputFormat,
+ useCaseMode: CameraUseCase.UseCaseMode,
+ effect: CameraEffect? = null
+): UseCaseGroup {
+ val previewUseCase =
+ createPreviewUseCase(
+ cameraInfo,
+ aspectRatio,
+ stabilizePreviewMode
+ )
+ val imageCaptureUseCase = if (useCaseMode != CameraUseCase.UseCaseMode.VIDEO_ONLY) {
+ createImageUseCase(cameraInfo, aspectRatio, dynamicRange, imageFormat)
+ } else {
+ null
+ }
+ val videoCaptureUseCase = if (useCaseMode != CameraUseCase.UseCaseMode.IMAGE_ONLY) {
+ createVideoUseCase(
+ cameraInfo,
+ aspectRatio,
+ targetFrameRate,
+ stabilizeVideoMode,
+ dynamicRange,
+ backgroundDispatcher
+ )
+ } else {
+ null
+ }
+
+ imageCaptureUseCase?.let {
+ setFlashModeInternal(
+ imageCapture = imageCaptureUseCase,
+ flashMode = initialTransientSettings.flashMode,
+ isFrontFacing = cameraInfo.appLensFacing == LensFacing.FRONT
+ )
+ }
+
+ return UseCaseGroup.Builder().apply {
+ Log.d(
+ TAG,
+ "Setting initial device rotation to ${initialTransientSettings.deviceRotation}"
+ )
+ setViewPort(
+ ViewPort.Builder(
+ aspectRatio.ratio,
+ // Initialize rotation to Preview's rotation, which comes from Display rotation
+ previewUseCase.targetRotation
+ ).build()
+ )
+ addUseCase(previewUseCase)
+ imageCaptureUseCase?.let {
+ if (dynamicRange == DynamicRange.SDR ||
+ imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
+ ) {
+ addUseCase(imageCaptureUseCase)
+ }
+ }
+
+ // Not to bind VideoCapture when Ultra HDR is enabled to keep the app design simple.
+ videoCaptureUseCase?.let {
+ if (imageFormat == ImageOutputFormat.JPEG) {
+ addUseCase(videoCaptureUseCase)
+ }
+ }
+
+ effect?.let { addEffect(it) }
+ }.build()
+}
+
+@OptIn(ExperimentalImageCaptureOutputFormat::class)
+private fun createImageUseCase(
+ cameraInfo: CameraInfo,
+ aspectRatio: AspectRatio,
+ dynamicRange: DynamicRange,
+ imageFormat: ImageOutputFormat
+): ImageCapture {
+ val builder = ImageCapture.Builder()
+ builder.setResolutionSelector(
+ getResolutionSelector(cameraInfo.sensorLandscapeRatio, aspectRatio)
+ )
+ if (dynamicRange != DynamicRange.SDR && imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
+ ) {
+ builder.setOutputFormat(ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR)
+ }
+ return builder.build()
+}
+
+private fun createVideoUseCase(
+ cameraInfo: CameraInfo,
+ aspectRatio: AspectRatio,
+ targetFrameRate: Int,
+ stabilizeVideoMode: Stabilization,
+ dynamicRange: DynamicRange,
+ backgroundDispatcher: CoroutineDispatcher
+): VideoCapture<Recorder> {
+ val sensorLandscapeRatio = cameraInfo.sensorLandscapeRatio
+ val recorder = Recorder.Builder()
+ .setAspectRatio(
+ getAspectRatioForUseCase(sensorLandscapeRatio, aspectRatio)
+ )
+ .setExecutor(backgroundDispatcher.asExecutor()).build()
+ return VideoCapture.Builder(recorder).apply {
+ // set video stabilization
+ if (stabilizeVideoMode == Stabilization.ON) {
+ setVideoStabilizationEnabled(true)
+ }
+ // set target fps
+ if (targetFrameRate != TARGET_FPS_AUTO) {
+ setTargetFrameRate(Range(targetFrameRate, targetFrameRate))
+ }
+
+ setDynamicRange(dynamicRange.toCXDynamicRange())
+ }.build()
+}
+
+private fun getAspectRatioForUseCase(sensorLandscapeRatio: Float, aspectRatio: AspectRatio): Int {
+ return when (aspectRatio) {
+ AspectRatio.THREE_FOUR -> androidx.camera.core.AspectRatio.RATIO_4_3
+ AspectRatio.NINE_SIXTEEN -> androidx.camera.core.AspectRatio.RATIO_16_9
+ 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())
+ ) {
+ androidx.camera.core.AspectRatio.RATIO_16_9
+ } else {
+ androidx.camera.core.AspectRatio.RATIO_4_3
+ }
+ }
+ }
+}
+
+context(CameraSessionContext)
+private fun createPreviewUseCase(
+ cameraInfo: CameraInfo,
+ aspectRatio: AspectRatio,
+ stabilizePreviewMode: Stabilization
+): Preview = Preview.Builder().apply {
+ updateCameraStateWithCaptureResults(targetCameraInfo = cameraInfo)
+
+ // set preview stabilization
+ if (stabilizePreviewMode == Stabilization.ON) {
+ setPreviewStabilizationEnabled(true)
+ }
+
+ setResolutionSelector(
+ getResolutionSelector(cameraInfo.sensorLandscapeRatio, aspectRatio)
+ )
+}.build()
+ .apply {
+ setSurfaceProvider { surfaceRequest ->
+ surfaceRequests.update { surfaceRequest }
+ }
+ }
+
+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 -> {
+ // 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()
+}
+
+context(CameraSessionContext)
+private fun setFlashModeInternal(
+ imageCapture: ImageCapture,
+ flashMode: FlashMode,
+ isFrontFacing: Boolean
+) {
+ val isScreenFlashRequired =
+ isFrontFacing && (flashMode == FlashMode.ON || flashMode == FlashMode.AUTO)
+
+ if (isScreenFlashRequired) {
+ imageCapture.screenFlash = object : ImageCapture.ScreenFlash {
+ override fun apply(
+ expirationTimeMillis: Long,
+ listener: ImageCapture.ScreenFlashListener
+ ) {
+ Log.d(TAG, "ImageCapture.ScreenFlash: apply")
+ screenFlashEvents.trySend(
+ CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.APPLY_UI) {
+ listener.onCompleted()
+ }
+ )
+ }
+
+ override fun clear() {
+ Log.d(TAG, "ImageCapture.ScreenFlash: clear")
+ screenFlashEvents.trySend(
+ CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) {}
+ )
+ }
+ }
+ }
+
+ imageCapture.flashMode = when (flashMode) {
+ FlashMode.OFF -> ImageCapture.FLASH_MODE_OFF // 2
+
+ FlashMode.ON -> if (isScreenFlashRequired) {
+ ImageCapture.FLASH_MODE_SCREEN // 3
+ } else {
+ ImageCapture.FLASH_MODE_ON // 1
+ }
+
+ FlashMode.AUTO -> if (isScreenFlashRequired) {
+ ImageCapture.FLASH_MODE_SCREEN // 3
+ } else {
+ ImageCapture.FLASH_MODE_AUTO // 0
+ }
+ }
+ Log.d(TAG, "Set flash mode to: ${imageCapture.flashMode}")
+}
+
+private suspend fun startVideoRecordingInternal(
+ initialMuted: Boolean,
+ videoCaptureUseCase: VideoCapture<Recorder>,
+ captureTypeSuffix: String,
+ context: Context,
+ videoCaptureUri: Uri?,
+ shouldUseUri: Boolean,
+ onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
+): Recording {
+ Log.d(TAG, "recordVideo")
+ // todo(b/336886716): default setting to enable or disable audio when permission is granted
+
+ // 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(
+ context,
+ Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+
+ val pendingRecord = if (shouldUseUri) {
+ val fileOutputOptions = FileOutputOptions.Builder(
+ File(videoCaptureUri!!.path!!)
+ ).build()
+ videoCaptureUseCase.output.prepareRecording(context, fileOutputOptions)
+ } else {
+ val name = "JCA-recording-${Date()}-$captureTypeSuffix.mp4"
+ val contentValues =
+ ContentValues().apply {
+ put(MediaStore.Video.Media.DISPLAY_NAME, name)
+ }
+ val mediaStoreOutput =
+ MediaStoreOutputOptions.Builder(
+ context.contentResolver,
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ )
+ .setContentValues(contentValues)
+ .build()
+ videoCaptureUseCase.output.prepareRecording(context, mediaStoreOutput)
+ }
+ pendingRecord.apply {
+ if (audioEnabled) {
+ withAudioEnabled()
+ }
+ }
+ val callbackExecutor: Executor =
+ (
+ currentCoroutineContext()[ContinuationInterceptor] as?
+ CoroutineDispatcher
+ )?.asExecutor() ?: ContextCompat.getMainExecutor(context)
+ return pendingRecord.start(callbackExecutor) { onVideoRecordEvent ->
+ Log.d(TAG, onVideoRecordEvent.toString())
+ when (onVideoRecordEvent) {
+ is VideoRecordEvent.Finalize -> {
+ when (onVideoRecordEvent.error) {
+ ERROR_NONE ->
+ onVideoRecord(
+ CameraUseCase.OnVideoRecordEvent.OnVideoRecorded(
+ onVideoRecordEvent.outputResults.outputUri
+ )
+ )
+
+ else ->
+ onVideoRecord(
+ CameraUseCase.OnVideoRecordEvent.OnVideoRecordError(
+ onVideoRecordEvent.cause
+ )
+ )
+ }
+ }
+
+ is VideoRecordEvent.Status -> {
+ onVideoRecord(
+ CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus(
+ onVideoRecordEvent.recordingStats.audioStats
+ .audioAmplitude
+ )
+ )
+ }
+ }
+ }.apply {
+ mute(initialMuted)
+ }
+}
+
+private suspend fun runVideoRecording(
+ camera: Camera,
+ videoCapture: VideoCapture<Recorder>,
+ captureTypeSuffix: String,
+ context: Context,
+ transientSettings: StateFlow<TransientSessionSettings?>,
+ videoCaptureUri: Uri?,
+ shouldUseUri: Boolean,
+ onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
+) {
+ var currentSettings = transientSettings.filterNotNull().first()
+
+ startVideoRecordingInternal(
+ initialMuted = currentSettings.audioMuted,
+ videoCapture,
+ captureTypeSuffix,
+ context,
+ videoCaptureUri,
+ shouldUseUri,
+ onVideoRecord
+ ).use { recording ->
+
+ fun TransientSessionSettings.isFlashModeOn() = flashMode == FlashMode.ON
+ val isFrontCameraSelector =
+ camera.cameraInfo.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
+
+ if (currentSettings.isFlashModeOn()) {
+ if (!isFrontCameraSelector) {
+ camera.cameraControl.enableTorch(true).await()
+ } else {
+ Log.d(TAG, "Unable to enable torch for front camera.")
+ }
+ }
+
+ transientSettings.filterNotNull()
+ .onCompletion {
+ // Could do some fancier tracking of whether the torch was enabled before
+ // calling this.
+ camera.cameraControl.enableTorch(false)
+ }
+ .collectLatest { newTransientSettings ->
+ if (currentSettings.audioMuted != newTransientSettings.audioMuted) {
+ recording.mute(newTransientSettings.audioMuted)
+ }
+ if (currentSettings.isFlashModeOn() != newTransientSettings.isFlashModeOn()) {
+ if (!isFrontCameraSelector) {
+ camera.cameraControl.enableTorch(newTransientSettings.isFlashModeOn())
+ } else {
+ Log.d(TAG, "Unable to update torch for front camera.")
+ }
+ }
+ currentSettings = newTransientSettings
+ }
+ }
+}
+
+context(CameraSessionContext)
+internal suspend fun processFocusMeteringEvents(cameraControl: CameraControl) {
+ surfaceRequests.map { surfaceRequest ->
+ surfaceRequest?.resolution?.run {
+ Log.d(
+ TAG,
+ "Waiting to process focus points for surface with resolution: " +
+ "$width x $height"
+ )
+ SurfaceOrientedMeteringPointFactory(width.toFloat(), height.toFloat())
+ }
+ }.collectLatest { meteringPointFactory ->
+ for (event in focusMeteringEvents) {
+ meteringPointFactory?.apply {
+ Log.d(TAG, "tapToFocus, processing event: $event")
+ val meteringPoint = createPoint(event.x, event.y)
+ val action = FocusMeteringAction.Builder(meteringPoint).build()
+ cameraControl.startFocusAndMetering(action)
+ } ?: run {
+ Log.w(TAG, "Ignoring event due to no SurfaceRequest: $event")
+ }
+ }
+ }
+}
+
+context(CameraSessionContext)
+internal suspend fun processVideoControlEvents(
+ camera: Camera,
+ videoCapture: VideoCapture<Recorder>?,
+ captureTypeSuffix: String
+) = coroutineScope {
+ var recordingJob: Job? = null
+
+ for (event in videoCaptureControlEvents) {
+ when (event) {
+ is VideoCaptureControlEvent.StartRecordingEvent -> {
+ if (videoCapture == null) {
+ throw RuntimeException(
+ "Attempted video recording with null videoCapture"
+ )
+ }
+
+ recordingJob = launch(start = CoroutineStart.UNDISPATCHED) {
+ runVideoRecording(
+ camera,
+ videoCapture,
+ captureTypeSuffix,
+ context,
+ transientSettings,
+ event.videoCaptureUri,
+ event.shouldUseUri,
+ event.onVideoRecord
+ )
+ }
+ }
+
+ VideoCaptureControlEvent.StopRecordingEvent -> {
+ recordingJob?.cancel()
+ recordingJob = null
+ }
+ }
+ }
+}
+
+/**
+ * Applies a CaptureCallback to the provided image capture builder
+ */
+context(CameraSessionContext)
+@OptIn(ExperimentalCamera2Interop::class)
+private fun Preview.Builder.updateCameraStateWithCaptureResults(
+ targetCameraInfo: CameraInfo
+): Preview.Builder {
+ val isFirstFrameTimestampUpdated = atomic(false)
+ val targetCameraLogicalId = Camera2CameraInfo.from(targetCameraInfo).cameraId
+ Camera2Interop.Extender(this).setSessionCaptureCallback(
+ object : CameraCaptureSession.CaptureCallback() {
+ override fun onCaptureCompleted(
+ session: CameraCaptureSession,
+ request: CaptureRequest,
+ result: TotalCaptureResult
+ ) {
+ super.onCaptureCompleted(session, request, result)
+ val logicalCameraId = session.device.id
+ if (logicalCameraId != targetCameraLogicalId) return
+ try {
+ val physicalCameraId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ result.get(CaptureResult.LOGICAL_MULTI_CAMERA_ACTIVE_PHYSICAL_ID)
+ } else {
+ null
+ }
+ currentCameraState.update { old ->
+ if (old.debugInfo.logicalCameraId != logicalCameraId ||
+ old.debugInfo.physicalCameraId != physicalCameraId
+ ) {
+ old.copy(debugInfo = DebugInfo(logicalCameraId, physicalCameraId))
+ } else {
+ old
+ }
+ }
+ if (!isFirstFrameTimestampUpdated.value) {
+ currentCameraState.update { old ->
+ old.copy(
+ sessionFirstFrameTimestamp = SystemClock.elapsedRealtimeNanos()
+ )
+ }
+ isFirstFrameTimestampUpdated.value = true
+ }
+ } catch (_: Exception) {
+ }
+ }
+ }
+ )
+ return this
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt
new file mode 100644
index 0000000..1425bbb
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.camera
+
+import android.content.Context
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.lifecycle.ProcessCameraProvider
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * Context that can be shared by all functions in a camera session.
+ *
+ * Can be used to confer context (such as reactive state or session-wide parameters)
+ * on context receivers using [with] in a camera session.
+ */
+internal data class CameraSessionContext(
+ val context: Context,
+ val cameraProvider: ProcessCameraProvider,
+ val backgroundDispatcher: CoroutineDispatcher,
+ val screenFlashEvents: SendChannel<CameraUseCase.ScreenFlashEvent>,
+ val focusMeteringEvents: Channel<CameraEvent.FocusMeteringEvent>,
+ val videoCaptureControlEvents: Channel<VideoCaptureControlEvent>,
+ val currentCameraState: MutableStateFlow<CameraState>,
+ val surfaceRequests: MutableStateFlow<SurfaceRequest?>,
+ val transientSettings: StateFlow<TransientSessionSettings?>
+)
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt
new file mode 100644
index 0000000..b96c6a3
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.camera
+
+import androidx.camera.core.CameraInfo
+import com.google.jetpackcamera.settings.model.AspectRatio
+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.Stabilization
+
+/**
+ * Camera settings that persist as long as a camera is running.
+ *
+ * Any change in these settings will require calling [ProcessCameraProvider.runWith] with
+ * updates [CameraSelector] and/or [UseCaseGroup]
+ */
+internal sealed interface PerpetualSessionSettings {
+ val aspectRatio: AspectRatio
+
+ data class SingleCamera(
+ val cameraInfo: CameraInfo,
+ override val aspectRatio: AspectRatio,
+ val captureMode: CaptureMode,
+ val targetFrameRate: Int,
+ val stabilizePreviewMode: Stabilization,
+ val stabilizeVideoMode: Stabilization,
+ val dynamicRange: DynamicRange,
+ val imageFormat: ImageOutputFormat
+ ) : PerpetualSessionSettings
+
+ data class ConcurrentCamera(
+ val primaryCameraInfo: CameraInfo,
+ val secondaryCameraInfo: CameraInfo,
+ override val aspectRatio: AspectRatio
+ ) : PerpetualSessionSettings
+}
+
+/**
+ * Camera settings that can change while the camera is running.
+ *
+ * Any changes in these settings can be applied either directly to use cases via their
+ * setter methods or to [androidx.camera.core.CameraControl].
+ * The use cases typically will not need to be re-bound.
+ */
+internal data class TransientSessionSettings(
+ val audioMuted: Boolean,
+ val deviceRotation: DeviceRotation,
+ val flashMode: FlashMode,
+ val zoomScale: Float
+)
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
index 4c8fbc8..02477d8 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt
@@ -22,13 +22,15 @@
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.ConcurrentCameraMode
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 com.google.jetpackcamera.settings.model.Stabilization
+import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.StateFlow
/**
@@ -40,7 +42,11 @@
*
* @return list of available lenses.
*/
- suspend fun initialize(disableVideoCapture: Boolean)
+ suspend fun initialize(
+ cameraAppSettings: CameraAppSettings,
+ useCaseMode: UseCaseMode,
+ isDebugMode: Boolean = false
+ )
/**
* Starts the camera.
@@ -66,7 +72,11 @@
ignoreUri: Boolean = false
): ImageCapture.OutputFileResults
- suspend fun startVideoRecording(onVideoRecord: (OnVideoRecordEvent) -> Unit)
+ suspend fun startVideoRecording(
+ videoCaptureUri: Uri?,
+ shouldUseUri: Boolean,
+ onVideoRecord: (OnVideoRecordEvent) -> Unit
+ )
fun stopVideoRecording()
@@ -76,7 +86,7 @@
fun getSurfaceRequest(): StateFlow<SurfaceRequest?>
- fun getScreenFlashEvents(): SharedFlow<ScreenFlashEvent>
+ fun getScreenFlashEvents(): ReceiveChannel<ScreenFlashEvent>
fun getCurrentSettings(): StateFlow<CameraAppSettings?>
@@ -96,12 +106,20 @@
fun setDeviceRotation(deviceRotation: DeviceRotation)
+ suspend fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode)
+
suspend fun setLowLightBoost(lowLightBoost: LowLightBoost)
suspend fun setImageFormat(imageFormat: ImageOutputFormat)
suspend fun setAudioMuted(isAudioMuted: Boolean)
+ suspend fun setVideoCaptureStabilization(videoCaptureStabilization: Stabilization)
+
+ suspend fun setPreviewStabilization(previewStabilization: Stabilization)
+
+ suspend fun setTargetFrameRate(targetFrameRate: Int)
+
/**
* Represents the events required for screen flash.
*/
@@ -116,15 +134,25 @@
* Represents the events for video recording.
*/
sealed interface OnVideoRecordEvent {
- object OnVideoRecorded : OnVideoRecordEvent
+ data class OnVideoRecorded(val savedUri: Uri) : OnVideoRecordEvent
data class OnVideoRecordStatus(val audioAmplitude: Double) : OnVideoRecordEvent
- object OnVideoRecordError : OnVideoRecordEvent
+ data class OnVideoRecordError(val error: Throwable?) : OnVideoRecordEvent
+ }
+
+ enum class UseCaseMode {
+ STANDARD,
+ IMAGE_ONLY,
+ VIDEO_ONLY
}
}
data class CameraState(
val zoomScale: Float = 1f,
- val sessionFirstFrameTimestamp: Long = 0L
+ val sessionFirstFrameTimestamp: Long = 0L,
+ val torchEnabled: Boolean = false,
+ val debugInfo: DebugInfo = DebugInfo(null, null)
)
+
+data class DebugInfo(val logicalCameraId: String?, val physicalCameraId: String?)
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
index 7561056..2f7f99a 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt
@@ -15,63 +15,36 @@
*/
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.Build
import android.os.Environment
-import android.os.SystemClock
+import android.os.Environment.DIRECTORY_DOCUMENTS
import android.provider.MediaStore
import android.util.Log
-import android.util.Range
-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.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
-import androidx.camera.core.resolutionselector.AspectRatioStrategy
-import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.takePicture
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.lifecycle.awaitInstance
-import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.Recorder
-import androidx.camera.video.Recording
-import androidx.camera.video.VideoCapture
-import androidx.camera.video.VideoRecordEvent
-import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE
-import androidx.core.content.ContextCompat
-import androidx.core.content.ContextCompat.checkSelfPermission
-import com.google.jetpackcamera.core.camera.CameraUseCase.ScreenFlashEvent.Type
-import com.google.jetpackcamera.core.camera.effects.SingleSurfaceForcingEffect
+import com.google.jetpackcamera.core.camera.DebugCameraInfoUtil.getAllCamerasPropertiesJSONArray
+import com.google.jetpackcamera.core.camera.DebugCameraInfoUtil.writeFileExternalStorage
+import com.google.jetpackcamera.core.common.DefaultDispatcher
+import com.google.jetpackcamera.core.common.IODispatcher
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.ConcurrentCameraMode
import com.google.jetpackcamera.settings.model.DeviceRotation
import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
@@ -82,36 +55,26 @@
import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
import com.google.jetpackcamera.settings.model.SystemConstraints
import dagger.hilt.android.scopes.ViewModelScoped
+import java.io.File
import java.io.FileNotFoundException
import java.text.SimpleDateFormat
import java.util.Calendar
-import java.util.Date
import java.util.Locale
-import java.util.concurrent.Executor
import javax.inject.Inject
-import 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.channels.trySendBlocking
import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
private const val TAG = "CameraXCameraUseCase"
const val TARGET_FPS_AUTO = 0
@@ -127,63 +90,38 @@
@Inject
constructor(
private val application: Application,
- private val coroutineScope: CoroutineScope,
- private val defaultDispatcher: CoroutineDispatcher,
- private val settingsRepository: SettingsRepository,
+ @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
+ @IODispatcher private val iODispatcher: CoroutineDispatcher,
private val constraintsRepository: SettableConstraintsRepository
) : CameraUseCase {
private lateinit var cameraProvider: ProcessCameraProvider
- private lateinit var imageCaptureUseCase: ImageCapture
+ private var imageCaptureUseCase: ImageCapture? = null
- /**
- * 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
private lateinit var systemConstraints: SystemConstraints
- private var disableVideoCapture by Delegates.notNull<Boolean>()
+ private var useCaseMode by Delegates.notNull<CameraUseCase.UseCaseMode>()
- private val screenFlashEvents: MutableSharedFlow<CameraUseCase.ScreenFlashEvent> =
- MutableSharedFlow()
+ private val screenFlashEvents: Channel<CameraUseCase.ScreenFlashEvent> =
+ Channel(capacity = Channel.UNLIMITED)
private val focusMeteringEvents =
Channel<CameraEvent.FocusMeteringEvent>(capacity = Channel.CONFLATED)
+ private val videoCaptureControlEvents = Channel<VideoCaptureControlEvent>()
private val currentSettings = MutableStateFlow<CameraAppSettings?>(null)
- override suspend fun initialize(externalImageCapture: Boolean) {
- this.disableVideoCapture = externalImageCapture
+ // Could be improved by setting initial value only when camera is initialized
+ 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()
+
+ override suspend fun initialize(
+ cameraAppSettings: CameraAppSettings,
+ useCaseMode: CameraUseCase.UseCaseMode,
+ isDebugMode: Boolean
+ ) {
+ this.useCaseMode = useCaseMode
cameraProvider = ProcessCameraProvider.awaitInstance(application)
// updates values for available cameras
@@ -198,6 +136,10 @@
// Build and update the system constraints
systemConstraints = SystemConstraints(
availableLenses = availableCameraLenses,
+ concurrentCamerasSupported = cameraProvider.availableConcurrentCameraInfos.any {
+ it.map { cameraInfo -> cameraInfo.cameraSelector.toAppLensFacing() }
+ .toSet() == setOf(LensFacing.FRONT, LensFacing.BACK)
+ },
perLensConstraints = buildMap {
val availableCameraInfos = cameraProvider.availableCameraInfos
for (lensFacing in availableCameraLenses) {
@@ -209,17 +151,19 @@
.toSet()
val supportedStabilizationModes = buildSet {
- if (isPreviewStabilizationSupported(camInfo)) {
+ if (camInfo.isPreviewStabilizationSupported) {
add(SupportedStabilizationMode.ON)
}
- if (isVideoStabilizationSupported(camInfo)) {
+ if (camInfo.isVideoStabilizationSupported) {
add(SupportedStabilizationMode.HIGH_QUALITY)
}
}
- val supportedFixedFrameRates = getSupportedFrameRates(camInfo)
- val supportedImageFormats = getSupportedImageFormats(camInfo)
+ val supportedFixedFrameRates =
+ camInfo.filterSupportedFixedFrameRates(FIXED_FRAME_RATES)
+ val supportedImageFormats = camInfo.supportedImageFormats
+ val hasFlashUnit = camInfo.hasFlashUnit()
put(
lensFacing,
@@ -233,7 +177,8 @@
// Ultra HDR now.
Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)),
Pair(CaptureMode.MULTI_STREAM, supportedImageFormats)
- )
+ ),
+ hasFlashUnit = hasFlashUnit
)
)
}
@@ -244,43 +189,27 @@
constraintsRepository.updateSystemConstraints(systemConstraints)
currentSettings.value =
- settingsRepository.defaultCameraAppSettings.first()
+ cameraAppSettings
.tryApplyDynamicRangeConstraints()
- .tryApplyAspectRatioForExternalCapture(externalImageCapture)
+ .tryApplyAspectRatioForExternalCapture(this.useCaseMode)
.tryApplyImageFormatConstraints()
+ .tryApplyFrameRateConstraints()
+ .tryApplyStabilizationConstraints()
+ .tryApplyConcurrentCameraModeConstraints()
+ if (isDebugMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ withContext(iODispatcher) {
+ val cameraProperties =
+ getAllCamerasPropertiesJSONArray(cameraProvider.availableCameraInfos).toString()
+ val file = File(
+ Environment.getExternalStoragePublicDirectory(DIRECTORY_DOCUMENTS),
+ "JCACameraProperties.json"
+ )
+ writeFileExternalStorage(file, cameraProperties)
+ Log.d(TAG, "JCACameraProperties written to ${file.path}. \n$cameraProperties")
+ }
+ }
}
- /**
- * Camera settings that persist as long as a camera is running.
- *
- * Any change in these settings will require calling [ProcessCameraProvider.runWith] with
- * updates [CameraSelector] and/or [UseCaseGroup]
- */
- private data class PerpetualSessionSettings(
- val cameraSelector: CameraSelector,
- val aspectRatio: AspectRatio,
- val captureMode: CaptureMode,
- val targetFrameRate: Int,
- val stabilizePreviewMode: Stabilization,
- val stabilizeVideoMode: Stabilization,
- val dynamicRange: DynamicRange,
- val imageFormat: ImageOutputFormat
- )
-
- /**
- * Camera settings that can change while the camera is running.
- *
- * Any changes in these settings can be applied either directly to use cases via their
- * setter methods or to [androidx.camera.core.CameraControl].
- * The use cases typically will not need to be re-bound.
- */
- private data class TransientSessionSettings(
- val audioMuted: Boolean,
- val deviceRotation: DeviceRotation,
- val flashMode: FlashMode,
- val zoomScale: Float
- )
-
override suspend fun runCamera() = coroutineScope {
Log.d(TAG, "runCamera")
@@ -295,127 +224,98 @@
zoomScale = currentCameraSettings.zoomScale
)
- val cameraSelector = when (currentCameraSettings.cameraLensFacing) {
- LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
- LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
- }
+ when (currentCameraSettings.concurrentCameraMode) {
+ ConcurrentCameraMode.OFF -> {
+ val cameraSelector = when (currentCameraSettings.cameraLensFacing) {
+ LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
+ LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
+ }
- PerpetualSessionSettings(
- cameraSelector = cameraSelector,
- aspectRatio = currentCameraSettings.aspectRatio,
- captureMode = currentCameraSettings.captureMode,
- targetFrameRate = currentCameraSettings.targetFrameRate,
- stabilizePreviewMode = currentCameraSettings.previewStabilization,
- stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization,
- dynamicRange = currentCameraSettings.dynamicRange,
- imageFormat = currentCameraSettings.imageFormat
- )
+ PerpetualSessionSettings.SingleCamera(
+ cameraInfo = cameraProvider.getCameraInfo(cameraSelector),
+ aspectRatio = currentCameraSettings.aspectRatio,
+ captureMode = currentCameraSettings.captureMode,
+ targetFrameRate = currentCameraSettings.targetFrameRate,
+ stabilizePreviewMode = currentCameraSettings.previewStabilization,
+ stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization,
+ dynamicRange = currentCameraSettings.dynamicRange,
+ imageFormat = currentCameraSettings.imageFormat
+ )
+ }
+ ConcurrentCameraMode.DUAL -> {
+ val primaryFacing = currentCameraSettings.cameraLensFacing
+ val secondaryFacing = primaryFacing.flip()
+ cameraProvider.availableConcurrentCameraInfos.firstNotNullOf {
+ var primaryCameraInfo: CameraInfo? = null
+ var secondaryCameraInfo: CameraInfo? = null
+ it.forEach { cameraInfo ->
+ if (cameraInfo.appLensFacing == primaryFacing) {
+ primaryCameraInfo = cameraInfo
+ } else if (cameraInfo.appLensFacing == secondaryFacing) {
+ secondaryCameraInfo = cameraInfo
+ }
+ }
+
+ primaryCameraInfo?.let { nonNullPrimary ->
+ secondaryCameraInfo?.let { nonNullSecondary ->
+ PerpetualSessionSettings.ConcurrentCamera(
+ primaryCameraInfo = nonNullPrimary,
+ secondaryCameraInfo = nonNullSecondary,
+ aspectRatio = currentCameraSettings.aspectRatio
+ )
+ }
+ }
+ }
+ }
+ }
}.distinctUntilChanged()
.collectLatest { sessionSettings ->
- Log.d(TAG, "Starting new camera session")
- val cameraInfo = sessionSettings.cameraSelector.filter(
- cameraProvider.availableCameraInfos
- ).first()
+ coroutineScope {
+ with(
+ CameraSessionContext(
+ context = application,
+ cameraProvider = cameraProvider,
+ backgroundDispatcher = defaultDispatcher,
+ screenFlashEvents = screenFlashEvents,
+ focusMeteringEvents = focusMeteringEvents,
+ videoCaptureControlEvents = videoCaptureControlEvents,
+ currentCameraState = _currentCameraState,
+ surfaceRequests = _surfaceRequest,
+ transientSettings = transientSettings
+ )
+ ) {
+ try {
+ when (sessionSettings) {
+ is PerpetualSessionSettings.SingleCamera -> runSingleCameraSession(
+ sessionSettings,
+ useCaseMode = useCaseMode
+ ) { imageCapture ->
+ imageCaptureUseCase = imageCapture
+ }
- val lensFacing = sessionSettings.cameraSelector.toAppLensFacing()
- val cameraConstraints = checkNotNull(
- systemConstraints.perLensConstraints[lensFacing]
- ) {
- "Unable to retrieve CameraConstraints for $lensFacing. " +
- "Was the use case initialized?"
- }
-
- val initialTransientSettings = transientSettings
- .filterNotNull()
- .first()
-
- val useCaseGroup = createUseCaseGroup(
- cameraInfo,
- sessionSettings,
- initialTransientSettings,
- cameraConstraints.supportedStabilizationModes,
- effect = when (sessionSettings.captureMode) {
- CaptureMode.SINGLE_STREAM -> SingleSurfaceForcingEffect(coroutineScope)
- CaptureMode.MULTI_STREAM -> null
- }
- )
-
- var prevTransientSettings = initialTransientSettings
- cameraProvider.runWith(sessionSettings.cameraSelector, useCaseGroup) { camera ->
- Log.d(TAG, "Camera session started")
-
- 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) {
- cameraInfo.zoomState.value?.let { zoomState ->
- val finalScale =
- (zoomState.zoomRatio * newTransientSettings.zoomScale).coerceIn(
- zoomState.minZoomRatio,
- zoomState.maxZoomRatio
+ is PerpetualSessionSettings.ConcurrentCamera ->
+ runConcurrentCameraSession(
+ sessionSettings,
+ useCaseMode = CameraUseCase.UseCaseMode.VIDEO_ONLY
)
- camera.cameraControl.setZoomRatio(finalScale)
- _currentCameraState.update { old ->
- old.copy(zoomScale = finalScale)
- }
}
+ } finally {
+ // TODO(tm): This shouldn't be necessary. Cancellation of the
+ // coroutineScope by collectLatest should cause this to
+ // occur naturally.
+ cameraProvider.unbindAll()
}
-
- if (prevTransientSettings.flashMode != newTransientSettings.flashMode) {
- setFlashModeInternal(
- flashMode = newTransientSettings.flashMode,
- isFrontFacing = sessionSettings.cameraSelector
- == CameraSelector.DEFAULT_FRONT_CAMERA
- )
- }
-
- 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
}
}
}
}
override suspend fun takePicture(onCaptureStarted: (() -> Unit)) {
+ if (imageCaptureUseCase == null) {
+ throw RuntimeException("Attempted take picture with null imageCapture use case")
+ }
try {
- val imageProxy = imageCaptureUseCase.takePicture(onCaptureStarted)
+ val imageProxy = imageCaptureUseCase!!.takePicture(onCaptureStarted)
Log.d(TAG, "onCaptureSuccess")
imageProxy.close()
} catch (exception: Exception) {
@@ -431,6 +331,9 @@
imageCaptureUri: Uri?,
ignoreUri: Boolean
): ImageCapture.OutputFileResults {
+ if (imageCaptureUseCase == null) {
+ throw RuntimeException("Attempted take picture with null imageCapture use case")
+ }
val eligibleContentValues = getEligibleContentValues()
val outputFileOptions: OutputFileOptions
if (ignoreUri) {
@@ -470,7 +373,7 @@
}
}
try {
- val outputFileResults = imageCaptureUseCase.takePicture(
+ val outputFileResults = imageCaptureUseCase!!.takePicture(
outputFileOptions,
onCaptureStarted
)
@@ -502,89 +405,26 @@
}
override suspend fun startVideoRecording(
+ videoCaptureUri: Uri?,
+ shouldUseUri: Boolean,
onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
) {
- if (videoCaptureUseCase == null) {
- throw RuntimeException("Attempted video recording with null videoCapture use case")
+ if (shouldUseUri && videoCaptureUri == null) {
+ val e = RuntimeException("Null Uri is provided.")
+ Log.d(TAG, "takePicture onError: $e")
+ throw e
}
- Log.d(TAG, "recordVideo")
- // todo(b/336886716): default setting to enable or disable audio when permission is granted
-
- // 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,
- Manifest.permission.RECORD_AUDIO
+ videoCaptureControlEvents.send(
+ VideoCaptureControlEvent.StartRecordingEvent(
+ videoCaptureUri,
+ shouldUseUri,
+ onVideoRecord
)
- == PackageManager.PERMISSION_GRANTED
- )
- val captureTypeString =
- when (captureMode) {
- CaptureMode.MULTI_STREAM -> "MultiStream"
- CaptureMode.SINGLE_STREAM -> "SingleStream"
- }
- val name = "JCA-recording-${Date()}-$captureTypeString.mp4"
- val contentValues =
- ContentValues().apply {
- put(MediaStore.Video.Media.DISPLAY_NAME, name)
- }
- val mediaStoreOutput =
- MediaStoreOutputOptions.Builder(
- application.contentResolver,
- MediaStore.Video.Media.EXTERNAL_CONTENT_URI
- )
- .setContentValues(contentValues)
- .build()
-
- val callbackExecutor: Executor =
- (
- currentCoroutineContext()[ContinuationInterceptor] as?
- CoroutineDispatcher
- )?.asExecutor() ?: ContextCompat.getMainExecutor(application)
- recording =
- videoCaptureUseCase!!.output
- .prepareRecording(application, mediaStoreOutput)
- .apply {
- if (audioEnabled) {
- withAudioEnabled()
- }
- }
- .start(callbackExecutor) { onVideoRecordEvent ->
- run {
- Log.d(TAG, onVideoRecordEvent.toString())
- when (onVideoRecordEvent) {
- is VideoRecordEvent.Finalize -> {
- when (onVideoRecordEvent.error) {
- ERROR_NONE ->
- onVideoRecord(
- CameraUseCase.OnVideoRecordEvent.OnVideoRecorded
- )
- else ->
- onVideoRecord(
- CameraUseCase.OnVideoRecordEvent.OnVideoRecordError
- )
- }
- }
- is VideoRecordEvent.Status -> {
- onVideoRecord(
- CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus(
- onVideoRecordEvent.recordingStats.audioStats.audioAmplitude
- )
- )
- }
- }
- }
- }
- currentSettings.value?.audioMuted?.let { recording?.mute(it) }
+ )
}
override fun stopVideoRecording() {
- Log.d(TAG, "stopRecording")
- recording?.stop()
+ videoCaptureControlEvents.trySendBlocking(VideoCaptureControlEvent.StopRecordingEvent)
}
override fun setZoomScale(scale: Float) {
@@ -593,13 +433,6 @@
}
}
- // Could be improved by setting initial value only when camera is initialized
- 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()
-
// Sets the camera to the designated lensFacing direction
override suspend fun setLensFacing(lensFacing: LensFacing) {
currentSettings.update { old ->
@@ -630,12 +463,16 @@
}
private fun CameraAppSettings.tryApplyAspectRatioForExternalCapture(
- externalImageCapture: Boolean
+ useCaseMode: CameraUseCase.UseCaseMode
): CameraAppSettings {
- if (externalImageCapture) {
- return this.copy(aspectRatio = AspectRatio.THREE_FOUR)
+ return when (useCaseMode) {
+ CameraUseCase.UseCaseMode.STANDARD -> this
+ CameraUseCase.UseCaseMode.IMAGE_ONLY ->
+ this.copy(aspectRatio = AspectRatio.THREE_FOUR)
+
+ CameraUseCase.UseCaseMode.VIDEO_ONLY ->
+ this.copy(aspectRatio = AspectRatio.NINE_SIXTEEN)
}
- return this
}
private fun CameraAppSettings.tryApplyImageFormatConstraints(): CameraAppSettings {
@@ -654,21 +491,71 @@
} ?: this
}
- override suspend fun tapToFocus(x: Float, y: Float) {
- Log.d(TAG, "tapToFocus, sending FocusMeteringEvent")
+ private fun CameraAppSettings.tryApplyFrameRateConstraints(): CameraAppSettings {
+ return systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints ->
+ with(constraints.supportedFixedFrameRates) {
+ val newTargetFrameRate = if (contains(targetFrameRate)) {
+ targetFrameRate
+ } else {
+ TARGET_FPS_AUTO
+ }
- 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))
- }
+ [email protected](
+ targetFrameRate = newTargetFrameRate
+ )
+ }
+ } ?: this
}
- override fun getScreenFlashEvents() = screenFlashEvents.asSharedFlow()
+ private fun CameraAppSettings.tryApplyStabilizationConstraints(): CameraAppSettings {
+ return systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints ->
+ with(constraints.supportedStabilizationModes) {
+ val newVideoStabilization = if (contains(SupportedStabilizationMode.HIGH_QUALITY) &&
+ (targetFrameRate != TARGET_FPS_60)
+ ) {
+ // unlike shouldVideoBeStabilized, doesn't check value of previewStabilization
+ videoCaptureStabilization
+ } else {
+ Stabilization.UNDEFINED
+ }
+ val newPreviewStabilization = if (contains(SupportedStabilizationMode.ON) &&
+ (targetFrameRate in setOf(TARGET_FPS_AUTO, TARGET_FPS_30))
+ ) {
+ previewStabilization
+ } else {
+ Stabilization.UNDEFINED
+ }
+
+ [email protected](
+ previewStabilization = newPreviewStabilization,
+ videoCaptureStabilization = newVideoStabilization
+ )
+ }
+ } ?: this
+ }
+
+ private fun CameraAppSettings.tryApplyConcurrentCameraModeConstraints(): CameraAppSettings =
+ when (concurrentCameraMode) {
+ ConcurrentCameraMode.OFF -> this
+ else ->
+ if (systemConstraints.concurrentCamerasSupported) {
+ copy(
+ targetFrameRate = TARGET_FPS_AUTO,
+ previewStabilization = Stabilization.OFF,
+ videoCaptureStabilization = Stabilization.OFF,
+ dynamicRange = DynamicRange.SDR,
+ captureMode = CaptureMode.MULTI_STREAM
+ )
+ } else {
+ copy(concurrentCameraMode = ConcurrentCameraMode.OFF)
+ }
+ }
+
+ override suspend fun tapToFocus(x: Float, y: Float) {
+ focusMeteringEvents.send(CameraEvent.FocusMeteringEvent(x, y))
+ }
+
+ override fun getScreenFlashEvents() = screenFlashEvents
override fun getCurrentSettings() = currentSettings.asStateFlow()
override fun setFlashMode(flashMode: FlashMode) {
@@ -677,58 +564,9 @@
}
}
- private fun setFlashModeInternal(flashMode: FlashMode, isFrontFacing: Boolean) {
- val isScreenFlashRequired =
- isFrontFacing && (flashMode == FlashMode.ON || flashMode == FlashMode.AUTO)
-
- if (isScreenFlashRequired) {
- imageCaptureUseCase.screenFlash = object : ScreenFlash {
- override fun apply(
- expirationTimeMillis: Long,
- listener: ImageCapture.ScreenFlashListener
- ) {
- Log.d(TAG, "ImageCapture.ScreenFlash: apply")
- coroutineScope.launch {
- screenFlashEvents.emit(
- CameraUseCase.ScreenFlashEvent(Type.APPLY_UI) {
- listener.onCompleted()
- }
- )
- }
- }
-
- override fun clear() {
- Log.d(TAG, "ImageCapture.ScreenFlash: clear")
- coroutineScope.launch {
- screenFlashEvents.emit(
- CameraUseCase.ScreenFlashEvent(Type.CLEAR_UI) {}
- )
- }
- }
- }
- }
-
- imageCaptureUseCase.flashMode = when (flashMode) {
- FlashMode.OFF -> ImageCapture.FLASH_MODE_OFF // 2
-
- FlashMode.ON -> if (isScreenFlashRequired) {
- ImageCapture.FLASH_MODE_SCREEN // 3
- } else {
- ImageCapture.FLASH_MODE_ON // 1
- }
-
- FlashMode.AUTO -> if (isScreenFlashRequired) {
- ImageCapture.FLASH_MODE_SCREEN // 3
- } else {
- ImageCapture.FLASH_MODE_AUTO // 0
- }
- }
- Log.d(TAG, "Set flash mode to: ${imageCaptureUseCase.flashMode}")
- }
-
override fun isScreenFlashEnabled() =
- imageCaptureUseCase.flashMode == ImageCapture.FLASH_MODE_SCREEN &&
- imageCaptureUseCase.screenFlash != null
+ imageCaptureUseCase?.flashMode == ImageCapture.FLASH_MODE_SCREEN &&
+ imageCaptureUseCase?.screenFlash != null
override suspend fun setAspectRatio(aspectRatio: AspectRatio) {
currentSettings.update { old ->
@@ -738,62 +576,16 @@
override suspend fun setCaptureMode(captureMode: CaptureMode) {
currentSettings.update { old ->
- old?.copy(captureMode = captureMode)?.tryApplyImageFormatConstraints()
+ old?.copy(captureMode = captureMode)
+ ?.tryApplyImageFormatConstraints()
+ ?.tryApplyConcurrentCameraModeConstraints()
}
}
- private fun createUseCaseGroup(
- cameraInfo: CameraInfo,
- sessionSettings: PerpetualSessionSettings,
- initialTransientSettings: TransientSessionSettings,
- supportedStabilizationModes: Set<SupportedStabilizationMode>,
- effect: CameraEffect? = null
- ): UseCaseGroup {
- val previewUseCase =
- createPreviewUseCase(cameraInfo, sessionSettings, supportedStabilizationModes)
- imageCaptureUseCase = createImageUseCase(cameraInfo, sessionSettings)
- if (!disableVideoCapture) {
- videoCaptureUseCase =
- createVideoUseCase(cameraInfo, sessionSettings, supportedStabilizationModes)
- }
-
- setFlashModeInternal(
- flashMode = initialTransientSettings.flashMode,
- isFrontFacing = sessionSettings.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
- )
-
- return UseCaseGroup.Builder().apply {
- Log.d(
- TAG,
- "Setting initial device rotation to ${initialTransientSettings.deviceRotation}"
- )
- setViewPort(
- ViewPort.Builder(
- sessionSettings.aspectRatio.ratio,
- initialTransientSettings.deviceRotation.toUiSurfaceRotation()
- ).build()
- )
- addUseCase(previewUseCase)
- if (sessionSettings.dynamicRange == DynamicRange.SDR ||
- sessionSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
- ) {
- addUseCase(imageCaptureUseCase)
- }
- // Not to bind VideoCapture when Ultra HDR is enabled to keep the app design simple.
- if (videoCaptureUseCase != null &&
- sessionSettings.imageFormat == ImageOutputFormat.JPEG
- ) {
- addUseCase(videoCaptureUseCase!!)
- }
-
- effect?.let { addEffect(it) }
-
- captureMode = sessionSettings.captureMode
- }.build()
- }
override suspend fun setDynamicRange(dynamicRange: DynamicRange) {
currentSettings.update { old ->
old?.copy(dynamicRange = dynamicRange)
+ ?.tryApplyConcurrentCameraModeConstraints()
}
}
@@ -803,35 +595,42 @@
}
}
+ override suspend fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) {
+ currentSettings.update { old ->
+ old?.copy(concurrentCameraMode = concurrentCameraMode)
+ ?.tryApplyConcurrentCameraModeConstraints()
+ }
+ }
+
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()
+ override suspend fun setPreviewStabilization(previewStabilization: Stabilization) {
+ currentSettings.update { old ->
+ old?.copy(
+ previewStabilization = previewStabilization
+ )?.tryApplyStabilizationConstraints()
+ ?.tryApplyConcurrentCameraModeConstraints()
+ }
}
- @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)
+ override suspend fun setVideoCaptureStabilization(videoCaptureStabilization: Stabilization) {
+ currentSettings.update { old ->
+ old?.copy(
+ videoCaptureStabilization = videoCaptureStabilization
+ )?.tryApplyStabilizationConstraints()
+ ?.tryApplyConcurrentCameraModeConstraints()
}
- return builder.build()
+ }
+
+ override suspend fun setTargetFrameRate(targetFrameRate: Int) {
+ currentSettings.update { old ->
+ old?.copy(targetFrameRate = targetFrameRate)?.tryApplyFrameRateConstraints()
+ ?.tryApplyConcurrentCameraModeConstraints()
+ }
}
override suspend fun setLowLightBoost(lowLightBoost: LowLightBoost) {
@@ -841,222 +640,12 @@
}
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(sensorLandscapeRatio, sessionSettings.aspectRatio)
- )
- .setExecutor(defaultDispatcher.asExecutor()).build()
- return VideoCapture.Builder(recorder).apply {
- // set video stabilization
- if (shouldVideoBeStabilized(sessionSettings, supportedStabilizationMode)
- ) {
- setVideoStabilizationEnabled(true)
- }
- // set target fps
- if (sessionSettings.targetFrameRate != TARGET_FPS_AUTO) {
- setTargetFrameRate(
- Range(sessionSettings.targetFrameRate, sessionSettings.targetFrameRate)
- )
- }
-
- setDynamicRange(sessionSettings.dynamicRange.toCXDynamicRange())
- }.build()
- }
-
- private fun getAspectRatioForUseCase(
- sensorLandscapeRatio: Float,
- aspectRatio: AspectRatio
- ): Int {
- return when (aspectRatio) {
- AspectRatio.THREE_FOUR -> RATIO_4_3
- AspectRatio.NINE_SIXTEEN -> RATIO_16_9
- 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
- }
- }
- }
- }
-
- private fun shouldVideoBeStabilized(
- sessionSettings: PerpetualSessionSettings,
- supportedStabilizationModes: Set<SupportedStabilizationMode>
- ): Boolean {
- // video is on and target fps is not 60
- return (sessionSettings.targetFrameRate != TARGET_FPS_60) &&
- (supportedStabilizationModes.contains(SupportedStabilizationMode.HIGH_QUALITY)) &&
- // high quality (video only) selected
- (
- sessionSettings.stabilizeVideoMode == Stabilization.ON &&
- sessionSettings.stabilizePreviewMode == Stabilization.UNDEFINED
- )
- }
-
- private fun createPreviewUseCase(
- cameraInfo: CameraInfo,
- sessionSettings: PerpetualSessionSettings,
- supportedStabilizationModes: Set<SupportedStabilizationMode>
- ): Preview {
- val previewUseCaseBuilder = Preview.Builder().apply {
- setTargetRotation(DeviceRotation.Natural.toUiSurfaceRotation())
- }
-
- setOnCaptureCompletedCallback(previewUseCaseBuilder)
-
- // set preview stabilization
- if (shouldPreviewBeStabilized(sessionSettings, supportedStabilizationModes)) {
- previewUseCaseBuilder.setPreviewStabilizationEnabled(true)
- }
-
- previewUseCaseBuilder.setResolutionSelector(
- getResolutionSelector(cameraInfo.sensorLandscapeRatio, sessionSettings.aspectRatio)
- )
-
- return previewUseCaseBuilder.build().apply {
- setSurfaceProvider { surfaceRequest ->
- _surfaceRequest.value = surfaceRequest
- }
- }
- }
-
- 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 -> {
- // 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()
- }
-
- private fun shouldPreviewBeStabilized(
- sessionSettings: PerpetualSessionSettings,
- supportedStabilizationModes: Set<SupportedStabilizationMode>
- ): Boolean {
- // only supported if target fps is 30 or none
- return (
- when (sessionSettings.targetFrameRate) {
- TARGET_FPS_AUTO, TARGET_FPS_30 -> true
- else -> false
- }
- ) &&
- (
- supportedStabilizationModes.contains(SupportedStabilizationMode.ON) &&
- sessionSettings.stabilizePreviewMode == Stabilization.ON
- )
- }
-
companion object {
private val FIXED_FRAME_RATES = setOf(TARGET_FPS_15, TARGET_FPS_30, TARGET_FPS_60)
-
- /**
- * Checks if preview stabilization is supported by the device.
- *
- */
- private fun isPreviewStabilizationSupported(cameraInfo: CameraInfo): Boolean {
- return Preview.getPreviewCapabilities(cameraInfo).isStabilizationSupported
- }
-
- /**
- * Checks if video stabilization is supported by the device.
- *
- */
- private fun isVideoStabilizationSupported(cameraInfo: CameraInfo): Boolean {
- return Recorder.getVideoCapabilities(cameraInfo).isStabilizationSupported
- }
-
- private fun getSupportedFrameRates(camInfo: CameraInfo): Set<Int> {
- return buildSet {
- camInfo.supportedFrameRateRanges.forEach { e ->
- if (e.upper == e.lower && FIXED_FRAME_RATES.contains(e.upper)) {
- add(e.upper)
- }
- }
- }
- }
- }
-}
-
-private fun CXDynamicRange.toSupportedAppDynamicRange(): DynamicRange? {
- return when (this) {
- CXDynamicRange.SDR -> DynamicRange.SDR
- CXDynamicRange.HLG_10_BIT -> DynamicRange.HLG10
- // All other dynamic ranges unsupported. Return null.
- else -> null
- }
-}
-
-private fun DynamicRange.toCXDynamicRange(): CXDynamicRange {
- return when (this) {
- DynamicRange.SDR -> CXDynamicRange.SDR
- DynamicRange.HLG10 -> CXDynamicRange.HLG_10_BIT
- }
-}
-
-private fun LensFacing.toCameraSelector(): CameraSelector = when (this) {
- LensFacing.FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
- LensFacing.BACK -> CameraSelector.DEFAULT_BACK_CAMERA
-}
-
-private fun CameraSelector.toAppLensFacing(): LensFacing = when (this) {
- CameraSelector.DEFAULT_FRONT_CAMERA -> LensFacing.FRONT
- CameraSelector.DEFAULT_BACK_CAMERA -> LensFacing.BACK
- else -> throw IllegalArgumentException(
- "Unknown CameraSelector -> LensFacing mapping. [CameraSelector: $this]"
- )
-}
-
-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/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt
new file mode 100644
index 0000000..1ea84a1
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.core.camera
+
+import android.annotation.SuppressLint
+import android.util.Log
+import androidx.camera.core.CompositionSettings
+import androidx.camera.core.TorchState
+import androidx.lifecycle.asFlow
+import com.google.jetpackcamera.settings.model.DynamicRange
+import com.google.jetpackcamera.settings.model.ImageOutputFormat
+import com.google.jetpackcamera.settings.model.Stabilization
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private const val TAG = "ConcurrentCameraSession"
+
+context(CameraSessionContext)
+@SuppressLint("RestrictedApi")
+internal suspend fun runConcurrentCameraSession(
+ sessionSettings: PerpetualSessionSettings.ConcurrentCamera,
+ useCaseMode: CameraUseCase.UseCaseMode
+) = coroutineScope {
+ val primaryLensFacing = sessionSettings.primaryCameraInfo.appLensFacing
+ val secondaryLensFacing = sessionSettings.secondaryCameraInfo.appLensFacing
+ Log.d(
+ TAG,
+ "Starting new concurrent camera session " +
+ "[primary: $primaryLensFacing, secondary: $secondaryLensFacing]"
+ )
+
+ val initialTransientSettings = transientSettings
+ .filterNotNull()
+ .first()
+
+ val useCaseGroup = createUseCaseGroup(
+ cameraInfo = sessionSettings.primaryCameraInfo,
+ initialTransientSettings = initialTransientSettings,
+ stabilizePreviewMode = Stabilization.OFF,
+ stabilizeVideoMode = Stabilization.OFF,
+ aspectRatio = sessionSettings.aspectRatio,
+ targetFrameRate = TARGET_FPS_AUTO,
+ dynamicRange = DynamicRange.SDR,
+ imageFormat = ImageOutputFormat.JPEG,
+ useCaseMode = useCaseMode
+ )
+
+ val cameraConfigs = listOf(
+ Pair(
+ sessionSettings.primaryCameraInfo.cameraSelector,
+ CompositionSettings.Builder()
+ .setAlpha(1.0f)
+ .setOffset(0.0f, 0.0f)
+ .setScale(1.0f, 1.0f)
+ .build()
+ ),
+ Pair(
+ sessionSettings.secondaryCameraInfo.cameraSelector,
+ CompositionSettings.Builder()
+ .setAlpha(1.0f)
+ .setOffset(2 / 3f - 0.1f, -2 / 3f + 0.1f)
+ .setScale(1 / 3f, 1 / 3f)
+ .build()
+ )
+ )
+
+ cameraProvider.runWithConcurrent(cameraConfigs, useCaseGroup) { concurrentCamera ->
+ Log.d(TAG, "Concurrent camera session started")
+ val primaryCamera = concurrentCamera.cameras.first {
+ it.cameraInfo.appLensFacing == sessionSettings.primaryCameraInfo.appLensFacing
+ }
+
+ launch {
+ processFocusMeteringEvents(primaryCamera.cameraControl)
+ }
+
+ launch {
+ processVideoControlEvents(
+ primaryCamera,
+ useCaseGroup.getVideoCapture(),
+ captureTypeSuffix = "DualCam"
+ )
+ }
+
+ launch {
+ sessionSettings.primaryCameraInfo.torchState.asFlow().collectLatest { torchState ->
+ currentCameraState.update { old ->
+ old.copy(torchEnabled = torchState == TorchState.ON)
+ }
+ }
+ }
+
+ applyDeviceRotation(initialTransientSettings.deviceRotation, useCaseGroup)
+ processTransientSettingEvents(
+ primaryCamera,
+ useCaseGroup,
+ initialTransientSettings,
+ transientSettings
+ )
+ }
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
index e6f98ea..a6a032f 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt
@@ -15,14 +15,19 @@
*/
package com.google.jetpackcamera.core.camera
+import android.annotation.SuppressLint
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
+import androidx.camera.core.CompositionSettings
+import androidx.camera.core.ConcurrentCamera
+import androidx.camera.core.ConcurrentCamera.SingleCameraConfig
import androidx.camera.core.UseCaseGroup
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
@@ -36,12 +41,25 @@
suspend fun <R> ProcessCameraProvider.runWith(
cameraSelector: CameraSelector,
useCases: UseCaseGroup,
- block: suspend (Camera) -> R
+ block: suspend CoroutineScope.(Camera) -> R
): R = coroutineScope {
val scopedLifecycle = CoroutineLifecycleOwner(coroutineContext)
block([email protected](scopedLifecycle, cameraSelector, useCases))
}
+@SuppressLint("RestrictedApi")
+suspend fun <R> ProcessCameraProvider.runWithConcurrent(
+ cameraConfigs: List<Pair<CameraSelector, CompositionSettings>>,
+ useCaseGroup: UseCaseGroup,
+ block: suspend CoroutineScope.(ConcurrentCamera) -> R
+): R = coroutineScope {
+ val scopedLifecycle = CoroutineLifecycleOwner(coroutineContext)
+ val singleCameraConfigs = cameraConfigs.map {
+ SingleCameraConfig(it.first, useCaseGroup, it.second, scopedLifecycle)
+ }
+ block([email protected](singleCameraConfigs))
+}
+
/**
* A [LifecycleOwner] that follows the lifecycle of a coroutine.
*
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/DebugCameraInfoUtil.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/DebugCameraInfoUtil.kt
new file mode 100644
index 0000000..cb3645e
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/DebugCameraInfoUtil.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.camera
+
+import android.hardware.camera2.CameraCharacteristics
+import android.os.Build
+import android.os.Environment
+import androidx.annotation.OptIn
+import androidx.annotation.RequiresApi
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.camera2.interop.ExperimentalCamera2Interop
+import androidx.camera.core.CameraInfo
+import java.io.File
+import java.io.FileOutputStream
+import org.json.JSONArray
+import org.json.JSONObject
+
+private const val TAG = "DebugCameraInfoUtil"
+object DebugCameraInfoUtil {
+ @OptIn(ExperimentalCamera2Interop::class)
+ @RequiresApi(Build.VERSION_CODES.P)
+ fun getAllCamerasPropertiesJSONArray(cameraInfos: List<CameraInfo>): JSONArray {
+ val result = JSONArray()
+ for (cameraInfo in cameraInfos) {
+ var camera2CameraInfo = Camera2CameraInfo.from(cameraInfo)
+ val logicalCameraId = camera2CameraInfo.cameraId
+ val logicalCameraData = JSONObject()
+ logicalCameraData.put(
+ "logical-$logicalCameraId",
+ getCameraPropertiesJSONObject(camera2CameraInfo)
+ )
+ for (physicalCameraInfo in cameraInfo.physicalCameraInfos) {
+ camera2CameraInfo = Camera2CameraInfo.from(physicalCameraInfo)
+ val physicalCameraId = camera2CameraInfo.cameraId
+ logicalCameraData.put(
+ "physical-$physicalCameraId",
+ getCameraPropertiesJSONObject(camera2CameraInfo)
+ )
+ }
+ result.put(logicalCameraData)
+ }
+ return result
+ }
+
+ @OptIn(ExperimentalCamera2Interop::class)
+ private fun getCameraPropertiesJSONObject(cameraInfo: Camera2CameraInfo): JSONObject {
+ val jsonObject = JSONObject()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_POSE_ROTATION)
+ ?.let {
+ jsonObject.put(
+ CameraCharacteristics.LENS_POSE_ROTATION.name,
+ it.contentToString()
+ )
+ }
+ cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_POSE_TRANSLATION)
+ ?.let {
+ jsonObject.put(
+ CameraCharacteristics.LENS_POSE_TRANSLATION.name,
+ it.contentToString()
+ )
+ }
+ cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_INTRINSIC_CALIBRATION)
+ ?.let {
+ jsonObject.put(
+ CameraCharacteristics.LENS_INTRINSIC_CALIBRATION.name,
+ it.contentToString()
+ )
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_DISTORTION)
+ ?.let {
+ jsonObject.put(
+ CameraCharacteristics.LENS_DISTORTION.name,
+ it.contentToString()
+ )
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ cameraInfo.getCameraCharacteristic(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE)
+ ?.let { jsonObject.put(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE.name, it) }
+ }
+ cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
+ ?.let {
+ jsonObject.put(
+ CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS.name,
+ it.contentToString()
+ )
+ }
+ cameraInfo.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE)
+ ?.let {
+ jsonObject.put(
+ CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE.name,
+ it
+ )
+ }
+ cameraInfo.getCameraCharacteristic(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)
+ ?.let {
+ jsonObject.put(
+ CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES.name,
+ it.contentToString()
+ )
+ }
+
+ return jsonObject
+ }
+
+ fun writeFileExternalStorage(file: File, textToWrite: String) {
+ // Checking the availability state of the External Storage.
+ val state = Environment.getExternalStorageState()
+ if (Environment.MEDIA_MOUNTED != state) {
+ // If it isn't mounted - we can't write into it.
+ return
+ }
+
+ file.createNewFile()
+ FileOutputStream(file).use { outputStream ->
+ outputStream.write(textToWrite.toByteArray())
+ }
+ }
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/VideoCaptureControlEvent.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/VideoCaptureControlEvent.kt
new file mode 100644
index 0000000..822c5cd
--- /dev/null
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/VideoCaptureControlEvent.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.camera
+
+import android.net.Uri
+
+/**
+ * Represents events that control video capture operations.
+ */
+sealed interface VideoCaptureControlEvent {
+
+ /**
+ * Starts video recording.
+ *
+ * @param onVideoRecord Callback to handle video recording events.
+ */
+ class StartRecordingEvent(
+ val videoCaptureUri: Uri?,
+ val shouldUseUri: Boolean,
+ val onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
+ ) : VideoCaptureControlEvent
+
+ /**
+ * Stops video recording.
+ */
+ data object StopRecordingEvent : VideoCaptureControlEvent
+}
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
index fc32ecd..0677b23 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/effects/ShaderCopy.kt
@@ -490,8 +490,7 @@
private const val EGL_GL_COLORSPACE_BT2020_HLG_EXT = 0x3540
private val TEN_BIT_REQUIRED_EGL_EXTENSIONS = listOf(
- "EGL_EXT_gl_colorspace_bt2020_hlg",
- "EGL_EXT_yuv_surface"
+ "EGL_EXT_gl_colorspace_bt2020_hlg"
)
private fun FloatArray.toBuffer(): FloatBuffer {
diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
index 4245f59..f865a63 100644
--- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
+++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt
@@ -25,27 +25,24 @@
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.ConcurrentCameraMode
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
-import kotlinx.coroutines.flow.MutableSharedFlow
+import com.google.jetpackcamera.settings.model.Stabilization
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
class FakeCameraUseCase(
- private val coroutineScope: CoroutineScope =
- CoroutineScope(SupervisorJob() + Dispatchers.Default),
defaultCameraSettings: CameraAppSettings = CameraAppSettings()
) : CameraUseCase {
private val availableLenses = listOf(LensFacing.FRONT, LensFacing.BACK)
@@ -60,11 +57,15 @@
var isLensFacingFront = false
private var isScreenFlash = true
- private var screenFlashEvents = MutableSharedFlow<CameraUseCase.ScreenFlashEvent>()
+ private var screenFlashEvents = Channel<CameraUseCase.ScreenFlashEvent>(capacity = UNLIMITED)
private val currentSettings = MutableStateFlow(defaultCameraSettings)
- override suspend fun initialize(disableVideoCapture: Boolean) {
+ override suspend fun initialize(
+ cameraAppSettings: CameraAppSettings,
+ useCaseMode: CameraUseCase.UseCaseMode,
+ isDebugMode: Boolean
+ ) {
initialized = true
}
@@ -103,14 +104,12 @@
throw IllegalStateException("Usecases not bound")
}
if (isScreenFlash) {
- coroutineScope.launch {
- screenFlashEvents.emit(
- CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.APPLY_UI) { }
- )
- screenFlashEvents.emit(
- CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
- )
- }
+ screenFlashEvents.trySend(
+ CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.APPLY_UI) { }
+ )
+ screenFlashEvents.trySend(
+ CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
+ )
}
numPicturesTaken += 1
}
@@ -127,12 +126,12 @@
}
fun emitScreenFlashEvent(event: CameraUseCase.ScreenFlashEvent) {
- coroutineScope.launch {
- screenFlashEvents.emit(event)
- }
+ screenFlashEvents.trySend(event)
}
override suspend fun startVideoRecording(
+ videoCaptureUri: Uri?,
+ shouldUseUri: Boolean,
onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
) {
if (!useCasesBinded) {
@@ -203,6 +202,12 @@
}
}
+ override suspend fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) {
+ currentSettings.update { old ->
+ old.copy(concurrentCameraMode = concurrentCameraMode)
+ }
+ }
+
override suspend fun setImageFormat(imageFormat: ImageOutputFormat) {
currentSettings.update { old ->
old.copy(imageFormat = imageFormat)
@@ -220,4 +225,22 @@
old.copy(audioMuted = isAudioMuted)
}
}
+
+ override suspend fun setVideoCaptureStabilization(videoCaptureStabilization: Stabilization) {
+ currentSettings.update { old ->
+ old.copy(videoCaptureStabilization = videoCaptureStabilization)
+ }
+ }
+
+ override suspend fun setPreviewStabilization(previewStabilization: Stabilization) {
+ currentSettings.update { old ->
+ old.copy(previewStabilization = previewStabilization)
+ }
+ }
+
+ override suspend fun setTargetFrameRate(targetFrameRate: Int) {
+ currentSettings.update { old ->
+ old.copy(targetFrameRate = targetFrameRate)
+ }
+ }
}
diff --git a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
index ad47e41..00cedf3 100644
--- a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
+++ b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt
@@ -17,10 +17,12 @@
import com.google.common.truth.Truth
import com.google.jetpackcamera.core.camera.CameraUseCase
+import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.LensFacing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -39,7 +41,7 @@
private val testScope = TestScope()
private val testDispatcher = StandardTestDispatcher(testScope.testScheduler)
- private val cameraUseCase = FakeCameraUseCase(testScope)
+ private val cameraUseCase = FakeCameraUseCase()
@Before
fun setup() {
@@ -53,7 +55,10 @@
@Test
fun canInitialize() = runTest(testDispatcher) {
- cameraUseCase.initialize(false)
+ cameraUseCase.initialize(
+ cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
+ useCaseMode = CameraUseCase.UseCaseMode.STANDARD
+ )
}
@Test
@@ -124,7 +129,7 @@
initAndRunCamera()
val events = mutableListOf<CameraUseCase.ScreenFlashEvent>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
- cameraUseCase.getScreenFlashEvents().toList(events)
+ cameraUseCase.getScreenFlashEvents().consumeAsFlow().toList(events)
}
// FlashMode.ON in front facing camera automatically enables screen flash
@@ -144,7 +149,10 @@
private fun TestScope.initAndRunCamera() {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
- cameraUseCase.initialize(false)
+ cameraUseCase.initialize(
+ cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
+ useCaseMode = CameraUseCase.UseCaseMode.STANDARD
+ )
cameraUseCase.runCamera()
}
}
diff --git a/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt b/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt
index 519f90f..2f93743 100644
--- a/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt
+++ b/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt
@@ -19,6 +19,7 @@
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -32,9 +33,22 @@
@InstallIn(SingletonComponent::class)
class CommonModule {
@Provides
+ @DefaultDispatcher
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+ @Provides
+ @IODispatcher
+ fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
+
@Singleton
@Provides
fun providesCoroutineScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class DefaultDispatcher
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class IODispatcher
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 8183542..fb10dc2 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
@@ -38,9 +38,7 @@
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
-const val TARGET_FPS_NONE = 0
const val TARGET_FPS_15 = 15
-const val TARGET_FPS_30 = 30
const val TARGET_FPS_60 = 60
/**
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 712a0cc..1daa078 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
@@ -35,7 +35,8 @@
val targetFrameRate: Int = TARGET_FPS_AUTO,
val imageFormat: ImageOutputFormat = ImageOutputFormat.JPEG,
val audioMuted: Boolean = false,
- val deviceRotation: DeviceRotation = DeviceRotation.Natural
+ val deviceRotation: DeviceRotation = DeviceRotation.Natural,
+ val concurrentCameraMode: ConcurrentCameraMode = ConcurrentCameraMode.OFF
)
fun SystemConstraints.forCurrentLens(cameraAppSettings: CameraAppSettings): CameraConstraints? {
diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ConcurrentCameraMode.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ConcurrentCameraMode.kt
new file mode 100644
index 0000000..621296a
--- /dev/null
+++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/ConcurrentCameraMode.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.settings.model
+
+enum class ConcurrentCameraMode {
+ OFF,
+ DUAL
+}
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 51a1eb4..8b75351 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
@@ -17,6 +17,7 @@
data class SystemConstraints(
val availableLenses: List<LensFacing>,
+ val concurrentCamerasSupported: Boolean,
val perLensConstraints: Map<LensFacing, CameraConstraints>
)
@@ -24,7 +25,8 @@
val supportedStabilizationModes: Set<SupportedStabilizationMode>,
val supportedFixedFrameRates: Set<Int>,
val supportedDynamicRanges: Set<DynamicRange>,
- val supportedImageFormatsMap: Map<CaptureMode, Set<ImageOutputFormat>>
+ val supportedImageFormatsMap: Map<CaptureMode, Set<ImageOutputFormat>>,
+ val hasFlashUnit: Boolean
)
/**
@@ -33,6 +35,7 @@
val TYPICAL_SYSTEM_CONSTRAINTS =
SystemConstraints(
availableLenses = listOf(LensFacing.FRONT, LensFacing.BACK),
+ concurrentCamerasSupported = false,
perLensConstraints = buildMap {
for (lensFacing in listOf(LensFacing.FRONT, LensFacing.BACK)) {
put(
@@ -44,7 +47,8 @@
supportedImageFormatsMap = mapOf(
Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)),
Pair(CaptureMode.MULTI_STREAM, setOf(ImageOutputFormat.JPEG))
- )
+ ),
+ hasFlashUnit = lensFacing == LensFacing.BACK
)
)
}
diff --git a/feature/preview/Android.bp b/feature/preview/Android.bp
index a146711..a3d8366 100644
--- a/feature/preview/Android.bp
+++ b/feature/preview/Android.bp
@@ -19,6 +19,7 @@
"hilt_android",
"androidx.hilt_hilt-navigation-compose",
"androidx.compose.ui_ui-tooling",
+ "kotlin-reflect",
"kotlinx_coroutines_guava",
"androidx.datastore_datastore",
"libprotobuf-java-lite",
diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts
index 81a3ddb..5ba5f3a 100644
--- a/feature/preview/build.gradle.kts
+++ b/feature/preview/build.gradle.kts
@@ -89,6 +89,8 @@
}
dependencies {
+ // Reflect
+ implementation(libs.kotlin.reflect)
// Compose
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
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
index 86e084f..04b7a5e 100644
--- 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
@@ -21,6 +21,8 @@
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.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG
import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
sealed interface CaptureModeToggleUiState {
@@ -43,6 +45,15 @@
VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG,
R.string.toast_video_capture_external_unsupported
),
+ IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED(
+ IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG,
+ R.string.toast_image_capture_external_unsupported
+
+ ),
+ IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA(
+ IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG,
+ R.string.toast_image_capture_unsupported_concurrent_camera
+ ),
HDR_VIDEO_UNSUPPORTED_ON_DEVICE(
HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG,
R.string.toast_hdr_video_unsupported_on_device
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt
index de1c7d8..dc3f8e7 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewMode.kt
@@ -36,4 +36,12 @@
val imageCaptureUri: Uri?,
val onImageCapture: (PreviewViewModel.ImageCaptureEvent) -> Unit
) : PreviewMode
+
+ /**
+ * Under this mode, the app is launched by an external intent to capture a video.
+ */
+ data class ExternalVideoCaptureMode(
+ val videoCaptureUri: Uri?,
+ val onVideoCapture: (PreviewViewModel.VideoCaptureEvent) -> Unit
+ ) : PreviewMode
}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
index f4c7e34..55583a2 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
@@ -59,6 +59,7 @@
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.ConcurrentCameraMode
import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
@@ -77,11 +78,12 @@
fun PreviewScreen(
onNavigateToSettings: () -> Unit,
previewMode: PreviewMode,
+ isDebugMode: Boolean,
modifier: Modifier = Modifier,
onRequestWindowColorMode: (Int) -> Unit = {},
onFirstFrameCaptureCompleted: () -> Unit = {},
viewModel: PreviewViewModel = hiltViewModel<PreviewViewModel, PreviewViewModel.Factory>
- { factory -> factory.create(previewMode) }
+ { factory -> factory.create(previewMode, isDebugMode) }
) {
Log.d(TAG, "PreviewScreen")
@@ -140,6 +142,7 @@
onChangeAspectRatio = viewModel::setAspectRatio,
onChangeCaptureMode = viewModel::setCaptureMode,
onChangeDynamicRange = viewModel::setDynamicRange,
+ onChangeConcurrentCameraMode = viewModel::setConcurrentCameraMode,
onLowLightBoost = viewModel::setLowLightBoost,
onChangeImageFormat = viewModel::setImageFormat,
onToggleWhenDisabled = viewModel::showSnackBarForDisabledHdrToggle,
@@ -173,6 +176,7 @@
onChangeAspectRatio: (AspectRatio) -> Unit = {},
onChangeCaptureMode: (CaptureMode) -> Unit = {},
onChangeDynamicRange: (DynamicRange) -> Unit = {},
+ onChangeConcurrentCameraMode: (ConcurrentCameraMode) -> Unit = {},
onLowLightBoost: (LowLightBoost) -> Unit = {},
onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
@@ -185,7 +189,11 @@
Boolean,
(PreviewViewModel.ImageCaptureEvent) -> Unit
) -> Unit = { _, _, _, _ -> },
- onStartVideoRecording: () -> Unit = {},
+ onStartVideoRecording: (
+ Uri?,
+ Boolean,
+ (PreviewViewModel.VideoCaptureEvent) -> Unit
+ ) -> Unit = { _, _, _ -> },
onStopVideoRecording: () -> Unit = {},
onToastShown: () -> Unit = {},
onRequestWindowColorMode: (Int) -> Unit = {},
@@ -232,13 +240,13 @@
isOpen = previewUiState.quickSettingsIsOpen,
toggleIsOpen = onToggleQuickSettings,
currentCameraSettings = previewUiState.currentCameraSettings,
- systemConstraints = previewUiState.systemConstraints,
onLensFaceClick = onSetLensFacing,
onFlashModeClick = onChangeFlash,
onAspectRatioClick = onChangeAspectRatio,
onCaptureModeClick = onChangeCaptureMode,
onDynamicRangeClick = onChangeDynamicRange,
onImageOutputFormatClick = onChangeImageFormat,
+ onConcurrentCameraModeClick = onChangeConcurrentCameraMode,
onLowLightBoostClick = onLowLightBoost
)
// relative-grid style overlay on top of preview display
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 dfd20d1..5152bbe 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
@@ -42,7 +42,10 @@
val lastBlinkTimeStamp: Long = 0,
val previewMode: PreviewMode,
val captureModeToggleUiState: CaptureModeToggleUiState,
- val sessionFirstFrameTimestamp: Long = 0L
+ val sessionFirstFrameTimestamp: Long = 0L,
+ val currentPhysicalCameraId: String? = null,
+ val currentLogicalCameraId: String? = null,
+ val isDebugMode: Boolean = false
) : 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 7142a1a..fef3aa1 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
@@ -26,27 +26,35 @@
import androidx.tracing.traceAsync
import com.google.jetpackcamera.core.camera.CameraUseCase
import com.google.jetpackcamera.core.common.traceFirstFramePreview
+import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
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.feature.preview.ui.VIDEO_CAPTURE_FAILURE_TAG
+import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_SUCCESS_TAG
import com.google.jetpackcamera.settings.ConstraintsRepository
+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.ConcurrentCameraMode
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.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.reflect.KProperty
+import kotlin.reflect.full.memberProperties
import kotlin.time.Duration.Companion.seconds
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineStart
@@ -59,6 +67,8 @@
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -72,7 +82,9 @@
@HiltViewModel(assistedFactory = PreviewViewModel.Factory::class)
class PreviewViewModel @AssistedInject constructor(
@Assisted val previewMode: PreviewMode,
+ @Assisted val isDebugMode: Boolean,
private val cameraUseCase: CameraUseCase,
+ private val settingsRepository: SettingsRepository,
private val constraintsRepository: ConstraintsRepository
) : ViewModel() {
private val _previewUiState: MutableStateFlow<PreviewUiState> =
@@ -95,11 +107,27 @@
// Eagerly initialize the CameraUseCase and encapsulate in a Deferred that can be
// used to ensure we don't start the camera before initialization is complete.
private var initializationDeferred: Deferred<Unit> = viewModelScope.async {
- cameraUseCase.initialize(previewMode is PreviewMode.ExternalImageCaptureMode)
+ cameraUseCase.initialize(
+ cameraAppSettings = settingsRepository.defaultCameraAppSettings.first(),
+ previewMode.toUseCaseMode(),
+ isDebugMode
+ )
}
init {
viewModelScope.launch {
+ launch {
+ var oldCameraAppSettings: CameraAppSettings? = null
+ settingsRepository.defaultCameraAppSettings.transform { new ->
+ val old = oldCameraAppSettings
+ if (old != null) {
+ emit(getSettingsDiff(old, new))
+ }
+ oldCameraAppSettings = new
+ }.collect { diffQueue ->
+ applySettingsDiff(diffQueue)
+ }
+ }
combine(
cameraUseCase.getCurrentSettings().filterNotNull(),
constraintsRepository.systemConstraints.filterNotNull(),
@@ -116,7 +144,10 @@
captureModeToggleUiState = getCaptureToggleUiState(
systemConstraints,
cameraAppSettings
- )
+ ),
+ isDebugMode = isDebugMode,
+ currentLogicalCameraId = cameraState.debugInfo.logicalCameraId,
+ currentPhysicalCameraId = cameraState.debugInfo.physicalCameraId
)
is PreviewUiState.NotReady ->
@@ -129,7 +160,10 @@
captureModeToggleUiState = getCaptureToggleUiState(
systemConstraints,
cameraAppSettings
- )
+ ),
+ isDebugMode = isDebugMode,
+ currentLogicalCameraId = cameraState.debugInfo.logicalCameraId,
+ currentPhysicalCameraId = cameraState.debugInfo.physicalCameraId
)
}
}
@@ -137,6 +171,70 @@
}
}
+ private fun PreviewMode.toUseCaseMode() = when (this) {
+ is PreviewMode.ExternalImageCaptureMode -> CameraUseCase.UseCaseMode.IMAGE_ONLY
+ is PreviewMode.ExternalVideoCaptureMode -> CameraUseCase.UseCaseMode.VIDEO_ONLY
+ is PreviewMode.StandardMode -> CameraUseCase.UseCaseMode.STANDARD
+ }
+
+ /**
+ * Returns the difference between two [CameraAppSettings] as a mapping of <[KProperty], [Any]>.
+ */
+ private fun getSettingsDiff(
+ oldCameraAppSettings: CameraAppSettings,
+ newCameraAppSettings: CameraAppSettings
+ ): Map<KProperty<Any?>, Any?> = buildMap<KProperty<Any?>, Any?> {
+ CameraAppSettings::class.memberProperties.forEach { property ->
+ if (property.get(oldCameraAppSettings) != property.get(newCameraAppSettings)) {
+ put(property, property.get(newCameraAppSettings))
+ }
+ }
+ }
+
+ /**
+ * Iterates through a queue of [Pair]<[KProperty], [Any]> and attempt to apply them to
+ * [CameraUseCase].
+ */
+ private suspend fun applySettingsDiff(diffSettingsMap: Map<KProperty<Any?>, Any?>) {
+ diffSettingsMap.entries.forEach { entry ->
+ when (entry.key) {
+ CameraAppSettings::cameraLensFacing -> {
+ cameraUseCase.setLensFacing(entry.value as LensFacing)
+ }
+
+ CameraAppSettings::flashMode -> {
+ cameraUseCase.setFlashMode(entry.value as FlashMode)
+ }
+
+ CameraAppSettings::captureMode -> {
+ cameraUseCase.setCaptureMode(entry.value as CaptureMode)
+ }
+
+ CameraAppSettings::aspectRatio -> {
+ cameraUseCase.setAspectRatio(entry.value as AspectRatio)
+ }
+
+ CameraAppSettings::previewStabilization -> {
+ cameraUseCase.setPreviewStabilization(entry.value as Stabilization)
+ }
+
+ CameraAppSettings::videoCaptureStabilization -> {
+ cameraUseCase.setVideoCaptureStabilization(
+ entry.value as Stabilization
+ )
+ }
+
+ CameraAppSettings::targetFrameRate -> {
+ cameraUseCase.setTargetFrameRate(entry.value as Int)
+ }
+
+ CameraAppSettings::darkMode -> {}
+
+ else -> TODO("Unhandled CameraAppSetting $entry")
+ }
+ }
+ }
+
private fun getCaptureToggleUiState(
systemConstraints: SystemConstraints,
cameraAppSettings: CameraAppSettings
@@ -152,12 +250,19 @@
it.size > 1
} ?: false
val isShown = previewMode is PreviewMode.ExternalImageCaptureMode ||
+ previewMode is PreviewMode.ExternalVideoCaptureMode ||
cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR ||
- cameraAppSettings.dynamicRange == DynamicRange.HLG10
+ cameraAppSettings.dynamicRange == DynamicRange.HLG10 ||
+ cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.DUAL
val enabled = previewMode !is PreviewMode.ExternalImageCaptureMode &&
- hdrDynamicRangeSupported && hdrImageFormatSupported
+ previewMode !is PreviewMode.ExternalVideoCaptureMode &&
+ hdrDynamicRangeSupported &&
+ hdrImageFormatSupported &&
+ cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF
return if (isShown) {
- val currentMode = if (previewMode is PreviewMode.ExternalImageCaptureMode ||
+ val currentMode = if (
+ cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF &&
+ previewMode is PreviewMode.ExternalImageCaptureMode ||
cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR
) {
CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE
@@ -170,11 +275,13 @@
CaptureModeToggleUiState.Disabled(
currentMode,
getCaptureToggleUiStateDisabledReason(
+ currentMode,
hdrDynamicRangeSupported,
hdrImageFormatSupported,
systemConstraints,
cameraAppSettings.cameraLensFacing,
- cameraAppSettings.captureMode
+ cameraAppSettings.captureMode,
+ cameraAppSettings.concurrentCameraMode
)
)
}
@@ -184,69 +291,96 @@
}
private fun getCaptureToggleUiStateDisabledReason(
+ captureModeToggleUiState: CaptureModeToggleUiState.ToggleMode,
hdrDynamicRangeSupported: Boolean,
hdrImageFormatSupported: Boolean,
systemConstraints: SystemConstraints,
currentLensFacing: LensFacing,
- currentCaptureMode: CaptureMode
+ currentCaptureMode: CaptureMode,
+ concurrentCameraMode: ConcurrentCameraMode
): 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
- }
+ when (captureModeToggleUiState) {
+ CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO -> {
+ if (previewMode is PreviewMode.ExternalVideoCaptureMode) {
+ return CaptureModeToggleUiState.DisabledReason
+ .IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED
}
- // 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
+
+ if (concurrentCameraMode == ConcurrentCameraMode.DUAL) {
+ return CaptureModeToggleUiState.DisabledReason
+ .IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA
+ }
+
+ if (!hdrImageFormatSupported) {
+ // First check if Ultra HDR image is supported on other capture modes
+ if (systemConstraints
+ .perLensConstraints[currentLensFacing]
+ ?.supportedImageFormatsMap
+ ?.anySupportsUltraHdr { it != currentCaptureMode } == true
+ ) {
+ return 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 any other lens supports HDR image
+ if (systemConstraints.anySupportsUltraHdr { it != currentLensFacing }) {
+ return CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_LENS
+ }
+
+ // No lenses support HDR image on device
+ return CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_DEVICE
}
- // 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) {
+
+ throw RuntimeException("Unknown DisabledReason for video mode.")
+ }
+
+ CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE -> {
+ if (previewMode is PreviewMode.ExternalImageCaptureMode) {
+ return CaptureModeToggleUiState.DisabledReason
+ .VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED
+ }
+
+ if (!hdrDynamicRangeSupported) {
+ if (systemConstraints.anySupportsHdrDynamicRange { it != currentLensFacing }) {
return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_LENS
}
+ return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_DEVICE
}
+
+ throw RuntimeException("Unknown DisabledReason for image mode.")
}
- return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_DEVICE
- } else {
- throw RuntimeException("Unknown CaptureModeUnsupportedReason.")
}
}
+ private fun SystemConstraints.anySupportsHdrDynamicRange(
+ lensFilter: (LensFacing) -> Boolean
+ ): Boolean = perLensConstraints.asSequence().firstOrNull {
+ lensFilter(it.key) && it.value.supportedDynamicRanges.size > 1
+ } != null
+
+ private fun Map<CaptureMode, Set<ImageOutputFormat>>.anySupportsUltraHdr(
+ captureModeFilter: (CaptureMode) -> Boolean
+ ): Boolean = asSequence().firstOrNull {
+ captureModeFilter(it.key) && it.value.contains(ImageOutputFormat.JPEG_ULTRA_HDR)
+ } != null
+
+ private fun SystemConstraints.anySupportsUltraHdr(
+ captureModeFilter: (CaptureMode) -> Boolean = { true },
+ lensFilter: (LensFacing) -> Boolean
+ ): Boolean = perLensConstraints.asSequence().firstOrNull { lensConstraints ->
+ lensFilter(lensConstraints.key) &&
+ lensConstraints.value.supportedImageFormatsMap.anySupportsUltraHdr {
+ captureModeFilter(it)
+ }
+ } != null
+
fun startCamera() {
Log.d(TAG, "startCamera")
stopCamera()
@@ -325,7 +459,29 @@
)
}
+ private fun showExternalVideoCaptureUnsupportedToast() {
+ viewModelScope.launch {
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
+ snackBarToShow = SnackbarData(
+ cookie = "Image-ExternalVideoCaptureMode",
+ stringResource = R.string.toast_image_capture_external_unsupported,
+ withDismissAction = true,
+ testTag = IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+ )
+ ) ?: old
+ }
+ }
+ }
+
fun captureImage() {
+ if (previewUiState.value is PreviewUiState.Ready &&
+ (previewUiState.value as PreviewUiState.Ready).previewMode is
+ PreviewMode.ExternalVideoCaptureMode
+ ) {
+ showExternalVideoCaptureUnsupportedToast()
+ return
+ }
Log.d(TAG, "captureImage")
viewModelScope.launch {
captureImageInternal(
@@ -348,6 +504,32 @@
ignoreUri: Boolean = false,
onImageCapture: (ImageCaptureEvent) -> Unit
) {
+ if (previewUiState.value is PreviewUiState.Ready &&
+ (previewUiState.value as PreviewUiState.Ready).previewMode is
+ PreviewMode.ExternalVideoCaptureMode
+ ) {
+ showExternalVideoCaptureUnsupportedToast()
+ return
+ }
+
+ if (previewUiState.value is PreviewUiState.Ready &&
+ (previewUiState.value as PreviewUiState.Ready).previewMode is
+ PreviewMode.ExternalVideoCaptureMode
+ ) {
+ viewModelScope.launch {
+ _previewUiState.update { old ->
+ (old as? PreviewUiState.Ready)?.copy(
+ snackBarToShow = SnackbarData(
+ cookie = "Image-ExternalVideoCaptureMode",
+ stringResource = R.string.toast_image_capture_external_unsupported,
+ withDismissAction = true,
+ testTag = IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
+ )
+ ) ?: old
+ }
+ }
+ return
+ }
Log.d(TAG, "captureImageWithUri")
viewModelScope.launch {
captureImageInternal(
@@ -424,7 +606,11 @@
}
}
- fun startVideoRecording() {
+ fun startVideoRecording(
+ videoCaptureUri: Uri?,
+ shouldUseUri: Boolean,
+ onVideoCapture: (VideoCaptureEvent) -> Unit
+ ) {
if (previewUiState.value is PreviewUiState.Ready &&
(previewUiState.value as PreviewUiState.Ready).previewMode is
PreviewMode.ExternalImageCaptureMode
@@ -448,23 +634,29 @@
recordingJob = viewModelScope.launch {
val cookie = "Video-${videoCaptureStartedCount.incrementAndGet()}"
try {
- cameraUseCase.startVideoRecording {
+ cameraUseCase.startVideoRecording(videoCaptureUri, shouldUseUri) {
var audioAmplitude = 0.0
var snackbarToShow: SnackbarData? = null
when (it) {
- CameraUseCase.OnVideoRecordEvent.OnVideoRecorded -> {
+ is CameraUseCase.OnVideoRecordEvent.OnVideoRecorded -> {
+ Log.d(TAG, "cameraUseCase.startRecording OnVideoRecorded")
+ onVideoCapture(VideoCaptureEvent.VideoSaved(it.savedUri))
snackbarToShow = SnackbarData(
cookie = cookie,
stringResource = R.string.toast_video_capture_success,
- withDismissAction = true
+ withDismissAction = true,
+ testTag = VIDEO_CAPTURE_SUCCESS_TAG
)
}
- CameraUseCase.OnVideoRecordEvent.OnVideoRecordError -> {
+ is CameraUseCase.OnVideoRecordEvent.OnVideoRecordError -> {
+ Log.d(TAG, "cameraUseCase.startRecording OnVideoRecordError")
+ onVideoCapture(VideoCaptureEvent.VideoCaptureError(it.error))
snackbarToShow = SnackbarData(
cookie = cookie,
stringResource = R.string.toast_video_capture_failure,
- withDismissAction = true
+ withDismissAction = true,
+ testTag = VIDEO_CAPTURE_FAILURE_TAG
)
}
@@ -517,6 +709,12 @@
}
}
+ fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) {
+ viewModelScope.launch {
+ cameraUseCase.setConcurrentCameraMode(concurrentCameraMode)
+ }
+ }
+
fun setLowLightBoost(lowLightBoost: LowLightBoost) {
viewModelScope.launch {
cameraUseCase.setLowLightBoost(lowLightBoost)
@@ -585,7 +783,7 @@
@AssistedFactory
interface Factory {
- fun create(previewMode: PreviewMode): PreviewViewModel
+ fun create(previewMode: PreviewMode, isDebugMode: Boolean): PreviewViewModel
}
sealed interface ImageCaptureEvent {
@@ -597,4 +795,14 @@
val exception: Exception
) : ImageCaptureEvent
}
+
+ sealed interface VideoCaptureEvent {
+ data class VideoSaved(
+ val savedUri: Uri
+ ) : VideoCaptureEvent
+
+ data class VideoCaptureError(
+ val error: Throwable?
+ ) : VideoCaptureEvent
+ }
}
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 7cd1a4f..7bd075a 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
@@ -45,7 +45,7 @@
init {
scope.launch {
- cameraUseCase.getScreenFlashEvents().collect { event ->
+ for (event in cameraUseCase.getScreenFlashEvents()) {
_screenFlashUiState.emit(
when (event.type) {
CameraUseCase.ScreenFlashEvent.Type.APPLY_UI ->
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 db13cd2..2ee1e78 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
@@ -26,6 +26,7 @@
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.filled.PictureInPicture
import androidx.compose.material.icons.outlined.Nightlight
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
@@ -167,3 +168,20 @@
R.string.quick_settings_lowlightboost_disabled_description
}
}
+
+enum class CameraConcurrentCameraMode : QuickSettingsEnum {
+ OFF {
+ override fun getDrawableResId() = R.drawable.picture_in_picture_off_icon
+ override fun getImageVector() = null
+ override fun getTextResId() = R.string.quick_settings_concurrent_camera_off
+ override fun getDescriptionResId() =
+ R.string.quick_settings_concurrent_camera_off_description
+ },
+ DUAL {
+ override fun getDrawableResId() = null
+ override fun getImageVector() = Icons.Filled.PictureInPicture
+ override fun getTextResId() = R.string.quick_settings_concurrent_camera_dual
+ override fun getDescriptionResId() =
+ R.string.quick_settings_concurrent_camera_dual_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 59e97bc..7dbb474 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
@@ -44,27 +44,28 @@
import com.google.jetpackcamera.feature.preview.R
import com.google.jetpackcamera.feature.preview.quicksettings.ui.ExpandedQuickSetRatio
import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_CAPTURE_MODE_BUTTON
+import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON
import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLASH_BUTTON
import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON
import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_HDR_BUTTON
-import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_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.QuickSetConcurrentCamera
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
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.ConcurrentCameraMode
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
@@ -75,7 +76,6 @@
fun QuickSettingsScreenOverlay(
previewUiState: PreviewUiState.Ready,
currentCameraSettings: CameraAppSettings,
- systemConstraints: SystemConstraints,
toggleIsOpen: () -> Unit,
onLensFaceClick: (lensFace: LensFacing) -> Unit,
onFlashModeClick: (flashMode: FlashMode) -> Unit,
@@ -83,6 +83,7 @@
onCaptureModeClick: (captureMode: CaptureMode) -> Unit,
onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit,
onImageOutputFormatClick: (imageOutputFormat: ImageOutputFormat) -> Unit,
+ onConcurrentCameraModeClick: (concurrentCameraMode: ConcurrentCameraMode) -> Unit,
onLowLightBoostClick: (lowLightBoost: LowLightBoost) -> Unit,
modifier: Modifier = Modifier,
isOpen: Boolean = false
@@ -125,7 +126,6 @@
ExpandedQuickSettingsUi(
previewUiState = previewUiState,
currentCameraSettings = currentCameraSettings,
- systemConstraints = systemConstraints,
shouldShowQuickSetting = shouldShowQuickSetting,
setVisibleQuickSetting = { enum: IsExpandedQuickSetting ->
shouldShowQuickSetting = enum
@@ -136,6 +136,7 @@
onCaptureModeClick = onCaptureModeClick,
onDynamicRangeClick = onDynamicRangeClick,
onImageOutputFormatClick = onImageOutputFormatClick,
+ onConcurrentCameraModeClick = onConcurrentCameraModeClick,
onLowLightBoostClick = onLowLightBoostClick
)
}
@@ -157,7 +158,6 @@
private fun ExpandedQuickSettingsUi(
previewUiState: PreviewUiState.Ready,
currentCameraSettings: CameraAppSettings,
- systemConstraints: SystemConstraints,
onLensFaceClick: (newLensFace: LensFacing) -> Unit,
onFlashModeClick: (flashMode: FlashMode) -> Unit,
onAspectRatioClick: (aspectRation: AspectRatio) -> Unit,
@@ -166,6 +166,7 @@
setVisibleQuickSetting: (IsExpandedQuickSetting) -> Unit,
onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit,
onImageOutputFormatClick: (imageOutputFormat: ImageOutputFormat) -> Unit,
+ onConcurrentCameraModeClick: (concurrentCameraMode: ConcurrentCameraMode) -> Unit,
onLowLightBoostClick: (lowLightBoost: LowLightBoost) -> Unit
) {
Column(
@@ -216,7 +217,9 @@
QuickSetCaptureMode(
modifier = Modifier.testTag(QUICK_SETTINGS_CAPTURE_MODE_BUTTON),
setCaptureMode = { c: CaptureMode -> onCaptureModeClick(c) },
- currentCaptureMode = currentCameraSettings.captureMode
+ currentCaptureMode = currentCameraSettings.captureMode,
+ enabled = currentCameraSettings.concurrentCameraMode ==
+ ConcurrentCameraMode.OFF
)
}
@@ -224,6 +227,24 @@
currentCameraSettings
)
add {
+ fun CameraConstraints.hdrDynamicRangeSupported(): Boolean =
+ this.supportedDynamicRanges.size > 1
+
+ fun CameraConstraints.hdrImageFormatSupported(): Boolean =
+ supportedImageFormatsMap[currentCameraSettings.captureMode]
+ ?.let { it.size > 1 } ?: false
+
+ // TODO(tm): Move this to PreviewUiState
+ fun shouldEnable(): Boolean = when {
+ currentCameraSettings.concurrentCameraMode !=
+ ConcurrentCameraMode.OFF -> false
+ else -> (
+ cameraConstraints?.hdrDynamicRangeSupported() == true &&
+ previewUiState.previewMode is PreviewMode.StandardMode
+ ) ||
+ cameraConstraints?.hdrImageFormatSupported() == true
+ }
+
QuickSetHdr(
modifier = Modifier.testTag(QUICK_SETTINGS_HDR_BUTTON),
onClick = { d: DynamicRange, i: ImageOutputFormat ->
@@ -234,24 +255,26 @@
selectedImageOutputFormat = currentCameraSettings.imageFormat,
hdrDynamicRange = currentCameraSettings.defaultHdrDynamicRange,
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
+ hdrDynamicRangeSupported =
+ cameraConstraints?.hdrDynamicRangeSupported() ?: false,
+ previewMode = previewUiState.previewMode,
+ enabled = shouldEnable()
)
}
add {
- QuickSetLowLightBoost(
- modifier = Modifier.testTag(QUICK_SETTINGS_LOW_LIGHT_BOOST_BUTTON),
- onClick = {
- l: LowLightBoost ->
- onLowLightBoostClick(l)
+ QuickSetConcurrentCamera(
+ modifier =
+ Modifier.testTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON),
+ setConcurrentCameraMode = { c: ConcurrentCameraMode ->
+ onConcurrentCameraModeClick(c)
},
- selectedLowLightBoost = currentCameraSettings.lowLightBoost
+ currentConcurrentCameraMode =
+ currentCameraSettings.concurrentCameraMode,
+ enabled =
+ previewUiState.systemConstraints.concurrentCamerasSupported &&
+ previewUiState.previewMode
+ !is PreviewMode.ExternalImageCaptureMode
)
}
}
@@ -280,7 +303,6 @@
captureModeToggleUiState = CaptureModeToggleUiState.Invisible
),
currentCameraSettings = CameraAppSettings(),
- systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS,
onLensFaceClick = { },
onFlashModeClick = { },
shouldShowQuickSetting = IsExpandedQuickSetting.NONE,
@@ -289,6 +311,7 @@
onCaptureModeClick = { },
onDynamicRangeClick = { },
onImageOutputFormatClick = { },
+ onConcurrentCameraModeClick = { },
onLowLightBoostClick = { }
)
}
@@ -306,7 +329,6 @@
captureModeToggleUiState = CaptureModeToggleUiState.Invisible
),
currentCameraSettings = CameraAppSettings(dynamicRange = DynamicRange.HLG10),
- systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS_WITH_HDR,
onLensFaceClick = { },
onFlashModeClick = { },
shouldShowQuickSetting = IsExpandedQuickSetting.NONE,
@@ -315,6 +337,7 @@
onCaptureModeClick = { },
onDynamicRangeClick = { },
onImageOutputFormatClick = { },
+ onConcurrentCameraModeClick = { },
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 0e6b85f..66e2bc7 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
@@ -49,6 +49,7 @@
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.CameraConcurrentCameraMode
import com.google.jetpackcamera.feature.preview.quicksettings.CameraDynamicRange
import com.google.jetpackcamera.feature.preview.quicksettings.CameraFlashMode
import com.google.jetpackcamera.feature.preview.quicksettings.CameraLensFace
@@ -56,6 +57,7 @@
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.ConcurrentCameraMode
import com.google.jetpackcamera.settings.model.DynamicRange
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.ImageOutputFormat
@@ -113,8 +115,8 @@
hdrDynamicRange: DynamicRange,
hdrImageFormat: ImageOutputFormat,
hdrDynamicRangeSupported: Boolean,
- hdrImageFormatSupported: Boolean,
- previewMode: PreviewMode
+ previewMode: PreviewMode,
+ enabled: Boolean
) {
val enum =
if (selectedDynamicRange == hdrDynamicRange ||
@@ -146,8 +148,7 @@
onClick(newDynamicRange, newImageOutputFormat)
},
isHighLighted = (selectedDynamicRange != DynamicRange.SDR),
- enabled = (hdrDynamicRangeSupported && previewMode is PreviewMode.StandardMode) ||
- hdrImageFormatSupported
+ enabled = enabled
)
}
@@ -250,7 +251,8 @@
fun QuickSetCaptureMode(
setCaptureMode: (CaptureMode) -> Unit,
currentCaptureMode: CaptureMode,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
) {
val enum: CameraCaptureMode =
when (currentCaptureMode) {
@@ -265,7 +267,33 @@
CaptureMode.MULTI_STREAM -> setCaptureMode(CaptureMode.SINGLE_STREAM)
CaptureMode.SINGLE_STREAM -> setCaptureMode(CaptureMode.MULTI_STREAM)
}
+ },
+ enabled = enabled
+ )
+}
+
+@Composable
+fun QuickSetConcurrentCamera(
+ setConcurrentCameraMode: (ConcurrentCameraMode) -> Unit,
+ currentConcurrentCameraMode: ConcurrentCameraMode,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
+) {
+ val enum: CameraConcurrentCameraMode =
+ when (currentConcurrentCameraMode) {
+ ConcurrentCameraMode.OFF -> CameraConcurrentCameraMode.OFF
+ ConcurrentCameraMode.DUAL -> CameraConcurrentCameraMode.DUAL
}
+ QuickSettingUiItem(
+ modifier = modifier,
+ enum = enum,
+ onClick = {
+ when (currentConcurrentCameraMode) {
+ ConcurrentCameraMode.OFF -> setConcurrentCameraMode(ConcurrentCameraMode.DUAL)
+ ConcurrentCameraMode.DUAL -> setConcurrentCameraMode(ConcurrentCameraMode.OFF)
+ }
+ },
+ enabled = enabled
)
}
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 7334407..5a226e6 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
@@ -16,6 +16,7 @@
package com.google.jetpackcamera.feature.preview.quicksettings.ui
const val QUICK_SETTINGS_CAPTURE_MODE_BUTTON = "QuickSettingsCaptureModeButton"
+const val QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON = "QuickSettingsConcurrentCameraModeButton"
const val QUICK_SETTINGS_DROP_DOWN = "QuickSettingsDropDown"
const val QUICK_SETTINGS_HDR_BUTTON = "QuickSettingsHdrButton"
const val QUICK_SETTINGS_FLASH_BUTTON = "QuickSettingsFlashButton"
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 a8c8a3b..9563db9 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
@@ -33,6 +33,7 @@
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.Videocam
import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -48,6 +49,7 @@
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.core.util.Preconditions
import com.google.jetpackcamera.feature.preview.CaptureModeToggleUiState
import com.google.jetpackcamera.feature.preview.MultipleEventsCutter
@@ -96,7 +98,11 @@
Boolean,
(PreviewViewModel.ImageCaptureEvent) -> Unit
) -> Unit = { _, _, _, _ -> },
- onStartVideoRecording: () -> Unit = {},
+ onStartVideoRecording: (
+ Uri?,
+ Boolean,
+ (PreviewViewModel.VideoCaptureEvent) -> Unit
+ ) -> Unit = { _, _, _ -> },
onStopVideoRecording: () -> Unit = {}
) {
// Show the current zoom level for a short period of time, only when the level changes.
@@ -131,6 +137,8 @@
previewUiState = previewUiState,
audioAmplitude = previewUiState.audioAmplitude,
zoomLevel = previewUiState.zoomScale,
+ physicalCameraId = previewUiState.currentPhysicalCameraId,
+ logicalCameraId = previewUiState.currentLogicalCameraId,
showZoomLevel = zoomLevelDisplayState.showZoomLevel,
isQuickSettingsOpen = previewUiState.quickSettingsIsOpen,
currentCameraSettings = previewUiState.currentCameraSettings,
@@ -200,6 +208,8 @@
modifier: Modifier = Modifier,
audioAmplitude: Double,
previewUiState: PreviewUiState.Ready,
+ physicalCameraId: String? = null,
+ logicalCameraId: String? = null,
zoomLevel: Float,
showZoomLevel: Boolean,
isQuickSettingsOpen: Boolean,
@@ -218,12 +228,25 @@
onToggleAudioMuted: () -> Unit = {},
onChangeImageFormat: (ImageOutputFormat) -> Unit = {},
onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {},
- onStartVideoRecording: () -> Unit = {},
+ onStartVideoRecording: (
+ Uri?,
+ Boolean,
+ (PreviewViewModel.VideoCaptureEvent) -> Unit
+ ) -> Unit = { _, _, _ -> },
onStopVideoRecording: () -> Unit = {}
) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
- if (showZoomLevel) {
- ZoomScaleText(zoomLevel)
+ CompositionLocalProvider(
+ LocalTextStyle provides LocalTextStyle.current.copy(fontSize = 20.sp)
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ if (showZoomLevel) {
+ ZoomScaleText(zoomLevel)
+ }
+ if (previewUiState.isDebugMode) {
+ CurrentCameraIdText(physicalCameraId, logicalCameraId)
+ }
+ }
}
Row(
@@ -292,7 +315,11 @@
(PreviewViewModel.ImageCaptureEvent) -> Unit
) -> Unit = { _, _, _, _ -> },
onToggleQuickSettings: () -> Unit = {},
- onStartVideoRecording: () -> Unit = {},
+ onStartVideoRecording: (
+ Uri?,
+ Boolean,
+ (PreviewViewModel.VideoCaptureEvent) -> Unit
+ ) -> Unit = { _, _, _ -> },
onStopVideoRecording: () -> Unit = {}
) {
val multipleEventsCutter = remember { MultipleEventsCutter() }
@@ -319,6 +346,14 @@
previewUiState.previewMode.onImageCapture
)
}
+
+ else -> {
+ onCaptureImageWithUri(
+ context.contentResolver,
+ null,
+ false
+ ) {}
+ }
}
}
if (isQuickSettingsOpen) {
@@ -326,12 +361,30 @@
}
},
onLongPress = {
- onStartVideoRecording()
+ when (previewUiState.previewMode) {
+ is PreviewMode.StandardMode -> {
+ onStartVideoRecording(null, false) {}
+ }
+
+ is PreviewMode.ExternalVideoCaptureMode -> {
+ onStartVideoRecording(
+ previewUiState.previewMode.videoCaptureUri,
+ true,
+ previewUiState.previewMode.onVideoCapture
+ )
+ }
+
+ else -> {
+ onStartVideoRecording(null, false) {}
+ }
+ }
if (isQuickSettingsOpen) {
onToggleQuickSettings()
}
},
- onRelease = { onStopVideoRecording() },
+ onRelease = {
+ onStopVideoRecording()
+ },
videoRecordingState = videoRecordingState
)
}
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 4995e97..25a0f28 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
@@ -35,6 +35,7 @@
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
@@ -89,7 +90,6 @@
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import com.google.jetpackcamera.feature.preview.PreviewUiState
import com.google.jetpackcamera.feature.preview.R
import com.google.jetpackcamera.feature.preview.VideoRecordingState
@@ -422,20 +422,41 @@
}
@Composable
-fun ZoomScaleText(zoomScale: Float, modifier: Modifier = Modifier) {
+fun ZoomScaleText(zoomScale: Float) {
val contentAlpha = animateFloatAsState(
targetValue = 10f,
label = "zoomScaleAlphaAnimation",
animationSpec = tween()
)
Text(
- modifier = Modifier.alpha(contentAlpha.value),
- text = "%.1fx".format(zoomScale),
- fontSize = 20.sp
+ modifier = Modifier
+ .alpha(contentAlpha.value)
+ .testTag(ZOOM_RATIO_TAG),
+ text = stringResource(id = R.string.zoom_scale_text, zoomScale)
)
}
@Composable
+fun CurrentCameraIdText(physicalCameraId: String?, logicalCameraId: String?) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Row {
+ Text(text = stringResource(R.string.debug_text_logical_camera_id_prefix))
+ Text(
+ modifier = Modifier.testTag(LOGICAL_CAMERA_ID_TAG),
+ text = logicalCameraId ?: "---"
+ )
+ }
+ Row {
+ Text(text = stringResource(R.string.debug_text_physical_camera_id_prefix))
+ Text(
+ modifier = Modifier.testTag(PHYSICAL_CAMERA_ID_TAG),
+ text = physicalCameraId ?: "---"
+ )
+ }
+ }
+}
+
+@Composable
fun CaptureButton(
onClick: () -> Unit,
onLongPress: () -> Unit,
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 5391125..077a971 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
@@ -19,7 +19,12 @@
const val FLIP_CAMERA_BUTTON = "FlipCameraButton"
const val IMAGE_CAPTURE_SUCCESS_TAG = "ImageCaptureSuccessTag"
const val IMAGE_CAPTURE_FAILURE_TAG = "ImageCaptureFailureTag"
-const val VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG = "ImageCaptureExternalUnsupportedTag"
+const val IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG = "ImageCaptureExternalUnsupportedTag"
+const val IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG =
+ "ImageCaptureUnsupportedConcurrentCameraTag"
+const val VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG = "VideoCaptureExternalUnsupportedTag"
+const val VIDEO_CAPTURE_SUCCESS_TAG = "VideoCaptureSuccessTag"
+const val VIDEO_CAPTURE_FAILURE_TAG = "VideoCaptureFailureTag"
const val PREVIEW_DISPLAY = "PreviewDisplay"
const val SCREEN_FLASH_OVERLAY = "ScreenFlashOverlay"
const val SETTINGS_BUTTON = "SettingsButton"
@@ -31,3 +36,6 @@
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"
+const val ZOOM_RATIO_TAG = "ZoomRatioTag"
+const val LOGICAL_CAMERA_ID_TAG = "LogicalCameraIdTag"
+const val PHYSICAL_CAMERA_ID_TAG = "PhysicalCameraIdTag"
diff --git a/feature/preview/src/main/res/drawable/picture_in_picture_off_icon.xml b/feature/preview/src/main/res/drawable/picture_in_picture_off_icon.xml
new file mode 100644
index 0000000..3c394b1
--- /dev/null
+++ b/feature/preview/src/main/res/drawable/picture_in_picture_off_icon.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2024 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+<vector android:height="72dp" android:tint="#000000"
+ android:viewportHeight="960" android:viewportWidth="960"
+ android:width="72dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="@android:color/white" android:pathData="M700,520Q725,520 742.5,502.5Q760,485 760,460L760,340Q760,315 742.5,297.5Q725,280 700,280L480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360L680,360L680,360L680,440L640,440Q623,440 611.5,451.5Q600,463 600,480Q600,497 612,508.5Q624,520 641,520L700,520ZM840,720Q825,720 812.5,709.5Q800,699 800,679L800,240Q800,240 800,240Q800,240 800,240L361,240Q341,240 331,227.5Q321,215 321,200Q321,185 331,172.5Q341,160 361,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,680Q880,700 867.5,710Q855,720 840,720ZM577,463L577,463L577,463L577,463Q577,463 577,463Q577,463 577,463ZM383,497L383,497Q383,497 383,497Q383,497 383,497L383,497Q383,497 383,497Q383,497 383,497L383,497ZM790,903L686,800L160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L160,160L240,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720L606,720L54,168Q42,156 42,139.5Q42,123 54,111Q66,99 82.5,99Q99,99 111,111L847,847Q859,859 859,875Q859,891 847,903Q835,915 818.5,915Q802,915 790,903Z"/>
+</vector>
diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml
index fc81069..77d80e0 100644
--- a/feature/preview/src/main/res/values/strings.xml
+++ b/feature/preview/src/main/res/values/strings.xml
@@ -20,6 +20,10 @@
<string name="flip_camera_content_description">Flip Camera</string>
<string name="audio_visualizer_icon">An icon of a microphone</string>
+ <string name="zoom_scale_text">%1$.2fx</string>
+
+ <string name="debug_text_physical_camera_id_prefix">Physical ID: </string>
+ <string name="debug_text_logical_camera_id_prefix">Logical ID: </string>
<string name="toast_image_capture_success">Image Capture Success</string>
<string name="toast_video_capture_success">Video Capture Success</string>
@@ -27,6 +31,8 @@
<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">Video not supported while app is in image-only capture mode</string>
+ <string name="toast_image_capture_external_unsupported">Image capture not supported while app is in video-only capture mode</string>
+ <string name="toast_image_capture_unsupported_concurrent_camera">Image capture not supported in dual camera 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>
@@ -74,4 +80,9 @@
<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>
+
+ <string name="quick_settings_concurrent_camera_off">SINGLE</string>
+ <string name="quick_settings_concurrent_camera_dual">DUAL</string>
+ <string name="quick_settings_concurrent_camera_off_description">Concurrent cameras off</string>
+ <string name="quick_settings_concurrent_camera_dual_description">Concurrent dual camera on</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 ee4bc9e..2d40334 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
@@ -22,6 +22,7 @@
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
+import com.google.jetpackcamera.settings.test.FakeSettingsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -47,8 +48,10 @@
Dispatchers.setMain(StandardTestDispatcher())
previewViewModel = PreviewViewModel(
PreviewMode.StandardMode {},
- cameraUseCase,
- constraintsRepository
+ false,
+ cameraUseCase = cameraUseCase,
+ constraintsRepository = constraintsRepository,
+ settingsRepository = FakeSettingsRepository
)
advanceUntilIdle()
}
@@ -87,7 +90,7 @@
@Test
fun startVideoRecording() = runTest(StandardTestDispatcher()) {
previewViewModel.startCameraUntilRunning()
- previewViewModel.startVideoRecording()
+ previewViewModel.startVideoRecording(null, false) {}
advanceUntilIdle()
assertThat(cameraUseCase.recordingInProgress).isTrue()
}
@@ -95,7 +98,7 @@
@Test
fun stopVideoRecording() = runTest(StandardTestDispatcher()) {
previewViewModel.startCameraUntilRunning()
- previewViewModel.startVideoRecording()
+ previewViewModel.startVideoRecording(null, false) {}
advanceUntilIdle()
previewViewModel.stopVideoRecording()
assertThat(cameraUseCase.recordingInProgress).isFalse()
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 9dd08cc..536e90e 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
@@ -20,6 +20,7 @@
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.DEFAULT_CAMERA_APP_SETTINGS
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.LensFacing
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -43,7 +44,7 @@
@get:Rule
val mainDispatcherRule = MainDispatcherRule(testDispatcher)
- private val cameraUseCase = FakeCameraUseCase(testScope)
+ private val cameraUseCase = FakeCameraUseCase()
private lateinit var screenFlash: ScreenFlash
@Before
@@ -109,7 +110,10 @@
private fun runCameraTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
- cameraUseCase.initialize(false)
+ cameraUseCase.initialize(
+ DEFAULT_CAMERA_APP_SETTINGS,
+ CameraUseCase.UseCaseMode.STANDARD
+ )
cameraUseCase.runCamera()
}
diff --git a/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt b/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
index ae1a0b0..dbbc72b 100644
--- a/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
+++ b/feature/settings/src/androidTest/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
@@ -22,7 +22,6 @@
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
-import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
import com.google.jetpackcamera.settings.model.DarkMode
import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
@@ -85,10 +84,7 @@
}
assertThat(uiState).isEqualTo(
- SettingsUiState.Enabled(
- cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
- systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS
- )
+ TYPICAL_SETTINGS_UISTATE
)
}
@@ -99,8 +95,8 @@
it is SettingsUiState.Enabled
}
- val initialCameraLensFacing = assertIsEnabled(initialState)
- .cameraAppSettings.cameraLensFacing
+ val initialCameraLensFacing =
+ assertIsEnabled(initialState).lensFlipUiState.currentLensFacing
val nextCameraLensFacing = if (initialCameraLensFacing == LensFacing.FRONT) {
LensFacing.BACK
} else {
@@ -111,7 +107,7 @@
advanceUntilIdle()
assertIsEnabled(settingsViewModel.settingsUiState.value).also {
- assertThat(it.cameraAppSettings.cameraLensFacing).isEqualTo(nextCameraLensFacing)
+ assertThat(it.lensFlipUiState.currentLensFacing).isEqualTo(nextCameraLensFacing)
}
}
@@ -122,14 +118,20 @@
it is SettingsUiState.Enabled
}
- val initialDarkMode = assertIsEnabled(initialState).cameraAppSettings.darkMode
+ val initialDarkMode =
+ (assertIsEnabled(initialState).darkModeUiState as DarkModeUiState.Enabled)
+ .currentDarkMode
settingsViewModel.setDarkMode(DarkMode.DARK)
advanceUntilIdle()
- val newDarkMode = assertIsEnabled(settingsViewModel.settingsUiState.value)
- .cameraAppSettings.darkMode
+ val newDarkMode =
+ (
+ assertIsEnabled(settingsViewModel.settingsUiState.value)
+ .darkModeUiState as DarkModeUiState.Enabled
+ )
+ .currentDarkMode
assertEquals(initialDarkMode, DarkMode.SYSTEM)
assertEquals(DarkMode.DARK, newDarkMode)
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt
index d5ebc0e..a3ab00e 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt
@@ -30,12 +30,10 @@
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.jetpackcamera.settings.model.AspectRatio
import com.google.jetpackcamera.settings.model.CaptureMode
-import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
import com.google.jetpackcamera.settings.model.DarkMode
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
-import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS
import com.google.jetpackcamera.settings.ui.AspectRatioSetting
import com.google.jetpackcamera.settings.ui.CaptureModeSetting
import com.google.jetpackcamera.settings.ui.DarkModeSetting
@@ -130,47 +128,32 @@
SectionHeader(title = stringResource(id = R.string.section_title_camera_settings))
DefaultCameraFacing(
- settingValue = (uiState.cameraAppSettings.cameraLensFacing == LensFacing.FRONT),
- enabled = with(uiState.systemConstraints.availableLenses) {
- size > 1 && contains(LensFacing.FRONT)
- },
+ lensUiState = uiState.lensFlipUiState,
setDefaultLensFacing = setDefaultLensFacing
)
FlashModeSetting(
- currentFlashMode = uiState.cameraAppSettings.flashMode,
+ flashUiState = uiState.flashUiState,
setFlashMode = setFlashMode
)
TargetFpsSetting(
- currentTargetFps = uiState.cameraAppSettings.targetFrameRate,
- supportedFps = uiState.systemConstraints.perLensConstraints.values.fold(emptySet()) {
- union, constraints ->
- union + constraints.supportedFixedFrameRates
- },
+ fpsUiState = uiState.fpsUiState,
setTargetFps = setTargetFrameRate
)
AspectRatioSetting(
- currentAspectRatio = uiState.cameraAppSettings.aspectRatio,
+ aspectRatioUiState = uiState.aspectRatioUiState,
setAspectRatio = setAspectRatio
)
CaptureModeSetting(
- currentCaptureMode = uiState.cameraAppSettings.captureMode,
+ captureModeUiState = uiState.captureModeUiState,
setCaptureMode = setCaptureMode
)
StabilizationSetting(
- currentVideoStabilization = uiState.cameraAppSettings.videoCaptureStabilization,
- currentPreviewStabilization = uiState.cameraAppSettings.previewStabilization,
- currentTargetFps = uiState.cameraAppSettings.targetFrameRate,
- supportedStabilizationMode = uiState.systemConstraints.perLensConstraints.values.fold(
- emptySet()
- ) {
- union, constraints ->
- union + constraints.supportedStabilizationModes
- },
+ stabilizationUiState = uiState.stabilizationUiState,
setVideoStabilization = setVideoStabilization,
setPreviewStabilization = setPreviewStabilization
)
@@ -178,7 +161,7 @@
SectionHeader(title = stringResource(id = R.string.section_title_app_settings))
DarkModeSetting(
- currentDarkMode = uiState.cameraAppSettings.darkMode,
+ darkModeUiState = uiState.darkModeUiState,
setDarkMode = setDarkMode
)
@@ -190,6 +173,8 @@
)
}
+// will allow you to open stabilization popup or give disabled rationale
+
data class VersionInfoHolder(
val versionName: String,
val buildType: String
@@ -201,10 +186,7 @@
private fun Preview_SettingsScreen() {
SettingsPreviewTheme {
SettingsScreen(
- uiState = SettingsUiState.Enabled(
- DEFAULT_CAMERA_APP_SETTINGS,
- TYPICAL_SYSTEM_CONSTRAINTS
- ),
+ uiState = TYPICAL_SETTINGS_UISTATE,
versionInfo = VersionInfoHolder(
versionName = "1.0.0",
buildType = "release"
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt
index 13cf5f0..7f882c3 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt
@@ -15,16 +15,199 @@
*/
package com.google.jetpackcamera.settings
-import com.google.jetpackcamera.settings.model.CameraAppSettings
-import com.google.jetpackcamera.settings.model.SystemConstraints
+import com.google.jetpackcamera.settings.DisabledRationale.DeviceUnsupportedRationale
+import com.google.jetpackcamera.settings.DisabledRationale.LensUnsupportedRationale
+import com.google.jetpackcamera.settings.model.AspectRatio
+import com.google.jetpackcamera.settings.model.CaptureMode
+import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
+import com.google.jetpackcamera.settings.model.DarkMode
+import com.google.jetpackcamera.settings.model.FlashMode
+import com.google.jetpackcamera.settings.model.LensFacing
+import com.google.jetpackcamera.settings.model.Stabilization
+import com.google.jetpackcamera.settings.ui.DEVICE_UNSUPPORTED_TAG
+import com.google.jetpackcamera.settings.ui.FPS_UNSUPPORTED_TAG
+import com.google.jetpackcamera.settings.ui.LENS_UNSUPPORTED_TAG
+import com.google.jetpackcamera.settings.ui.STABILIZATION_UNSUPPORTED_TAG
/**
* Defines the current state of the [SettingsScreen].
*/
sealed interface SettingsUiState {
- object Disabled : SettingsUiState
+ data object Disabled : SettingsUiState
data class Enabled(
- val cameraAppSettings: CameraAppSettings,
- val systemConstraints: SystemConstraints
+ val aspectRatioUiState: AspectRatioUiState,
+ val captureModeUiState: CaptureModeUiState,
+ val darkModeUiState: DarkModeUiState,
+ val flashUiState: FlashUiState,
+ val fpsUiState: FpsUiState,
+ val lensFlipUiState: FlipLensUiState,
+ val stabilizationUiState: StabilizationUiState
) : SettingsUiState
}
+
+/** State for the individual options on Popup dialog settings */
+sealed interface SingleSelectableState {
+ data object Selectable : SingleSelectableState
+ data class Disabled(val disabledRationale: DisabledRationale) : SingleSelectableState
+}
+
+/** Contains information on why a setting is disabled */
+// TODO(b/360921588): Display information on UI regarding disabled rationale
+sealed interface DisabledRationale {
+ val affectedSettingNameResId: Int
+ val reasonTextResId: Int
+ val testTag: String
+
+ /**
+ * Text will be [affectedSettingNameResId] is [R.string.device_unsupported]
+ */
+ data class DeviceUnsupportedRationale(override val affectedSettingNameResId: Int) :
+ DisabledRationale {
+ override val reasonTextResId: Int = R.string.device_unsupported
+ override val testTag = DEVICE_UNSUPPORTED_TAG
+ }
+
+ data class FpsUnsupportedRationale(
+ override val affectedSettingNameResId: Int,
+ val currentFps: Int
+ ) : DisabledRationale {
+ override val reasonTextResId: Int = R.string.fps_unsupported
+ override val testTag = FPS_UNSUPPORTED_TAG
+ }
+
+ data class StabilizationUnsupportedRationale(override val affectedSettingNameResId: Int) :
+ DisabledRationale {
+ override val reasonTextResId = R.string.stabilization_unsupported
+ override val testTag = STABILIZATION_UNSUPPORTED_TAG
+ }
+
+ sealed interface LensUnsupportedRationale : DisabledRationale {
+ data class FrontLensUnsupportedRationale(override val affectedSettingNameResId: Int) :
+ LensUnsupportedRationale {
+ override val reasonTextResId: Int = R.string.front_lens_unsupported
+ override val testTag = LENS_UNSUPPORTED_TAG
+ }
+
+ data class RearLensUnsupportedRationale(override val affectedSettingNameResId: Int) :
+ LensUnsupportedRationale {
+ override val reasonTextResId: Int = R.string.rear_lens_unsupported
+ override val testTag = LENS_UNSUPPORTED_TAG
+ }
+ }
+}
+
+fun getLensUnsupportedRationale(
+ lensFacing: LensFacing,
+ affectedSettingNameResId: Int
+): LensUnsupportedRationale {
+ return when (lensFacing) {
+ LensFacing.BACK -> LensUnsupportedRationale.RearLensUnsupportedRationale(
+ affectedSettingNameResId
+ )
+
+ LensFacing.FRONT -> LensUnsupportedRationale.FrontLensUnsupportedRationale(
+ affectedSettingNameResId
+ )
+ }
+}
+
+/* Settings that currently have constraints **/
+
+sealed interface FpsUiState {
+ data class Enabled(
+ val currentSelection: Int,
+ val fpsAutoState: SingleSelectableState,
+ val fpsFifteenState: SingleSelectableState,
+ val fpsThirtyState: SingleSelectableState,
+ val fpsSixtyState: SingleSelectableState,
+ // Contains text like "Selected FPS only supported by rear lens"
+ val additionalContext: String = ""
+ ) : FpsUiState
+
+ // FPS selection completely disabled. Cannot open dialog.
+ data class Disabled(val disabledRationale: DisabledRationale) : FpsUiState
+}
+
+sealed interface FlipLensUiState {
+ val currentLensFacing: LensFacing
+
+ data class Enabled(
+ override val currentLensFacing: LensFacing
+ ) : FlipLensUiState
+
+ data class Disabled(
+ override val currentLensFacing: LensFacing,
+ val disabledRationale: DisabledRationale
+ ) : FlipLensUiState
+}
+
+sealed interface StabilizationUiState {
+ data class Enabled(
+ val currentPreviewStabilization: Stabilization,
+ val currentVideoStabilization: Stabilization,
+ val stabilizationOnState: SingleSelectableState,
+ val stabilizationHighQualityState: SingleSelectableState,
+ // Contains text like "Selected stabilization mode only supported by rear lens"
+ val additionalContext: String = ""
+ ) : StabilizationUiState
+
+ // Stabilization selection completely disabled. Cannot open dialog.
+ data class Disabled(val disabledRationale: DisabledRationale) : StabilizationUiState
+}
+
+/* Settings that don't currently depend on constraints */
+
+// this could be constrained w/ a check to see if a torch is available?
+sealed interface FlashUiState {
+ data class Enabled(
+ val currentFlashMode: FlashMode,
+ val additionalContext: String = ""
+ ) : FlashUiState
+}
+
+sealed interface AspectRatioUiState {
+ data class Enabled(
+ val currentAspectRatio: AspectRatio,
+ val additionalContext: String = ""
+ ) : AspectRatioUiState
+}
+
+sealed interface CaptureModeUiState {
+ data class Enabled(
+ val currentCaptureMode: CaptureMode,
+ val additionalContext: String = ""
+ ) : CaptureModeUiState
+}
+
+sealed interface DarkModeUiState {
+ data class Enabled(
+ val currentDarkMode: DarkMode,
+ val additionalContext: String = ""
+ ) : DarkModeUiState
+}
+
+/**
+ * Settings Ui State for testing, based on Typical System Constraints.
+ * @see[com.google.jetpackcamera.settings.model.SystemConstraints]
+ */
+val TYPICAL_SETTINGS_UISTATE = SettingsUiState.Enabled(
+ aspectRatioUiState = AspectRatioUiState.Enabled(DEFAULT_CAMERA_APP_SETTINGS.aspectRatio),
+ captureModeUiState = CaptureModeUiState.Enabled(DEFAULT_CAMERA_APP_SETTINGS.captureMode),
+ darkModeUiState = DarkModeUiState.Enabled(DEFAULT_CAMERA_APP_SETTINGS.darkMode),
+ flashUiState =
+ FlashUiState.Enabled(currentFlashMode = DEFAULT_CAMERA_APP_SETTINGS.flashMode),
+ fpsUiState = FpsUiState.Enabled(
+ currentSelection = DEFAULT_CAMERA_APP_SETTINGS.targetFrameRate,
+ fpsAutoState = SingleSelectableState.Selectable,
+ fpsFifteenState = SingleSelectableState.Selectable,
+ fpsThirtyState = SingleSelectableState.Selectable,
+ fpsSixtyState = SingleSelectableState.Disabled(
+ DeviceUnsupportedRationale(R.string.fps_rationale_prefix)
+ )
+ ),
+ lensFlipUiState = FlipLensUiState.Enabled(DEFAULT_CAMERA_APP_SETTINGS.cameraLensFacing),
+ stabilizationUiState =
+ StabilizationUiState.Disabled(
+ DeviceUnsupportedRationale(R.string.stabilization_rationale_prefix)
+ )
+)
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt
index ea8caf7..43e7a50 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt
@@ -18,12 +18,22 @@
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.google.jetpackcamera.settings.DisabledRationale.DeviceUnsupportedRationale
+import com.google.jetpackcamera.settings.DisabledRationale.FpsUnsupportedRationale
+import com.google.jetpackcamera.settings.DisabledRationale.StabilizationUnsupportedRationale
import com.google.jetpackcamera.settings.model.AspectRatio
+import com.google.jetpackcamera.settings.model.CameraAppSettings
import com.google.jetpackcamera.settings.model.CaptureMode
import com.google.jetpackcamera.settings.model.DarkMode
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
+import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
+import com.google.jetpackcamera.settings.model.SystemConstraints
+import com.google.jetpackcamera.settings.ui.FPS_15
+import com.google.jetpackcamera.settings.ui.FPS_30
+import com.google.jetpackcamera.settings.ui.FPS_60
+import com.google.jetpackcamera.settings.ui.FPS_AUTO
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
@@ -34,6 +44,7 @@
import kotlinx.coroutines.launch
private const val TAG = "SettingsViewModel"
+private val fpsOptions = setOf(FPS_15, FPS_30, FPS_60)
/**
* [ViewModel] for [SettingsScreen].
@@ -50,8 +61,14 @@
constraintsRepository.systemConstraints.filterNotNull()
) { updatedSettings, constraints ->
SettingsUiState.Enabled(
- cameraAppSettings = updatedSettings,
- systemConstraints = constraints
+ aspectRatioUiState = AspectRatioUiState.Enabled(updatedSettings.aspectRatio),
+ captureModeUiState = CaptureModeUiState.Enabled(updatedSettings.captureMode),
+ darkModeUiState = DarkModeUiState.Enabled(updatedSettings.darkMode),
+ flashUiState = FlashUiState.Enabled(updatedSettings.flashMode),
+ fpsUiState = getFpsUiState(constraints, updatedSettings),
+ lensFlipUiState = getLensFlipUiState(constraints, updatedSettings),
+ stabilizationUiState = getStabilizationUiState(constraints, updatedSettings)
+
)
}.stateIn(
scope = viewModelScope,
@@ -59,6 +76,317 @@
initialValue = SettingsUiState.Disabled
)
+ private fun getStabilizationUiState(
+ systemConstraints: SystemConstraints,
+ cameraAppSettings: CameraAppSettings
+ ): StabilizationUiState {
+ val deviceStabilizations: Set<SupportedStabilizationMode> =
+ systemConstraints
+ .perLensConstraints[cameraAppSettings.cameraLensFacing]
+ ?.supportedStabilizationModes
+ ?: emptySet()
+
+ // if no lens supports
+ if (deviceStabilizations.isEmpty()) {
+ return StabilizationUiState.Disabled(
+ DeviceUnsupportedRationale(
+ R.string.stabilization_rationale_prefix
+ )
+ )
+ }
+
+ // if a lens supports but it isn't the current
+ if (systemConstraints.perLensConstraints[cameraAppSettings.cameraLensFacing]
+ ?.supportedStabilizationModes?.isEmpty() == true
+ ) {
+ return StabilizationUiState.Disabled(
+ getLensUnsupportedRationale(
+ cameraAppSettings.cameraLensFacing,
+ R.string.stabilization_rationale_prefix
+ )
+ )
+ }
+
+ // if fps is too high for any stabilization
+ if (cameraAppSettings.targetFrameRate >= TARGET_FPS_60) {
+ return StabilizationUiState.Disabled(
+ FpsUnsupportedRationale(
+ R.string.stabilization_rationale_prefix,
+ FPS_60
+ )
+ )
+ }
+
+ return StabilizationUiState.Enabled(
+ currentPreviewStabilization = cameraAppSettings.previewStabilization,
+ currentVideoStabilization = cameraAppSettings.videoCaptureStabilization,
+ stabilizationOnState = getPreviewStabilizationState(
+ currentFrameRate = cameraAppSettings.targetFrameRate,
+ defaultLensFacing = cameraAppSettings.cameraLensFacing,
+ deviceStabilizations = deviceStabilizations,
+ currentLensStabilizations = systemConstraints
+ .perLensConstraints[cameraAppSettings.cameraLensFacing]
+ ?.supportedStabilizationModes
+ ),
+ stabilizationHighQualityState =
+ getVideoStabilizationState(
+ currentFrameRate = cameraAppSettings.targetFrameRate,
+ deviceStabilizations = deviceStabilizations,
+ defaultLensFacing = cameraAppSettings.cameraLensFacing,
+ currentLensStabilizations = systemConstraints
+ .perLensConstraints[cameraAppSettings.cameraLensFacing]
+ ?.supportedStabilizationModes
+ )
+ )
+ }
+
+ private fun getPreviewStabilizationState(
+ currentFrameRate: Int,
+ defaultLensFacing: LensFacing,
+ deviceStabilizations: Set<SupportedStabilizationMode>,
+ currentLensStabilizations: Set<SupportedStabilizationMode>?
+ ): SingleSelectableState {
+ // if unsupported by device
+ if (!deviceStabilizations.contains(SupportedStabilizationMode.ON)) {
+ return SingleSelectableState.Disabled(
+ disabledRationale =
+ DeviceUnsupportedRationale(R.string.stabilization_rationale_prefix)
+ )
+ }
+
+ // if unsupported by by current lens
+ if (currentLensStabilizations?.contains(SupportedStabilizationMode.ON) == false) {
+ return SingleSelectableState.Disabled(
+ getLensUnsupportedRationale(
+ defaultLensFacing,
+ R.string.stabilization_rationale_prefix
+ )
+ )
+ }
+
+ // if fps is unsupported by preview stabilization
+ if (currentFrameRate == TARGET_FPS_60 || currentFrameRate == TARGET_FPS_15) {
+ return SingleSelectableState.Disabled(
+ FpsUnsupportedRationale(
+ R.string.stabilization_rationale_prefix,
+ currentFrameRate
+ )
+ )
+ }
+
+ return SingleSelectableState.Selectable
+ }
+
+ private fun getVideoStabilizationState(
+ currentFrameRate: Int,
+ defaultLensFacing: LensFacing,
+ deviceStabilizations: Set<SupportedStabilizationMode>,
+ currentLensStabilizations: Set<SupportedStabilizationMode>?
+ ): SingleSelectableState {
+ // if unsupported by device
+ if (!deviceStabilizations.contains(SupportedStabilizationMode.ON)) {
+ return SingleSelectableState.Disabled(
+ disabledRationale =
+ DeviceUnsupportedRationale(R.string.stabilization_rationale_prefix)
+ )
+ }
+
+ // if unsupported by by current lens
+ if (currentLensStabilizations?.contains(SupportedStabilizationMode.HIGH_QUALITY) == false) {
+ return SingleSelectableState.Disabled(
+ getLensUnsupportedRationale(
+ defaultLensFacing,
+ R.string.stabilization_rationale_prefix
+ )
+ )
+ }
+ // if fps is unsupported by preview stabilization
+ if (currentFrameRate == TARGET_FPS_60) {
+ return SingleSelectableState.Disabled(
+ FpsUnsupportedRationale(
+ R.string.stabilization_rationale_prefix,
+ currentFrameRate
+ )
+ )
+ }
+
+ return SingleSelectableState.Selectable
+ }
+
+ /**
+ * Enables or disables default camera switch based on:
+ * - number of cameras available
+ * - if there is a front and rear camera, the camera that the setting would switch to must also
+ * support the other settings
+ * */
+ private fun getLensFlipUiState(
+ systemConstraints: SystemConstraints,
+ currentSettings: CameraAppSettings
+ ): FlipLensUiState {
+ // if there is only one lens, stop here
+ if (!with(systemConstraints.availableLenses) {
+ size > 1 && contains(com.google.jetpackcamera.settings.model.LensFacing.FRONT)
+ }
+ ) {
+ return FlipLensUiState.Disabled(
+ currentLensFacing = currentSettings.cameraLensFacing,
+ disabledRationale =
+ DeviceUnsupportedRationale(
+ // display the lens that isnt supported
+ when (currentSettings.cameraLensFacing) {
+ LensFacing.BACK -> R.string.front_lens_rationale_prefix
+ LensFacing.FRONT -> R.string.rear_lens_rationale_prefix
+ }
+ )
+ )
+ }
+
+ // If multiple lens available, continue
+ val newLensFacing = if (currentSettings.cameraLensFacing == LensFacing.FRONT) {
+ LensFacing.BACK
+ } else {
+ LensFacing.FRONT
+ }
+ val newLensConstraints = systemConstraints.perLensConstraints[newLensFacing]!!
+ // make sure all current settings wont break constraint when changing new default lens
+
+ // if new lens won't support current fps
+ if (currentSettings.targetFrameRate != FPS_AUTO &&
+ !newLensConstraints.supportedFixedFrameRates
+ .contains(currentSettings.targetFrameRate)
+ ) {
+ return FlipLensUiState.Disabled(
+ currentLensFacing = currentSettings.cameraLensFacing,
+ disabledRationale = FpsUnsupportedRationale(
+ when (currentSettings.cameraLensFacing) {
+ LensFacing.BACK -> R.string.front_lens_rationale_prefix
+ LensFacing.FRONT -> R.string.rear_lens_rationale_prefix
+ },
+ currentSettings.targetFrameRate
+ )
+ )
+ }
+
+ // if preview stabilization is currently on and the other lens won't support it
+ if (currentSettings.previewStabilization == Stabilization.ON) {
+ if (!newLensConstraints.supportedStabilizationModes.contains(
+ SupportedStabilizationMode.ON
+ )
+ ) {
+ return FlipLensUiState.Disabled(
+ currentLensFacing = currentSettings.cameraLensFacing,
+ disabledRationale = StabilizationUnsupportedRationale(
+ when (currentSettings.cameraLensFacing) {
+ LensFacing.BACK -> R.string.front_lens_rationale_prefix
+ LensFacing.FRONT -> R.string.rear_lens_rationale_prefix
+ }
+ )
+ )
+ }
+ }
+ // if video stabilization is currently on and the other lens won't support it
+ if (currentSettings.videoCaptureStabilization == Stabilization.ON) {
+ if (!newLensConstraints.supportedStabilizationModes
+ .contains(SupportedStabilizationMode.HIGH_QUALITY)
+ ) {
+ return FlipLensUiState.Disabled(
+ currentLensFacing = currentSettings.cameraLensFacing,
+ disabledRationale = StabilizationUnsupportedRationale(
+ when (currentSettings.cameraLensFacing) {
+ LensFacing.BACK -> R.string.front_lens_rationale_prefix
+ LensFacing.FRONT -> R.string.rear_lens_rationale_prefix
+ }
+ )
+ )
+ }
+ }
+
+ return FlipLensUiState.Enabled(currentLensFacing = currentSettings.cameraLensFacing)
+ }
+
+ private fun getFpsUiState(
+ systemConstraints: SystemConstraints,
+ cameraAppSettings: CameraAppSettings
+ ): FpsUiState {
+ val optionConstraintRationale: MutableMap<Int, SingleSelectableState> = mutableMapOf()
+
+ val currentLensFrameRates: Set<Int> = systemConstraints
+ .perLensConstraints[cameraAppSettings.cameraLensFacing]
+ ?.supportedFixedFrameRates ?: emptySet()
+
+ // if device supports no fixed frame rates, disable
+ if (currentLensFrameRates.isEmpty()) {
+ return FpsUiState.Disabled(
+ DeviceUnsupportedRationale(R.string.no_fixed_fps_rationale_prefix)
+ )
+ }
+
+ // provide selectable states for each of the fps options
+ fpsOptions.forEach { fpsOption ->
+ val fpsUiState = isFpsOptionEnabled(
+ fpsOption,
+ cameraAppSettings.cameraLensFacing,
+ currentLensFrameRates,
+ systemConstraints.perLensConstraints[cameraAppSettings.cameraLensFacing]
+ ?.supportedFixedFrameRates ?: emptySet(),
+ cameraAppSettings.previewStabilization,
+ cameraAppSettings.videoCaptureStabilization
+ )
+ if (fpsUiState is SingleSelectableState.Disabled) {
+ Log.d(TAG, "fps option $fpsOption disabled. ${fpsUiState.disabledRationale::class}")
+ }
+ optionConstraintRationale[fpsOption] = fpsUiState
+ }
+ return FpsUiState.Enabled(
+ currentSelection = cameraAppSettings.targetFrameRate,
+ fpsAutoState = SingleSelectableState.Selectable,
+ fpsFifteenState = optionConstraintRationale[FPS_15]!!,
+ fpsThirtyState = optionConstraintRationale[FPS_30]!!,
+ fpsSixtyState = optionConstraintRationale[FPS_60]!!
+ )
+ }
+
+ /**
+ * Auxiliary function to determine if an FPS option should be disabled or not
+ */
+ private fun isFpsOptionEnabled(
+ fpsOption: Int,
+ defaultLensFacing: LensFacing,
+ deviceFrameRates: Set<Int>,
+ lensFrameRates: Set<Int>,
+ previewStabilization: Stabilization,
+ videoStabilization: Stabilization
+ ): SingleSelectableState {
+ // if device doesnt support the fps option, disable
+ if (!deviceFrameRates.contains(fpsOption)) {
+ return SingleSelectableState.Disabled(
+ disabledRationale = DeviceUnsupportedRationale(R.string.fps_rationale_prefix)
+ )
+ }
+ // if the current lens doesnt support the fps, disable
+ if (!lensFrameRates.contains(fpsOption)) {
+ Log.d(TAG, "FPS disabled for current lens")
+
+ return SingleSelectableState.Disabled(
+ getLensUnsupportedRationale(defaultLensFacing, R.string.fps_rationale_prefix)
+ )
+ }
+
+ // if stabilization is on and the option is incompatible, disable
+ if ((
+ previewStabilization == Stabilization.ON &&
+ (fpsOption == FPS_15 || fpsOption == FPS_60)
+ ) ||
+ (videoStabilization == Stabilization.ON && fpsOption == FPS_60)
+ ) {
+ return SingleSelectableState.Disabled(
+ StabilizationUnsupportedRationale(R.string.fps_rationale_prefix)
+ )
+ }
+
+ return SingleSelectableState.Selectable
+ }
+
fun setDefaultLensFacing(lensFacing: LensFacing) {
viewModelScope.launch {
settingsRepository.updateDefaultLensFacing(lensFacing)
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt
index 559f24f..e8c02fb 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt
@@ -38,7 +38,7 @@
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -52,14 +52,22 @@
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.google.jetpackcamera.settings.AspectRatioUiState
+import com.google.jetpackcamera.settings.CaptureModeUiState
+import com.google.jetpackcamera.settings.DarkModeUiState
+import com.google.jetpackcamera.settings.DisabledRationale
+import com.google.jetpackcamera.settings.FlashUiState
+import com.google.jetpackcamera.settings.FlipLensUiState
+import com.google.jetpackcamera.settings.FpsUiState
import com.google.jetpackcamera.settings.R
+import com.google.jetpackcamera.settings.SingleSelectableState
+import com.google.jetpackcamera.settings.StabilizationUiState
import com.google.jetpackcamera.settings.model.AspectRatio
import com.google.jetpackcamera.settings.model.CaptureMode
import com.google.jetpackcamera.settings.model.DarkMode
import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.LensFacing
import com.google.jetpackcamera.settings.model.Stabilization
-import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
import com.google.jetpackcamera.settings.ui.theme.SettingsPreviewTheme
const val FPS_AUTO = 0
@@ -107,27 +115,38 @@
@Composable
fun DefaultCameraFacing(
- settingValue: Boolean,
- enabled: Boolean,
- setDefaultLensFacing: (LensFacing) -> Unit,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ lensUiState: FlipLensUiState,
+ setDefaultLensFacing: (LensFacing) -> Unit
) {
SwitchSettingUI(
- modifier = modifier,
+ modifier = modifier.apply {
+ if (lensUiState is FlipLensUiState.Disabled) {
+ testTag(lensUiState.disabledRationale.testTag)
+ }
+ },
title = stringResource(id = R.string.default_facing_camera_title),
- description = null,
+ description = when (lensUiState) {
+ is FlipLensUiState.Disabled -> {
+ disabledRationaleString(disabledRationale = lensUiState.disabledRationale)
+ }
+
+ is FlipLensUiState.Enabled -> {
+ null
+ }
+ },
leadingIcon = null,
onSwitchChanged = { on ->
setDefaultLensFacing(if (on) LensFacing.FRONT else LensFacing.BACK)
},
- settingValue = settingValue,
- enabled = enabled
+ settingValue = lensUiState.currentLensFacing == LensFacing.FRONT,
+ enabled = lensUiState is FlipLensUiState.Enabled
)
}
@Composable
fun DarkModeSetting(
- currentDarkMode: DarkMode,
+ darkModeUiState: DarkModeUiState,
setDarkMode: (DarkMode) -> Unit,
modifier: Modifier = Modifier
) {
@@ -135,26 +154,34 @@
modifier = modifier,
title = stringResource(id = R.string.dark_mode_title),
leadingIcon = null,
- description = when (currentDarkMode) {
- DarkMode.SYSTEM -> stringResource(id = R.string.dark_mode_description_system)
- DarkMode.DARK -> stringResource(id = R.string.dark_mode_description_dark)
- DarkMode.LIGHT -> stringResource(id = R.string.dark_mode_description_light)
+ enabled = true,
+ description = when (darkModeUiState) {
+ is DarkModeUiState.Enabled -> {
+ when (darkModeUiState.currentDarkMode) {
+ DarkMode.SYSTEM -> stringResource(id = R.string.dark_mode_description_system)
+ DarkMode.DARK -> stringResource(id = R.string.dark_mode_description_dark)
+ DarkMode.LIGHT -> stringResource(id = R.string.dark_mode_description_light)
+ }
+ }
},
popupContents = {
Column(Modifier.selectableGroup()) {
SingleChoiceSelector(
text = stringResource(id = R.string.dark_mode_selector_dark),
- selected = currentDarkMode == DarkMode.DARK,
+ selected = darkModeUiState.currentDarkMode == DarkMode.DARK,
+ enabled = true,
onClick = { setDarkMode(DarkMode.DARK) }
)
SingleChoiceSelector(
text = stringResource(id = R.string.dark_mode_selector_light),
- selected = currentDarkMode == DarkMode.LIGHT,
+ selected = darkModeUiState.currentDarkMode == DarkMode.LIGHT,
+ enabled = true,
onClick = { setDarkMode(DarkMode.LIGHT) }
)
SingleChoiceSelector(
text = stringResource(id = R.string.dark_mode_selector_system),
- selected = currentDarkMode == DarkMode.SYSTEM,
+ selected = darkModeUiState.currentDarkMode == DarkMode.SYSTEM,
+ enabled = true,
onClick = { setDarkMode(DarkMode.SYSTEM) }
)
}
@@ -164,7 +191,7 @@
@Composable
fun FlashModeSetting(
- currentFlashMode: FlashMode,
+ flashUiState: FlashUiState,
setFlashMode: (FlashMode) -> Unit,
modifier: Modifier = Modifier
) {
@@ -172,26 +199,35 @@
modifier = modifier,
title = stringResource(id = R.string.flash_mode_title),
leadingIcon = null,
- description = when (currentFlashMode) {
- FlashMode.AUTO -> stringResource(id = R.string.flash_mode_description_auto)
- FlashMode.ON -> stringResource(id = R.string.flash_mode_description_on)
- FlashMode.OFF -> stringResource(id = R.string.flash_mode_description_off)
+ enabled = true,
+ description =
+ if (flashUiState is FlashUiState.Enabled) {
+ when (flashUiState.currentFlashMode) {
+ FlashMode.AUTO -> stringResource(id = R.string.flash_mode_description_auto)
+ FlashMode.ON -> stringResource(id = R.string.flash_mode_description_on)
+ FlashMode.OFF -> stringResource(id = R.string.flash_mode_description_off)
+ }
+ } else {
+ TODO("flash mode currently has no disabled criteria")
},
popupContents = {
Column(Modifier.selectableGroup()) {
SingleChoiceSelector(
text = stringResource(id = R.string.flash_mode_selector_auto),
- selected = currentFlashMode == FlashMode.AUTO,
+ selected = flashUiState.currentFlashMode == FlashMode.AUTO,
+ enabled = true,
onClick = { setFlashMode(FlashMode.AUTO) }
)
SingleChoiceSelector(
text = stringResource(id = R.string.flash_mode_selector_on),
- selected = currentFlashMode == FlashMode.ON,
+ selected = flashUiState.currentFlashMode == FlashMode.ON,
+ enabled = true,
onClick = { setFlashMode(FlashMode.ON) }
)
SingleChoiceSelector(
text = stringResource(id = R.string.flash_mode_selector_off),
- selected = currentFlashMode == FlashMode.OFF,
+ selected = flashUiState.currentFlashMode == FlashMode.OFF,
+ enabled = true,
onClick = { setFlashMode(FlashMode.OFF) }
)
}
@@ -201,7 +237,7 @@
@Composable
fun AspectRatioSetting(
- currentAspectRatio: AspectRatio,
+ aspectRatioUiState: AspectRatioUiState,
setAspectRatio: (AspectRatio) -> Unit,
modifier: Modifier = Modifier
) {
@@ -209,26 +245,38 @@
modifier = modifier,
title = stringResource(id = R.string.aspect_ratio_title),
leadingIcon = null,
- description = when (currentAspectRatio) {
- AspectRatio.NINE_SIXTEEN -> stringResource(id = R.string.aspect_ratio_description_9_16)
- AspectRatio.THREE_FOUR -> stringResource(id = R.string.aspect_ratio_description_3_4)
- AspectRatio.ONE_ONE -> stringResource(id = R.string.aspect_ratio_description_1_1)
+ description =
+ if (aspectRatioUiState is AspectRatioUiState.Enabled) {
+ when (aspectRatioUiState.currentAspectRatio) {
+ AspectRatio.NINE_SIXTEEN -> stringResource(
+ id = R.string.aspect_ratio_description_9_16
+ )
+
+ AspectRatio.THREE_FOUR -> stringResource(id = R.string.aspect_ratio_description_3_4)
+ AspectRatio.ONE_ONE -> stringResource(id = R.string.aspect_ratio_description_1_1)
+ }
+ } else {
+ TODO("aspect ratio currently has no disabled criteria")
},
+ enabled = true,
popupContents = {
Column(Modifier.selectableGroup()) {
SingleChoiceSelector(
text = stringResource(id = R.string.aspect_ratio_selector_9_16),
- selected = currentAspectRatio == AspectRatio.NINE_SIXTEEN,
+ selected = aspectRatioUiState.currentAspectRatio == AspectRatio.NINE_SIXTEEN,
+ enabled = true,
onClick = { setAspectRatio(AspectRatio.NINE_SIXTEEN) }
)
SingleChoiceSelector(
text = stringResource(id = R.string.aspect_ratio_selector_3_4),
- selected = currentAspectRatio == AspectRatio.THREE_FOUR,
+ selected = aspectRatioUiState.currentAspectRatio == AspectRatio.THREE_FOUR,
+ enabled = true,
onClick = { setAspectRatio(AspectRatio.THREE_FOUR) }
)
SingleChoiceSelector(
text = stringResource(id = R.string.aspect_ratio_selector_1_1),
- selected = currentAspectRatio == AspectRatio.ONE_ONE,
+ selected = aspectRatioUiState.currentAspectRatio == AspectRatio.ONE_ONE,
+ enabled = true,
onClick = { setAspectRatio(AspectRatio.ONE_ONE) }
)
}
@@ -238,7 +286,7 @@
@Composable
fun CaptureModeSetting(
- currentCaptureMode: CaptureMode,
+ captureModeUiState: CaptureModeUiState,
setCaptureMode: (CaptureMode) -> Unit,
modifier: Modifier = Modifier
) {
@@ -246,25 +294,33 @@
modifier = modifier,
title = stringResource(R.string.capture_mode_title),
leadingIcon = null,
- description = when (currentCaptureMode) {
- CaptureMode.MULTI_STREAM -> stringResource(
- id = R.string.capture_mode_description_multi_stream
- )
+ enabled = true,
+ description =
+ if (captureModeUiState is CaptureModeUiState.Enabled) {
+ when (captureModeUiState.currentCaptureMode) {
+ CaptureMode.MULTI_STREAM -> stringResource(
+ id = R.string.capture_mode_description_multi_stream
+ )
- CaptureMode.SINGLE_STREAM -> stringResource(
- id = R.string.capture_mode_description_single_stream
- )
+ CaptureMode.SINGLE_STREAM -> stringResource(
+ id = R.string.capture_mode_description_single_stream
+ )
+ }
+ } else {
+ TODO("capture mode currently has no disabled criteria")
},
popupContents = {
Column(Modifier.selectableGroup()) {
SingleChoiceSelector(
text = stringResource(id = R.string.capture_mode_selector_multi_stream),
- selected = currentCaptureMode == CaptureMode.MULTI_STREAM,
+ selected = captureModeUiState.currentCaptureMode == CaptureMode.MULTI_STREAM,
+ enabled = true,
onClick = { setCaptureMode(CaptureMode.MULTI_STREAM) }
)
SingleChoiceSelector(
text = stringResource(id = R.string.capture_mode_description_single_stream),
- selected = currentCaptureMode == CaptureMode.SINGLE_STREAM,
+ selected = captureModeUiState.currentCaptureMode == CaptureMode.SINGLE_STREAM,
+ enabled = true,
onClick = { setCaptureMode(CaptureMode.SINGLE_STREAM) }
)
}
@@ -274,20 +330,21 @@
@Composable
fun TargetFpsSetting(
- currentTargetFps: Int,
- supportedFps: Set<Int>,
+ fpsUiState: FpsUiState,
setTargetFps: (Int) -> Unit,
modifier: Modifier = Modifier
) {
BasicPopupSetting(
- modifier = modifier,
+ modifier = modifier.apply {
+ if (fpsUiState is FpsUiState.Disabled) {
+ testTag(fpsUiState.disabledRationale.testTag)
+ }
+ },
title = stringResource(id = R.string.fps_title),
- enabled = supportedFps.isNotEmpty(),
+ enabled = fpsUiState is FpsUiState.Enabled,
leadingIcon = null,
- description = if (supportedFps.isEmpty()) {
- stringResource(id = R.string.fps_description_unavailable)
- } else {
- when (currentTargetFps) {
+ description = if (fpsUiState is FpsUiState.Enabled) {
+ when (fpsUiState.currentSelection) {
FPS_15 -> stringResource(id = R.string.fps_description, FPS_15)
FPS_30 -> stringResource(id = R.string.fps_description, FPS_30)
FPS_60 -> stringResource(id = R.string.fps_description, FPS_60)
@@ -295,27 +352,46 @@
id = R.string.fps_description_auto
)
}
+ } else {
+ disabledRationaleString((fpsUiState as FpsUiState.Disabled).disabledRationale)
},
popupContents = {
- Column(Modifier.selectableGroup()) {
- Text(
- text = stringResource(id = R.string.fps_stabilization_disclaimer),
- fontStyle = FontStyle.Italic,
- color = MaterialTheme.colorScheme.onPrimaryContainer
- )
-
- SingleChoiceSelector(
- text = stringResource(id = R.string.fps_selector_auto),
- selected = currentTargetFps == FPS_AUTO,
- onClick = { setTargetFps(FPS_AUTO) }
- )
- listOf(FPS_15, FPS_30, FPS_60).forEach { fpsOption ->
- SingleChoiceSelector(
- text = "%d".format(fpsOption),
- selected = currentTargetFps == fpsOption,
- onClick = { setTargetFps(fpsOption) },
- enabled = supportedFps.contains(fpsOption)
+ if (fpsUiState is FpsUiState.Enabled) {
+ Column(Modifier.selectableGroup()) {
+ Text(
+ text = stringResource(id = R.string.fps_stabilization_disclaimer),
+ fontStyle = FontStyle.Italic,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
)
+
+ SingleChoiceSelector(
+ text = stringResource(id = R.string.fps_selector_auto),
+ selected = fpsUiState.currentSelection == FPS_AUTO,
+ onClick = { setTargetFps(FPS_AUTO) },
+ enabled = fpsUiState.fpsAutoState is SingleSelectableState.Selectable
+ )
+ listOf(FPS_15, FPS_30, FPS_60).forEach { fpsOption ->
+ SingleChoiceSelector(
+ text = "%d".format(fpsOption),
+ selected = fpsUiState.currentSelection == fpsOption,
+ onClick = { setTargetFps(fpsOption) },
+ enabled = when (fpsOption) {
+ FPS_15 ->
+ fpsUiState.fpsFifteenState is
+ SingleSelectableState.Selectable
+
+ FPS_30 ->
+ fpsUiState.fpsThirtyState is
+ SingleSelectableState.Selectable
+
+ FPS_60 ->
+ fpsUiState.fpsSixtyState is
+ SingleSelectableState.Selectable
+
+ else -> false
+ }
+ )
+ }
}
}
}
@@ -352,49 +428,44 @@
* HIGH_QUALITY - Video will be stabilized, preview might be stabilized, depending on the device.
* OFF - Preview and video stabilization is disabled.
*
- * @param supportedStabilizationMode the enabled condition for this setting.
+ * @param stabilizationUiState the state for this setting.
*/
@Composable
fun StabilizationSetting(
- currentPreviewStabilization: Stabilization,
- currentVideoStabilization: Stabilization,
- currentTargetFps: Int,
- supportedStabilizationMode: Set<SupportedStabilizationMode>,
+ stabilizationUiState: StabilizationUiState,
setVideoStabilization: (Stabilization) -> Unit,
setPreviewStabilization: (Stabilization) -> Unit,
modifier: Modifier = Modifier
) {
- // if the preview stabilization was left ON and the target frame rate was set to 15,
- // this setting needs to be reset to OFF
- LaunchedEffect(key1 = currentTargetFps, key2 = currentPreviewStabilization) {
- if (currentTargetFps == FPS_15 &&
- currentPreviewStabilization == Stabilization.ON
- ) {
- setPreviewStabilization(Stabilization.UNDEFINED)
- }
- }
// entire setting disabled when no available fps or target fps = 60
// stabilization is unsupported >30 fps
BasicPopupSetting(
- modifier = modifier,
+ modifier = modifier.apply {
+ when (stabilizationUiState) {
+ is StabilizationUiState.Disabled ->
+ testTag(stabilizationUiState.disabledRationale.testTag)
+
+ else -> {}
+ }
+ },
title = stringResource(R.string.video_stabilization_title),
leadingIcon = null,
- enabled = (
- supportedStabilizationMode.isNotEmpty() &&
- currentTargetFps != FPS_60
- ),
- description = if (supportedStabilizationMode.isEmpty()) {
- stringResource(id = R.string.stabilization_description_unsupported_device)
- } else if (currentTargetFps == FPS_60) {
- stringResource(id = R.string.stabilization_description_unsupported_fps)
- } else {
- stringResource(
- id = getStabilizationStringRes(
- previewStabilization = currentPreviewStabilization,
- videoStabilization = currentVideoStabilization
+ enabled = stabilizationUiState is StabilizationUiState.Enabled,
+ description = when (stabilizationUiState) {
+ is StabilizationUiState.Enabled ->
+ stringResource(
+ id = getStabilizationStringRes(
+ previewStabilization = stabilizationUiState.currentPreviewStabilization,
+ videoStabilization = stabilizationUiState.currentVideoStabilization
+ )
)
- )
+
+ is StabilizationUiState.Disabled -> {
+ // disabled setting description
+ disabledRationaleString(stabilizationUiState.disabledRationale)
+ }
},
+
popupContents = {
Column(Modifier.selectableGroup()) {
Text(
@@ -406,55 +477,96 @@
// on (preview) selector
// disabled if target fps != (30 or off)
// TODO(b/328223562): device always resolves to 30fps when using preview stabilization
- SingleChoiceSelector(
- text = stringResource(id = R.string.stabilization_selector_on),
- secondaryText = stringResource(id = R.string.stabilization_selector_on_info),
- enabled =
- (
- when (currentTargetFps) {
- FPS_AUTO, FPS_30 -> true
- else -> false
- }
- ) &&
- supportedStabilizationMode.contains(SupportedStabilizationMode.ON),
- selected = (currentPreviewStabilization == Stabilization.ON) &&
- (currentVideoStabilization != Stabilization.OFF),
- onClick = {
- setVideoStabilization(Stabilization.UNDEFINED)
- setPreviewStabilization(Stabilization.ON)
- }
- )
+ when (stabilizationUiState) {
+ is StabilizationUiState.Enabled -> {
+ SingleChoiceSelector(
+ modifier = Modifier.apply {
+ if (stabilizationUiState.stabilizationOnState
+ is SingleSelectableState.Disabled
+ ) {
+ testTag(
+ stabilizationUiState.stabilizationOnState
+ .disabledRationale.testTag
+ )
+ }
+ },
+ text = stringResource(id = R.string.stabilization_selector_on),
+ secondaryText = stringResource(
+ id = R.string.stabilization_selector_on_info
+ ),
+ enabled = stabilizationUiState.stabilizationOnState is
+ SingleSelectableState.Selectable,
+ selected = (
+ stabilizationUiState.currentPreviewStabilization
+ == Stabilization.ON
+ ) &&
+ (
+ stabilizationUiState.currentVideoStabilization
+ != Stabilization.OFF
+ ),
+ onClick = {
+ setVideoStabilization(Stabilization.UNDEFINED)
+ setPreviewStabilization(Stabilization.ON)
+ }
+ )
- // high quality selector
- // disabled if target fps = 60 (see VideoCapabilities.isStabilizationSupported)
- SingleChoiceSelector(
- text = stringResource(id = R.string.stabilization_selector_high_quality),
- secondaryText = stringResource(
- id = R.string.stabilization_selector_high_quality_info
- ),
- enabled = (currentTargetFps != FPS_60) &&
- supportedStabilizationMode.contains(
- SupportedStabilizationMode.HIGH_QUALITY
- ),
+ // high quality selector
+ // disabled if target fps = 60 (see VideoCapabilities.isStabilizationSupported)
+ SingleChoiceSelector(
+ modifier = Modifier.apply {
+ if (stabilizationUiState.stabilizationHighQualityState
+ is SingleSelectableState.Disabled
+ ) {
+ testTag(
+ stabilizationUiState.stabilizationHighQualityState
+ .disabledRationale.testTag
+ )
+ }
+ },
+ text = stringResource(
+ id = R.string.stabilization_selector_high_quality
+ ),
+ secondaryText = stringResource(
+ id = R.string.stabilization_selector_high_quality_info
+ ),
+ enabled = stabilizationUiState.stabilizationHighQualityState
+ == SingleSelectableState.Selectable,
- selected = (currentPreviewStabilization == Stabilization.UNDEFINED) &&
- (currentVideoStabilization == Stabilization.ON),
- onClick = {
- setVideoStabilization(Stabilization.ON)
- setPreviewStabilization(Stabilization.UNDEFINED)
- }
- )
+ selected = (
+ stabilizationUiState.currentPreviewStabilization
+ == Stabilization.UNDEFINED
+ ) &&
+ (
+ stabilizationUiState.currentVideoStabilization
+ == Stabilization.ON
+ ),
+ onClick = {
+ setVideoStabilization(Stabilization.ON)
+ setPreviewStabilization(Stabilization.UNDEFINED)
+ }
+ )
- // off selector
- SingleChoiceSelector(
- text = stringResource(id = R.string.stabilization_selector_off),
- selected = (currentPreviewStabilization != Stabilization.ON) &&
- (currentVideoStabilization != Stabilization.ON),
- onClick = {
- setVideoStabilization(Stabilization.OFF)
- setPreviewStabilization(Stabilization.OFF)
+ // off selector
+ SingleChoiceSelector(
+ text = stringResource(id = R.string.stabilization_selector_off),
+ selected = (
+ stabilizationUiState.currentPreviewStabilization
+ != Stabilization.ON
+ ) &&
+ (
+ stabilizationUiState.currentVideoStabilization
+ != Stabilization.ON
+ ),
+ onClick = {
+ setVideoStabilization(Stabilization.OFF)
+ setPreviewStabilization(Stabilization.OFF)
+ },
+ enabled = true
+ )
}
- )
+
+ else -> {}
+ }
}
}
)
@@ -465,7 +577,8 @@
SettingUI(
modifier = modifier,
title = stringResource(id = R.string.version_info_title),
- leadingIcon = null
+ leadingIcon = null,
+ enabled = true
) {
val versionString = versionName +
if (buildType.isNotEmpty()) {
@@ -492,7 +605,7 @@
leadingIcon: @Composable (() -> Unit)?,
popupContents: @Composable () -> Unit,
modifier: Modifier = Modifier,
- enabled: Boolean = true
+ enabled: Boolean
) {
val popupStatus = remember { mutableStateOf(false) }
SettingUI(
@@ -565,25 +678,25 @@
title: String,
leadingIcon: @Composable (() -> Unit)?,
modifier: Modifier = Modifier,
- enabled: Boolean = true,
+ enabled: Boolean,
description: String? = null,
trailingContent: @Composable (() -> Unit)?
) {
ListItem(
modifier = modifier,
headlineContent = {
- when (enabled) {
- true -> Text(title)
- false -> {
- Text(text = title, color = LocalContentColor.current.copy(alpha = .7f))
- }
+ if (enabled) {
+ Text(title)
+ } else {
+ Text(text = title, color = LocalContentColor.current.copy(alpha = .7f))
}
},
supportingContent = {
if (description != null) {
- when (enabled) {
- true -> Text(description)
- false -> Text(
+ if (enabled) {
+ Text(description)
+ } else {
+ Text(
text = description,
color = LocalContentColor.current.copy(alpha = .7f)
)
@@ -605,7 +718,7 @@
onClick: () -> Unit,
modifier: Modifier = Modifier,
secondaryText: String? = null,
- enabled: Boolean = true
+ enabled: Boolean
) {
Row(
modifier
@@ -634,6 +747,34 @@
}
}
+@Composable
+@ReadOnlyComposable
+fun disabledRationaleString(disabledRationale: DisabledRationale): String {
+ return when (disabledRationale) {
+ is DisabledRationale.DeviceUnsupportedRationale -> stringResource(
+
+ disabledRationale.reasonTextResId,
+ stringResource(disabledRationale.affectedSettingNameResId)
+ )
+
+ is DisabledRationale.FpsUnsupportedRationale -> stringResource(
+ disabledRationale.reasonTextResId,
+ stringResource(disabledRationale.affectedSettingNameResId),
+ disabledRationale.currentFps
+ )
+
+ is DisabledRationale.LensUnsupportedRationale -> stringResource(
+ disabledRationale.reasonTextResId,
+ stringResource(disabledRationale.affectedSettingNameResId)
+ )
+
+ is DisabledRationale.StabilizationUnsupportedRationale -> stringResource(
+ disabledRationale.reasonTextResId,
+ stringResource(disabledRationale.affectedSettingNameResId)
+ )
+ }
+}
+
@Preview(name = "Light Mode")
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt
index ef11b24..8253fc1 100644
--- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt
+++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/TestTags.kt
@@ -16,3 +16,9 @@
package com.google.jetpackcamera.settings.ui
const val BACK_BUTTON = "BackButton"
+
+// unsupported rationale tags
+const val DEVICE_UNSUPPORTED_TAG = "DeviceUnsupportedTag"
+const val STABILIZATION_UNSUPPORTED_TAG = "StabilizationUnsupportedTag"
+const val LENS_UNSUPPORTED_TAG = "LensUnsupportedTag"
+const val FPS_UNSUPPORTED_TAG = "FpsUnsupportedTag"
diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml
index ad8aff6..e41f4fd 100644
--- a/feature/settings/src/main/res/values/strings.xml
+++ b/feature/settings/src/main/res/values/strings.xml
@@ -101,6 +101,24 @@
<string name="fps_stabilization_disclaimer">*Available stabilization modes may change due to selected frame rate.</string>
<string name="lens_stabilization_disclaimer">*Some devices may not support stabilization on both lens.</string>
+ <!-- disabled rationale strings-->
+ <string name="device_unsupported">%1$s is unsupported by the device</string>
+ <string name="fps_unsupported"> %1$s is unsupported at %2$d fps</string>
+ <string name="stabilization_unsupported">%$1s is unsupported by the current stabilization</string>
+ <string name="current_lens_unsupported">%$s is unsupported by the current lens</string>
+ <string name="rear_lens_unsupported">%$s is unsupported by the rear lens</string>
+ <string name="front_lens_unsupported">%$s is unsupported by the front lens</string>
+
+
+ <!-- Rationale prefixes -->
+ <string name="stabilization_rationale_prefix">Stabilization</string>
+ <string name="lens_rationale_prefix">Lens flip</string>
+ <string name="fps_rationale_prefix">Fps</string>
+
+ <string name="front_lens_rationale_prefix">Front lens</string>
+ <string name="rear_lens_rationale_prefix">Rear lens</string>
+ <string name="no_fixed_fps_rationale_prefix">Fixed frame rate</string>
+
<!-- Version info strings -->
<string name="version_info_title">Version</string>
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 930f838..36ea132 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -47,6 +47,7 @@
protobuf = "3.25.2"
robolectric = "4.11.1"
truth = "1.4.2"
+rules = "1.6.1"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
@@ -59,6 +60,7 @@
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }
androidx-graphics-core = { module = "androidx.graphics:graphics-core", version.ref = "androidxGraphicsCore" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" }
+androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigationCompose" }
@@ -84,6 +86,7 @@
futures-ktx = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "androidxConcurrentFutures" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
junit = { module = "junit:junit", version.ref = "junit" }
+kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinPlugin" }
kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
@@ -92,6 +95,7 @@
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }
+rules = { group = "androidx.test", name = "rules", version.ref = "rules" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index b021ba1..7c7842f 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/11932573/artifacts/repository")
+ setUrl("https://androidx.dev/snapshots/builds/12167802/artifacts/repository")
}
google()
mavenCentral()