1 /* <lambda>null2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.jetpackcamera.feature.preview 17 18 import android.content.ContentResolver 19 import android.net.Uri 20 import android.os.SystemClock 21 import android.util.Log 22 import androidx.camera.core.SurfaceRequest 23 import androidx.lifecycle.ViewModel 24 import androidx.lifecycle.viewModelScope 25 import androidx.tracing.Trace 26 import androidx.tracing.traceAsync 27 import com.google.jetpackcamera.core.camera.CameraUseCase 28 import com.google.jetpackcamera.core.common.traceFirstFramePreview 29 import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG 30 import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_FAILURE_TAG 31 import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG 32 import com.google.jetpackcamera.feature.preview.ui.SnackbarData 33 import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG 34 import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_FAILURE_TAG 35 import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_SUCCESS_TAG 36 import com.google.jetpackcamera.settings.ConstraintsRepository 37 import com.google.jetpackcamera.settings.SettingsRepository 38 import com.google.jetpackcamera.settings.model.AspectRatio 39 import com.google.jetpackcamera.settings.model.CameraAppSettings 40 import com.google.jetpackcamera.settings.model.CameraConstraints 41 import com.google.jetpackcamera.settings.model.CaptureMode 42 import com.google.jetpackcamera.settings.model.ConcurrentCameraMode 43 import com.google.jetpackcamera.settings.model.DeviceRotation 44 import com.google.jetpackcamera.settings.model.DynamicRange 45 import com.google.jetpackcamera.settings.model.FlashMode 46 import com.google.jetpackcamera.settings.model.ImageOutputFormat 47 import com.google.jetpackcamera.settings.model.LensFacing 48 import com.google.jetpackcamera.settings.model.LowLightBoost 49 import com.google.jetpackcamera.settings.model.Stabilization 50 import com.google.jetpackcamera.settings.model.SystemConstraints 51 import com.google.jetpackcamera.settings.model.forCurrentLens 52 import dagger.assisted.Assisted 53 import dagger.assisted.AssistedFactory 54 import dagger.assisted.AssistedInject 55 import dagger.hilt.android.lifecycle.HiltViewModel 56 import kotlin.reflect.KProperty 57 import kotlin.reflect.full.memberProperties 58 import kotlin.time.Duration.Companion.seconds 59 import kotlinx.atomicfu.atomic 60 import kotlinx.coroutines.CoroutineStart 61 import kotlinx.coroutines.Deferred 62 import kotlinx.coroutines.Job 63 import kotlinx.coroutines.async 64 import kotlinx.coroutines.delay 65 import kotlinx.coroutines.flow.MutableStateFlow 66 import kotlinx.coroutines.flow.StateFlow 67 import kotlinx.coroutines.flow.asStateFlow 68 import kotlinx.coroutines.flow.combine 69 import kotlinx.coroutines.flow.filterNotNull 70 import kotlinx.coroutines.flow.first 71 import kotlinx.coroutines.flow.transform 72 import kotlinx.coroutines.flow.transformWhile 73 import kotlinx.coroutines.flow.update 74 import kotlinx.coroutines.launch 75 76 private const val TAG = "PreviewViewModel" 77 private const val IMAGE_CAPTURE_TRACE = "JCA Image Capture" 78 79 /** 80 * [ViewModel] for [PreviewScreen]. 81 */ 82 @HiltViewModel(assistedFactory = PreviewViewModel.Factory::class) 83 class PreviewViewModel @AssistedInject constructor( 84 @Assisted val previewMode: PreviewMode, 85 @Assisted val isDebugMode: Boolean, 86 private val cameraUseCase: CameraUseCase, 87 private val settingsRepository: SettingsRepository, 88 private val constraintsRepository: ConstraintsRepository 89 ) : ViewModel() { 90 private val _previewUiState: MutableStateFlow<PreviewUiState> = 91 MutableStateFlow(PreviewUiState.NotReady) 92 93 val previewUiState: StateFlow<PreviewUiState> = 94 _previewUiState.asStateFlow() 95 96 val surfaceRequest: StateFlow<SurfaceRequest?> = cameraUseCase.getSurfaceRequest() 97 98 private var runningCameraJob: Job? = null 99 100 private var recordingJob: Job? = null 101 102 val screenFlash = ScreenFlash(cameraUseCase, viewModelScope) 103 104 private val snackBarCount = atomic(0) 105 private val videoCaptureStartedCount = atomic(0) 106 107 // Eagerly initialize the CameraUseCase and encapsulate in a Deferred that can be 108 // used to ensure we don't start the camera before initialization is complete. 109 private var initializationDeferred: Deferred<Unit> = viewModelScope.async { 110 cameraUseCase.initialize( 111 cameraAppSettings = settingsRepository.defaultCameraAppSettings.first(), 112 previewMode.toUseCaseMode(), 113 isDebugMode 114 ) 115 } 116 117 init { 118 viewModelScope.launch { 119 launch { 120 var oldCameraAppSettings: CameraAppSettings? = null 121 settingsRepository.defaultCameraAppSettings.transform { new -> 122 val old = oldCameraAppSettings 123 if (old != null) { 124 emit(getSettingsDiff(old, new)) 125 } 126 oldCameraAppSettings = new 127 }.collect { diffQueue -> 128 applySettingsDiff(diffQueue) 129 } 130 } 131 combine( 132 cameraUseCase.getCurrentSettings().filterNotNull(), 133 constraintsRepository.systemConstraints.filterNotNull(), 134 cameraUseCase.getCurrentCameraState() 135 ) { cameraAppSettings, systemConstraints, cameraState -> 136 _previewUiState.update { old -> 137 when (old) { 138 is PreviewUiState.Ready -> 139 old.copy( 140 currentCameraSettings = cameraAppSettings, 141 systemConstraints = systemConstraints, 142 zoomScale = cameraState.zoomScale, 143 sessionFirstFrameTimestamp = cameraState.sessionFirstFrameTimestamp, 144 captureModeToggleUiState = getCaptureToggleUiState( 145 systemConstraints, 146 cameraAppSettings 147 ), 148 isDebugMode = isDebugMode, 149 currentLogicalCameraId = cameraState.debugInfo.logicalCameraId, 150 currentPhysicalCameraId = cameraState.debugInfo.physicalCameraId 151 ) 152 153 is PreviewUiState.NotReady -> 154 PreviewUiState.Ready( 155 currentCameraSettings = cameraAppSettings, 156 systemConstraints = systemConstraints, 157 zoomScale = cameraState.zoomScale, 158 sessionFirstFrameTimestamp = cameraState.sessionFirstFrameTimestamp, 159 previewMode = previewMode, 160 captureModeToggleUiState = getCaptureToggleUiState( 161 systemConstraints, 162 cameraAppSettings 163 ), 164 isDebugMode = isDebugMode, 165 currentLogicalCameraId = cameraState.debugInfo.logicalCameraId, 166 currentPhysicalCameraId = cameraState.debugInfo.physicalCameraId 167 ) 168 } 169 } 170 }.collect {} 171 } 172 } 173 174 private fun PreviewMode.toUseCaseMode() = when (this) { 175 is PreviewMode.ExternalImageCaptureMode -> CameraUseCase.UseCaseMode.IMAGE_ONLY 176 is PreviewMode.ExternalVideoCaptureMode -> CameraUseCase.UseCaseMode.VIDEO_ONLY 177 is PreviewMode.StandardMode -> CameraUseCase.UseCaseMode.STANDARD 178 } 179 180 /** 181 * Returns the difference between two [CameraAppSettings] as a mapping of <[KProperty], [Any]>. 182 */ 183 private fun getSettingsDiff( 184 oldCameraAppSettings: CameraAppSettings, 185 newCameraAppSettings: CameraAppSettings 186 ): Map<KProperty<Any?>, Any?> = buildMap<KProperty<Any?>, Any?> { 187 CameraAppSettings::class.memberProperties.forEach { property -> 188 if (property.get(oldCameraAppSettings) != property.get(newCameraAppSettings)) { 189 put(property, property.get(newCameraAppSettings)) 190 } 191 } 192 } 193 194 /** 195 * Iterates through a queue of [Pair]<[KProperty], [Any]> and attempt to apply them to 196 * [CameraUseCase]. 197 */ 198 private suspend fun applySettingsDiff(diffSettingsMap: Map<KProperty<Any?>, Any?>) { 199 diffSettingsMap.entries.forEach { entry -> 200 when (entry.key) { 201 CameraAppSettings::cameraLensFacing -> { 202 cameraUseCase.setLensFacing(entry.value as LensFacing) 203 } 204 205 CameraAppSettings::flashMode -> { 206 cameraUseCase.setFlashMode(entry.value as FlashMode) 207 } 208 209 CameraAppSettings::captureMode -> { 210 cameraUseCase.setCaptureMode(entry.value as CaptureMode) 211 } 212 213 CameraAppSettings::aspectRatio -> { 214 cameraUseCase.setAspectRatio(entry.value as AspectRatio) 215 } 216 217 CameraAppSettings::previewStabilization -> { 218 cameraUseCase.setPreviewStabilization(entry.value as Stabilization) 219 } 220 221 CameraAppSettings::videoCaptureStabilization -> { 222 cameraUseCase.setVideoCaptureStabilization( 223 entry.value as Stabilization 224 ) 225 } 226 227 CameraAppSettings::targetFrameRate -> { 228 cameraUseCase.setTargetFrameRate(entry.value as Int) 229 } 230 231 CameraAppSettings::darkMode -> {} 232 233 else -> TODO("Unhandled CameraAppSetting $entry") 234 } 235 } 236 } 237 238 private fun getCaptureToggleUiState( 239 systemConstraints: SystemConstraints, 240 cameraAppSettings: CameraAppSettings 241 ): CaptureModeToggleUiState { 242 val cameraConstraints: CameraConstraints? = systemConstraints.forCurrentLens( 243 cameraAppSettings 244 ) 245 val hdrDynamicRangeSupported = cameraConstraints?.let { 246 it.supportedDynamicRanges.size > 1 247 } ?: false 248 val hdrImageFormatSupported = 249 cameraConstraints?.supportedImageFormatsMap?.get(cameraAppSettings.captureMode)?.let { 250 it.size > 1 251 } ?: false 252 val isShown = previewMode is PreviewMode.ExternalImageCaptureMode || 253 previewMode is PreviewMode.ExternalVideoCaptureMode || 254 cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR || 255 cameraAppSettings.dynamicRange == DynamicRange.HLG10 || 256 cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.DUAL 257 val enabled = previewMode !is PreviewMode.ExternalImageCaptureMode && 258 previewMode !is PreviewMode.ExternalVideoCaptureMode && 259 hdrDynamicRangeSupported && 260 hdrImageFormatSupported && 261 cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF 262 return if (isShown) { 263 val currentMode = if ( 264 cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF && 265 previewMode is PreviewMode.ExternalImageCaptureMode || 266 cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR 267 ) { 268 CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE 269 } else { 270 CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO 271 } 272 if (enabled) { 273 CaptureModeToggleUiState.Enabled(currentMode) 274 } else { 275 CaptureModeToggleUiState.Disabled( 276 currentMode, 277 getCaptureToggleUiStateDisabledReason( 278 currentMode, 279 hdrDynamicRangeSupported, 280 hdrImageFormatSupported, 281 systemConstraints, 282 cameraAppSettings.cameraLensFacing, 283 cameraAppSettings.captureMode, 284 cameraAppSettings.concurrentCameraMode 285 ) 286 ) 287 } 288 } else { 289 CaptureModeToggleUiState.Invisible 290 } 291 } 292 293 private fun getCaptureToggleUiStateDisabledReason( 294 captureModeToggleUiState: CaptureModeToggleUiState.ToggleMode, 295 hdrDynamicRangeSupported: Boolean, 296 hdrImageFormatSupported: Boolean, 297 systemConstraints: SystemConstraints, 298 currentLensFacing: LensFacing, 299 currentCaptureMode: CaptureMode, 300 concurrentCameraMode: ConcurrentCameraMode 301 ): CaptureModeToggleUiState.DisabledReason { 302 when (captureModeToggleUiState) { 303 CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_VIDEO -> { 304 if (previewMode is PreviewMode.ExternalVideoCaptureMode) { 305 return CaptureModeToggleUiState.DisabledReason 306 .IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED 307 } 308 309 if (concurrentCameraMode == ConcurrentCameraMode.DUAL) { 310 return CaptureModeToggleUiState.DisabledReason 311 .IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA 312 } 313 314 if (!hdrImageFormatSupported) { 315 // First check if Ultra HDR image is supported on other capture modes 316 if (systemConstraints 317 .perLensConstraints[currentLensFacing] 318 ?.supportedImageFormatsMap 319 ?.anySupportsUltraHdr { it != currentCaptureMode } == true 320 ) { 321 return when (currentCaptureMode) { 322 CaptureMode.MULTI_STREAM -> 323 CaptureModeToggleUiState.DisabledReason 324 .HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM 325 326 CaptureMode.SINGLE_STREAM -> 327 CaptureModeToggleUiState.DisabledReason 328 .HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM 329 } 330 } 331 332 // Check if any other lens supports HDR image 333 if (systemConstraints.anySupportsUltraHdr { it != currentLensFacing }) { 334 return CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_LENS 335 } 336 337 // No lenses support HDR image on device 338 return CaptureModeToggleUiState.DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_DEVICE 339 } 340 341 throw RuntimeException("Unknown DisabledReason for video mode.") 342 } 343 344 CaptureModeToggleUiState.ToggleMode.CAPTURE_TOGGLE_IMAGE -> { 345 if (previewMode is PreviewMode.ExternalImageCaptureMode) { 346 return CaptureModeToggleUiState.DisabledReason 347 .VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED 348 } 349 350 if (!hdrDynamicRangeSupported) { 351 if (systemConstraints.anySupportsHdrDynamicRange { it != currentLensFacing }) { 352 return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_LENS 353 } 354 return CaptureModeToggleUiState.DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_DEVICE 355 } 356 357 throw RuntimeException("Unknown DisabledReason for image mode.") 358 } 359 } 360 } 361 362 private fun SystemConstraints.anySupportsHdrDynamicRange( 363 lensFilter: (LensFacing) -> Boolean 364 ): Boolean = perLensConstraints.asSequence().firstOrNull { 365 lensFilter(it.key) && it.value.supportedDynamicRanges.size > 1 366 } != null 367 368 private fun Map<CaptureMode, Set<ImageOutputFormat>>.anySupportsUltraHdr( 369 captureModeFilter: (CaptureMode) -> Boolean 370 ): Boolean = asSequence().firstOrNull { 371 captureModeFilter(it.key) && it.value.contains(ImageOutputFormat.JPEG_ULTRA_HDR) 372 } != null 373 374 private fun SystemConstraints.anySupportsUltraHdr( 375 captureModeFilter: (CaptureMode) -> Boolean = { true }, 376 lensFilter: (LensFacing) -> Boolean 377 ): Boolean = perLensConstraints.asSequence().firstOrNull { lensConstraints -> 378 lensFilter(lensConstraints.key) && 379 lensConstraints.value.supportedImageFormatsMap.anySupportsUltraHdr { 380 captureModeFilter(it) 381 } 382 } != null 383 384 fun startCamera() { 385 Log.d(TAG, "startCamera") 386 stopCamera() 387 runningCameraJob = viewModelScope.launch { 388 if (Trace.isEnabled()) { 389 launch(start = CoroutineStart.UNDISPATCHED) { 390 val startTraceTimestamp: Long = SystemClock.elapsedRealtimeNanos() 391 traceFirstFramePreview(cookie = 1) { 392 _previewUiState.transformWhile { 393 var continueCollecting = true 394 (it as? PreviewUiState.Ready)?.let { uiState -> 395 if (uiState.sessionFirstFrameTimestamp > startTraceTimestamp) { 396 emit(Unit) 397 continueCollecting = false 398 } 399 } 400 continueCollecting 401 }.collect {} 402 } 403 } 404 } 405 // Ensure CameraUseCase is initialized before starting camera 406 initializationDeferred.await() 407 // TODO(yasith): Handle Exceptions from binding use cases 408 cameraUseCase.runCamera() 409 } 410 } 411 412 fun stopCamera() { 413 Log.d(TAG, "stopCamera") 414 runningCameraJob?.apply { 415 if (isActive) { 416 cancel() 417 } 418 } 419 } 420 421 fun setFlash(flashMode: FlashMode) { 422 viewModelScope.launch { 423 // apply to cameraUseCase 424 cameraUseCase.setFlashMode(flashMode) 425 } 426 } 427 428 fun setAspectRatio(aspectRatio: AspectRatio) { 429 viewModelScope.launch { 430 cameraUseCase.setAspectRatio(aspectRatio) 431 } 432 } 433 434 fun setCaptureMode(captureMode: CaptureMode) { 435 viewModelScope.launch { 436 cameraUseCase.setCaptureMode(captureMode) 437 } 438 } 439 440 /** Sets the camera to a designated lens facing */ 441 fun setLensFacing(newLensFacing: LensFacing) { 442 viewModelScope.launch { 443 // apply to cameraUseCase 444 cameraUseCase.setLensFacing(newLensFacing) 445 } 446 } 447 448 fun setAudioMuted(shouldMuteAudio: Boolean) { 449 viewModelScope.launch { 450 cameraUseCase.setAudioMuted(shouldMuteAudio) 451 } 452 453 Log.d( 454 TAG, 455 "Toggle Audio ${ 456 (previewUiState.value as PreviewUiState.Ready) 457 .currentCameraSettings.audioMuted 458 }" 459 ) 460 } 461 462 private fun showExternalVideoCaptureUnsupportedToast() { 463 viewModelScope.launch { 464 _previewUiState.update { old -> 465 (old as? PreviewUiState.Ready)?.copy( 466 snackBarToShow = SnackbarData( 467 cookie = "Image-ExternalVideoCaptureMode", 468 stringResource = R.string.toast_image_capture_external_unsupported, 469 withDismissAction = true, 470 testTag = IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG 471 ) 472 ) ?: old 473 } 474 } 475 } 476 477 fun captureImage() { 478 if (previewUiState.value is PreviewUiState.Ready && 479 (previewUiState.value as PreviewUiState.Ready).previewMode is 480 PreviewMode.ExternalVideoCaptureMode 481 ) { 482 showExternalVideoCaptureUnsupportedToast() 483 return 484 } 485 Log.d(TAG, "captureImage") 486 viewModelScope.launch { 487 captureImageInternal( 488 doTakePicture = { 489 cameraUseCase.takePicture { 490 _previewUiState.update { old -> 491 (old as? PreviewUiState.Ready)?.copy( 492 lastBlinkTimeStamp = System.currentTimeMillis() 493 ) ?: old 494 } 495 } 496 } 497 ) 498 } 499 } 500 501 fun captureImageWithUri( 502 contentResolver: ContentResolver, 503 imageCaptureUri: Uri?, 504 ignoreUri: Boolean = false, 505 onImageCapture: (ImageCaptureEvent) -> Unit 506 ) { 507 if (previewUiState.value is PreviewUiState.Ready && 508 (previewUiState.value as PreviewUiState.Ready).previewMode is 509 PreviewMode.ExternalVideoCaptureMode 510 ) { 511 showExternalVideoCaptureUnsupportedToast() 512 return 513 } 514 515 if (previewUiState.value is PreviewUiState.Ready && 516 (previewUiState.value as PreviewUiState.Ready).previewMode is 517 PreviewMode.ExternalVideoCaptureMode 518 ) { 519 viewModelScope.launch { 520 _previewUiState.update { old -> 521 (old as? PreviewUiState.Ready)?.copy( 522 snackBarToShow = SnackbarData( 523 cookie = "Image-ExternalVideoCaptureMode", 524 stringResource = R.string.toast_image_capture_external_unsupported, 525 withDismissAction = true, 526 testTag = IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG 527 ) 528 ) ?: old 529 } 530 } 531 return 532 } 533 Log.d(TAG, "captureImageWithUri") 534 viewModelScope.launch { 535 captureImageInternal( 536 doTakePicture = { 537 cameraUseCase.takePicture({ 538 _previewUiState.update { old -> 539 (old as? PreviewUiState.Ready)?.copy( 540 lastBlinkTimeStamp = System.currentTimeMillis() 541 ) ?: old 542 } 543 }, contentResolver, imageCaptureUri, ignoreUri).savedUri 544 }, 545 onSuccess = { savedUri -> onImageCapture(ImageCaptureEvent.ImageSaved(savedUri)) }, 546 onFailure = { exception -> 547 onImageCapture(ImageCaptureEvent.ImageCaptureError(exception)) 548 } 549 ) 550 } 551 } 552 553 private suspend fun <T> captureImageInternal( 554 doTakePicture: suspend () -> T, 555 onSuccess: (T) -> Unit = {}, 556 onFailure: (exception: Exception) -> Unit = {} 557 ) { 558 val cookieInt = snackBarCount.incrementAndGet() 559 val cookie = "Image-$cookieInt" 560 try { 561 traceAsync(IMAGE_CAPTURE_TRACE, cookieInt) { 562 doTakePicture() 563 }.also { result -> 564 onSuccess(result) 565 } 566 Log.d(TAG, "cameraUseCase.takePicture success") 567 SnackbarData( 568 cookie = cookie, 569 stringResource = R.string.toast_image_capture_success, 570 withDismissAction = true, 571 testTag = IMAGE_CAPTURE_SUCCESS_TAG 572 ) 573 } catch (exception: Exception) { 574 onFailure(exception) 575 Log.d(TAG, "cameraUseCase.takePicture error", exception) 576 SnackbarData( 577 cookie = cookie, 578 stringResource = R.string.toast_capture_failure, 579 withDismissAction = true, 580 testTag = IMAGE_CAPTURE_FAILURE_TAG 581 ) 582 }.also { snackBarData -> 583 _previewUiState.update { old -> 584 (old as? PreviewUiState.Ready)?.copy( 585 // todo: remove snackBar after postcapture screen implemented 586 snackBarToShow = snackBarData 587 ) ?: old 588 } 589 } 590 } 591 592 fun showSnackBarForDisabledHdrToggle(disabledReason: CaptureModeToggleUiState.DisabledReason) { 593 val cookieInt = snackBarCount.incrementAndGet() 594 val cookie = "DisabledHdrToggle-$cookieInt" 595 viewModelScope.launch { 596 _previewUiState.update { old -> 597 (old as? PreviewUiState.Ready)?.copy( 598 snackBarToShow = SnackbarData( 599 cookie = cookie, 600 stringResource = disabledReason.reasonTextResId, 601 withDismissAction = true, 602 testTag = disabledReason.testTag 603 ) 604 ) ?: old 605 } 606 } 607 } 608 609 fun startVideoRecording( 610 videoCaptureUri: Uri?, 611 shouldUseUri: Boolean, 612 onVideoCapture: (VideoCaptureEvent) -> Unit 613 ) { 614 if (previewUiState.value is PreviewUiState.Ready && 615 (previewUiState.value as PreviewUiState.Ready).previewMode is 616 PreviewMode.ExternalImageCaptureMode 617 ) { 618 Log.d(TAG, "externalVideoRecording") 619 viewModelScope.launch { 620 _previewUiState.update { old -> 621 (old as? PreviewUiState.Ready)?.copy( 622 snackBarToShow = SnackbarData( 623 cookie = "Video-ExternalImageCaptureMode", 624 stringResource = R.string.toast_video_capture_external_unsupported, 625 withDismissAction = true, 626 testTag = VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG 627 ) 628 ) ?: old 629 } 630 } 631 return 632 } 633 Log.d(TAG, "startVideoRecording") 634 recordingJob = viewModelScope.launch { 635 val cookie = "Video-${videoCaptureStartedCount.incrementAndGet()}" 636 try { 637 cameraUseCase.startVideoRecording(videoCaptureUri, shouldUseUri) { 638 var audioAmplitude = 0.0 639 var snackbarToShow: SnackbarData? = null 640 when (it) { 641 is CameraUseCase.OnVideoRecordEvent.OnVideoRecorded -> { 642 Log.d(TAG, "cameraUseCase.startRecording OnVideoRecorded") 643 onVideoCapture(VideoCaptureEvent.VideoSaved(it.savedUri)) 644 snackbarToShow = SnackbarData( 645 cookie = cookie, 646 stringResource = R.string.toast_video_capture_success, 647 withDismissAction = true, 648 testTag = VIDEO_CAPTURE_SUCCESS_TAG 649 ) 650 } 651 652 is CameraUseCase.OnVideoRecordEvent.OnVideoRecordError -> { 653 Log.d(TAG, "cameraUseCase.startRecording OnVideoRecordError") 654 onVideoCapture(VideoCaptureEvent.VideoCaptureError(it.error)) 655 snackbarToShow = SnackbarData( 656 cookie = cookie, 657 stringResource = R.string.toast_video_capture_failure, 658 withDismissAction = true, 659 testTag = VIDEO_CAPTURE_FAILURE_TAG 660 ) 661 } 662 663 is CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus -> { 664 audioAmplitude = it.audioAmplitude 665 } 666 } 667 668 viewModelScope.launch { 669 _previewUiState.update { old -> 670 (old as? PreviewUiState.Ready)?.copy( 671 snackBarToShow = snackbarToShow, 672 audioAmplitude = audioAmplitude 673 ) ?: old 674 } 675 } 676 } 677 _previewUiState.update { old -> 678 (old as? PreviewUiState.Ready)?.copy( 679 videoRecordingState = VideoRecordingState.ACTIVE 680 ) ?: old 681 } 682 Log.d(TAG, "cameraUseCase.startRecording success") 683 } catch (exception: IllegalStateException) { 684 Log.d(TAG, "cameraUseCase.startVideoRecording error", exception) 685 } 686 } 687 } 688 689 fun stopVideoRecording() { 690 Log.d(TAG, "stopVideoRecording") 691 viewModelScope.launch { 692 _previewUiState.update { old -> 693 (old as? PreviewUiState.Ready)?.copy( 694 videoRecordingState = VideoRecordingState.INACTIVE 695 ) ?: old 696 } 697 } 698 cameraUseCase.stopVideoRecording() 699 recordingJob?.cancel() 700 } 701 702 fun setZoomScale(scale: Float) { 703 cameraUseCase.setZoomScale(scale = scale) 704 } 705 706 fun setDynamicRange(dynamicRange: DynamicRange) { 707 viewModelScope.launch { 708 cameraUseCase.setDynamicRange(dynamicRange) 709 } 710 } 711 712 fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) { 713 viewModelScope.launch { 714 cameraUseCase.setConcurrentCameraMode(concurrentCameraMode) 715 } 716 } 717 718 fun setLowLightBoost(lowLightBoost: LowLightBoost) { 719 viewModelScope.launch { 720 cameraUseCase.setLowLightBoost(lowLightBoost) 721 } 722 } 723 724 fun setImageFormat(imageFormat: ImageOutputFormat) { 725 viewModelScope.launch { 726 cameraUseCase.setImageFormat(imageFormat) 727 } 728 } 729 730 // modify ui values 731 fun toggleQuickSettings() { 732 viewModelScope.launch { 733 _previewUiState.update { old -> 734 (old as? PreviewUiState.Ready)?.copy( 735 quickSettingsIsOpen = !old.quickSettingsIsOpen 736 ) ?: old 737 } 738 } 739 } 740 741 fun tapToFocus(x: Float, y: Float) { 742 Log.d(TAG, "tapToFocus") 743 viewModelScope.launch { 744 cameraUseCase.tapToFocus(x, y) 745 } 746 } 747 748 /** 749 * Sets current value of [PreviewUiState.Ready.toastMessageToShow] to null. 750 */ 751 fun onToastShown() { 752 viewModelScope.launch { 753 // keeps the composable up on screen longer to be detected by UiAutomator 754 delay(2.seconds) 755 _previewUiState.update { old -> 756 (old as? PreviewUiState.Ready)?.copy( 757 toastMessageToShow = null 758 ) ?: old 759 } 760 } 761 } 762 763 fun onSnackBarResult(cookie: String) { 764 viewModelScope.launch { 765 _previewUiState.update { old -> 766 (old as? PreviewUiState.Ready)?.snackBarToShow?.let { 767 if (it.cookie == cookie) { 768 // If the latest snackbar had a result, then clear snackBarToShow 769 old.copy(snackBarToShow = null) 770 } else { 771 old 772 } 773 } ?: old 774 } 775 } 776 } 777 778 fun setDisplayRotation(deviceRotation: DeviceRotation) { 779 viewModelScope.launch { 780 cameraUseCase.setDeviceRotation(deviceRotation) 781 } 782 } 783 784 @AssistedFactory 785 interface Factory { 786 fun create(previewMode: PreviewMode, isDebugMode: Boolean): PreviewViewModel 787 } 788 789 sealed interface ImageCaptureEvent { 790 data class ImageSaved( 791 val savedUri: Uri? = null 792 ) : ImageCaptureEvent 793 794 data class ImageCaptureError( 795 val exception: Exception 796 ) : ImageCaptureEvent 797 } 798 799 sealed interface VideoCaptureEvent { 800 data class VideoSaved( 801 val savedUri: Uri 802 ) : VideoCaptureEvent 803 804 data class VideoCaptureError( 805 val error: Throwable? 806 ) : VideoCaptureEvent 807 } 808 } 809