summaryrefslogtreecommitdiff
path: root/feature/preview
diff options
context:
space:
mode:
Diffstat (limited to 'feature/preview')
-rw-r--r--feature/preview/Android.bp14
-rw-r--r--feature/preview/build.gradle.kts52
-rw-r--r--feature/preview/src/androidTest/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt114
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt293
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt5
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt118
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt87
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt98
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponents.kt123
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt20
-rw-r--r--feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ToastMessage.kt36
-rw-r--r--feature/preview/src/main/res/drawable/baseline_video_stable_24.xml21
-rw-r--r--feature/preview/src/main/res/values/strings.xml8
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt11
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt120
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/rules/MainDispatcherRule.kt39
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt133
-rw-r--r--feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/workaround/ComposableCaptureToImage.kt248
18 files changed, 1405 insertions, 135 deletions
diff --git a/feature/preview/Android.bp b/feature/preview/Android.bp
index ed5c86e..19485f4 100644
--- a/feature/preview/Android.bp
+++ b/feature/preview/Android.bp
@@ -14,22 +14,22 @@ android_library {
"androidx.compose.runtime_runtime",
"androidx.compose.material3_material3",
"androidx.compose.ui_ui-tooling-preview",
+ "androidx.tracing_tracing-ktx",
"hilt_android",
- "androidx.hilt_hilt-navigation-compose",
+ "androidx.hilt_hilt-navigation-compose",
"androidx.compose.ui_ui-tooling",
"kotlinx_coroutines_guava",
"androidx.datastore_datastore",
"libprotobuf-java-lite",
- "androidx.camera_camera-core",
+ "androidx.camera_camera-core",
"androidx.camera_camera-viewfinder",
"jetpack-camera-app_data_settings",
- "jetpack-camera-app_domain_camera",
- "jetpack-camera-app_camera-viewfinder-compose",
- "jetpack-camera-app_feature_quicksettings",
+ "jetpack-camera-app_domain_camera",
+ "jetpack-camera-app_camera-viewfinder-compose",
+ "jetpack-camera-app_feature_quicksettings",
],
sdk_version: "34",
min_sdk_version: "21",
- manifest:"src/main/AndroidManifest.xml"
+ manifest: "src/main/AndroidManifest.xml",
}
-
diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts
index 121ac17..126fe88 100644
--- a/feature/preview/build.gradle.kts
+++ b/feature/preview/build.gradle.kts
@@ -15,10 +15,10 @@
*/
plugins {
- id("com.android.library")
- id("org.jetbrains.kotlin.android")
- id("kotlin-kapt")
- id("com.google.dagger.hilt.android")
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.kapt)
+ alias(libs.plugins.dagger.hilt.android)
}
android {
@@ -58,47 +58,57 @@ android {
testOptions {
unitTests {
isReturnDefaultValues = true
+ isIncludeAndroidResources = true
}
}
}
dependencies {
// Compose
- val composeBom = platform("androidx.compose:compose-bom:2023.08.00")
+ val composeBom = platform(libs.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
// Compose - Material Design 3
- implementation("androidx.compose.material3:material3")
+ implementation(libs.compose.material3)
// Compose - Android Studio Preview support
- implementation("androidx.compose.ui:ui-tooling-preview")
- debugImplementation("androidx.compose.ui:ui-tooling")
+ implementation(libs.compose.ui.tooling.preview)
+ debugImplementation(libs.compose.ui.tooling)
// Compose - Integration with ViewModels with Navigation and Hilt
- implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
+ implementation(libs.hilt.navigation.compose)
// Compose - Testing
- androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ androidTestImplementation(libs.compose.junit)
+ debugImplementation(libs.compose.test.manifest)
+ // noinspection TestManifestGradleConfiguration: required for release build unit tests
+ testImplementation(libs.compose.test.manifest)
+ testImplementation(libs.compose.junit)
// Testing
- testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.3")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
- testImplementation("org.mockito:mockito-core:5.2.0")
- testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6")
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ testImplementation(libs.mockito.core)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.robolectric)
+ debugImplementation(libs.androidx.test.monitor)
+ implementation(libs.androidx.junit)
// Guava
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.4.1")
+ implementation(libs.kotlinx.coroutines.guava)
// CameraX
- val camerax_version = "1.4.0-SNAPSHOT"
- implementation("androidx.camera:camera-core:${camerax_version}")
- implementation("androidx.camera:camera-view:${camerax_version}")
+ implementation(libs.camera.core)
+ implementation(libs.camera.view)
// Hilt
- implementation("com.google.dagger:hilt-android:2.44")
- kapt("com.google.dagger:hilt-compiler:2.44")
+ implementation(libs.dagger.hilt.android)
+ kapt(libs.dagger.hilt.compiler)
+
+ //Tracing
+ implementation(libs.androidx.tracing)
// Project dependencies
implementation(project(":data:settings"))
diff --git a/feature/preview/src/androidTest/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt b/feature/preview/src/androidTest/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt
new file mode 100644
index 0000000..7c369e3
--- /dev/null
+++ b/feature/preview/src/androidTest/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.feature.preview.ui
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.test.assertHeightIsAtLeast
+import androidx.compose.ui.test.assertWidthIsAtLeast
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.getBoundsInRoot
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.unit.height
+import androidx.compose.ui.unit.width
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.jetpackcamera.feature.preview.ScreenFlash
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ScreenFlashComponentsKtTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val screenFlashUiState: MutableState<ScreenFlash.ScreenFlashUiState> =
+ mutableStateOf(ScreenFlash.ScreenFlashUiState())
+
+ @Before
+ fun setUp() {
+ composeTestRule.setContent {
+ ScreenFlashScreen(
+ screenFlashUiState = screenFlashUiState.value,
+ onInitialBrightnessCalculated = {}
+ )
+ }
+ }
+
+ @Test
+ fun screenFlashOverlay_doesNotExistByDefault() = runTest {
+ composeTestRule.awaitIdle()
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertDoesNotExist()
+ }
+
+ @Test
+ fun screenFlashOverlay_existsAfterStateIsEnabled() = runTest {
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true)
+
+ composeTestRule.awaitIdle()
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertExists()
+ }
+
+ @Test
+ fun screenFlashOverlay_doesNotExistWhenDisabledAfterEnabled() = runTest {
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true)
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = false)
+
+ composeTestRule.awaitIdle()
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertDoesNotExist()
+ }
+
+ @Test
+ fun screenFlashOverlay_sizeFillsMaxSize() = runTest {
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true)
+
+ composeTestRule.awaitIdle()
+ val rootBounds = composeTestRule.onRoot().getBoundsInRoot()
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay"))
+ .assertWidthIsAtLeast(rootBounds.width)
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay"))
+ .assertHeightIsAtLeast(rootBounds.height)
+ }
+
+ @Test
+ fun screenFlashOverlay_fullWhiteWhenEnabled() = runTest {
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true)
+
+ composeTestRule.awaitIdle()
+ val overlayScreenShot =
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).captureToImage()
+
+ // check a few pixels near center instead of whole image to save time
+ val overlayPixels = IntArray(4)
+ overlayScreenShot.readPixels(
+ overlayPixels,
+ overlayScreenShot.width / 2,
+ overlayScreenShot.height / 2,
+ 2,
+ 2
+ )
+ overlayPixels.forEach {
+ assertEquals(Color.White.toArgb(), it)
+ }
+ }
+}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
index f3366cf..c5cfdd2 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
@@ -15,6 +15,7 @@
*/
package com.google.jetpackcamera.feature.preview
+import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
@@ -46,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -55,13 +57,19 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
+import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON
import com.google.jetpackcamera.feature.preview.ui.CaptureButton
import com.google.jetpackcamera.feature.preview.ui.FlipCameraButton
import com.google.jetpackcamera.feature.preview.ui.PreviewDisplay
+import com.google.jetpackcamera.feature.preview.ui.ScreenFlashScreen
import com.google.jetpackcamera.feature.preview.ui.SettingsNavButton
+import com.google.jetpackcamera.feature.preview.ui.ShowTestableToast
+import com.google.jetpackcamera.feature.preview.ui.StabilizationIcon
import com.google.jetpackcamera.feature.preview.ui.TestingButton
import com.google.jetpackcamera.feature.preview.ui.ZoomScaleText
-import com.google.jetpackcamera.feature.quicksettings.QuickSettingsScreen
+import com.google.jetpackcamera.feature.quicksettings.QuickSettingsScreenOverlay
+import com.google.jetpackcamera.feature.quicksettings.ui.QuickSettingsIndicators
+import com.google.jetpackcamera.feature.quicksettings.ui.ToggleQuickSettingsButton
import com.google.jetpackcamera.settings.model.CaptureMode
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation
@@ -77,12 +85,16 @@ private const val ZOOM_SCALE_SHOW_TIMEOUT_MS = 3000L
fun PreviewScreen(
onPreviewViewModel: (PreviewViewModel) -> Unit,
onNavigateToSettings: () -> Unit,
- viewModel: PreviewViewModel = hiltViewModel()
+ viewModel: PreviewViewModel = hiltViewModel(),
+ previewMode: PreviewMode
) {
Log.d(TAG, "PreviewScreen")
val previewUiState: PreviewUiState by viewModel.previewUiState.collectAsState()
+ val screenFlashUiState: ScreenFlash.ScreenFlashUiState
+ by viewModel.screenFlash.screenFlashUiState.collectAsState()
+
val lifecycleOwner = LocalLifecycleOwner.current
val deferredSurfaceProvider = remember { CompletableDeferred<SurfaceProvider>() }
@@ -118,74 +130,127 @@ fun PreviewScreen(
Text(text = stringResource(R.string.camera_not_ready), color = Color.White)
}
} else if (previewUiState.cameraState == CameraState.READY) {
- // display camera feed. this stays behind everything else
- PreviewDisplay(
- onFlipCamera = viewModel::flipCamera,
- onTapToFocus = viewModel::tapToFocus,
- onZoomChange = { zoomChange: Float ->
- viewModel.setZoomScale(zoomChange)
- zoomScaleShow = true
- zoomHandler.postDelayed({ zoomScaleShow = false }, ZOOM_SCALE_SHOW_TIMEOUT_MS)
- },
- aspectRatio = previewUiState.currentCameraSettings.aspectRatio,
- deferredSurfaceProvider = deferredSurfaceProvider
- )
- // overlay
Box(
- modifier = Modifier
- .semantics {
- testTagsAsResourceId = true
- }
- .fillMaxSize()
+ modifier = Modifier.semantics {
+ testTagsAsResourceId = true
+ }
) {
- // hide settings, quickSettings, and quick capture mode button
- when (previewUiState.videoRecordingState) {
- VideoRecordingState.ACTIVE -> {}
- VideoRecordingState.INACTIVE -> {
- QuickSettingsScreen(
- modifier = Modifier
- .align(Alignment.TopCenter),
- isOpen = previewUiState.quickSettingsIsOpen,
- toggleIsOpen = { viewModel.toggleQuickSettings() },
- currentCameraSettings = previewUiState.currentCameraSettings,
- onLensFaceClick = viewModel::flipCamera,
- onFlashModeClick = viewModel::setFlash,
- onAspectRatioClick = {
- viewModel.setAspectRatio(it)
- }
- // onTimerClick = {}/*TODO*/
- )
+ // display camera feed. this stays behind everything else
+ PreviewDisplay(
+ onFlipCamera = viewModel::flipCamera,
+ onTapToFocus = viewModel::tapToFocus,
+ onZoomChange = { zoomChange: Float ->
+ viewModel.setZoomScale(zoomChange)
+ zoomScaleShow = true
+ zoomHandler.postDelayed({ zoomScaleShow = false }, ZOOM_SCALE_SHOW_TIMEOUT_MS)
+ },
+ aspectRatio = previewUiState.currentCameraSettings.aspectRatio,
+ deferredSurfaceProvider = deferredSurfaceProvider
+ )
- SettingsNavButton(
- modifier = Modifier
- .align(Alignment.TopStart)
- .padding(12.dp),
- onNavigateToSettings = onNavigateToSettings
- )
+ QuickSettingsScreenOverlay(
+ modifier = Modifier,
+ isOpen = previewUiState.quickSettingsIsOpen,
+ toggleIsOpen = { viewModel.toggleQuickSettings() },
+ currentCameraSettings = previewUiState.currentCameraSettings,
+ onLensFaceClick = viewModel::flipCamera,
+ onFlashModeClick = viewModel::setFlash,
+ onAspectRatioClick = {
+ viewModel.setAspectRatio(it)
+ }
+ // onTimerClick = {}/*TODO*/
+ )
+ // relative-grid style overlay on top of preview display
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ // hide settings, quickSettings, and quick capture mode button
+ when (previewUiState.videoRecordingState) {
+ VideoRecordingState.ACTIVE -> {}
+ VideoRecordingState.INACTIVE -> {
+ // 3-segmented row to keep quick settings button centered
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min)
+ ) {
+ // row to left of quick settings button
+ Row(
+ modifier = Modifier
+ .weight(1f),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // button to open default settings page
+ SettingsNavButton(
+ modifier = Modifier
+ .padding(12.dp),
+ onNavigateToSettings = onNavigateToSettings
+ )
+ if (!previewUiState.quickSettingsIsOpen) {
+ QuickSettingsIndicators(
+ currentCameraSettings = previewUiState
+ .currentCameraSettings,
+ onFlashModeClick = viewModel::setFlash
+ )
+ }
+ }
+ // quick settings button
+ ToggleQuickSettingsButton(
+ toggleDropDown = { viewModel.toggleQuickSettings() },
+ isOpen = previewUiState.quickSettingsIsOpen
+ )
- TestingButton(
- modifier = Modifier
- .testTag("ToggleCaptureMode")
- .align(Alignment.TopEnd)
- .padding(12.dp),
- onClick = { viewModel.toggleCaptureMode() },
- text = stringResource(
- when (previewUiState.currentCameraSettings.captureMode) {
- CaptureMode.SINGLE_STREAM -> R.string.capture_mode_single_stream
- CaptureMode.MULTI_STREAM -> R.string.capture_mode_multi_stream
+ // Row to right of quick settings
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TestingButton(
+ modifier = Modifier
+ .testTag("ToggleCaptureMode"),
+ onClick = { viewModel.toggleCaptureMode() },
+ text = stringResource(
+ when (previewUiState.currentCameraSettings.captureMode) {
+ CaptureMode.SINGLE_STREAM ->
+ R.string.capture_mode_single_stream
+
+ CaptureMode.MULTI_STREAM ->
+ R.string.capture_mode_multi_stream
+ }
+ )
+ )
+ StabilizationIcon(
+ supportedStabilizationMode = previewUiState
+ .currentCameraSettings.supportedStabilizationModes,
+ videoStabilization = previewUiState
+ .currentCameraSettings.videoCaptureStabilization,
+ previewStabilization = previewUiState
+ .currentCameraSettings.previewStabilization
+ )
}
- )
- )
+ }
+ }
}
- }
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.align(Alignment.BottomCenter)
- ) {
+ // this component places a gap in the center of the column that will push out the top
+ // and bottom edges. This will also allow the addition of vertical button bars on the
+ // sides of the screen
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ ) {}
+
if (zoomScaleShow) {
ZoomScaleText(zoomScale = zoomScale)
}
+
+ // 3-segmented row to keep capture button centered
Row(
modifier =
Modifier
@@ -193,6 +258,7 @@ fun PreviewScreen(
.height(IntrinsicSize.Min)
) {
when (previewUiState.videoRecordingState) {
+ // hide first segment while recording in progress
VideoRecordingState.ACTIVE -> {
Spacer(
modifier = Modifier
@@ -200,41 +266,114 @@ fun PreviewScreen(
.weight(1f)
)
}
-
+ // show first segment when not recording
VideoRecordingState.INACTIVE -> {
- FlipCameraButton(
+ Row(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
- onClick = { viewModel.flipCamera() },
- // enable only when phone has front and rear camera
- enabledCondition =
- previewUiState.currentCameraSettings.isBackCameraAvailable &&
- previewUiState.currentCameraSettings.isFrontCameraAvailable
- )
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (!previewUiState.quickSettingsIsOpen) {
+ FlipCameraButton(
+ onClick = { viewModel.flipCamera() },
+ // enable only when phone has front and rear camera
+ enabledCondition =
+ previewUiState
+ .currentCameraSettings
+ .isBackCameraAvailable &&
+ previewUiState
+ .currentCameraSettings
+ .isFrontCameraAvailable
+ )
+ }
+ }
}
}
val multipleEventsCutter = remember { MultipleEventsCutter() }
- /*todo: close quick settings on start record/image capture*/
+ val context = LocalContext.current
CaptureButton(
+ modifier = Modifier
+ .testTag(CAPTURE_BUTTON),
onClick = {
- multipleEventsCutter.processEvent { viewModel.captureImage() }
+ multipleEventsCutter.processEvent {
+ when (previewMode) {
+ is PreviewMode.StandardMode -> {
+ viewModel.captureImage()
+ }
+
+ is PreviewMode.ExternalImageCaptureMode -> {
+ viewModel.captureImage(
+ context.contentResolver,
+ previewMode.imageCaptureUri,
+ previewMode.onImageCapture
+ )
+ }
+ }
+ }
+ if (previewUiState.quickSettingsIsOpen) {
+ viewModel.toggleQuickSettings()
+ }
+ },
+ onLongPress = {
+ viewModel.startVideoRecording()
+ if (previewUiState.quickSettingsIsOpen) {
+ viewModel.toggleQuickSettings()
+ }
},
- onLongPress = { viewModel.startVideoRecording() },
onRelease = { viewModel.stopVideoRecording() },
videoRecordingState = previewUiState.videoRecordingState
)
- /* spacer is a placeholder to maintain the proportionate location of this row of
- UI elements. if you want to add another element, replace it with ONE element.
- If you want to add multiple components, use a container (Box, Row, Column, etc.)
- */
- Spacer(
+ // You can replace this row so long as the weight of the component is 1f to
+ // ensure the capture button remains centered.
+ Row(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
- )
+ ) {
+ /*TODO("Place other components here") */
+ }
}
}
+ // displays toast when there is a message to show
+ if (previewUiState.toastMessageToShow != null) {
+ ShowTestableToast(
+ modifier = Modifier
+ .testTag(previewUiState.toastMessageToShow!!.testTag),
+ toastMessage = previewUiState.toastMessageToShow!!,
+ onToastShown = viewModel::onToastShown
+ )
+ }
+
+ // Screen flash overlay that stays on top of everything but invisible normally. This should
+ // not be enabled based on whether screen flash is enabled because a previous image capture
+ // may still be running after flash mode change and clear actions (e.g. brightness restore)
+ // may need to be handled later. Compose smart recomposition should be able to optimize this
+ // if the relevant states are no longer changing.
+ ScreenFlashScreen(
+ screenFlashUiState = screenFlashUiState,
+ onInitialBrightnessCalculated = viewModel.screenFlash::setClearUiScreenBrightness
+ )
}
}
}
+
+/**
+ * This interface is determined before the Preview UI is launched and passed into PreviewScreen. The
+ * UX differs depends on which mode the Preview is launched under.
+ */
+sealed interface PreviewMode {
+ /**
+ * The default mode for the app.
+ */
+ object StandardMode : PreviewMode
+
+ /**
+ * Under this mode, the app is launched by an external intent to capture an image.
+ */
+ data class ExternalImageCaptureMode(
+ val imageCaptureUri: Uri?,
+ val onImageCapture: (PreviewViewModel.ImageCaptureEvent) -> Unit
+ ) : PreviewMode
+}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt
index 1c368b2..976a9f9 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt
@@ -16,6 +16,7 @@
package com.google.jetpackcamera.feature.preview
import androidx.camera.core.CameraSelector
+import com.google.jetpackcamera.feature.preview.ui.ToastMessage
import com.google.jetpackcamera.settings.model.CameraAppSettings
/**
@@ -27,7 +28,9 @@ data class PreviewUiState(
val currentCameraSettings: CameraAppSettings,
val lensFacing: Int = CameraSelector.LENS_FACING_BACK,
val videoRecordingState: VideoRecordingState = VideoRecordingState.INACTIVE,
- val quickSettingsIsOpen: Boolean = false
+ val quickSettingsIsOpen: Boolean = false,
+ // todo: remove after implementing post capture screen
+ val toastMessageToShow: ToastMessage? = null
)
/**
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt
index e7b1544..5a43e60 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt
@@ -15,26 +15,37 @@
*/
package com.google.jetpackcamera.feature.preview
+import android.content.ContentResolver
+import android.net.Uri
import android.util.Log
import android.view.Display
-import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview.SurfaceProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import androidx.tracing.traceAsync
import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.jetpackcamera.feature.preview.ui.ToastMessage
import com.google.jetpackcamera.settings.SettingsRepository
import com.google.jetpackcamera.settings.model.AspectRatio
import com.google.jetpackcamera.settings.model.CaptureMode
import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
import com.google.jetpackcamera.settings.model.FlashMode
import dagger.hilt.android.lifecycle.HiltViewModel
+import java.lang.Exception
import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
private const val TAG = "PreviewViewModel"
+private const val IMAGE_CAPTURE_TRACE = "JCA Image Capture"
+
+// toast test descriptions
+const val IMAGE_CAPTURE_SUCCESS_TOAST_TAG = "ImageCaptureSuccessToast"
+const val IMAGE_CAPTURE_FAIL_TOAST_TAG = "ImageCaptureFailureToast"
/**
* [ViewModel] for [PreviewScreen].
@@ -45,7 +56,6 @@ class PreviewViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
// only reads from settingsRepository. do not push changes to repository from here
) : ViewModel() {
-
private val _previewUiState: MutableStateFlow<PreviewUiState> =
MutableStateFlow(PreviewUiState(currentCameraSettings = DEFAULT_CAMERA_APP_SETTINGS))
@@ -54,6 +64,8 @@ class PreviewViewModel @Inject constructor(
private var recordingJob: Job? = null
+ val screenFlash = ScreenFlash(cameraUseCase, viewModelScope)
+
init {
viewModelScope.launch {
settingsRepository.cameraAppSettings.collect {
@@ -112,7 +124,10 @@ class PreviewViewModel @Inject constructor(
)
)
// apply to cameraUseCase
- cameraUseCase.setFlashMode(previewUiState.value.currentCameraSettings.flashMode)
+ cameraUseCase.setFlashMode(
+ previewUiState.value.currentCameraSettings.flashMode,
+ previewUiState.value.currentCameraSettings.isFrontCameraFacing
+ )
}
}
@@ -181,8 +196,10 @@ class PreviewViewModel @Inject constructor(
)
)
// apply to cameraUseCase
- cameraUseCase
- .flipCamera(previewUiState.value.currentCameraSettings.isFrontCameraFacing)
+ cameraUseCase.flipCamera(
+ previewUiState.value.currentCameraSettings.isFrontCameraFacing,
+ previewUiState.value.currentCameraSettings.flashMode
+ )
}
}
}
@@ -190,12 +207,71 @@ class PreviewViewModel @Inject constructor(
fun captureImage() {
Log.d(TAG, "captureImage")
viewModelScope.launch {
- try {
- cameraUseCase.takePicture()
- Log.d(TAG, "cameraUseCase.takePicture success")
- } catch (exception: ImageCaptureException) {
- Log.d(TAG, "cameraUseCase.takePicture error")
- Log.d(TAG, exception.toString())
+ traceAsync(IMAGE_CAPTURE_TRACE, 0) {
+ try {
+ cameraUseCase.takePicture()
+ // todo: remove toast after postcapture screen implemented
+ _previewUiState.emit(
+ previewUiState.value.copy(
+ toastMessageToShow = ToastMessage(
+ stringResource = R.string.toast_image_capture_success,
+ testTag = IMAGE_CAPTURE_SUCCESS_TOAST_TAG
+ )
+ )
+ )
+ Log.d(TAG, "cameraUseCase.takePicture success")
+ } catch (exception: Exception) {
+ // todo: remove toast after postcapture screen implemented
+ _previewUiState.emit(
+ previewUiState.value.copy(
+ toastMessageToShow = ToastMessage(
+ stringResource = R.string.toast_capture_failure,
+ testTag = IMAGE_CAPTURE_FAIL_TOAST_TAG
+ )
+ )
+ )
+ Log.d(TAG, "cameraUseCase.takePicture error")
+ Log.d(TAG, exception.toString())
+ }
+ }
+ }
+ }
+
+ fun captureImage(
+ contentResolver: ContentResolver,
+ imageCaptureUri: Uri?,
+ onImageCapture: (ImageCaptureEvent) -> Unit
+ ) {
+ Log.d(TAG, "captureImageWithUri")
+ viewModelScope.launch {
+ traceAsync(IMAGE_CAPTURE_TRACE, 0) {
+ try {
+ cameraUseCase.takePicture(contentResolver, imageCaptureUri)
+ // todo: remove toast after postcapture screen implemented
+ _previewUiState.emit(
+ previewUiState.value.copy(
+ toastMessageToShow = ToastMessage(
+ stringResource = R.string.toast_image_capture_success,
+ testTag = IMAGE_CAPTURE_SUCCESS_TOAST_TAG
+ )
+ )
+ )
+ onImageCapture(ImageCaptureEvent.ImageSaved)
+ Log.d(TAG, "cameraUseCase.takePicture success")
+ } catch (exception: Exception) {
+ // todo: remove toast after postcapture screen implemented
+ _previewUiState.emit(
+ previewUiState.value.copy(
+ toastMessageToShow = ToastMessage(
+ stringResource = R.string.toast_capture_failure,
+ testTag = IMAGE_CAPTURE_FAIL_TOAST_TAG
+ )
+ )
+ )
+ Log.d(TAG, "cameraUseCase.takePicture error")
+ Log.d(TAG, exception.toString())
+ onImageCapture(ImageCaptureEvent.ImageCaptureError(exception))
+ }
}
}
}
@@ -259,4 +335,24 @@ class PreviewViewModel @Inject constructor(
y = y
)
}
+
+ fun onToastShown() {
+ viewModelScope.launch {
+ // keeps the composable up on screen longer to be detected by UiAutomator
+ delay(2.seconds)
+ _previewUiState.emit(
+ previewUiState.value.copy(
+ toastMessageToShow = null
+ )
+ )
+ }
+ }
+
+ sealed interface ImageCaptureEvent {
+ object ImageSaved : ImageCaptureEvent
+
+ data class ImageCaptureError(
+ val exception: Exception
+ ) : ImageCaptureEvent
+ }
}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt
new file mode 100644
index 0000000..d29b4b4
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ScreenFlash.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.feature.preview
+
+import com.google.jetpackcamera.domain.camera.CameraUseCase
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+private const val TAG = "ScreenFlash"
+
+/**
+ * Contains the UI state maintaining logic for screen flash feature.
+ */
+// TODO: Add this to ViewModelScoped so that it can be injected automatically. However, the current
+// ViewModel and Hilt APIs probably don't support injecting the viewModelScope.
+class ScreenFlash(
+ private val cameraUseCase: CameraUseCase,
+ private val scope: CoroutineScope
+) {
+ data class ScreenFlashUiState(
+ val enabled: Boolean = false,
+ val onChangeComplete: () -> Unit = {},
+ // restored during CLEAR_UI event
+ val screenBrightnessToRestore: Float? = null
+ )
+
+ private val _screenFlashUiState: MutableStateFlow<ScreenFlashUiState> =
+ MutableStateFlow(ScreenFlashUiState())
+ val screenFlashUiState: StateFlow<ScreenFlashUiState> = _screenFlashUiState
+
+ init {
+ scope.launch {
+ cameraUseCase.getScreenFlashEvents().collect { event ->
+ _screenFlashUiState.emit(
+ when (event.type) {
+ CameraUseCase.ScreenFlashEvent.Type.APPLY_UI ->
+ screenFlashUiState.value.copy(
+ enabled = true,
+ onChangeComplete = event.onComplete
+ )
+
+ CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI ->
+ screenFlashUiState.value.copy(
+ enabled = false,
+ onChangeComplete = {
+ event.onComplete()
+ // reset ui state on CLEAR_UI event completion
+ scope.launch {
+ _screenFlashUiState.emit(
+ ScreenFlashUiState()
+ )
+ }
+ }
+ )
+ }
+ )
+ }
+ }
+ }
+
+ /**
+ * Sets the screenBrightness value to the value right before APPLY_UI event for the next
+ * CLEAR_UI event, will be set to unknown (null) again after CLEAR_UI event is completed.
+ */
+ fun setClearUiScreenBrightness(brightness: Float) {
+ scope.launch {
+ _screenFlashUiState.emit(
+ screenFlashUiState.value.copy(screenBrightnessToRestore = brightness)
+ )
+ }
+ }
+}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt
index a952bcf..01f09c8 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt
@@ -18,6 +18,7 @@ package com.google.jetpackcamera.feature.preview.ui
import android.util.Log
import android.view.Display
import android.view.View
+import android.widget.Toast
import androidx.camera.core.Preview
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -44,24 +45,67 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.jetpackcamera.feature.preview.R
import com.google.jetpackcamera.feature.preview.VideoRecordingState
import com.google.jetpackcamera.settings.model.AspectRatio
+import com.google.jetpackcamera.settings.model.Stabilization
+import com.google.jetpackcamera.settings.model.SupportedStabilizationMode
import com.google.jetpackcamera.viewfinder.CameraPreview
import kotlinx.coroutines.CompletableDeferred
private const val TAG = "PreviewScreen"
-/** this is the preview surface display. This view implements gestures tap to focus, pinch to zoom,
- * and double tap to flip camera */
+/**
+ * An invisible box that will display a [Toast] with specifications set by a [ToastMessage].
+ *
+ * @param toastMessage the specifications for the [Toast].
+ * @param onToastShown called once the Toast has been displayed.
+ */
+@Composable
+fun ShowTestableToast(
+ modifier: Modifier = Modifier,
+ toastMessage: ToastMessage,
+ onToastShown: () -> Unit
+) {
+ val toastShownStatus = remember { mutableStateOf(false) }
+ Box(
+ // box seems to need to have some size to be detected by UiAutomator
+ modifier = modifier
+ .size(20.dp)
+ .testTag(toastMessage.testTag)
+ ) {
+ // prevents toast from being spammed
+ if (!toastShownStatus.value) {
+ Toast.makeText(
+ LocalContext.current,
+ stringResource(id = toastMessage.stringResource),
+ toastMessage.toastLength
+ )
+ .show()
+ toastShownStatus.value = true
+ onToastShown()
+ }
+ }
+ Log.d(TAG, "Toast Displayed with message: ${stringResource(id = toastMessage.stringResource)}")
+}
+
+/**
+ * this is the preview surface display. This view implements gestures tap to focus, pinch to zoom,
+ * and double-tap to flip camera
+ */
@Composable
fun PreviewDisplay(
onTapToFocus: (Display, Int, Int, Float, Float) -> Unit,
@@ -140,6 +184,29 @@ fun PreviewDisplay(
}
}
+@Composable
+fun StabilizationIcon(
+ supportedStabilizationMode: List<SupportedStabilizationMode>,
+ videoStabilization: Stabilization,
+ previewStabilization: Stabilization
+) {
+ if (supportedStabilizationMode.isNotEmpty() &&
+ (videoStabilization == Stabilization.ON || previewStabilization == Stabilization.ON)
+ ) {
+ val descriptionText = if (videoStabilization == Stabilization.ON) {
+ stringResource(id = R.string.stabilization_icon_description_preview_and_video)
+ } else {
+ // previewStabilization will not be on for high quality
+ stringResource(id = R.string.stabilization_icon_description_video_only)
+ }
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_video_stable_24),
+ contentDescription = descriptionText,
+ tint = Color.White
+ )
+ }
+}
+
/**
* A temporary button that can be added to preview for quick testing purposes
*/
@@ -160,21 +227,18 @@ fun FlipCameraButton(
enabledCondition: Boolean,
onClick: () -> Unit
) {
- Box(modifier = modifier) {
- IconButton(
- modifier = Modifier
- .align(Alignment.Center)
- .size(40.dp),
- onClick = onClick,
- enabled = enabledCondition
- ) {
- Icon(
- imageVector = Icons.Filled.Refresh,
- tint = Color.White,
- contentDescription = stringResource(id = R.string.flip_camera_content_description),
- modifier = Modifier.size(72.dp)
- )
- }
+ IconButton(
+ modifier = modifier
+ .size(40.dp),
+ onClick = onClick,
+ enabled = enabledCondition
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Refresh,
+ tint = Color.White,
+ contentDescription = stringResource(id = R.string.flip_camera_content_description),
+ modifier = Modifier.size(72.dp)
+ )
}
}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponents.kt
new file mode 100644
index 0000000..4b02195
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponents.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.feature.preview.ui
+
+import android.app.Activity
+import android.util.Log
+import android.view.Window
+import android.view.WindowManager
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import com.google.jetpackcamera.feature.preview.ScreenFlash
+
+private const val TAG = "ScreenFlashComponents"
+
+@Composable
+fun ScreenFlashScreen(
+ screenFlashUiState: ScreenFlash.ScreenFlashUiState,
+ onInitialBrightnessCalculated: (Float) -> Unit
+) {
+ ScreenFlashOverlay(screenFlashUiState)
+
+ if (screenFlashUiState.enabled) {
+ BrightnessMaximization(onInitialBrightnessCalculated = onInitialBrightnessCalculated)
+ } else {
+ screenFlashUiState.screenBrightnessToRestore?.let {
+ // non-null brightness value means there is a value to restore
+ BrightnessRestoration(
+ brightness = it
+ )
+ }
+ }
+}
+
+@Composable
+fun ScreenFlashOverlay(screenFlashUiState: ScreenFlash.ScreenFlashUiState) {
+ // Update overlay transparency gradually
+ val alpha by animateFloatAsState(
+ targetValue = if (screenFlashUiState.enabled) 1f else 0f,
+ label = "screenFlashAlphaAnimation",
+ animationSpec = tween(),
+ finishedListener = { screenFlashUiState.onChangeComplete() }
+ )
+ Box(
+ modifier = Modifier
+ .run {
+ if (screenFlashUiState.enabled) {
+ this.testTag("ScreenFlashOverlay")
+ } else {
+ this
+ }
+ }
+ .fillMaxSize()
+ .background(color = Color.White.copy(alpha = alpha))
+ )
+}
+
+@Composable
+fun BrightnessMaximization(onInitialBrightnessCalculated: (Float) -> Unit) {
+ // This Composable is attached to Activity in current code, so will have Activity context.
+ // If the Composable is attached to somewhere else in future, this needs to be updated too.
+ val activity = LocalContext.current as? Activity ?: run {
+ Log.e(TAG, "ScreenBrightness: could not find Activity context")
+ return
+ }
+
+ val initialScreenBrightness = remember {
+ getScreenBrightness(activity.window)
+ }
+ LaunchedEffect(initialScreenBrightness) {
+ onInitialBrightnessCalculated(initialScreenBrightness)
+ }
+
+ LaunchedEffect(Unit) {
+ setBrightness(activity, WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL)
+ }
+}
+
+@Composable
+fun BrightnessRestoration(brightness: Float) {
+ // This Composable is attached to Activity right now, so will have Activity context.
+ // If the Composable is attached to somewhere else in future, this needs to be updated too.
+ val activity = LocalContext.current as? Activity ?: run {
+ Log.e(TAG, "ScreenBrightness: could not find Activity context")
+ return
+ }
+
+ LaunchedEffect(brightness) {
+ setBrightness(activity, brightness)
+ }
+}
+
+fun getScreenBrightness(window: Window): Float = window.attributes.screenBrightness
+
+fun setBrightness(activity: Activity, value: Float) {
+ Log.d(TAG, "setBrightness: value = $value")
+ val layoutParams: WindowManager.LayoutParams = activity.window.attributes
+ layoutParams.screenBrightness = value
+ activity.window.attributes = layoutParams
+}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt
new file mode 100644
index 0000000..da4204a
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.feature.preview.ui
+
+const val CAPTURE_BUTTON = "CaptureButton"
+const val SETTINGS_BUTTON = "SettingsButton"
+const val DEFAULT_CAMERA_FACING_SETTING = "SetDefaultCameraFacingSwitch"
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ToastMessage.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ToastMessage.kt
new file mode 100644
index 0000000..b7003da
--- /dev/null
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/ToastMessage.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.feature.preview.ui
+
+import android.widget.Toast
+
+/**
+ * Helper class containing information used to create a [Toast].
+ *
+ * @param stringResource the resource ID of to be displayed.
+ * @param isLongToast determines if the display time is [Toast.LENGTH_LONG] or [Toast.LENGTH_SHORT].
+ * @property testTag the identifiable resource ID of a [ShowTestableToast] on screen.
+ */
+class ToastMessage(
+ val stringResource: Int,
+ isLongToast: Boolean = false,
+ val testTag: String = ""
+) {
+ val toastLength: Int = when (isLongToast) {
+ true -> Toast.LENGTH_LONG
+ false -> Toast.LENGTH_SHORT
+ }
+}
diff --git a/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml b/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml
new file mode 100644
index 0000000..54f9651
--- /dev/null
+++ b/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2023 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
+
+ <path android:fillColor="@android:color/white" android:pathData="M20,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.1,4 20,4zM4,18V6h2.95l-2.33,8.73L16.82,18H4zM20,18h-2.95l2.34,-8.73L7.18,6H20V18z"/>
+
+</vector>
diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml
index 4d11b0a..b2713f5 100644
--- a/feature/preview/src/main/res/values/strings.xml
+++ b/feature/preview/src/main/res/values/strings.xml
@@ -20,4 +20,10 @@
<string name="capture_mode_single_stream">Single Stream</string>
<string name="capture_mode_multi_stream">Multi Stream</string>
<string name="flip_camera_content_description">Flip Camera</string>
-</resources> \ No newline at end of file
+
+ <string name="toast_image_capture_success">Image Capture Success</string>
+ <string name="toast_capture_failure">Image Capture Failure</string>
+ <string name="stabilization_icon_description_preview_and_video">Preview is Stabilized</string>
+ <string name="stabilization_icon_description_video_only">Only Video is Stabilized</string>
+
+</resources>
diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt
index 70dc496..4e98b0d 100644
--- a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt
+++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt
@@ -15,6 +15,7 @@
*/
package com.google.jetpackcamera.feature.preview
+import android.content.ContentResolver
import androidx.camera.core.Preview.SurfaceProvider
import com.google.jetpackcamera.domain.camera.test.FakeCameraUseCase
import com.google.jetpackcamera.settings.model.FlashMode
@@ -69,6 +70,16 @@ class PreviewViewModelTest {
}
@Test
+ fun captureImageWithUri() = runTest(StandardTestDispatcher()) {
+ val surfaceProvider: SurfaceProvider = mock()
+ val contentResolver: ContentResolver = mock()
+ previewViewModel.runCamera(surfaceProvider)
+ previewViewModel.captureImage(contentResolver, null) {}
+ advanceUntilIdle()
+ assertEquals(cameraUseCase.numPicturesTaken, 1)
+ }
+
+ @Test
fun startVideoRecording() = runTest(StandardTestDispatcher()) {
previewViewModel.runCamera(mock())
previewViewModel.startVideoRecording()
diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt
new file mode 100644
index 0000000..2c0e8d8
--- /dev/null
+++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.feature.preview
+
+import android.content.ContentResolver
+import androidx.camera.core.Preview
+import com.google.jetpackcamera.domain.camera.CameraUseCase
+import com.google.jetpackcamera.domain.camera.test.FakeCameraUseCase
+import com.google.jetpackcamera.feature.preview.rules.MainDispatcherRule
+import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS
+import com.google.jetpackcamera.settings.model.FlashMode
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ScreenFlashTest {
+ private val testScope = TestScope()
+ private val testDispatcher = StandardTestDispatcher(testScope.testScheduler)
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule(testDispatcher)
+
+ private val cameraUseCase = FakeCameraUseCase(testScope)
+ private lateinit var screenFlash: ScreenFlash
+
+ @Before
+ fun setup() = runTest(testDispatcher) {
+ screenFlash = ScreenFlash(cameraUseCase, testScope)
+
+ val surfaceProvider: Preview.SurfaceProvider = Mockito.mock()
+ cameraUseCase.initialize(DEFAULT_CAMERA_APP_SETTINGS)
+ cameraUseCase.runCamera(surfaceProvider, DEFAULT_CAMERA_APP_SETTINGS)
+ }
+
+ @Test
+ fun initialScreenFlashUiState_disabledByDefault() {
+ assertEquals(false, screenFlash.screenFlashUiState.value.enabled)
+ }
+
+ @Test
+ fun captureScreenFlashImage_screenFlashUiStateChangedInCorrectSequence() =
+ runTest(testDispatcher) {
+ val states = mutableListOf<ScreenFlash.ScreenFlashUiState>()
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ screenFlash.screenFlashUiState.toList(states)
+ }
+
+ // FlashMode.ON in front facing camera automatically enables screen flash
+ cameraUseCase.setFlashMode(FlashMode.ON, true)
+ val contentResolver: ContentResolver = Mockito.mock()
+ cameraUseCase.takePicture(contentResolver, null)
+
+ advanceUntilIdle()
+ assertEquals(
+ listOf(
+ false,
+ true,
+ false
+ ),
+ states.map { it.enabled }
+ )
+ }
+
+ @Test
+ fun emitClearUiEvent_screenFlashUiStateContainsClearUiScreenBrightness() =
+ runTest(testDispatcher) {
+ screenFlash.setClearUiScreenBrightness(5.0f)
+ cameraUseCase.emitScreenFlashEvent(
+ CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
+ )
+
+ advanceUntilIdle()
+ assertEquals(
+ 5.0f,
+ screenFlash.screenFlashUiState.value.screenBrightnessToRestore
+ )
+ }
+
+ @Test
+ fun invokeOnChangeCompleteAfterClearUiEvent_screenFlashUiStateReset() =
+ runTest(testDispatcher) {
+ screenFlash.setClearUiScreenBrightness(5.0f)
+ cameraUseCase.emitScreenFlashEvent(
+ CameraUseCase.ScreenFlashEvent(CameraUseCase.ScreenFlashEvent.Type.CLEAR_UI) { }
+ )
+
+ advanceUntilIdle()
+ screenFlash.screenFlashUiState.value.onChangeComplete()
+
+ advanceUntilIdle()
+ assertEquals(
+ ScreenFlash.ScreenFlashUiState(),
+ screenFlash.screenFlashUiState.value
+ )
+ }
+}
diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/rules/MainDispatcherRule.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/rules/MainDispatcherRule.kt
new file mode 100644
index 0000000..d1ddd15
--- /dev/null
+++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/rules/MainDispatcherRule.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.feature.preview.rules
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+class MainDispatcherRule(private val dispatcher: CoroutineDispatcher) : TestRule {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ override fun apply(base: Statement?, description: Description?) = object : Statement() {
+ override fun evaluate() {
+ Dispatchers.setMain(dispatcher)
+ try {
+ base!!.evaluate()
+ } finally {
+ Dispatchers.resetMain()
+ }
+ }
+ }
+}
diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt
new file mode 100644
index 0000000..3dae6fe
--- /dev/null
+++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ui/ScreenFlashComponentsKtTest.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.feature.preview.ui
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.test.assertHeightIsAtLeast
+import androidx.compose.ui.test.assertWidthIsAtLeast
+import androidx.compose.ui.test.getBoundsInRoot
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.unit.height
+import androidx.compose.ui.unit.width
+import com.google.jetpackcamera.feature.preview.ScreenFlash
+import com.google.jetpackcamera.feature.preview.rules.MainDispatcherRule
+import com.google.jetpackcamera.feature.preview.workaround.captureToImage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import org.robolectric.shadows.ShadowPixelCopy
+
+// TODO: After device tests are added to github workflow, remove the tests here since they are
+// duplicated in androidTest and fits there better
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class ScreenFlashComponentsKtTest {
+ private val testScope = TestScope()
+ private val testDispatcher = StandardTestDispatcher(testScope.testScheduler)
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule(testDispatcher)
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val screenFlashUiState: MutableState<ScreenFlash.ScreenFlashUiState> =
+ mutableStateOf(ScreenFlash.ScreenFlashUiState())
+
+ @Before
+ fun setUp() {
+ composeTestRule.setContent {
+ ScreenFlashScreen(
+ screenFlashUiState = screenFlashUiState.value,
+ onInitialBrightnessCalculated = {}
+ )
+ }
+ }
+
+ @Test
+ fun screenFlashOverlay_doesNotExistByDefault() = runTest {
+ advanceUntilIdle()
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertDoesNotExist()
+ }
+
+ @Test
+ fun screenFlashOverlay_existsAfterStateIsEnabled() = runTest {
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true)
+
+ advanceUntilIdle()
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertExists()
+ }
+
+ @Test
+ fun screenFlashOverlay_doesNotExistWhenDisabledAfterEnabled() = runTest {
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true)
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = false)
+
+ advanceUntilIdle()
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).assertDoesNotExist()
+ }
+
+ @Test
+ fun screenFlashOverlay_sizeFillsMaxSize() = runTest {
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true)
+
+ advanceUntilIdle()
+ val rootBounds = composeTestRule.onRoot().getBoundsInRoot()
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay"))
+ .assertWidthIsAtLeast(rootBounds.width)
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay"))
+ .assertHeightIsAtLeast(rootBounds.height)
+ }
+
+ @Test
+ @GraphicsMode(GraphicsMode.Mode.NATIVE)
+ @Config(shadows = [ShadowPixelCopy::class])
+ fun screenFlashOverlay_fullWhiteWhenEnabled() = runTest {
+ screenFlashUiState.value = ScreenFlash.ScreenFlashUiState(enabled = true)
+
+ advanceUntilIdle()
+ val overlayScreenShot =
+ composeTestRule.onNode(hasTestTag("ScreenFlashOverlay")).captureToImage()
+
+ // check a few pixels near center instead of whole image to save time
+ val overlayPixels = IntArray(4)
+ overlayScreenShot.readPixels(
+ overlayPixels,
+ overlayScreenShot.width / 2,
+ overlayScreenShot.height / 2,
+ 2,
+ 2
+ )
+ overlayPixels.forEach {
+ assertEquals(Color.White.toArgb(), it)
+ }
+ }
+}
diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/workaround/ComposableCaptureToImage.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/workaround/ComposableCaptureToImage.kt
new file mode 100644
index 0000000..00b0bc2
--- /dev/null
+++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/workaround/ComposableCaptureToImage.kt
@@ -0,0 +1,248 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.feature.preview.workaround
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.graphics.Bitmap
+import android.graphics.Rect
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.view.PixelCopy
+import android.view.View
+import android.view.Window
+import androidx.annotation.DoNotInline
+import androidx.annotation.RequiresApi
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.ViewRootForTest
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.window.DialogWindowProvider
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.platform.graphics.HardwareRendererCompat
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.math.roundToInt
+
+/**
+ * Workaround captureToImage method.
+ *
+ * Once composable + robolectric graphics bugs are fixed, this can be replaced with the actual
+ * [androidx.compose.ui.test.SemanticsNodeInteraction.captureToImage]. Alternative is to use
+ * instrumentations tests, but they are not run at github workflows.
+ *
+ * See [robolectric issue 8071](https://github.com/robolectric/robolectric/issues/8071) for details.
+ */
+@OptIn(ExperimentalTestApi::class)
+@RequiresApi(Build.VERSION_CODES.O)
+fun SemanticsNodeInteraction.captureToImage(): ImageBitmap {
+ val node = fetchSemanticsNode("Failed to capture a node to bitmap.")
+ // Validate we are in popup
+ val popupParentMaybe = node.findClosestParentNode(includeSelf = true) {
+ it.config.contains(SemanticsProperties.IsPopup)
+ }
+ if (popupParentMaybe != null) {
+ return processMultiWindowScreenshot(node)
+ }
+
+ val view = (node.root as ViewRootForTest).view
+
+ // If we are in dialog use its window to capture the bitmap
+ val dialogParentNodeMaybe = node.findClosestParentNode(includeSelf = true) {
+ it.config.contains(SemanticsProperties.IsDialog)
+ }
+ var dialogWindow: Window? = null
+ if (dialogParentNodeMaybe != null) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+ // TODO(b/163023027)
+ throw IllegalArgumentException("Cannot currently capture dialogs on API lower than 28!")
+ }
+
+ dialogWindow = findDialogWindowProviderInParent(view)?.window
+ ?: throw IllegalArgumentException(
+ "Could not find a dialog window provider to capture its bitmap"
+ )
+ }
+
+ val windowToUse = dialogWindow ?: view.context.getActivityWindow()
+
+ val nodeBounds = node.boundsInRoot
+ val nodeBoundsRect = Rect(
+ nodeBounds.left.roundToInt(),
+ nodeBounds.top.roundToInt(),
+ nodeBounds.right.roundToInt(),
+ nodeBounds.bottom.roundToInt()
+ )
+
+ val locationInWindow = intArrayOf(0, 0)
+ view.getLocationInWindow(locationInWindow)
+ val x = locationInWindow[0]
+ val y = locationInWindow[1]
+
+ // Now these are bounds in window
+ nodeBoundsRect.offset(x, y)
+
+ return windowToUse.captureRegionToImage(nodeBoundsRect)
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+private fun SemanticsNode.findClosestParentNode(
+ includeSelf: Boolean = false,
+ selector: (SemanticsNode) -> Boolean
+): SemanticsNode? {
+ var currentParent = if (includeSelf) this else parent
+ while (currentParent != null) {
+ if (selector(currentParent)) {
+ return currentParent
+ } else {
+ currentParent = currentParent.parent
+ }
+ }
+
+ return null
+}
+
+@ExperimentalTestApi
+@RequiresApi(Build.VERSION_CODES.O)
+private fun processMultiWindowScreenshot(node: SemanticsNode): ImageBitmap {
+ val nodePositionInScreen = findNodePosition(node)
+ val nodeBoundsInRoot = node.boundsInRoot
+
+ val combinedBitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
+
+ val finalBitmap = Bitmap.createBitmap(
+ combinedBitmap,
+ (nodePositionInScreen.x + nodeBoundsInRoot.left).roundToInt(),
+ (nodePositionInScreen.y + nodeBoundsInRoot.top).roundToInt(),
+ nodeBoundsInRoot.width.roundToInt(),
+ nodeBoundsInRoot.height.roundToInt()
+ )
+ return finalBitmap.asImageBitmap()
+}
+
+private fun findNodePosition(node: SemanticsNode): Offset {
+ val view = (node.root as ViewRootForTest).view
+ val locationOnScreen = intArrayOf(0, 0)
+ view.getLocationOnScreen(locationOnScreen)
+ val x = locationOnScreen[0]
+ val y = locationOnScreen[1]
+
+ return Offset(x.toFloat(), y.toFloat())
+}
+
+internal fun findDialogWindowProviderInParent(view: View): DialogWindowProvider? {
+ if (view is DialogWindowProvider) {
+ return view
+ }
+ val parent = view.parent ?: return null
+ if (parent is View) {
+ return findDialogWindowProviderInParent(parent)
+ }
+ return null
+}
+
+private fun Context.getActivityWindow(): Window {
+ fun Context.getActivity(): Activity {
+ return when (this) {
+ is Activity -> this
+ is ContextWrapper -> this.baseContext.getActivity()
+ else -> throw IllegalStateException(
+ "Context is not an Activity context, but a ${javaClass.simpleName} context. " +
+ "An Activity context is required to get a Window instance"
+ )
+ }
+ }
+ return getActivity().window
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+private fun Window.captureRegionToImage(boundsInWindow: Rect): ImageBitmap {
+ // Turn on hardware rendering, if necessary
+ return withDrawingEnabled {
+ // Then we generate the bitmap
+ generateBitmap(boundsInWindow).asImageBitmap()
+ }
+}
+
+private fun <R> withDrawingEnabled(block: () -> R): R {
+ val wasDrawingEnabled = HardwareRendererCompat.isDrawingEnabled()
+ try {
+ if (!wasDrawingEnabled) {
+ HardwareRendererCompat.setDrawingEnabled(true)
+ }
+ return block.invoke()
+ } finally {
+ if (!wasDrawingEnabled) {
+ HardwareRendererCompat.setDrawingEnabled(false)
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+private fun Window.generateBitmap(boundsInWindow: Rect): Bitmap {
+ val destBitmap =
+ Bitmap.createBitmap(
+ boundsInWindow.width(),
+ boundsInWindow.height(),
+ Bitmap.Config.ARGB_8888
+ )
+ generateBitmapFromPixelCopy(boundsInWindow, destBitmap)
+ return destBitmap
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+private object PixelCopyHelper {
+ @DoNotInline
+ fun request(
+ source: Window,
+ srcRect: Rect?,
+ dest: Bitmap,
+ listener: PixelCopy.OnPixelCopyFinishedListener,
+ listenerThread: Handler
+ ) {
+ PixelCopy.request(source, srcRect, dest, listener, listenerThread)
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+private fun Window.generateBitmapFromPixelCopy(boundsInWindow: Rect, destBitmap: Bitmap) {
+ val latch = CountDownLatch(1)
+ var copyResult = 0
+ val onCopyFinished = PixelCopy.OnPixelCopyFinishedListener { result ->
+ copyResult = result
+ latch.countDown()
+ }
+ PixelCopyHelper.request(
+ this,
+ boundsInWindow,
+ destBitmap,
+ onCopyFinished,
+ Handler(Looper.getMainLooper())
+ )
+
+ if (!latch.await(1, TimeUnit.SECONDS)) {
+ throw AssertionError("Failed waiting for PixelCopy!")
+ }
+ if (copyResult != PixelCopy.SUCCESS) {
+ throw AssertionError("PixelCopy failed!")
+ }
+}