diff options
Diffstat (limited to 'feature/preview')
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!") + } +} |