diff --git a/core/camera/build.gradle.kts b/core/camera/build.gradle.kts index 4fb048a69..50d0b3b65 100644 --- a/core/camera/build.gradle.kts +++ b/core/camera/build.gradle.kts @@ -94,10 +94,16 @@ dependencies { testImplementation(libs.mockito.core) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.rules) + androidTestImplementation(libs.truth) // Futures implementation(libs.futures.ktx) + // LiveData + implementation(libs.androidx.lifecycle.livedata) + // CameraX implementation(libs.camera.core) implementation(libs.camera.camera2) diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt new file mode 100644 index 000000000..d818eb5af --- /dev/null +++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.core.camera + +import android.app.Application +import android.content.ContentResolver +import android.graphics.SurfaceTexture +import android.net.Uri +import android.view.Surface +import androidx.concurrent.futures.DirectExecutor +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecordError +import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus +import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecorded +import com.google.jetpackcamera.core.camera.utils.APP_REQUIRED_PERMISSIONS +import com.google.jetpackcamera.settings.ConstraintsRepository +import com.google.jetpackcamera.settings.SettableConstraintsRepository +import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl +import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS +import com.google.jetpackcamera.settings.model.FlashMode +import com.google.jetpackcamera.settings.model.LensFacing +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.produceIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.fail +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@LargeTest +@RunWith(AndroidJUnit4::class) +class CameraXCameraUseCaseTest { + + companion object { + private const val STATUS_VERIFY_COUNT = 5 + private const val GENERAL_TIMEOUT_MS = 3_000L + private const val STATUS_VERIFY_TIMEOUT_MS = 10_000L + } + + @get:Rule + val permissionsRule: GrantPermissionRule = + GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray()) + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context = instrumentation.context + private val application = context.applicationContext as Application + private val videosToDelete = mutableSetOf() + private lateinit var useCaseScope: CoroutineScope + + @Before + fun setup() { + useCaseScope = CoroutineScope(Dispatchers.Default) + } + + @After + fun tearDown() { + useCaseScope.cancel() + deleteVideos() + } + + @Test + fun canRecordVideo(): Unit = runBlocking { + // Arrange. + val cameraUseCase = createAndInitCameraXUseCase() + cameraUseCase.runCameraOnMain() + + // Act. + val recordEvent = cameraUseCase.startRecordingAndGetEvents() + + // Assert. + recordEvent.onRecordStatus.await(STATUS_VERIFY_TIMEOUT_MS) + + // Act. + cameraUseCase.stopVideoRecording() + + // Assert. + recordEvent.onRecorded.await() + } + + @Test + fun recordVideoWithFlashModeOn_shouldEnableTorch(): Unit = runBlocking { + // Arrange. + val lensFacing = LensFacing.BACK + val constraintsRepository = SettableConstraintsRepositoryImpl() + val cameraUseCase = createAndInitCameraXUseCase( + constraintsRepository = constraintsRepository + ) + assumeTrue("No flash unit, skip the test.", constraintsRepository.hasFlashUnit(lensFacing)) + cameraUseCase.runCameraOnMain() + + // Arrange: Create a ReceiveChannel to observe the torch enabled state. + val torchEnabled: ReceiveChannel = cameraUseCase.getCurrentCameraState() + .map { it.torchEnabled } + .produceIn(this) + + // Assert: The initial torch enabled should be false. + torchEnabled.awaitValue(false) + + // Act: Start recording with FlashMode.ON + cameraUseCase.setFlashMode(FlashMode.ON) + val recordEvent = cameraUseCase.startRecordingAndGetEvents() + + // Assert: Torch enabled transitions to true. + torchEnabled.awaitValue(true) + + // Act: Ensure enough data is received and stop recording. + recordEvent.onRecordStatus.await(STATUS_VERIFY_TIMEOUT_MS) + cameraUseCase.stopVideoRecording() + + // Assert: Torch enabled transitions to false. + torchEnabled.awaitValue(false) + + // Clean-up. + torchEnabled.cancel() + } + + private suspend fun createAndInitCameraXUseCase( + appSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS, + constraintsRepository: SettableConstraintsRepository = SettableConstraintsRepositoryImpl() + ) = CameraXCameraUseCase( + application, + useCaseScope, + Dispatchers.Default, + constraintsRepository + ).apply { + initialize(appSettings, false) + providePreviewSurface() + } + + private data class RecordEvents( + val onRecorded: CompletableDeferred, + val onRecordStatus: CompletableDeferred + ) + + private suspend fun CompletableDeferred<*>.await(timeoutMs: Long = GENERAL_TIMEOUT_MS) = + withTimeoutOrNull(timeoutMs) { + await() + Unit + } ?: fail("Timeout while waiting for the Deferred to complete") + + private suspend fun ReceiveChannel.awaitValue( + expectedValue: T, + timeoutMs: Long = GENERAL_TIMEOUT_MS + ) = withTimeoutOrNull(timeoutMs) { + for (value in this@awaitValue) { + if (value == expectedValue) return@withTimeoutOrNull + } + } ?: fail("Timeout while waiting for expected value: $expectedValue") + + private suspend fun CameraXCameraUseCase.startRecordingAndGetEvents( + statusVerifyCount: Int = STATUS_VERIFY_COUNT + ): RecordEvents { + val onRecorded = CompletableDeferred() + val onRecordStatus = CompletableDeferred() + var statusCount = 0 + startVideoRecording { + when (it) { + is OnVideoRecorded -> { + val videoUri = it.savedUri + if (videoUri != Uri.EMPTY) { + videosToDelete.add(videoUri) + } + onRecorded.complete(Unit) + } + is OnVideoRecordError -> onRecorded.complete(Unit) + is OnVideoRecordStatus -> { + statusCount++ + if (statusCount == statusVerifyCount) { + onRecordStatus.complete(Unit) + } + } + } + } + return RecordEvents(onRecorded, onRecordStatus) + } + + private fun CameraXCameraUseCase.providePreviewSurface() { + useCaseScope.launch { + getSurfaceRequest().filterNotNull().first().let { surfaceRequest -> + val surfaceTexture = SurfaceTexture(0) + surfaceTexture.setDefaultBufferSize(640, 480) + val surface = Surface(surfaceTexture) + surfaceRequest.provideSurface(surface, DirectExecutor.INSTANCE) { + surface.release() + surfaceTexture.release() + } + } + } + } + + private suspend fun CameraXCameraUseCase.runCameraOnMain() { + useCaseScope.launch(Dispatchers.Main) { runCamera() } + instrumentation.waitForIdleSync() + } + + private suspend fun ConstraintsRepository.hasFlashUnit(lensFacing: LensFacing): Boolean = + systemConstraints.first()!!.perLensConstraints[lensFacing]!!.hasFlashUnit + + private fun deleteVideos() { + for (uri in videosToDelete) { + when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> { + try { + context.contentResolver.delete(uri, null, null) + } catch (e: RuntimeException) { + // Ignore any exception. + } + } + ContentResolver.SCHEME_FILE -> { + File(uri.path!!).delete() + } + } + } + } +} diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/AppTestUtil.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/AppTestUtil.kt new file mode 100644 index 000000000..509029e25 --- /dev/null +++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/AppTestUtil.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.core.camera.utils + +import android.os.Build + +val APP_REQUIRED_PERMISSIONS: List = buildList { + add(android.Manifest.permission.CAMERA) + add(android.Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT <= 28) { + add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + } +} diff --git a/core/camera/src/main/AndroidManifest.xml b/core/camera/src/main/AndroidManifest.xml index a593c3bb8..1c0b84307 100644 --- a/core/camera/src/main/AndroidManifest.xml +++ b/core/camera/src/main/AndroidManifest.xml @@ -14,6 +14,11 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - + + + + \ No newline at end of file diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt index 7f52e39b8..741127ddc 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt @@ -123,7 +123,7 @@ interface CameraUseCase { * Represents the events for video recording. */ sealed interface OnVideoRecordEvent { - object OnVideoRecorded : OnVideoRecordEvent + data class OnVideoRecorded(val savedUri: Uri) : OnVideoRecordEvent data class OnVideoRecordStatus(val audioAmplitude: Double) : OnVideoRecordEvent @@ -133,5 +133,6 @@ interface CameraUseCase { data class CameraState( val zoomScale: Float = 1f, - val sessionFirstFrameTimestamp: Long = 0L + val sessionFirstFrameTimestamp: Long = 0L, + val torchEnabled: Boolean = false ) diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt index 6cbfbf9b4..63c35b830 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt @@ -36,6 +36,7 @@ import androidx.camera.camera2.interop.Camera2Interop import androidx.camera.camera2.interop.ExperimentalCamera2Interop import androidx.camera.core.AspectRatio.RATIO_16_9 import androidx.camera.core.AspectRatio.RATIO_4_3 +import androidx.camera.core.Camera import androidx.camera.core.CameraEffect import androidx.camera.core.CameraInfo import androidx.camera.core.CameraSelector @@ -49,6 +50,8 @@ import androidx.camera.core.ImageCaptureException import androidx.camera.core.Preview import androidx.camera.core.SurfaceOrientedMeteringPointFactory import androidx.camera.core.SurfaceRequest +import androidx.camera.core.TorchState +import androidx.camera.core.UseCase import androidx.camera.core.UseCaseGroup import androidx.camera.core.ViewPort import androidx.camera.core.resolutionselector.AspectRatioStrategy @@ -62,8 +65,10 @@ import androidx.camera.video.Recording import androidx.camera.video.VideoCapture import androidx.camera.video.VideoRecordEvent import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE +import androidx.concurrent.futures.await import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.checkSelfPermission +import androidx.lifecycle.asFlow import com.google.jetpackcamera.core.camera.CameraUseCase.ScreenFlashEvent.Type import com.google.jetpackcamera.core.camera.effects.SingleSurfaceForcingEffect import com.google.jetpackcamera.settings.SettableConstraintsRepository @@ -94,8 +99,11 @@ import kotlin.properties.Delegates import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow @@ -109,6 +117,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -168,8 +177,6 @@ constructor( imageCaptureExtender.setSessionCaptureCallback(captureCallback) } - private var videoCaptureUseCase: VideoCapture? = null - private var recording: Recording? = null private lateinit var captureMode: CaptureMode private lateinit var systemConstraints: SystemConstraints private var disableVideoCapture by Delegates.notNull() @@ -178,6 +185,7 @@ constructor( MutableSharedFlow() private val focusMeteringEvents = Channel(capacity = Channel.CONFLATED) + private val videoCaptureControlEvents = Channel() private val currentSettings = MutableStateFlow(null) @@ -222,6 +230,7 @@ constructor( val supportedFixedFrameRates = getSupportedFrameRates(camInfo) val supportedImageFormats = getSupportedImageFormats(camInfo) + val hasFlashUnit = camInfo.hasFlashUnit() put( lensFacing, @@ -235,7 +244,8 @@ constructor( // Ultra HDR now. Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)), Pair(CaptureMode.MULTI_STREAM, supportedImageFormats) - ) + ), + hasFlashUnit = hasFlashUnit ) ) } @@ -357,6 +367,23 @@ constructor( } } + launch { + processVideoControlEvents( + camera, + useCaseGroup.getVideoCapture(), + sessionSettings, + transientSettings + ) + } + + launch { + cameraInfo.torchState.asFlow().collectLatest { torchState -> + _currentCameraState.update { old -> + old.copy(torchEnabled = torchState == TorchState.ON) + } + } + } + transientSettings.filterNotNull().collectLatest { newTransientSettings -> // Apply camera control settings if (prevTransientSettings.zoomScale != newTransientSettings.zoomScale) { @@ -417,6 +444,91 @@ constructor( } } + private suspend fun processVideoControlEvents( + camera: Camera, + videoCapture: VideoCapture?, + sessionSettings: PerpetualSessionSettings, + transientSettings: StateFlow + ) = coroutineScope { + var recordingJob: Job? = null + + for (event in videoCaptureControlEvents) { + when (event) { + is VideoCaptureControlEvent.StartRecordingEvent -> { + if (videoCapture == null) { + throw RuntimeException( + "Attempted video recording with null videoCapture" + ) + } + + recordingJob = launch(start = CoroutineStart.UNDISPATCHED) { + runVideoRecording( + camera, + videoCapture, + sessionSettings, + transientSettings, + event.onVideoRecord + ) + } + } + + VideoCaptureControlEvent.StopRecordingEvent -> { + recordingJob?.cancel() + recordingJob = null + } + } + } + } + + private suspend fun runVideoRecording( + camera: Camera, + videoCapture: VideoCapture, + sessionSettings: PerpetualSessionSettings, + transientSettings: StateFlow, + onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit + ) { + var currentSettings = transientSettings.filterNotNull().first() + + startVideoRecordingInternal( + initialMuted = currentSettings.audioMuted, + videoCapture, + onVideoRecord + ).use { recording -> + + fun TransientSessionSettings.isFlashModeOn() = flashMode == FlashMode.ON + val isFrontCameraSelector = + sessionSettings.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA + + if (currentSettings.isFlashModeOn()) { + if (!isFrontCameraSelector) { + camera.cameraControl.enableTorch(true).await() + } else { + Log.d(TAG, "Unable to enable torch for front camera.") + } + } + + transientSettings.filterNotNull() + .onCompletion { + // Could do some fancier tracking of whether the torch was enabled before + // calling this. + camera.cameraControl.enableTorch(false) + } + .collectLatest { newTransientSettings -> + if (currentSettings.audioMuted != newTransientSettings.audioMuted) { + recording.mute(newTransientSettings.audioMuted) + } + if (currentSettings.isFlashModeOn() != newTransientSettings.isFlashModeOn()) { + if (!isFrontCameraSelector) { + camera.cameraControl.enableTorch(newTransientSettings.isFlashModeOn()) + } else { + Log.d(TAG, "Unable to update torch for front camera.") + } + } + currentSettings = newTransientSettings + } + } + } + override suspend fun takePicture(onCaptureStarted: (() -> Unit)) { try { val imageProxy = imageCaptureUseCase.takePicture(onCaptureStarted) @@ -508,9 +620,20 @@ constructor( override suspend fun startVideoRecording( onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit ) { - if (videoCaptureUseCase == null) { - throw RuntimeException("Attempted video recording with null videoCapture use case") - } + videoCaptureControlEvents.send( + VideoCaptureControlEvent.StartRecordingEvent(onVideoRecord) + ) + } + + override fun stopVideoRecording() { + videoCaptureControlEvents.trySendBlocking(VideoCaptureControlEvent.StopRecordingEvent) + } + + private suspend fun startVideoRecordingInternal( + initialMuted: Boolean, + videoCaptureUseCase: VideoCapture, + onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit + ): Recording { Log.d(TAG, "recordVideo") // todo(b/336886716): default setting to enable or disable audio when permission is granted @@ -549,48 +672,46 @@ constructor( currentCoroutineContext()[ContinuationInterceptor] as? CoroutineDispatcher )?.asExecutor() ?: ContextCompat.getMainExecutor(application) - recording = - videoCaptureUseCase!!.output - .prepareRecording(application, mediaStoreOutput) - .apply { - if (audioEnabled) { - withAudioEnabled() - } + return videoCaptureUseCase.output + .prepareRecording(application, mediaStoreOutput) + .apply { + if (audioEnabled) { + withAudioEnabled() } - .start(callbackExecutor) { onVideoRecordEvent -> - run { - Log.d(TAG, onVideoRecordEvent.toString()) - when (onVideoRecordEvent) { - is VideoRecordEvent.Finalize -> { - when (onVideoRecordEvent.error) { - ERROR_NONE -> - onVideoRecord( - CameraUseCase.OnVideoRecordEvent.OnVideoRecorded + } + .start(callbackExecutor) { onVideoRecordEvent -> + run { + Log.d(TAG, onVideoRecordEvent.toString()) + when (onVideoRecordEvent) { + is VideoRecordEvent.Finalize -> { + when (onVideoRecordEvent.error) { + ERROR_NONE -> + onVideoRecord( + CameraUseCase.OnVideoRecordEvent.OnVideoRecorded( + onVideoRecordEvent.outputResults.outputUri ) + ) - else -> - onVideoRecord( - CameraUseCase.OnVideoRecordEvent.OnVideoRecordError - ) - } + else -> + onVideoRecord( + CameraUseCase.OnVideoRecordEvent.OnVideoRecordError + ) } + } - is VideoRecordEvent.Status -> { - onVideoRecord( - CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus( - onVideoRecordEvent.recordingStats.audioStats.audioAmplitude - ) + is VideoRecordEvent.Status -> { + onVideoRecord( + CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus( + onVideoRecordEvent.recordingStats.audioStats + .audioAmplitude ) - } + ) } } } - currentSettings.value?.audioMuted?.let { recording?.mute(it) } - } - - override fun stopVideoRecording() { - Log.d(TAG, "stopRecording") - recording?.stop() + }.apply { + mute(initialMuted) + } } override fun setZoomScale(scale: Float) { @@ -801,6 +922,7 @@ constructor( val previewUseCase = createPreviewUseCase(cameraInfo, sessionSettings, supportedStabilizationModes) imageCaptureUseCase = createImageUseCase(cameraInfo, sessionSettings) + var videoCaptureUseCase: VideoCapture? = null if (!disableVideoCapture) { videoCaptureUseCase = createVideoUseCase(cameraInfo, sessionSettings, supportedStabilizationModes) @@ -832,7 +954,7 @@ constructor( if (videoCaptureUseCase != null && sessionSettings.imageFormat == ImageOutputFormat.JPEG ) { - addUseCase(videoCaptureUseCase!!) + addUseCase(videoCaptureUseCase) } effect?.let { addEffect(it) } @@ -912,9 +1034,6 @@ constructor( } override suspend fun setAudioMuted(isAudioMuted: Boolean) { - // toggle mute for current in progress recording - recording?.mute(!isAudioMuted) - currentSettings.update { old -> old?.copy(audioMuted = isAudioMuted) } @@ -1126,3 +1245,9 @@ private fun Int.toAppImageFormat(): ImageOutputFormat? { else -> null } } + +private fun UseCaseGroup.getVideoCapture() = getUseCaseOrNull>() + +private inline fun UseCaseGroup.getUseCaseOrNull(): T? { + return useCases.filterIsInstance().singleOrNull() +} diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt index e6f98ea94..33bcbb9a2 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CoroutineCameraProvider.kt @@ -23,6 +23,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope @@ -36,7 +37,7 @@ import kotlinx.coroutines.coroutineScope suspend fun ProcessCameraProvider.runWith( cameraSelector: CameraSelector, useCases: UseCaseGroup, - block: suspend (Camera) -> R + block: suspend CoroutineScope.(Camera) -> R ): R = coroutineScope { val scopedLifecycle = CoroutineLifecycleOwner(coroutineContext) block(this@runWith.bindToLifecycle(scopedLifecycle, cameraSelector, useCases)) diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/VideoCaptureControlEvent.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/VideoCaptureControlEvent.kt new file mode 100644 index 000000000..1ec334e56 --- /dev/null +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/VideoCaptureControlEvent.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.core.camera + +/** + * Represents events that control video capture operations. + */ +sealed interface VideoCaptureControlEvent { + + /** + * Starts video recording. + * + * @param onVideoRecord Callback to handle video recording events. + */ + class StartRecordingEvent(val onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit) : + VideoCaptureControlEvent + + /** + * Stops video recording. + */ + data object StopRecordingEvent : VideoCaptureControlEvent +} diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt index 51a1eb480..ae4aa315f 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/Constraints.kt @@ -24,7 +24,8 @@ data class CameraConstraints( val supportedStabilizationModes: Set, val supportedFixedFrameRates: Set, val supportedDynamicRanges: Set, - val supportedImageFormatsMap: Map> + val supportedImageFormatsMap: Map>, + val hasFlashUnit: Boolean ) /** @@ -44,7 +45,8 @@ val TYPICAL_SYSTEM_CONSTRAINTS = supportedImageFormatsMap = mapOf( Pair(CaptureMode.SINGLE_STREAM, setOf(ImageOutputFormat.JPEG)), Pair(CaptureMode.MULTI_STREAM, setOf(ImageOutputFormat.JPEG)) - ) + ), + hasFlashUnit = lensFacing == LensFacing.BACK ) ) } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt index c89fe49c6..441e5d60f 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 @@ -533,7 +533,7 @@ class PreviewViewModel @AssistedInject constructor( var audioAmplitude = 0.0 var snackbarToShow: SnackbarData? = null when (it) { - CameraUseCase.OnVideoRecordEvent.OnVideoRecorded -> { + is CameraUseCase.OnVideoRecordEvent.OnVideoRecorded -> { snackbarToShow = SnackbarData( cookie = cookie, stringResource = R.string.toast_video_capture_success, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6405ad589..36ea1328b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,7 @@ mockitoCore = "5.6.0" protobuf = "3.25.2" robolectric = "4.11.1" truth = "1.4.2" +rules = "1.6.1" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } @@ -59,6 +60,7 @@ androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "a androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" } androidx-graphics-core = { module = "androidx.graphics:graphics-core", version.ref = "androidxGraphicsCore" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" } +androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidxLifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigationCompose" } @@ -93,6 +95,7 @@ mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } truth = { module = "com.google.truth:truth", version.ref = "truth" } +rules = { group = "androidx.test", name = "rules", version.ref = "rules" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }