diff --git a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt index 4932b9fac..6f9c07dec 100644 --- a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt +++ b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt @@ -34,6 +34,7 @@ import com.google.jetpackcamera.feature.postcapture.ui.PostCaptureLayout import com.google.jetpackcamera.feature.postcapture.ui.SaveCurrentMediaButton import com.google.jetpackcamera.feature.postcapture.ui.ShareCurrentMediaButton import com.google.jetpackcamera.feature.postcapture.utils.MediaSharing +import com.google.jetpackcamera.ui.components.capture.SnackBarController import com.google.jetpackcamera.ui.components.capture.TestableSnackbar import com.google.jetpackcamera.ui.uistate.SnackBarUiState import com.google.jetpackcamera.ui.uistate.postcapture.DeleteButtonUiState @@ -73,8 +74,8 @@ fun PostCaptureScreen( onSaveMedia = viewModel::saveCurrentMedia, onShareCurrentMedia = viewModel::onShareCurrentMedia, onLoadVideo = viewModel::loadCurrentVideo, - onSnackBarResult = viewModel::onSnackBarResult, - snackBarUiState = snackBarUiState + snackBarUiState = snackBarUiState, + snackBarController = viewModel.snackBarController ) } @@ -87,8 +88,8 @@ fun PostCaptureComponent( onShareCurrentMedia: () -> Unit, onDeleteMedia: (onSuccessCallback: () -> Unit) -> Unit, onLoadVideo: () -> Unit, - onSnackBarResult: (String) -> Unit, - snackBarUiState: SnackBarUiState = SnackBarUiState.Disabled + snackBarUiState: SnackBarUiState = SnackBarUiState.Disabled, + snackBarController: SnackBarController? = null ) { when (uiState) { PostCaptureUiState.Loading -> { @@ -139,12 +140,14 @@ fun PostCaptureComponent( if (snackBarUiState is SnackBarUiState.Enabled) { val snackBarData = snackBarUiState.snackBarQueue.peek() if (snackBarData != null) { - TestableSnackbar( - modifier = modifier.testTag(snackBarData.testTag), - snackbarToShow = snackBarData, - snackbarHostState = snackbarHostState, - onSnackbarResult = onSnackBarResult - ) + snackBarController?.let { + TestableSnackbar( + modifier = modifier.testTag(snackBarData.testTag), + snackbarToShow = snackBarData, + snackbarHostState = snackbarHostState, + snackBarController = snackBarController + ) + } } } } diff --git a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModel.kt b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModel.kt index 608c1d395..9aca959f6 100644 --- a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModel.kt +++ b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModel.kt @@ -36,19 +36,18 @@ import com.google.jetpackcamera.feature.postcapture.ui.SNACKBAR_POST_CAPTURE_IMA import com.google.jetpackcamera.feature.postcapture.ui.SNACKBAR_POST_CAPTURE_VIDEO_DELETE_FAILURE import com.google.jetpackcamera.feature.postcapture.ui.SNACKBAR_POST_CAPTURE_VIDEO_SAVE_FAILURE import com.google.jetpackcamera.feature.postcapture.ui.SNACKBAR_POST_CAPTURE_VIDEO_SAVE_SUCCESS +import com.google.jetpackcamera.ui.components.capture.SnackBarController +import com.google.jetpackcamera.ui.components.capture.SnackBarControllerImpl import com.google.jetpackcamera.ui.uistate.SnackBarUiState import com.google.jetpackcamera.ui.uistate.SnackbarData import com.google.jetpackcamera.ui.uistate.postcapture.DeleteButtonUiState import com.google.jetpackcamera.ui.uistate.postcapture.MediaViewerUiState import com.google.jetpackcamera.ui.uistate.postcapture.PostCaptureUiState import com.google.jetpackcamera.ui.uistate.postcapture.ShareButtonUiState -import com.google.jetpackcamera.ui.uistateadapter.from import com.google.jetpackcamera.ui.uistateadapter.postcapture.from import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import java.util.LinkedList import javax.inject.Inject -import kotlinx.atomicfu.atomic import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel @@ -111,8 +110,10 @@ class PostCaptureViewModel @Inject constructor( MutableStateFlow(SnackBarUiState.Enabled()) val snackBarUiState: StateFlow = _snackBarUiState.asStateFlow() - private val snackBarCount = atomic(0) - + val snackBarController: SnackBarController = SnackBarControllerImpl( + viewModelScope = viewModelScope, + snackBarUiState = _snackBarUiState + ) private var player: ExoPlayer? = null private val playerState = MutableStateFlow(PlayerState.Unavailable) @@ -302,7 +303,7 @@ class PostCaptureViewModel @Inject constructor( */ private suspend fun saveMedia(mediaDescriptor: MediaDescriptor.Content) { - val cookieInt = snackBarCount.incrementAndGet() + val cookieInt = snackBarController.incrementAndGetSnackBarCount() val cookie = "MediaSave-$cookieInt" val result: Uri? try { @@ -321,7 +322,7 @@ class PostCaptureViewModel @Inject constructor( SNACKBAR_POST_CAPTURE_VIDEO_SAVE_SUCCESS } - addSnackBarData( + snackBarController.addSnackBarData( SnackbarData( cookie = cookie, stringResource = stringResource, @@ -343,7 +344,7 @@ class PostCaptureViewModel @Inject constructor( SNACKBAR_POST_CAPTURE_VIDEO_SAVE_FAILURE } - addSnackBarData( + snackBarController.addSnackBarData( SnackbarData( cookie = cookie, stringResource = stringResource, @@ -364,7 +365,7 @@ class PostCaptureViewModel @Inject constructor( SNACKBAR_POST_CAPTURE_VIDEO_SAVE_FAILURE } - addSnackBarData( + snackBarController.addSnackBarData( SnackbarData( cookie = cookie, stringResource = stringResource, @@ -388,9 +389,9 @@ class PostCaptureViewModel @Inject constructor( false } if (!result) { - val cookieInt = snackBarCount.incrementAndGet() + val cookieInt = snackBarController.incrementAndGetSnackBarCount() val cookie = "MediaDelete-$cookieInt" - addSnackBarData( + snackBarController.addSnackBarData( SnackbarData( cookie = cookie, stringResource = when (mediaDescriptor) { @@ -426,39 +427,6 @@ class PostCaptureViewModel @Inject constructor( } } } - - // snackbar interaction - private fun addSnackBarData(snackBarData: SnackbarData) { - viewModelScope.launch { - _snackBarUiState.update { old -> - val newQueue = LinkedList(old.snackBarQueue) - - newQueue.add(snackBarData) - Log.d(TAG, "SnackBar added. Queue size: ${newQueue.size}") - old.copy( - snackBarQueue = newQueue - ) - } - } - } - - fun onSnackBarResult(cookie: String) { - viewModelScope.launch { - _snackBarUiState.update { state -> - val newQueue = LinkedList(state.snackBarQueue) - val snackBarData = newQueue.poll() - if (snackBarData != null && snackBarData.cookie == cookie) { - // If the latest snackBar had a result, then clear snackBarToShow - Log.d(TAG, "SnackBar removed. Queue size: ${newQueue.size}") - state.copy( - snackBarQueue = newQueue - ) - } else { - state - } - } - } - } } sealed interface PlayerState { diff --git a/feature/postcapture/src/test/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModelTest.kt b/feature/postcapture/src/test/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModelTest.kt index 142191d5b..1676da374 100644 --- a/feature/postcapture/src/test/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModelTest.kt +++ b/feature/postcapture/src/test/java/com/google/jetpackcamera/feature/postcapture/PostCaptureViewModelTest.kt @@ -349,7 +349,7 @@ internal class PostCaptureViewModelTest { val cookie = snackBarUiState.snackBarQueue.first().cookie // When - viewModel.onSnackBarResult(cookie) + viewModel.snackBarController.onSnackBarResult(cookie) advanceUntilIdle() // Then @@ -367,7 +367,7 @@ internal class PostCaptureViewModelTest { advanceUntilIdle() // When - viewModel.onSnackBarResult("incorrect_cookie") + viewModel.snackBarController.onSnackBarResult("incorrect_cookie") advanceUntilIdle() // Then @@ -382,7 +382,7 @@ internal class PostCaptureViewModelTest { assertThat(snackBarUiState.snackBarQueue).isEmpty() // When - viewModel.onSnackBarResult("any_cookie") + viewModel.snackBarController.onSnackBarResult("any_cookie") advanceUntilIdle() // Then 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 618b5a0f7..9287567b8 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 @@ -16,7 +16,6 @@ package com.google.jetpackcamera.feature.preview import android.Manifest -import android.content.ContentResolver import android.os.Build import android.util.Log import android.util.Range @@ -65,19 +64,11 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.google.jetpackcamera.core.camera.InitialRecordingSettings import com.google.jetpackcamera.core.camera.VideoRecordingState -import com.google.jetpackcamera.model.AspectRatio import com.google.jetpackcamera.model.CaptureEvent import com.google.jetpackcamera.model.CaptureMode -import com.google.jetpackcamera.model.ConcurrentCameraMode -import com.google.jetpackcamera.model.DynamicRange import com.google.jetpackcamera.model.ExternalCaptureMode -import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.ImageCaptureEvent -import com.google.jetpackcamera.model.ImageOutputFormat -import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.LensToZoom -import com.google.jetpackcamera.model.StreamConfig -import com.google.jetpackcamera.model.TestPattern import com.google.jetpackcamera.model.VideoCaptureEvent import com.google.jetpackcamera.ui.components.capture.AmplitudeToggleButton import com.google.jetpackcamera.ui.components.capture.CAPTURE_MODE_TOGGLE_BUTTON @@ -93,19 +84,23 @@ import com.google.jetpackcamera.ui.components.capture.PreviewDisplay import com.google.jetpackcamera.ui.components.capture.PreviewLayout import com.google.jetpackcamera.ui.components.capture.R import com.google.jetpackcamera.ui.components.capture.ScreenFlashScreen +import com.google.jetpackcamera.ui.components.capture.SnackBarController import com.google.jetpackcamera.ui.components.capture.StabilizationIcon import com.google.jetpackcamera.ui.components.capture.TestableSnackbar import com.google.jetpackcamera.ui.components.capture.VIDEO_QUALITY_TAG import com.google.jetpackcamera.ui.components.capture.VideoQualityIcon import com.google.jetpackcamera.ui.components.capture.ZoomButtonRow import com.google.jetpackcamera.ui.components.capture.ZoomStateManager +import com.google.jetpackcamera.ui.components.capture.controller.CaptureController +import com.google.jetpackcamera.ui.components.capture.controller.CaptureScreenController import com.google.jetpackcamera.ui.components.capture.debouncedOrientationFlow import com.google.jetpackcamera.ui.components.capture.debug.DebugOverlay +import com.google.jetpackcamera.ui.components.capture.debug.controller.DebugController import com.google.jetpackcamera.ui.components.capture.quicksettings.QuickSettingsBottomSheet +import com.google.jetpackcamera.ui.components.capture.quicksettings.controller.QuickSettingsController import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.FlashModeIndicator import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.HdrIndicator import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.ToggleQuickSettingsButton -import com.google.jetpackcamera.ui.uistate.DisableRationale import com.google.jetpackcamera.ui.uistate.SnackBarUiState import com.google.jetpackcamera.ui.uistate.capture.AudioUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState @@ -117,7 +112,6 @@ import com.google.jetpackcamera.ui.uistate.capture.ScreenFlashUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomControlUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState -import com.google.jetpackcamera.ui.uistate.capture.compound.FocusedQuickSetting import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch @@ -151,9 +145,9 @@ fun PreviewScreen( by viewModel.surfaceRequest.collectAsState() LifecycleStartEffect(Unit) { - viewModel.startCamera() + viewModel.cameraController.startCamera() onStopOrDispose { - viewModel.stopCamera() + viewModel.cameraController.stopCamera() } } @@ -198,7 +192,9 @@ fun PreviewScreen( val context = LocalContext.current LaunchedEffect(Unit) { - debouncedOrientationFlow(context).collect(viewModel::setDisplayRotation) + debouncedOrientationFlow(context).collect( + viewModel.captureScreenController::setDisplayRotation + ) } val scope = rememberCoroutineScope() val zoomStateManager = remember { @@ -213,11 +209,10 @@ fun PreviewScreen( ) ?.initialZoomRatio ?: 1f, - onAnimateStateChanged = viewModel::setZoomAnimationState, - onChangeZoomLevel = viewModel::changeZoomRatio, zoomRange = (currentUiState.zoomUiState as? ZoomUiState.Enabled) ?.primaryZoomRange - ?: Range(1f, 1f) + ?: Range(1f, 1f), + zoomController = viewModel.zoomController ) } @@ -251,8 +246,10 @@ fun PreviewScreen( val oldZoomRatios = it.zoomRatios val oldAudioEnabled = it.isAudioEnabled Log.d(TAG, "reset pre recording settings") - viewModel.setAudioEnabled(oldAudioEnabled) - viewModel.setLensFacing(oldPrimaryLensFacing) + viewModel.captureScreenController.setAudioEnabled(oldAudioEnabled) + viewModel.quickSettingsController.setLensFacing( + oldPrimaryLensFacing + ) zoomStateManager.apply { absoluteZoom( targetZoomLevel = oldZoomRatios[oldPrimaryLensFacing] ?: 1f, @@ -279,11 +276,7 @@ fun PreviewScreen( screenFlashUiState = screenFlashUiState, surfaceRequest = surfaceRequest, onNavigateToSettings = onNavigateToSettings, - onClearUiScreenBrightness = viewModel::setClearUiScreenBrightness, - onSetLensFacing = viewModel::setLensFacing, - onTapToFocus = viewModel::tapToFocus, - onSetTestPattern = viewModel::setTestPattern, - onSetImageWell = viewModel::imageWellToRepository, + onClearUiScreenBrightness = viewModel.screenFlash::setClearUiScreenBrightness, onAbsoluteZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> scope.launch { zoomStateManager.absoluteZoom( @@ -316,30 +309,15 @@ fun PreviewScreen( ) } }, - - onSetCaptureMode = viewModel::setCaptureMode, - onChangeFlash = viewModel::setFlash, - onChangeAspectRatio = viewModel::setAspectRatio, - onSetStreamConfig = viewModel::setStreamConfig, - onChangeDynamicRange = viewModel::setDynamicRange, - onChangeConcurrentCameraMode = viewModel::setConcurrentCameraMode, - onChangeImageFormat = viewModel::setImageFormat, - onDisabledCaptureMode = viewModel::enqueueDisabledHdrToggleSnackBar, - onToggleQuickSettings = viewModel::toggleQuickSettings, - onSetFocusedSetting = viewModel::setFocusedSetting, - onToggleDebugOverlay = viewModel::toggleDebugOverlay, - onToggleDebugHidingComponents = viewModel::toggleDebugHidingComponents, - onSetPause = viewModel::setPaused, - onSetAudioEnabled = viewModel::setAudioEnabled, - onCaptureImage = viewModel::captureImage, - onStartVideoRecording = viewModel::startVideoRecording, - onStopVideoRecording = viewModel::stopVideoRecording, - onLockVideoRecording = viewModel::setLockedRecording, onRequestWindowColorMode = onRequestWindowColorMode, - onSnackBarResult = viewModel::onSnackBarResult, onNavigatePostCapture = onNavigateToPostCapture, debugUiState = debugUiState, - snackBarUiState = snackBarUiState + snackBarUiState = snackBarUiState, + debugController = viewModel.debugController, + snackBarController = viewModel.snackBarController, + quickSettingsController = viewModel.quickSettingsController, + captureScreenController = viewModel.captureScreenController, + captureController = viewModel.captureController ) val readStoragePermission: PermissionState = rememberPermissionState( Manifest.permission.READ_EXTERNAL_STORAGE @@ -349,7 +327,7 @@ fun PreviewScreen( if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || readStoragePermission.status.isGranted ) { - viewModel.updateLastCapturedMedia() + viewModel.captureScreenController.updateLastCapturedMedia() } } } @@ -365,41 +343,23 @@ private fun ContentScreen( modifier: Modifier = Modifier, onNavigateToSettings: () -> Unit = {}, onClearUiScreenBrightness: (Float) -> Unit = {}, - onSetCaptureMode: (CaptureMode) -> Unit = {}, - onSetLensFacing: (newLensFacing: LensFacing) -> Unit = {}, - onTapToFocus: (x: Float, y: Float) -> Unit = { _, _ -> }, - onSetTestPattern: (TestPattern) -> Unit = {}, - onSetImageWell: () -> Unit = {}, onAbsoluteZoom: (Float, LensToZoom) -> Unit = { _, _ -> }, onScaleZoom: (Float, LensToZoom) -> Unit = { _, _ -> }, onIncrementZoom: (Float, LensToZoom) -> Unit = { _, _ -> }, onAnimateZoom: (Float, LensToZoom) -> Unit = { _, _ -> }, - onChangeFlash: (FlashMode) -> Unit = {}, - onChangeAspectRatio: (AspectRatio) -> Unit = {}, - onSetStreamConfig: (StreamConfig) -> Unit = {}, - onChangeDynamicRange: (DynamicRange) -> Unit = {}, - onChangeConcurrentCameraMode: (ConcurrentCameraMode) -> Unit = {}, - onChangeImageFormat: (ImageOutputFormat) -> Unit = {}, - onDisabledCaptureMode: (DisableRationale) -> Unit = {}, - onToggleQuickSettings: () -> Unit = {}, - onSetFocusedSetting: (FocusedQuickSetting) -> Unit = {}, - onToggleDebugOverlay: () -> Unit = {}, - onToggleDebugHidingComponents: () -> Unit = {}, - onSetPause: (Boolean) -> Unit = {}, - onSetAudioEnabled: (Boolean) -> Unit = {}, - onCaptureImage: (ContentResolver) -> Unit = {}, - onStartVideoRecording: () -> Unit = {}, - onStopVideoRecording: () -> Unit = {}, - onLockVideoRecording: (Boolean) -> Unit = {}, onRequestWindowColorMode: (Int) -> Unit = {}, - onSnackBarResult: (String) -> Unit = {}, onNavigatePostCapture: () -> Unit = {}, debugUiState: DebugUiState = DebugUiState.Disabled, - snackBarUiState: SnackBarUiState = SnackBarUiState.Disabled + snackBarUiState: SnackBarUiState = SnackBarUiState.Disabled, + debugController: DebugController? = null, + quickSettingsController: QuickSettingsController? = null, + snackBarController: SnackBarController? = null, + captureScreenController: CaptureScreenController? = null, + captureController: CaptureController? = null ) { val onFlipCamera = { if (captureUiState.flipLensUiState is FlipLensUiState.Available) { - onSetLensFacing( + quickSettingsController?.setLensFacing( ( captureUiState.flipLensUiState as FlipLensUiState.Available ) @@ -411,9 +371,9 @@ private fun ContentScreen( val isAudioEnabled = remember(captureUiState) { captureUiState.audioUiState is AudioUiState.Enabled.On } - val onToggleAudio = remember(isAudioEnabled) { + val onToggleAudio: () -> Unit = remember(isAudioEnabled) { { - onSetAudioEnabled(!isAudioEnabled) + captureScreenController?.setAudioEnabled(!isAudioEnabled) } } @@ -443,7 +403,7 @@ private fun ContentScreen( PreviewDisplay( previewDisplayUiState = captureUiState.previewDisplayUiState, onFlipCamera = onFlipCamera, - onTapToFocus = onTapToFocus, + onTapToFocus = captureScreenController?.let { it::tapToFocus } ?: { _, _ -> }, onScaleZoom = { onScaleZoom(it, LensToZoom.PRIMARY) }, surfaceRequest = surfaceRequest, onRequestWindowColorMode = onRequestWindowColorMode, @@ -455,7 +415,7 @@ private fun ContentScreen( if ((captureUiState.quickSettingsUiState as? QuickSettingsUiState.Available) ?.quickSettingsIsOpen == true ) { - onToggleQuickSettings() + quickSettingsController?.toggleQuickSettings() } action() } @@ -467,21 +427,23 @@ private fun ContentScreen( )?.quickSettingsIsOpen ?: false, onCaptureImage = { runCaptureAction { - onCaptureImage(it) + captureController?.captureImage(it) } }, onIncrementZoom = { targetZoom -> onIncrementZoom(targetZoom, LensToZoom.PRIMARY) }, - onToggleQuickSettings = onToggleQuickSettings, onStartVideoRecording = { runCaptureAction { - onStartVideoRecording() + captureController?.startVideoRecording() } }, - onStopVideoRecording = - onStopVideoRecording, - onLockVideoRecording = onLockVideoRecording + onStopVideoRecording = { captureController?.stopVideoRecording() }, + onLockVideoRecording = { isLocked -> + captureController?.setLockedRecording( + isLocked + ) + } ) }, flipCameraButton = { @@ -524,14 +486,17 @@ private fun ContentScreen( enter = fadeIn(), exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) ) { - ToggleQuickSettingsButton( - modifier = it, - toggleBottomSheet = onToggleQuickSettings, - isOpen = ( - captureUiState.quickSettingsUiState - as? QuickSettingsUiState.Available - )?.quickSettingsIsOpen == true - ) + quickSettingsController?.let { quickSettingsController -> + ToggleQuickSettingsButton( + modifier = it, + isOpen = ( + captureUiState.quickSettingsUiState + as? QuickSettingsUiState.Available + )?.quickSettingsIsOpen == true, + quickSettingsController = quickSettingsController + + ) + } } }, audioToggleButton = { @@ -547,43 +512,36 @@ private fun ContentScreen( uiState = captureUiState.captureModeToggleUiState as CaptureModeToggleUiState.Available, - onChangeCaptureMode = onSetCaptureMode, - onToggleWhenDisabled = onDisabledCaptureMode, + quickSettingsController = quickSettingsController, + snackBarController = snackBarController, modifier = it.testTag(CAPTURE_MODE_TOGGLE_BUTTON) ) } }, quickSettingsOverlay = { - QuickSettingsBottomSheet( - modifier = it, - quickSettingsUiState = captureUiState.quickSettingsUiState, - toggleQuickSettings = onToggleQuickSettings, - onSetFocusedSetting = onSetFocusedSetting, - onLensFaceClick = onSetLensFacing, - onFlashModeClick = onChangeFlash, - onAspectRatioClick = onChangeAspectRatio, - onStreamConfigClick = onSetStreamConfig, - onDynamicRangeClick = onChangeDynamicRange, - onImageOutputFormatClick = onChangeImageFormat, - onConcurrentCameraModeClick = onChangeConcurrentCameraMode, - onCaptureModeClick = onSetCaptureMode, - onNavigateToSettings = { - onToggleQuickSettings() - onNavigateToSettings() - } - ) + quickSettingsController?.let { quickSettingsController -> + QuickSettingsBottomSheet( + modifier = it, + quickSettingsUiState = captureUiState.quickSettingsUiState, + onNavigateToSettings = { + quickSettingsController.toggleQuickSettings() + onNavigateToSettings() + }, + quickSettingsController = quickSettingsController + ) + } }, debugOverlay = { modifier, extraControls -> - (debugUiState as? DebugUiState.Enabled)?.let { - DebugOverlay( - modifier = modifier, - toggleIsOpen = onToggleDebugOverlay, - debugUiState = it, - onSetTestPattern = onSetTestPattern, - onChangeZoomRatio = { f: Float -> onAbsoluteZoom(f, LensToZoom.PRIMARY) }, - extraControls = extraControls.orEmpty(), - onToggleHidingComponents = onToggleDebugHidingComponents - ) + (debugUiState as? DebugUiState.Enabled)?.let { debugUiState -> + debugController?.let { debugController -> + DebugOverlay( + modifier = modifier, + debugUiState = debugUiState, + onChangeZoomRatio = { f: Float -> onAbsoluteZoom(f, LensToZoom.PRIMARY) }, + extraControls = extraControls.orEmpty(), + debugController = debugController + ) + } } }, debugVisibilityWrapper = { content -> @@ -607,18 +565,20 @@ private fun ContentScreen( if (snackBarUiState is SnackBarUiState.Enabled) { val snackBarData = snackBarUiState.snackBarQueue.peek() if (snackBarData != null) { - TestableSnackbar( - modifier = modifier.testTag(snackBarData.testTag), - snackbarToShow = snackBarData, - snackbarHostState = snackbarHostState, - onSnackbarResult = onSnackBarResult - ) + snackBarController?.let { snackBarController -> + TestableSnackbar( + modifier = modifier.testTag(snackBarData.testTag), + snackbarToShow = snackBarData, + snackbarHostState = snackbarHostState, + snackBarController = snackBarController + ) + } } } }, pauseToggleButton = { PauseResumeToggleButton( - onSetPause = onSetPause, + onSetPause = captureScreenController?.let { it::setPaused } ?: { _ -> }, currentRecordingState = captureUiState.videoRecordingState ) }, @@ -629,7 +589,7 @@ private fun ContentScreen( modifier = modifier, imageWellUiState = it, onClick = { - onSetImageWell() + captureScreenController?.imageWellToRepository() onNavigatePostCapture() } ) 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 73f62c62b..cff2e7184 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,77 +15,58 @@ */ package com.google.jetpackcamera.feature.preview -import android.content.ContentResolver import android.net.Uri -import android.os.SystemClock import android.util.Log import androidx.camera.core.SurfaceRequest import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.tracing.Trace -import androidx.tracing.traceAsync import com.google.jetpackcamera.core.camera.CameraSystem import com.google.jetpackcamera.core.camera.CameraSystem.Companion.applyDiffs -import com.google.jetpackcamera.core.camera.OnVideoRecordEvent import com.google.jetpackcamera.core.common.DefaultSaveMode -import com.google.jetpackcamera.core.common.traceFirstFramePreview -import com.google.jetpackcamera.data.media.MediaDescriptor import com.google.jetpackcamera.data.media.MediaRepository import com.google.jetpackcamera.feature.preview.navigation.getCaptureUris import com.google.jetpackcamera.feature.preview.navigation.getDebugSettings import com.google.jetpackcamera.feature.preview.navigation.getExternalCaptureMode import com.google.jetpackcamera.feature.preview.navigation.getRequestedSaveMode -import com.google.jetpackcamera.model.AspectRatio -import com.google.jetpackcamera.model.CameraZoomRatio import com.google.jetpackcamera.model.CaptureEvent -import com.google.jetpackcamera.model.CaptureMode -import com.google.jetpackcamera.model.ConcurrentCameraMode import com.google.jetpackcamera.model.DebugSettings -import com.google.jetpackcamera.model.DeviceRotation -import com.google.jetpackcamera.model.DynamicRange import com.google.jetpackcamera.model.ExternalCaptureMode -import com.google.jetpackcamera.model.FlashMode -import com.google.jetpackcamera.model.ImageCaptureEvent -import com.google.jetpackcamera.model.ImageOutputFormat import com.google.jetpackcamera.model.IntProgress -import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.LowLightBoostState import com.google.jetpackcamera.model.SaveLocation import com.google.jetpackcamera.model.SaveMode -import com.google.jetpackcamera.model.StreamConfig -import com.google.jetpackcamera.model.TestPattern -import com.google.jetpackcamera.model.VideoCaptureEvent import com.google.jetpackcamera.settings.ConstraintsRepository import com.google.jetpackcamera.settings.SettingsRepository import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.applyExternalCaptureMode -import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG -import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_FAILURE_TAG -import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_SUCCESS_TAG import com.google.jetpackcamera.ui.components.capture.LOW_LIGHT_BOOST_FAILURE_TAG import com.google.jetpackcamera.ui.components.capture.R import com.google.jetpackcamera.ui.components.capture.ScreenFlash -import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG -import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_FAILURE_TAG -import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_SUCCESS_TAG -import com.google.jetpackcamera.ui.uistate.DisableRationale +import com.google.jetpackcamera.ui.components.capture.SnackBarController +import com.google.jetpackcamera.ui.components.capture.SnackBarControllerImpl +import com.google.jetpackcamera.ui.components.capture.controller.CameraController +import com.google.jetpackcamera.ui.components.capture.controller.CameraControllerImpl +import com.google.jetpackcamera.ui.components.capture.controller.CaptureController +import com.google.jetpackcamera.ui.components.capture.controller.CaptureControllerImpl +import com.google.jetpackcamera.ui.components.capture.controller.CaptureScreenController +import com.google.jetpackcamera.ui.components.capture.controller.CaptureScreenControllerImpl +import com.google.jetpackcamera.ui.components.capture.controller.ZoomController +import com.google.jetpackcamera.ui.components.capture.controller.ZoomControllerImpl +import com.google.jetpackcamera.ui.components.capture.debug.controller.DebugController +import com.google.jetpackcamera.ui.components.capture.debug.controller.DebugControllerImpl +import com.google.jetpackcamera.ui.components.capture.quicksettings.controller.QuickSettingsController +import com.google.jetpackcamera.ui.components.capture.quicksettings.controller.QuickSettingsControllerImpl import com.google.jetpackcamera.ui.uistate.SnackBarUiState import com.google.jetpackcamera.ui.uistate.SnackbarData import com.google.jetpackcamera.ui.uistate.capture.DebugUiState -import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState import com.google.jetpackcamera.ui.uistate.capture.TrackedCaptureUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState -import com.google.jetpackcamera.ui.uistate.capture.compound.FocusedQuickSetting import com.google.jetpackcamera.ui.uistateadapter.capture.compound.captureUiState import com.google.jetpackcamera.ui.uistateadapter.capture.debugUiState import dagger.hilt.android.lifecycle.HiltViewModel -import java.util.LinkedList import javax.inject.Inject -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel @@ -97,12 +78,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformWhile -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch private const val TAG = "PreviewViewModel" -private const val IMAGE_CAPTURE_TRACE = "JCA Image Capture" /** * [ViewModel] for [PreviewScreen]. @@ -129,10 +107,6 @@ class PreviewViewModel @Inject constructor( private val _captureEvents = Channel() val captureEvents: ReceiveChannel = _captureEvents - private var runningCameraJob: Job? = null - - private var recordingJob: Job? = null - private val externalCaptureMode: ExternalCaptureMode = savedStateHandle.getExternalCaptureMode() private val externalUris: List = savedStateHandle.getCaptureUris() private lateinit var externalUriProgress: IntProgress @@ -143,9 +117,6 @@ class PreviewViewModel @Inject constructor( val screenFlash = ScreenFlash(cameraSystem, viewModelScope) - private val snackBarCount = atomic(0) - private val videoCaptureStartedCount = atomic(0) - // Eagerly initialize the CameraSystem and encapsulate in a Deferred that can be // used to ensure we don't start the camera before initialization is complete. private var initializationDeferred: Deferred = viewModelScope.async { @@ -180,6 +151,70 @@ class PreviewViewModel @Inject constructor( initialValue = DebugUiState.Disabled ) + val quickSettingsController: QuickSettingsController = QuickSettingsControllerImpl( + trackedCaptureUiState = trackedCaptureUiState, + viewModelScope = viewModelScope, + cameraSystem = cameraSystem, + externalCaptureMode = externalCaptureMode + ) + + val debugController: DebugController = DebugControllerImpl( + cameraSystem = cameraSystem, + trackedCaptureUiState = trackedCaptureUiState + ) + + val snackBarController: SnackBarController = SnackBarControllerImpl( + viewModelScope = viewModelScope, + snackBarUiState = _snackBarUiState + ) + + val captureScreenController: CaptureScreenController = CaptureScreenControllerImpl( + viewModelScope = viewModelScope, + cameraSystem = cameraSystem, + trackedCaptureUiState = trackedCaptureUiState, + mediaRepository = mediaRepository, + captureUiState = captureUiState + ) + + val zoomController: ZoomController = ZoomControllerImpl( + cameraSystem = cameraSystem, + trackedCaptureUiState = trackedCaptureUiState + ) + + val cameraController: CameraController = CameraControllerImpl( + initializationDeferred = initializationDeferred, + captureUiState = captureUiState, + viewModelScope = viewModelScope, + cameraSystem = cameraSystem + ) + + val captureController: CaptureController = CaptureControllerImpl( + trackedCaptureUiState = trackedCaptureUiState, + viewModelScope = viewModelScope, + cameraSystem = cameraSystem, + mediaRepository = mediaRepository, + saveMode = saveMode, + externalCaptureMode = externalCaptureMode, + externalCapturesCallback = { + if (externalUris.isNotEmpty()) { + if (!this::externalUriProgress.isInitialized) { + externalUriProgress = IntProgress(1, 1..externalUris.size) + } + val progress = externalUriProgress + if (progress.currentValue < progress.range.endInclusive) externalUriProgress++ + Pair( + SaveLocation.Explicit(externalUris[progress.currentValue - 1]), + progress + ) + } else { + Pair(SaveLocation.Default, null) + } + }, + captureEvents = _captureEvents, + captureScreenController = captureScreenController, + snackBarController = snackBarController + ) + init { viewModelScope.launch { launch { @@ -199,9 +234,9 @@ class PreviewViewModel @Inject constructor( .distinctUntilChanged() .collect { state -> if (state is LowLightBoostState.Error) { - val cookieInt = snackBarCount.incrementAndGet() + val cookieInt = snackBarController.incrementAndGetSnackBarCount() Log.d(TAG, "LowLightBoostState changed to Error #$cookieInt") - addSnackBarData( + snackBarController.addSnackBarData( SnackbarData( cookie = "LowLightBoost-$cookieInt", stringResource = R.string.low_light_boost_error_toast_message, @@ -214,484 +249,4 @@ class PreviewViewModel @Inject constructor( } } } - fun toggleDebugHidingComponents() { - trackedCaptureUiState.update { old -> - old.copy(debugHidingComponents = !old.debugHidingComponents) - } - } - - /** - * Sets the media from the image well to the [MediaRepository]. - */ - fun imageWellToRepository() { - (captureUiState.value as? CaptureUiState.Ready) - ?.let { it.imageWellUiState as? ImageWellUiState.LastCapture } - ?.let { postCurrentMediaToMediaRepository(it.mediaDescriptor) } - } - - private fun postCurrentMediaToMediaRepository(mediaDescriptor: MediaDescriptor) { - viewModelScope.launch { - mediaRepository.setCurrentMedia(mediaDescriptor) - } - } - - fun updateLastCapturedMedia() { - viewModelScope.launch { - trackedCaptureUiState.update { old -> - old.copy(recentCapturedMedia = mediaRepository.getLastCapturedMedia()) - } - } - } - - fun startCamera() { - Log.d(TAG, "startCamera") - stopCamera() - runningCameraJob = viewModelScope.launch { - if (Trace.isEnabled()) { - launch(start = CoroutineStart.UNDISPATCHED) { - val startTraceTimestamp: Long = SystemClock.elapsedRealtimeNanos() - traceFirstFramePreview(cookie = 1) { - captureUiState.transformWhile { - var continueCollecting = true - (it as? CaptureUiState.Ready)?.let { uiState -> - if (uiState.sessionFirstFrameTimestamp > startTraceTimestamp) { - emit(Unit) - continueCollecting = false - } - } - continueCollecting - }.collect {} - } - } - } - // Ensure CameraSystem is initialized before starting camera - initializationDeferred.await() - // TODO(yasith): Handle Exceptions from binding use cases - cameraSystem.runCamera() - } - } - - fun stopCamera() { - Log.d(TAG, "stopCamera") - runningCameraJob?.apply { - if (isActive) { - cancel() - } - } - } - - fun setFlash(flashMode: FlashMode) { - viewModelScope.launch { - // apply to cameraSystem - cameraSystem.setFlashMode(flashMode) - } - } - - fun setAspectRatio(aspectRatio: AspectRatio) { - viewModelScope.launch { - cameraSystem.setAspectRatio(aspectRatio) - } - } - - fun setStreamConfig(streamConfig: StreamConfig) { - viewModelScope.launch { - cameraSystem.setStreamConfig(streamConfig) - } - } - - /** Sets the camera to a designated lens facing */ - fun setLensFacing(newLensFacing: LensFacing) { - viewModelScope.launch { - // apply to cameraSystem - cameraSystem.setLensFacing(newLensFacing) - } - } - - fun setAudioEnabled(shouldEnableAudio: Boolean) { - viewModelScope.launch { - cameraSystem.setAudioEnabled(shouldEnableAudio) - } - - Log.d( - TAG, - "Toggle Audio: $shouldEnableAudio" - ) - } - - fun setPaused(shouldBePaused: Boolean) { - viewModelScope.launch { - if (shouldBePaused) { - cameraSystem.pauseVideoRecording() - } else { - cameraSystem.resumeVideoRecording() - } - } - } - - private fun addSnackBarData(snackBarData: SnackbarData) { - viewModelScope.launch { - _snackBarUiState.update { old -> - val newQueue = LinkedList(old.snackBarQueue) - newQueue.add(snackBarData) - Log.d(TAG, "SnackBar added. Queue size: ${newQueue.size}") - old.copy( - snackBarQueue = newQueue - ) - } - } - } - - private fun enqueueExternalImageCaptureUnsupportedSnackBar() { - addSnackBarData( - SnackbarData( - cookie = "Image-ExternalVideoCaptureMode", - stringResource = R.string.toast_image_capture_external_unsupported, - withDismissAction = true, - testTag = IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG - ) - ) - } - - private fun nextSaveLocation(saveMode: SaveMode): Pair { - return when (externalCaptureMode) { - ExternalCaptureMode.ImageCapture, - ExternalCaptureMode.MultipleImageCapture, - ExternalCaptureMode.VideoCapture -> { - if (externalUris.isNotEmpty()) { - if (!this::externalUriProgress.isInitialized) { - externalUriProgress = IntProgress(1, 1..externalUris.size) - } - val progress = externalUriProgress - if (progress.currentValue < progress.range.endInclusive) externalUriProgress++ - Pair( - SaveLocation.Explicit(externalUris[progress.currentValue - 1]), - progress - ) - } else { - Pair(SaveLocation.Default, null) - } - } - - ExternalCaptureMode.Standard -> { - val defaultSaveLocation = - if (saveMode is SaveMode.CacheAndReview) { - SaveLocation.Cache(saveMode.cacheDir) - } else { - SaveLocation.Default - } - Pair(defaultSaveLocation, null) - } - } - } - - fun captureImage(contentResolver: ContentResolver) { - if (captureUiState.value is CaptureUiState.Ready && - (captureUiState.value as CaptureUiState.Ready).externalCaptureMode == - ExternalCaptureMode.VideoCapture - ) { - enqueueExternalImageCaptureUnsupportedSnackBar() - return - } - - if (captureUiState.value is CaptureUiState.Ready && - (captureUiState.value as CaptureUiState.Ready).externalCaptureMode == - ExternalCaptureMode.VideoCapture - ) { - addSnackBarData( - SnackbarData( - cookie = "Image-ExternalVideoCaptureMode", - stringResource = R.string.toast_image_capture_external_unsupported, - withDismissAction = true, - testTag = IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG - ) - ) - return - } - Log.d(TAG, "captureImage") - viewModelScope.launch { - val (saveLocation, progress) = nextSaveLocation(saveMode) - captureImageInternal( - saveLocation = saveLocation, - doTakePicture = { - cameraSystem.takePicture(contentResolver, saveLocation) { - trackedCaptureUiState.update { old -> - old.copy(lastBlinkTimeStamp = System.currentTimeMillis()) - } - }.savedUri - }, - onSuccess = { savedUri -> - val event = if (progress != null) { - ImageCaptureEvent.SequentialImageSaved(savedUri, progress) - } else { - if (saveLocation is SaveLocation.Cache) { - ImageCaptureEvent.SingleImageCached(savedUri) - } else { - ImageCaptureEvent.SingleImageSaved(savedUri) - } - } - if (saveLocation !is SaveLocation.Cache) { - updateLastCapturedMedia() - } else { - savedUri?.let { - postCurrentMediaToMediaRepository( - MediaDescriptor.Content.Image(it, null, true) - ) - } - } - _captureEvents.trySend(event) - }, - onFailure = { exception -> - val event = if (progress != null) { - ImageCaptureEvent.SequentialImageCaptureError(exception, progress) - } else { - ImageCaptureEvent.SingleImageCaptureError(exception) - } - - _captureEvents.trySend(event) - } - ) - } - } - - private suspend fun captureImageInternal( - saveLocation: SaveLocation, - doTakePicture: suspend () -> T, - onSuccess: (T) -> Unit = {}, - onFailure: (exception: Exception) -> Unit = {} - ) { - val cookieInt = snackBarCount.incrementAndGet() - val cookie = "Image-$cookieInt" - val snackBarData = try { - traceAsync(IMAGE_CAPTURE_TRACE, cookieInt) { - doTakePicture() - }.also { result -> - onSuccess(result) - } - Log.d(TAG, "cameraSystem.takePicture success") - // don't display snackbar for successful capture - if (saveLocation is SaveLocation.Cache) { - null - } else { - SnackbarData( - cookie = cookie, - stringResource = R.string.toast_image_capture_success, - withDismissAction = true, - testTag = IMAGE_CAPTURE_SUCCESS_TAG - ) - } - } catch (exception: Exception) { - onFailure(exception) - Log.d(TAG, "cameraSystem.takePicture error", exception) - SnackbarData( - cookie = cookie, - stringResource = R.string.toast_capture_failure, - withDismissAction = true, - testTag = IMAGE_CAPTURE_FAILURE_TAG - ) - } - snackBarData?.let { addSnackBarData(it) } - } - - fun enqueueDisabledHdrToggleSnackBar(disabledReason: DisableRationale) { - val cookieInt = snackBarCount.incrementAndGet() - val cookie = "DisabledHdrToggle-$cookieInt" - addSnackBarData( - SnackbarData( - cookie = cookie, - stringResource = disabledReason.reasonTextResId, - withDismissAction = true, - testTag = disabledReason.testTag - ) - ) - } - - fun startVideoRecording() { - if (captureUiState.value is CaptureUiState.Ready && - (captureUiState.value as CaptureUiState.Ready).externalCaptureMode == - ExternalCaptureMode.ImageCapture - ) { - Log.d(TAG, "externalVideoRecording") - addSnackBarData( - SnackbarData( - cookie = "Video-ExternalImageCaptureMode", - stringResource = R.string.toast_video_capture_external_unsupported, - withDismissAction = true, - testTag = VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG - ) - ) - return - } - Log.d(TAG, "startVideoRecording") - recordingJob = viewModelScope.launch { - val cookie = "Video-${videoCaptureStartedCount.incrementAndGet()}" - val saveMode = saveMode - val (saveLocation, _) = nextSaveLocation(saveMode) - try { - cameraSystem.startVideoRecording(saveLocation) { - var snackbarToShow: SnackbarData? - when (it) { - is OnVideoRecordEvent.OnVideoRecorded -> { - Log.d(TAG, "cameraSystem.startRecording OnVideoRecorded") - val event = if (saveLocation is SaveLocation.Cache) { - VideoCaptureEvent.VideoCached(it.savedUri) - } else { - VideoCaptureEvent.VideoSaved(it.savedUri) - } - - if (saveLocation !is SaveLocation.Cache) { - updateLastCapturedMedia() - } else { - postCurrentMediaToMediaRepository( - MediaDescriptor.Content.Video(it.savedUri, null, true) - ) - } - - _captureEvents.trySend(event) - // don't display snackbar for successful capture - snackbarToShow = if (saveLocation is SaveLocation.Cache) { - null - } else { - SnackbarData( - cookie = cookie, - stringResource = R.string.toast_video_capture_success, - withDismissAction = true, - testTag = VIDEO_CAPTURE_SUCCESS_TAG - ) - } - } - - is OnVideoRecordEvent.OnVideoRecordError -> { - Log.d(TAG, "cameraSystem.startRecording OnVideoRecordError") - _captureEvents.trySend(VideoCaptureEvent.VideoCaptureError(it.error)) - snackbarToShow = SnackbarData( - cookie = cookie, - stringResource = R.string.toast_video_capture_failure, - withDismissAction = true, - testTag = VIDEO_CAPTURE_FAILURE_TAG - ) - } - } - - snackbarToShow?.let { data -> addSnackBarData(data) } - } - Log.d(TAG, "cameraSystem.startRecording success") - } catch (exception: IllegalStateException) { - Log.d(TAG, "cameraSystem.startVideoRecording error", exception) - } - } - } - - fun stopVideoRecording() { - Log.d(TAG, "stopVideoRecording") - viewModelScope.launch { - cameraSystem.stopVideoRecording() - recordingJob?.cancel() - } - } - - /** - "Locks" the video recording such that the user no longer needs to keep their finger pressed on the capture button - */ - fun setLockedRecording(isLocked: Boolean) { - trackedCaptureUiState.update { old -> - old.copy(isRecordingLocked = isLocked) - } - } - - fun setZoomAnimationState(targetValue: Float?) { - trackedCaptureUiState.update { old -> - old.copy(zoomAnimationTarget = targetValue) - } - } - - fun changeZoomRatio(newZoomState: CameraZoomRatio) { - cameraSystem.changeZoomRatio(newZoomState = newZoomState) - } - - fun setTestPattern(newTestPattern: TestPattern) { - cameraSystem.setTestPattern(newTestPattern = newTestPattern) - } - - fun setDynamicRange(dynamicRange: DynamicRange) { - if (externalCaptureMode != ExternalCaptureMode.ImageCapture && - externalCaptureMode != ExternalCaptureMode.MultipleImageCapture - ) { - viewModelScope.launch { - cameraSystem.setDynamicRange(dynamicRange) - } - } - } - - fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) { - viewModelScope.launch { - cameraSystem.setConcurrentCameraMode(concurrentCameraMode) - } - } - - fun setImageFormat(imageFormat: ImageOutputFormat) { - if (externalCaptureMode != ExternalCaptureMode.VideoCapture) { - viewModelScope.launch { - cameraSystem.setImageFormat(imageFormat) - } - } - } - - fun setCaptureMode(captureMode: CaptureMode) { - viewModelScope.launch { - cameraSystem.setCaptureMode(captureMode) - } - } - - fun toggleQuickSettings() { - trackedCaptureUiState.update { old -> - old.copy(isQuickSettingsOpen = !old.isQuickSettingsOpen) - } - } - - fun setFocusedSetting(focusedQuickSetting: FocusedQuickSetting) { - trackedCaptureUiState.update { old -> - old.copy(focusedQuickSetting = focusedQuickSetting) - } - } - - fun toggleDebugOverlay() { - trackedCaptureUiState.update { old -> - old.copy(isDebugOverlayOpen = !old.isDebugOverlayOpen) - } - } - - fun tapToFocus(x: Float, y: Float) { - Log.d(TAG, "tapToFocus") - viewModelScope.launch { - cameraSystem.tapToFocus(x, y) - } - } - - fun onSnackBarResult(cookie: String) { - viewModelScope.launch { - _snackBarUiState.update { old -> - val newQueue = LinkedList(old.snackBarQueue) - val snackBarData = newQueue.poll() - if (snackBarData != null && snackBarData.cookie == cookie) { - // If the latest snackBar had a result, then clear snackBarToShow - Log.d(TAG, "SnackBar removed. Queue size: ${newQueue.size}") - old.copy( - snackBarQueue = newQueue - ) - } else { - old - } - } - } - } - - fun setClearUiScreenBrightness(brightness: Float) { - screenFlash.setClearUiScreenBrightness(brightness) - } - - fun setDisplayRotation(deviceRotation: DeviceRotation) { - viewModelScope.launch { - cameraSystem.setDeviceRotation(deviceRotation) - } - } } 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 5dd34fc68..6742a8d28 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 @@ -87,7 +87,7 @@ class PreviewViewModelTest { val contentResolver: ContentResolver = ApplicationProvider.getApplicationContext().contentResolver startCameraUntilRunning() - previewViewModel.captureImage(contentResolver) + previewViewModel.captureController.captureImage(contentResolver) advanceUntilIdle() assertThat(cameraSystem.numPicturesTaken).isEqualTo(1) } @@ -95,7 +95,7 @@ class PreviewViewModelTest { @Test fun startVideoRecording() = runTest(StandardTestDispatcher()) { startCameraUntilRunning() - previewViewModel.startVideoRecording() + previewViewModel.captureController.startVideoRecording() advanceUntilIdle() assertThat(cameraSystem.recordingInProgress).isTrue() } @@ -103,17 +103,17 @@ class PreviewViewModelTest { @Test fun stopVideoRecording() = runTest(StandardTestDispatcher()) { startCameraUntilRunning() - previewViewModel.startVideoRecording() + previewViewModel.captureController.startVideoRecording() advanceUntilIdle() - previewViewModel.stopVideoRecording() + previewViewModel.captureController.stopVideoRecording() advanceUntilIdle() assertThat(cameraSystem.recordingInProgress).isFalse() } @Test fun setFlash() = runTest(StandardTestDispatcher()) { - previewViewModel.startCamera() - previewViewModel.setFlash(FlashMode.AUTO) + previewViewModel.cameraController.startCamera() + previewViewModel.quickSettingsController.setFlash(FlashMode.AUTO) advanceUntilIdle() assertIsReady(previewViewModel.captureUiState.value).also { @@ -136,7 +136,7 @@ class PreviewViewModelTest { .selectedLensFacing ).isEqualTo(LensFacing.BACK) } - previewViewModel.setLensFacing(LensFacing.FRONT) + previewViewModel.quickSettingsController.setLensFacing(LensFacing.FRONT) advanceUntilIdle() // ui state and camera should both be true now @@ -160,7 +160,7 @@ class PreviewViewModelTest { } // Toggle to open - previewViewModel.toggleQuickSettings() + previewViewModel.quickSettingsController.toggleQuickSettings() advanceUntilIdle() assertIsReady(previewViewModel.captureUiState.value).also { val quickSettings = it.quickSettingsUiState as QuickSettingsUiState.Available @@ -168,7 +168,7 @@ class PreviewViewModelTest { } // Toggle back to closed - previewViewModel.toggleQuickSettings() + previewViewModel.quickSettingsController.toggleQuickSettings() advanceUntilIdle() assertIsReady(previewViewModel.captureUiState.value).also { val quickSettings = it.quickSettingsUiState as QuickSettingsUiState.Available @@ -177,7 +177,7 @@ class PreviewViewModelTest { } private fun TestScope.startCameraUntilRunning() { - previewViewModel.startCamera() + previewViewModel.cameraController.startCamera() advanceUntilIdle() } } diff --git a/ui/components/capture/build.gradle.kts b/ui/components/capture/build.gradle.kts index 90cc1e81c..ece39b1a8 100644 --- a/ui/components/capture/build.gradle.kts +++ b/ui/components/capture/build.gradle.kts @@ -100,6 +100,8 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + implementation(libs.kotlinx.atomicfu) + implementation(project(":ui:uistate")) implementation(project(":ui:uistate:capture")) implementation(project(":core:camera")) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index fb512dd7c..5fb5040df 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -108,6 +108,7 @@ import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.StabilizationMode import com.google.jetpackcamera.model.VideoQuality +import com.google.jetpackcamera.ui.components.capture.quicksettings.controller.QuickSettingsController import com.google.jetpackcamera.ui.uistate.DisableRationale import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.SnackbarData @@ -279,8 +280,8 @@ fun AmplitudeToggleButton( @Composable fun CaptureModeToggleButton( uiState: CaptureModeToggleUiState.Available, - onChangeCaptureMode: (CaptureMode) -> Unit, - onToggleWhenDisabled: (DisableRationale) -> Unit, + quickSettingsController: QuickSettingsController?, + snackBarController: SnackBarController?, modifier: Modifier = Modifier ) { // Captures image (left), else captures video (right). @@ -302,7 +303,7 @@ fun CaptureModeToggleButton( checked = toggleState, onCheckedChange = { isChecked -> val newCaptureMode = if (isChecked) CaptureMode.VIDEO_ONLY else CaptureMode.IMAGE_ONLY - onChangeCaptureMode(newCaptureMode) + quickSettingsController?.setCaptureMode(newCaptureMode) }, onToggleWhenDisabled = { val disabledReason: DisableRationale? = @@ -315,7 +316,7 @@ fun CaptureModeToggleButton( as? SingleSelectableUiState.Disabled ) ?.disabledReason - disabledReason?.let { onToggleWhenDisabled(it) } + disabledReason?.let { snackBarController?.enqueueDisabledHdrToggleSnackBar(it) } }, enabled = enabled, leftIcon = if (uiState.selectedCaptureMode == @@ -359,7 +360,7 @@ fun TestableSnackbar( modifier: Modifier = Modifier, snackbarToShow: SnackbarData, snackbarHostState: SnackbarHostState, - onSnackbarResult: (String) -> Unit + snackBarController: SnackBarController ) { Box( // box seems to need to have some size to be detected by UiAutomator @@ -385,11 +386,13 @@ fun TestableSnackbar( ) when (result) { SnackbarResult.ActionPerformed, - SnackbarResult.Dismissed -> onSnackbarResult(snackbarToShow.cookie) + SnackbarResult.Dismissed -> snackBarController.onSnackBarResult( + snackbarToShow.cookie + ) } } catch (e: Exception) { // This is equivalent to dismissing the snackbar - onSnackbarResult(snackbarToShow.cookie) + snackBarController.onSnackBarResult(snackbarToShow.cookie) } } } @@ -600,12 +603,12 @@ fun CaptureButton( modifier: Modifier = Modifier, captureButtonUiState: CaptureButtonUiState, isQuickSettingsOpen: Boolean, - onToggleQuickSettings: () -> Unit = {}, onIncrementZoom: (Float) -> Unit = {}, onCaptureImage: (ContentResolver) -> Unit = {}, onStartVideoRecording: () -> Unit = {}, onStopVideoRecording: () -> Unit = {}, - onLockVideoRecording: (Boolean) -> Unit = {} + onLockVideoRecording: (Boolean) -> Unit = {}, + quickSettingsController: QuickSettingsController? = null ) { val context = LocalContext.current @@ -617,7 +620,7 @@ fun CaptureButton( onCaptureImage(context.contentResolver) } if (isQuickSettingsOpen) { - onToggleQuickSettings() + quickSettingsController?.toggleQuickSettings() } }, onStartRecording = { diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SnackBarController.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SnackBarController.kt new file mode 100644 index 000000000..a1cbd1f24 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SnackBarController.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2026 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.ui.components.capture + +import com.google.jetpackcamera.ui.uistate.DisableRationale +import com.google.jetpackcamera.ui.uistate.SnackbarData + +interface SnackBarController { + fun enqueueDisabledHdrToggleSnackBar(disabledReason: DisableRationale) + fun onSnackBarResult(cookie: String) + fun incrementAndGetSnackBarCount(): Int + fun addSnackBarData(snackBarData: SnackbarData) +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SnackBarControllerImpl.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SnackBarControllerImpl.kt new file mode 100644 index 000000000..0af482e9d --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SnackBarControllerImpl.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2026 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.ui.components.capture + +import android.util.Log +import com.google.jetpackcamera.ui.uistate.DisableRationale +import com.google.jetpackcamera.ui.uistate.SnackBarUiState +import com.google.jetpackcamera.ui.uistate.SnackbarData +import java.util.LinkedList +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private const val TAG = "SnackBarControllerImpl" + +class SnackBarControllerImpl( + private val viewModelScope: CoroutineScope, + private val snackBarUiState: MutableStateFlow +) : SnackBarController { + val snackBarCount = atomic(0) + override fun enqueueDisabledHdrToggleSnackBar(disabledReason: DisableRationale) { + val cookieInt = incrementAndGetSnackBarCount() + val cookie = "DisabledHdrToggle-$cookieInt" + addSnackBarData( + SnackbarData( + cookie = cookie, + stringResource = disabledReason.reasonTextResId, + withDismissAction = true, + testTag = disabledReason.testTag + ) + ) + } + + override fun onSnackBarResult(cookie: String) { + viewModelScope.launch { + snackBarUiState.update { old -> + val newQueue = LinkedList(old.snackBarQueue) + val snackBarData = newQueue.poll() + if (snackBarData != null && snackBarData.cookie == cookie) { + // If the latest snackBar had a result, then clear snackBarToShow + Log.d(TAG, "SnackBar removed. Queue size: ${newQueue.size}") + old.copy( + snackBarQueue = newQueue + ) + } else { + old + } + } + } + } + + override fun incrementAndGetSnackBarCount(): Int { + return snackBarCount.incrementAndGet() + } + + override fun addSnackBarData(snackBarData: SnackbarData) { + viewModelScope.launch { + snackBarUiState.update { old -> + val newQueue = LinkedList(old.snackBarQueue) + newQueue.add(snackBarData) + Log.d(TAG, "SnackBar added. Queue size: ${newQueue.size}") + old.copy( + snackBarQueue = newQueue + ) + } + } + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ZoomStateManager.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ZoomStateManager.kt index bff65ce3b..f793febc5 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ZoomStateManager.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ZoomStateManager.kt @@ -25,6 +25,7 @@ import androidx.core.util.toClosedRange import com.google.jetpackcamera.model.CameraZoomRatio import com.google.jetpackcamera.model.LensToZoom import com.google.jetpackcamera.model.ZoomStrategy +import com.google.jetpackcamera.ui.components.capture.controller.ZoomController /** * Manages the camera's zoom level and handles interactions related to zooming. @@ -41,11 +42,10 @@ import com.google.jetpackcamera.model.ZoomStrategy class ZoomStateManager( initialZoomLevel: Float, zoomRange: Range, - val onChangeZoomLevel: (CameraZoomRatio) -> Unit, - val onAnimateStateChanged: (Float?) -> Unit + private val zoomController: ZoomController ) { init { - onAnimateStateChanged(null) + zoomController.setZoomAnimationState(null) } private var functionalZoom = initialZoomLevel @@ -62,7 +62,7 @@ class ZoomStateManager( private suspend fun mutateZoom(block: suspend () -> Unit) { mutatorMutex.mutate { - onAnimateStateChanged(null) + zoomController.setZoomAnimationState(null) block() } } @@ -81,7 +81,7 @@ class ZoomStateManager( if (lensToZoom == LensToZoom.PRIMARY) { functionalZoom = targetZoomLevel.coerceIn(functionalZoomRange.toClosedRange()) } - onChangeZoomLevel( + zoomController.setZoomRatio( CameraZoomRatio( ZoomStrategy.Absolute( targetZoomLevel.coerceIn(functionalZoomRange.toClosedRange()), @@ -133,7 +133,7 @@ class ZoomStateManager( lensToZoom: LensToZoom ) { mutatorMutex.mutate { - onAnimateStateChanged(targetZoomLevel) + zoomController.setZoomAnimationState(targetZoomLevel) Animatable(initialValue = functionalZoom).animateTo( targetValue = targetZoomLevel, @@ -141,7 +141,7 @@ class ZoomStateManager( ) { // this is called every animation frame functionalZoom = value.coerceIn(functionalZoomRange.toClosedRange()) - onChangeZoomLevel( + zoomController.setZoomRatio( CameraZoomRatio( ZoomStrategy.Absolute( functionalZoom, diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CameraController.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CameraController.kt new file mode 100644 index 000000000..b427d74b9 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CameraController.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.controller + +/** + * Interface for controlling camera lifecycle and basic operations. + */ +interface CameraController { + /** + * Starts the camera. + */ + fun startCamera() + + /** + * Stops the camera. + */ + fun stopCamera() +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CameraControllerImpl.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CameraControllerImpl.kt new file mode 100644 index 000000000..a59c37d41 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CameraControllerImpl.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.controller + +import android.os.SystemClock +import android.util.Log +import androidx.lifecycle.viewModelScope +import androidx.tracing.Trace +import com.google.jetpackcamera.core.camera.CameraSystem +import com.google.jetpackcamera.core.common.traceFirstFramePreview +import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.launch + +private const val TAG = "CameraControllerImpl" + +/** + * Implementation of [CameraController] that manages the camera lifecycle. + * + * @param initializationDeferred A [Deferred] that completes when the camera system is initialized. + * @param captureUiState The [StateFlow] of the capture UI state. + * @param viewModelScope The [CoroutineScope] for launching coroutines. + * @param cameraSystem The [CameraSystem] to interact with. + */ +class CameraControllerImpl( + private val initializationDeferred: Deferred, + private val captureUiState: StateFlow, + private val viewModelScope: CoroutineScope, + private val cameraSystem: CameraSystem +) : CameraController { + private var runningCameraJob: Job? = null + + override fun startCamera() { + Log.d(TAG, "startCamera") + stopCamera() + runningCameraJob = viewModelScope.launch { + if (Trace.isEnabled()) { + launch(start = CoroutineStart.UNDISPATCHED) { + val startTraceTimestamp: Long = SystemClock.elapsedRealtimeNanos() + traceFirstFramePreview(cookie = 1) { + captureUiState.transformWhile { + var continueCollecting = true + (it as? CaptureUiState.Ready)?.let { uiState -> + if (uiState.sessionFirstFrameTimestamp > startTraceTimestamp) { + emit(Unit) + continueCollecting = false + } + } + continueCollecting + }.collect {} + } + } + } + // Ensure CameraSystem is initialized before starting camera + initializationDeferred.await() + // TODO(yasith): Handle Exceptions from binding use cases + cameraSystem.runCamera() + } + } + + override fun stopCamera() { + Log.d(TAG, "stopCamera") + runningCameraJob?.apply { + if (isActive) { + cancel() + } + } + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureController.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureController.kt new file mode 100644 index 000000000..53ead8098 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureController.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.ui.components.capture.controller + +import android.content.ContentResolver + +/** + * Interface for controlling capture operations like taking photos and recording videos. + */ +interface CaptureController { + /** + * Captures a single image. + * + * @param contentResolver The [ContentResolver] to use for saving the image. + */ + fun captureImage(contentResolver: ContentResolver) + + /** + * Starts video recording. + */ + fun startVideoRecording() + + /** + * Stops the current video recording. + */ + fun stopVideoRecording() + + /** + * Sets whether the recording is locked. + * + * @param isLocked True if the recording should be locked, false otherwise. + */ + fun setLockedRecording(isLocked: Boolean) +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureControllerImpl.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureControllerImpl.kt new file mode 100644 index 000000000..f8fc3e122 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureControllerImpl.kt @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.ui.components.capture.controller + +import android.content.ContentResolver +import android.util.Log +import androidx.tracing.traceAsync +import com.google.jetpackcamera.core.camera.CameraSystem +import com.google.jetpackcamera.core.camera.OnVideoRecordEvent +import com.google.jetpackcamera.data.media.MediaDescriptor +import com.google.jetpackcamera.data.media.MediaRepository +import com.google.jetpackcamera.model.CaptureEvent +import com.google.jetpackcamera.model.ExternalCaptureMode +import com.google.jetpackcamera.model.ImageCaptureEvent +import com.google.jetpackcamera.model.IntProgress +import com.google.jetpackcamera.model.SaveLocation +import com.google.jetpackcamera.model.SaveMode +import com.google.jetpackcamera.model.VideoCaptureEvent +import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG +import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_FAILURE_TAG +import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_SUCCESS_TAG +import com.google.jetpackcamera.ui.components.capture.R +import com.google.jetpackcamera.ui.components.capture.SnackBarController +import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG +import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_FAILURE_TAG +import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_SUCCESS_TAG +import com.google.jetpackcamera.ui.components.capture.controller.Utils.nextSaveLocation +import com.google.jetpackcamera.ui.components.capture.controller.Utils.postCurrentMediaToMediaRepository +import com.google.jetpackcamera.ui.uistate.SnackbarData +import com.google.jetpackcamera.ui.uistate.capture.TrackedCaptureUiState +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private const val TAG = "CaptureButtonControllerImpl" + +private const val IMAGE_CAPTURE_TRACE = "JCA Image Capture" + +/** + * Implementation of [CaptureController] that interacts with [CameraSystem] and [MediaRepository]. + * + * @property trackedCaptureUiState State for tracking UI changes during capture. + * @property viewModelScope Scope for launching coroutines. + * @property cameraSystem The camera system to perform capture operations. + * @property mediaRepository Repository for managing captured media. + * @property saveMode Mode for saving captured media. + * @property externalCaptureMode Mode for external capture requests. + * @property externalCapturesCallback Callback for getting external capture information. + * @property captureEvents Channel for sending capture-related events. + * @property captureScreenController Controller for UI-related capture screen actions. + * @property snackBarController Controller for showing snackbars. + */ +class CaptureControllerImpl( + private val trackedCaptureUiState: MutableStateFlow, + private val viewModelScope: CoroutineScope, + private val cameraSystem: CameraSystem, + private val mediaRepository: MediaRepository, + private val saveMode: SaveMode, + private val externalCaptureMode: ExternalCaptureMode, + private val externalCapturesCallback: () -> Pair, + private val captureEvents: Channel, + private val captureScreenController: CaptureScreenController, + private val snackBarController: SnackBarController? +) : CaptureController { + + private val traceCookie = atomic(0) + private val videoCaptureStartedCount = atomic(0) + private var recordingJob: Job? = null + + override fun captureImage(contentResolver: ContentResolver) { + if (externalCaptureMode == ExternalCaptureMode.VideoCapture) { + snackBarController?.addSnackBarData( + SnackbarData( + cookie = "Image-ExternalVideoCaptureMode", + stringResource = R.string.toast_image_capture_external_unsupported, + withDismissAction = true, + testTag = IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG + ) + ) + return + } + Log.d(TAG, "captureImage") + viewModelScope.launch { + val (saveLocation, progress) = nextSaveLocation( + saveMode, + externalCaptureMode, + externalCapturesCallback + ) + captureImageInternal( + saveLocation = saveLocation, + doTakePicture = { + cameraSystem.takePicture(contentResolver, saveLocation) { + trackedCaptureUiState.update { old -> + old.copy(lastBlinkTimeStamp = System.currentTimeMillis()) + } + }.savedUri + }, + onSuccess = { savedUri -> + val event = if (progress != null) { + ImageCaptureEvent.SequentialImageSaved(savedUri, progress) + } else { + if (saveLocation is SaveLocation.Cache) { + ImageCaptureEvent.SingleImageCached(savedUri) + } else { + ImageCaptureEvent.SingleImageSaved(savedUri) + } + } + if (saveLocation !is SaveLocation.Cache) { + captureScreenController.updateLastCapturedMedia() + } else { + savedUri?.let { + postCurrentMediaToMediaRepository( + viewModelScope, + mediaRepository, + MediaDescriptor.Content.Image(it, null, true) + ) + } + } + captureEvents.trySend(event) + }, + onFailure = { exception -> + val event = if (progress != null) { + ImageCaptureEvent.SequentialImageCaptureError(exception, progress) + } else { + ImageCaptureEvent.SingleImageCaptureError(exception) + } + + captureEvents.trySend(event) + } + ) + } + } + + override fun startVideoRecording() { + if (externalCaptureMode == ExternalCaptureMode.ImageCapture) { + Log.d(TAG, "externalVideoRecording") + snackBarController?.addSnackBarData( + SnackbarData( + cookie = "Video-ExternalImageCaptureMode", + stringResource = R.string.toast_video_capture_external_unsupported, + withDismissAction = true, + testTag = VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG + ) + ) + return + } + Log.d(TAG, "startVideoRecording") + recordingJob = viewModelScope.launch { + val cookie = "Video-${videoCaptureStartedCount.incrementAndGet()}" + val (saveLocation, _) = nextSaveLocation( + saveMode, + externalCaptureMode, + externalCapturesCallback + ) + try { + cameraSystem.startVideoRecording(saveLocation) { + var snackbarToShow: SnackbarData? + when (it) { + is OnVideoRecordEvent.OnVideoRecorded -> { + Log.d(TAG, "cameraSystem.startRecording OnVideoRecorded") + val event = if (saveLocation is SaveLocation.Cache) { + VideoCaptureEvent.VideoCached(it.savedUri) + } else { + VideoCaptureEvent.VideoSaved(it.savedUri) + } + + if (saveLocation !is SaveLocation.Cache) { + captureScreenController.updateLastCapturedMedia() + } else { + postCurrentMediaToMediaRepository( + viewModelScope, + mediaRepository, + MediaDescriptor.Content.Video(it.savedUri, null, true) + ) + } + + captureEvents.trySend(event) + // don't display snackbar for successful capture + snackbarToShow = if (saveLocation is SaveLocation.Cache) { + null + } else { + SnackbarData( + cookie = cookie, + stringResource = R.string.toast_video_capture_success, + withDismissAction = true, + testTag = VIDEO_CAPTURE_SUCCESS_TAG + ) + } + } + + is OnVideoRecordEvent.OnVideoRecordError -> { + Log.d(TAG, "cameraSystem.startRecording OnVideoRecordError") + captureEvents.trySend(VideoCaptureEvent.VideoCaptureError(it.error)) + snackbarToShow = SnackbarData( + cookie = cookie, + stringResource = R.string.toast_video_capture_failure, + withDismissAction = true, + testTag = VIDEO_CAPTURE_FAILURE_TAG + ) + } + } + + snackbarToShow?.let { data -> + snackBarController?.addSnackBarData(data) + } + } + Log.d(TAG, "cameraSystem.startRecording success") + } catch (exception: IllegalStateException) { + Log.d(TAG, "cameraSystem.startVideoRecording error", exception) + } + } + } + + override fun stopVideoRecording() { + Log.d(TAG, "stopVideoRecording") + viewModelScope.launch { + cameraSystem.stopVideoRecording() + recordingJob?.cancel() + } + } + + private suspend fun captureImageInternal( + saveLocation: SaveLocation, + doTakePicture: suspend () -> T, + onSuccess: (T) -> Unit = {}, + onFailure: (exception: Exception) -> Unit = {} + ) { + val cookieInt = snackBarController?.incrementAndGetSnackBarCount() + ?: traceCookie.incrementAndGet() + val cookie = "Image-$cookieInt" + val snackBarData = try { + traceAsync(IMAGE_CAPTURE_TRACE, cookieInt) { + doTakePicture() + }.also { result -> + onSuccess(result) + } + Log.d(TAG, "cameraSystem.takePicture success") + // don't display snackbar for successful capture + if (saveLocation is SaveLocation.Cache) { + null + } else { + SnackbarData( + cookie = cookie, + stringResource = R.string.toast_image_capture_success, + withDismissAction = true, + testTag = IMAGE_CAPTURE_SUCCESS_TAG + ) + } + } catch (exception: Exception) { + onFailure(exception) + Log.d(TAG, "cameraSystem.takePicture error", exception) + SnackbarData( + cookie = cookie, + stringResource = R.string.toast_capture_failure, + withDismissAction = true, + testTag = IMAGE_CAPTURE_FAILURE_TAG + ) + } + snackBarData?.let { + snackBarController?.addSnackBarData( + it + ) + } + } + + override fun setLockedRecording(isLocked: Boolean) { + trackedCaptureUiState.update { old -> + old.copy(isRecordingLocked = isLocked) + } + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureScreenController.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureScreenController.kt new file mode 100644 index 000000000..b316d6737 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureScreenController.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.controller + +import com.google.jetpackcamera.model.DeviceRotation + +/** + * Interface for controlling actions on the capture screen. + */ +interface CaptureScreenController { + /** + * Sets the display rotation. + * + * @param deviceRotation The device rotation to set. + */ + fun setDisplayRotation(deviceRotation: DeviceRotation) + + /** + * Initiates a tap-to-focus action at the given coordinates. + * + * @param x The x-coordinate of the tap. + * @param y The y-coordinate of the tap. + */ + fun tapToFocus(x: Float, y: Float) + + /** + * Enables or disables audio recording. + * + * @param shouldEnableAudio Whether audio should be enabled. + */ + fun setAudioEnabled(shouldEnableAudio: Boolean) + + /** + * Updates the UI with the last captured media. + */ + fun updateLastCapturedMedia() + + /** + * Transfers the media from the image well to the repository. + */ + fun imageWellToRepository() + + /** + * Pauses or resumes video recording. + * + * @param shouldBePaused Whether the recording should be paused. + */ + fun setPaused(shouldBePaused: Boolean) +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureScreenControllerImpl.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureScreenControllerImpl.kt new file mode 100644 index 000000000..9fc5684cf --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/CaptureScreenControllerImpl.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.controller + +import android.util.Log +import com.google.jetpackcamera.core.camera.CameraSystem +import com.google.jetpackcamera.data.media.MediaRepository +import com.google.jetpackcamera.model.DeviceRotation +import com.google.jetpackcamera.ui.components.capture.controller.Utils.postCurrentMediaToMediaRepository +import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState +import com.google.jetpackcamera.ui.uistate.capture.TrackedCaptureUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private const val TAG = "CaptureScreenControllerImpl" + +/** + * Implementation of [CaptureScreenController] that handles UI events on the capture screen. + * + * @param viewModelScope The [CoroutineScope] for launching coroutines. + * @param cameraSystem The [CameraSystem] to interact with. + * @param trackedCaptureUiState The [MutableStateFlow] of the tracked capture UI state. + * @param mediaRepository The [MediaRepository] to interact with media. + * @param captureUiState The [StateFlow] of the capture UI state. + */ +class CaptureScreenControllerImpl( + private val viewModelScope: CoroutineScope, + private val cameraSystem: CameraSystem, + private val trackedCaptureUiState: MutableStateFlow, + private val mediaRepository: MediaRepository, + private val captureUiState: StateFlow +) : CaptureScreenController { + + override fun setDisplayRotation(deviceRotation: DeviceRotation) { + viewModelScope.launch { + cameraSystem.setDeviceRotation(deviceRotation) + } + } + + override fun tapToFocus(x: Float, y: Float) { + Log.d(TAG, "tapToFocus") + viewModelScope.launch { + cameraSystem.tapToFocus(x, y) + } + } + + override fun setAudioEnabled(shouldEnableAudio: Boolean) { + viewModelScope.launch { + cameraSystem.setAudioEnabled(shouldEnableAudio) + } + + Log.d( + TAG, + "Toggle Audio: $shouldEnableAudio" + ) + } + + override fun updateLastCapturedMedia() { + viewModelScope.launch { + trackedCaptureUiState.update { old -> + old.copy(recentCapturedMedia = mediaRepository.getLastCapturedMedia()) + } + } + } + + override fun imageWellToRepository() { + (captureUiState.value as? CaptureUiState.Ready) + ?.let { it.imageWellUiState as? ImageWellUiState.LastCapture } + ?.let { + postCurrentMediaToMediaRepository( + viewModelScope, + mediaRepository, + it.mediaDescriptor + ) + } + } + + override fun setPaused(shouldBePaused: Boolean) { + viewModelScope.launch { + if (shouldBePaused) { + cameraSystem.pauseVideoRecording() + } else { + cameraSystem.resumeVideoRecording() + } + } + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/Utils.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/Utils.kt new file mode 100644 index 000000000..28fe0f715 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/Utils.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.controller + +import com.google.jetpackcamera.data.media.MediaDescriptor +import com.google.jetpackcamera.data.media.MediaRepository +import com.google.jetpackcamera.model.ExternalCaptureMode +import com.google.jetpackcamera.model.IntProgress +import com.google.jetpackcamera.model.SaveLocation +import com.google.jetpackcamera.model.SaveMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +object Utils { + /** + * Posts the given [MediaDescriptor] to the [MediaRepository] as the current media. + * + * @param viewModelScope The [CoroutineScope] for launching the coroutine. + * @param mediaRepository The repository to update. + * @param mediaDescriptor The media to set as current. + */ + fun postCurrentMediaToMediaRepository( + viewModelScope: CoroutineScope, + mediaRepository: MediaRepository, + mediaDescriptor: MediaDescriptor + ) { + viewModelScope.launch { + mediaRepository.setCurrentMedia(mediaDescriptor) + } + } + + /** + * Determines the next [SaveLocation] and optional [IntProgress] for a capture. + * + * @param saveMode The current [SaveMode]. + * @param externalCaptureMode The current [ExternalCaptureMode]. + * @param externalCapturesCallback A callback to retrieve the save location and progress for + * external captures. + * @return A [Pair] containing the [SaveLocation] and optional [IntProgress]. + */ + fun nextSaveLocation( + saveMode: SaveMode, + externalCaptureMode: ExternalCaptureMode, + externalCapturesCallback: () -> Pair + ): Pair { + return when (externalCaptureMode) { + ExternalCaptureMode.ImageCapture, + ExternalCaptureMode.MultipleImageCapture, + ExternalCaptureMode.VideoCapture -> { + externalCapturesCallback() + } + + ExternalCaptureMode.Standard -> { + val defaultSaveLocation = + if (saveMode is SaveMode.CacheAndReview) { + SaveLocation.Cache(saveMode.cacheDir) + } else { + SaveLocation.Default + } + Pair(defaultSaveLocation, null) + } + } + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/ZoomController.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/ZoomController.kt new file mode 100644 index 000000000..b624d3435 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/ZoomController.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.ui.components.capture.controller + +import com.google.jetpackcamera.model.CameraZoomRatio + +/** + * Interface for controlling camera zoom. + */ +interface ZoomController { + /** + * Sets the camera's zoom ratio. + * + * @param zoomRatio The [CameraZoomRatio] to set. + */ + fun setZoomRatio(zoomRatio: CameraZoomRatio) + + /** + * Sets the target value for the zoom animation. + * + * @param targetValue The target zoom ratio for the animation, or null to clear it. + */ + fun setZoomAnimationState(targetValue: Float?) +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/ZoomControllerImpl.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/ZoomControllerImpl.kt new file mode 100644 index 000000000..09fa44fec --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/controller/ZoomControllerImpl.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.controller + +import com.google.jetpackcamera.core.camera.CameraSystem +import com.google.jetpackcamera.model.CameraZoomRatio +import com.google.jetpackcamera.ui.uistate.capture.TrackedCaptureUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * Implementation of [ZoomController] that updates the camera's zoom and tracked UI state. + * + * @param cameraSystem The camera system to update zoom on. + * @param trackedCaptureUiState State for tracking zoom changes. + */ +class ZoomControllerImpl( + private val cameraSystem: CameraSystem, + private val trackedCaptureUiState: MutableStateFlow +) : ZoomController { + + override fun setZoomRatio(zoomRatio: CameraZoomRatio) { + cameraSystem.changeZoomRatio( + newZoomState = zoomRatio + ) + } + + override fun setZoomAnimationState(targetValue: Float?) { + trackedCaptureUiState.update { old -> + old.copy(zoomAnimationTarget = targetValue) + } + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/DebugOverlayComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/DebugOverlayComponents.kt index f4dc7abb3..516298cf0 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/DebugOverlayComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/DebugOverlayComponents.kt @@ -77,6 +77,7 @@ import com.google.jetpackcamera.ui.components.capture.LOGICAL_CAMERA_ID_TAG import com.google.jetpackcamera.ui.components.capture.PHYSICAL_CAMERA_ID_TAG import com.google.jetpackcamera.ui.components.capture.R import com.google.jetpackcamera.ui.components.capture.ZOOM_RATIO_TAG +import com.google.jetpackcamera.ui.components.capture.debug.controller.DebugController import com.google.jetpackcamera.ui.uistate.capture.DebugUiState import kotlin.math.abs @@ -168,22 +169,20 @@ private fun ToggleVisibilityButton( fun DebugOverlay( modifier: Modifier = Modifier, onChangeZoomRatio: (Float) -> Unit, - onSetTestPattern: (TestPattern) -> Unit, - toggleIsOpen: () -> Unit, - onToggleHidingComponents: () -> Unit, debugUiState: DebugUiState.Enabled, - vararg extraControls: @Composable () -> Unit + vararg extraControls: @Composable () -> Unit, + debugController: DebugController ) { Box(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.padding(top = 100.dp)) { ToggleVisibilityButton( - onToggleHidingComponents = onToggleHidingComponents, + onToggleHidingComponents = debugController::toggleDebugHidingComponents, isHidingComponents = debugUiState.debugHidingComponents ) if (!debugUiState.debugHidingComponents) { DebugConsole( debugUiState = debugUiState, - onToggleDebugOverlay = toggleIsOpen, + onToggleDebugOverlay = debugController::toggleDebugOverlay, extraControls = extraControls ) } @@ -193,8 +192,8 @@ fun DebugOverlay( DebugDialogContainer( modifier = Modifier, onChangeZoomRatio = onChangeZoomRatio, - onSetTestPattern = onSetTestPattern, - toggleIsOpen = toggleIsOpen, + onSetTestPattern = debugController::setTestPattern, + toggleIsOpen = debugController::toggleDebugOverlay, debugUiState = it ) } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/controller/DebugController.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/controller/DebugController.kt new file mode 100644 index 000000000..bd3f26f9d --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/controller/DebugController.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.debug.controller + +import com.google.jetpackcamera.model.TestPattern + +/** + * This file contains the [DebugController] interface for managing debug actions. + */ + +/** + * Interface for controlling debug features. + */ +interface DebugController { + /** + * Toggles the visibility of debug UI components. + */ + fun toggleDebugHidingComponents() + + /** + * Toggles the debug overlay. + */ + fun toggleDebugOverlay() + + /** + * Sets the test pattern for the camera. + * + * @param testPattern The test pattern to set. + */ + fun setTestPattern(testPattern: TestPattern) +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/controller/DebugControllerImpl.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/controller/DebugControllerImpl.kt new file mode 100644 index 000000000..74bf57fb6 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/debug/controller/DebugControllerImpl.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.debug.controller + +import com.google.jetpackcamera.core.camera.CameraSystem +import com.google.jetpackcamera.model.TestPattern +import com.google.jetpackcamera.ui.uistate.capture.TrackedCaptureUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * Implementation of [DebugController] that interacts with [CameraSystem] and updates + * [trackedCaptureUiState]. + * + * @property cameraSystem The camera system to control. + * @property trackedCaptureUiState The state flow to update with debug information. + */ +class DebugControllerImpl( + private val cameraSystem: CameraSystem, + private val trackedCaptureUiState: MutableStateFlow +) : DebugController { + override fun toggleDebugHidingComponents() { + trackedCaptureUiState.update { old -> + old.copy(debugHidingComponents = !old.debugHidingComponents) + } + } + + override fun toggleDebugOverlay() { + trackedCaptureUiState.update { old -> + old.copy(isDebugOverlayOpen = !old.isDebugOverlayOpen) + } + } + + override fun setTestPattern(testPattern: TestPattern) { + cameraSystem.setTestPattern( + newTestPattern = testPattern + ) + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt index c182c4e5e..733f137d5 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt @@ -42,6 +42,7 @@ import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_RATIO_BUTTO import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_STREAM_CONFIG_BUTTON import com.google.jetpackcamera.ui.components.capture.R import com.google.jetpackcamera.ui.components.capture.SETTINGS_BUTTON +import com.google.jetpackcamera.ui.components.capture.quicksettings.controller.QuickSettingsController import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.QuickFlipCamera import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.QuickNavSettings import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.QuickSetConcurrentCamera @@ -72,32 +73,23 @@ import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState fun QuickSettingsBottomSheet( modifier: Modifier = Modifier, quickSettingsUiState: QuickSettingsUiState, - toggleQuickSettings: () -> Unit, - onSetFocusedSetting: (FocusedQuickSetting) -> Unit, onNavigateToSettings: () -> Unit, - onLensFaceClick: (lensFace: LensFacing) -> Unit, - onFlashModeClick: (flashMode: FlashMode) -> Unit, - onAspectRatioClick: (aspectRation: AspectRatio) -> Unit, - onStreamConfigClick: (streamConfig: StreamConfig) -> Unit, - onDynamicRangeClick: (dynamicRange: DynamicRange) -> Unit, - onImageOutputFormatClick: (imageOutputFormat: ImageOutputFormat) -> Unit, - onConcurrentCameraModeClick: (concurrentCameraMode: ConcurrentCameraMode) -> Unit, - onCaptureModeClick: (CaptureMode) -> Unit + quickSettingsController: QuickSettingsController ) { if (quickSettingsUiState is QuickSettingsUiState.Available) { - val onUnFocus = { onSetFocusedSetting(FocusedQuickSetting.NONE) } + val onUnFocus = { quickSettingsController.setFocusedSetting(FocusedQuickSetting.NONE) } val displayedQuickSettings: List<@Composable () -> Unit> = when (quickSettingsUiState.focusedQuickSetting) { FocusedQuickSetting.ASPECT_RATIO -> focusedRatioButtons( onUnFocus = onUnFocus, - onSetAspectRatio = onAspectRatioClick, + onSetAspectRatio = quickSettingsController::setAspectRatio, aspectRatioUiState = quickSettingsUiState.aspectRatioUiState ) FocusedQuickSetting.CAPTURE_MODE -> focusedCaptureModeButtons( onUnFocus = onUnFocus, - onSetCaptureMode = onCaptureModeClick, + onSetCaptureMode = quickSettingsController::setCaptureMode, captureModeUiState = quickSettingsUiState.captureModeUiState ) @@ -107,7 +99,7 @@ fun QuickSettingsBottomSheet( add { QuickSetFlash( modifier = Modifier.testTag(QUICK_SETTINGS_FLASH_BUTTON), - onClick = { f: FlashMode -> onFlashModeClick(f) }, + onClick = { f: FlashMode -> quickSettingsController.setFlash(f) }, flashModeUiState = quickSettingsUiState.flashModeUiState ) } @@ -123,7 +115,9 @@ fun QuickSettingsBottomSheet( .testTag(BTN_QUICK_SETTINGS_FOCUS_CAPTURE_MODE) .semantics { description?.let { stateDescription = it } }, setCaptureMode = { - onSetFocusedSetting(FocusedQuickSetting.CAPTURE_MODE) + quickSettingsController.setFocusedSetting( + FocusedQuickSetting.CAPTURE_MODE + ) }, captureModeUiState = quickSettingsUiState.captureModeUiState ) @@ -132,7 +126,9 @@ fun QuickSettingsBottomSheet( add { QuickFlipCamera( modifier = Modifier.testTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON), - setLensFacing = { l: LensFacing -> onLensFaceClick(l) }, + setLensFacing = { l: LensFacing -> + quickSettingsController.setLensFacing(l) + }, flipLensUiState = quickSettingsUiState.flipLensUiState ) } @@ -141,7 +137,9 @@ fun QuickSettingsBottomSheet( ToggleFocusedQuickSetRatio( modifier = Modifier.testTag(QUICK_SETTINGS_RATIO_BUTTON), setRatio = { - onSetFocusedSetting(FocusedQuickSetting.ASPECT_RATIO) + quickSettingsController.setFocusedSetting( + FocusedQuickSetting.ASPECT_RATIO + ) }, isHighlightEnabled = false, aspectRatioUiState = quickSettingsUiState.aspectRatioUiState @@ -153,7 +151,9 @@ fun QuickSettingsBottomSheet( modifier = Modifier.testTag( QUICK_SETTINGS_STREAM_CONFIG_BUTTON ), - setStreamConfig = { c: StreamConfig -> onStreamConfigClick(c) }, + setStreamConfig = { c: StreamConfig -> + quickSettingsController.setStreamConfig(c) + }, streamConfigUiState = quickSettingsUiState.streamConfigUiState ) } @@ -162,8 +162,8 @@ fun QuickSettingsBottomSheet( QuickSetHdr( modifier = Modifier.testTag(QUICK_SETTINGS_HDR_BUTTON), onClick = { d: DynamicRange, i: ImageOutputFormat -> - onDynamicRangeClick(d) - onImageOutputFormatClick(i) + quickSettingsController.setDynamicRange(d) + quickSettingsController.setImageFormat(i) }, hdrUiState = quickSettingsUiState.hdrUiState ) @@ -174,7 +174,7 @@ fun QuickSettingsBottomSheet( modifier = Modifier.testTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON), setConcurrentCameraMode = { c: ConcurrentCameraMode -> - onConcurrentCameraModeClick(c) + quickSettingsController.setConcurrentCameraMode(c) }, concurrentCameraUiState = quickSettingsUiState .concurrentCameraUiState @@ -198,7 +198,7 @@ fun QuickSettingsBottomSheet( modifier = modifier, onDismiss = { onUnFocus() - toggleQuickSettings() + quickSettingsController.toggleQuickSettings() }, sheetState = sheetState, *displayedQuickSettings.toTypedArray() @@ -268,17 +268,8 @@ fun ExpandedQuickSettingsUiPreview() { ), quickSettingsIsOpen = true ), - onLensFaceClick = { }, - onFlashModeClick = { }, - onAspectRatioClick = { }, - onStreamConfigClick = { }, - onDynamicRangeClick = { }, - onImageOutputFormatClick = { }, - onConcurrentCameraModeClick = { }, - toggleQuickSettings = { }, - onNavigateToSettings = { }, - onCaptureModeClick = { }, - onSetFocusedSetting = {} + onNavigateToSettings = {}, + quickSettingsController = MockQuickSettingsController() ) } } @@ -339,17 +330,30 @@ fun ExpandedQuickSettingsUiPreview_WithHdr() { ), quickSettingsIsOpen = true ), - onLensFaceClick = { }, - onFlashModeClick = { }, - onAspectRatioClick = { }, - onStreamConfigClick = { }, - onDynamicRangeClick = { }, - onImageOutputFormatClick = { }, - onConcurrentCameraModeClick = { }, - toggleQuickSettings = { }, onNavigateToSettings = { }, - onCaptureModeClick = { }, - onSetFocusedSetting = {} + quickSettingsController = MockQuickSettingsController() ) } } + +class MockQuickSettingsController : QuickSettingsController { + override fun toggleQuickSettings() {} + + override fun setFocusedSetting(focusedQuickSetting: FocusedQuickSetting) {} + + override fun setLensFacing(lensFace: LensFacing) {} + + override fun setFlash(flashMode: FlashMode) {} + + override fun setAspectRatio(aspectRatio: AspectRatio) {} + + override fun setStreamConfig(streamConfig: StreamConfig) {} + + override fun setDynamicRange(dynamicRange: DynamicRange) {} + + override fun setImageFormat(imageOutputFormat: ImageOutputFormat) {} + + override fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) {} + + override fun setCaptureMode(captureMode: CaptureMode) {} +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/controller/QuickSettingsController.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/controller/QuickSettingsController.kt new file mode 100644 index 000000000..a4271b698 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/controller/QuickSettingsController.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.quicksettings.controller + +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.ConcurrentCameraMode +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.ImageOutputFormat +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.model.StreamConfig +import com.google.jetpackcamera.ui.uistate.capture.compound.FocusedQuickSetting + +/** + * This file contains the [QuickSettingsController] interface for managing quick settings actions. + */ + +/** + * Interface for controlling quick settings. + */ +interface QuickSettingsController { + /** + * Toggles the visibility of the quick settings menu. + */ + fun toggleQuickSettings() + + /** + * Sets the currently focused quick setting. + * + * @param focusedQuickSetting The quick setting to focus. + */ + fun setFocusedSetting(focusedQuickSetting: FocusedQuickSetting) + + /** + * Sets the lens facing (front or back camera). + * + * @param lensFace The lens facing to set. + */ + fun setLensFacing(lensFace: LensFacing) + + /** + * Sets the flash mode. + * + * @param flashMode The flash mode to set. + */ + fun setFlash(flashMode: FlashMode) + + /** + * Sets the aspect ratio for capture. + * + * @param aspectRatio The aspect ratio to set. + */ + fun setAspectRatio(aspectRatio: AspectRatio) + + /** + * Sets the stream configuration (e.g. preview, video capture). + * + * @param streamConfig The stream configuration to set. + */ + fun setStreamConfig(streamConfig: StreamConfig) + + /** + * Sets the dynamic range (e.g. SDR, HDR). + * + * @param dynamicRange The dynamic range to set. + */ + fun setDynamicRange(dynamicRange: DynamicRange) + + /** + * Sets the image output format (e.g. JPEG, RAW). + * + * @param imageOutputFormat The image format to set. + */ + fun setImageFormat(imageOutputFormat: ImageOutputFormat) + + /** + * Sets the concurrent camera mode. + * + * @param concurrentCameraMode The concurrent camera mode to set. + */ + fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) + + /** + * Sets the capture mode (e.g. multi-camera, single-camera). + * + * @param captureMode The capture mode to set. + */ + fun setCaptureMode(captureMode: CaptureMode) +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/controller/QuickSettingsControllerImpl.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/controller/QuickSettingsControllerImpl.kt new file mode 100644 index 000000000..ea34b8670 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/controller/QuickSettingsControllerImpl.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 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.ui.components.capture.quicksettings.controller + +import com.google.jetpackcamera.core.camera.CameraSystem +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.ConcurrentCameraMode +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.ExternalCaptureMode +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.ImageOutputFormat +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.model.StreamConfig +import com.google.jetpackcamera.ui.uistate.capture.TrackedCaptureUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.FocusedQuickSetting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Implementation of [QuickSettingsController] that interacts with [CameraSystem] and updates + * [trackedCaptureUiState]. + * + * @property trackedCaptureUiState The state flow to update with quick settings information. + * @property viewModelScope The coroutine scope for launching camera operations. + * @property cameraSystem The camera system to control. + * @property externalCaptureMode The current external capture mode. + */ +class QuickSettingsControllerImpl( + private val trackedCaptureUiState: MutableStateFlow, + private val viewModelScope: CoroutineScope, + private val cameraSystem: CameraSystem, + private val externalCaptureMode: ExternalCaptureMode +) : QuickSettingsController { + override fun toggleQuickSettings() { + trackedCaptureUiState.update { old -> + old.copy(isQuickSettingsOpen = !old.isQuickSettingsOpen) + } + } + + override fun setFocusedSetting(focusedQuickSetting: FocusedQuickSetting) { + trackedCaptureUiState.update { old -> + old.copy(focusedQuickSetting = focusedQuickSetting) + } + } + + override fun setLensFacing(lensFace: LensFacing) { + viewModelScope.launch { + // apply to cameraSystem + cameraSystem.setLensFacing(lensFace) + } + } + + override fun setFlash(flashMode: FlashMode) { + viewModelScope.launch { + // apply to cameraSystem + cameraSystem.setFlashMode(flashMode) + } + } + + override fun setAspectRatio(aspectRatio: AspectRatio) { + viewModelScope.launch { + cameraSystem.setAspectRatio(aspectRatio) + } + } + + override fun setStreamConfig(streamConfig: StreamConfig) { + viewModelScope.launch { + cameraSystem.setStreamConfig(streamConfig) + } + } + + override fun setDynamicRange(dynamicRange: DynamicRange) { + if (externalCaptureMode != ExternalCaptureMode.ImageCapture && + externalCaptureMode != ExternalCaptureMode.MultipleImageCapture + ) { + viewModelScope.launch { + cameraSystem.setDynamicRange(dynamicRange) + } + } + } + + override fun setImageFormat(imageOutputFormat: ImageOutputFormat) { + if (externalCaptureMode != ExternalCaptureMode.VideoCapture) { + viewModelScope.launch { + cameraSystem.setImageFormat(imageOutputFormat) + } + } + } + + override fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) { + viewModelScope.launch { + cameraSystem.setConcurrentCameraMode(concurrentCameraMode) + } + } + + override fun setCaptureMode(captureMode: CaptureMode) { + viewModelScope.launch { + cameraSystem.setCaptureMode(captureMode) + } + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt index 77b625933..92b774ead 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt @@ -100,6 +100,7 @@ import com.google.jetpackcamera.ui.components.capture.quicksettings.CameraFlashM import com.google.jetpackcamera.ui.components.capture.quicksettings.CameraLensFace import com.google.jetpackcamera.ui.components.capture.quicksettings.CameraStreamConfig import com.google.jetpackcamera.ui.components.capture.quicksettings.QuickSettingsEnum +import com.google.jetpackcamera.ui.components.capture.quicksettings.controller.QuickSettingsController import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureModeUiState @@ -469,9 +470,9 @@ fun QuickSetConcurrentCamera( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ToggleQuickSettingsButton( - toggleBottomSheet: () -> Unit, isOpen: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + quickSettingsController: QuickSettingsController ) { val buttonSize = IconButtonDefaults.mediumContainerSize( IconButtonDefaults.IconButtonWidthOption.Narrow @@ -490,7 +491,7 @@ fun ToggleQuickSettingsButton( closedDescription } }, - onClick = toggleBottomSheet, + onClick = quickSettingsController::toggleQuickSettings, colors = IconButtonDefaults.iconButtonColors( // Set the background color of the button containerColor = Color.White.copy(alpha = 0.08f),