diff --git a/domain/camera/build.gradle b/domain/camera/build.gradle index 8bf17a19..890d9a3a 100644 --- a/domain/camera/build.gradle +++ b/domain/camera/build.gradle @@ -42,7 +42,7 @@ dependencies { implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0' // CameraX - def camerax_version = "1.1.0-beta01" + def camerax_version = "1.3.0-alpha05" implementation "androidx.camera:camera-core:${camerax_version}" implementation "androidx.camera:camera-camera2:${camerax_version}" implementation "androidx.camera:camera-lifecycle:${camerax_version}" diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt index 57796699..e555818d 100644 --- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt +++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt @@ -43,4 +43,8 @@ interface CameraUseCase { ) suspend fun takePicture() + + suspend fun startVideoRecording() + + fun stopVideoRecording() } \ No newline at end of file diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt index d060dd79..10621946 100644 --- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt +++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt @@ -17,6 +17,8 @@ package com.google.jetpackcamera.domain.camera import android.app.Application +import android.content.ContentValues +import android.provider.MediaStore import android.util.Log import android.util.Rational import androidx.camera.core.CameraSelector @@ -28,10 +30,18 @@ import androidx.camera.core.Preview import androidx.camera.core.UseCaseGroup import androidx.camera.core.ViewPort import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.MediaStoreOutputOptions +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture import androidx.concurrent.futures.await +import androidx.core.content.ContextCompat +import androidx.core.util.Consumer +import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.asExecutor +import java.util.Date import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope import javax.inject.Inject @@ -54,12 +64,18 @@ class CameraXCameraUseCase @Inject constructor( private val previewUseCase = Preview.Builder() .build() + private val recorder = Recorder.Builder().setExecutor(defaultDispatcher.asExecutor()).build() + private val videoCaptureUseCase = VideoCapture.withOutput(recorder) + private val useCaseGroup = UseCaseGroup.Builder() .setViewPort(ViewPort.Builder(ASPECT_RATIO_16_9, previewUseCase.targetRotation).build()) .addUseCase(previewUseCase) .addUseCase(imageCaptureUseCase) + .addUseCase(videoCaptureUseCase) .build() + private var recording : Recording? = null + override suspend fun initialize(): List { cameraProvider = ProcessCameraProvider.getInstance(application).await() @@ -81,7 +97,6 @@ class CameraXCameraUseCase @Inject constructor( Log.d(TAG, "startPreview") val cameraSelector = cameraLensToSelector(lensFacing) - previewUseCase.setSurfaceProvider(surfaceProvider) cameraProvider.runWith(cameraSelector, useCaseGroup) { @@ -107,6 +122,31 @@ class CameraXCameraUseCase @Inject constructor( }) } + override suspend fun startVideoRecording() { + Log.d(TAG, "recordVideo") + val name = "JCA-recording-${Date()}.mp4" + val contentValues = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, name) + } + val mediaStoreOutput = MediaStoreOutputOptions.Builder(application.contentResolver, + MediaStore.Video.Media.EXTERNAL_CONTENT_URI) + .setContentValues(contentValues) + .build() + + recording = videoCaptureUseCase.output + .prepareRecording(application, mediaStoreOutput) + .start(ContextCompat.getMainExecutor(application), Consumer { videoRecordEvent -> + run { + Log.d(TAG, videoRecordEvent.toString()) + } + }) + } + + override fun stopVideoRecording() { + Log.d(TAG, "stopRecording") + recording?.stop() + } + private fun cameraLensToSelector(@LensFacing lensFacing: Int): CameraSelector = when (lensFacing) { CameraSelector.LENS_FACING_FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt index d0d63c00..e72763f8 100644 --- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt +++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt @@ -31,6 +31,8 @@ class FakeCameraUseCase : CameraUseCase { var previewStarted = false var numPicturesTaken = 0 + var recordingInProgress = false + override suspend fun initialize(): List { initialized = true return availableLenses @@ -56,4 +58,12 @@ class FakeCameraUseCase : CameraUseCase { } numPicturesTaken += 1 } + + override suspend fun startVideoRecording() { + recordingInProgress = true + } + + override fun stopVideoRecording() { + recordingInProgress = false + } } \ No newline at end of file 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 cfc7b605..a5a568b8 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,8 +16,10 @@ package com.google.jetpackcamera.feature.preview +import android.provider.MediaStore.Video import android.util.Log import androidx.camera.core.Preview.SurfaceProvider +import androidx.compose.foundation.Canvas import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box @@ -27,7 +29,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -123,7 +124,10 @@ fun PreviewScreen( modifier = Modifier.align(Alignment.BottomCenter) ) { CaptureButton( - onClick = { viewModel.captureImage() } + onClick = { viewModel.captureImage() }, + onLongPress = { viewModel.startVideoRecording() }, + onRelease = { viewModel.stopVideoRecording() }, + state = previewUiState.videoRecordingState ) } } @@ -131,13 +135,35 @@ fun PreviewScreen( } @Composable -fun CaptureButton(onClick: () -> Unit) { - Button( - onClick = onClick, - shape = CircleShape, +fun CaptureButton( + onClick: () -> Unit, + onLongPress: () -> Unit, + onRelease: () -> Unit, + state: VideoRecordingState +) { + Box( modifier = Modifier + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + onLongPress() + }, onPress = { + awaitRelease() + onRelease() + }, onTap = { onClick() }) + } .size(120.dp) .padding(18.dp) .border(4.dp, Color.White, CircleShape) - ) {} + ) { + Canvas(modifier = Modifier.size(110.dp), onDraw = { + drawCircle( + color = + when (state) { + VideoRecordingState.INACTIVE -> Color.Transparent + VideoRecordingState.ACTIVE -> Color.Red + } + ) + }) + } } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt index a63a9004..ab230e7e 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt @@ -24,9 +24,24 @@ import androidx.camera.core.CameraSelector */ data class PreviewUiState( val cameraState: CameraState = CameraState.NOT_READY, - val lensFacing: Int = CameraSelector.LENS_FACING_FRONT + val lensFacing: Int = CameraSelector.LENS_FACING_FRONT, + val videoRecordingState: VideoRecordingState = VideoRecordingState.INACTIVE, ) +/** + * Defines the current state of Video Recording + */ +enum class VideoRecordingState { + /** + * Camera is not currently recording a video + */ + INACTIVE, + /** + * Camera is currently recording a video + */ + ACTIVE +} + /** * Defines the current state of the camera. */ 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 c7d8b7f6..902db5b9 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 @@ -18,14 +18,12 @@ package com.google.jetpackcamera.feature.preview import android.util.Log import androidx.camera.core.ImageCaptureException -import androidx.lifecycle.LifecycleOwner +import androidx.camera.core.Preview.SurfaceProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.camera.core.Preview.SurfaceProvider import com.google.jetpackcamera.domain.camera.CameraUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -46,6 +44,8 @@ class PreviewViewModel @Inject constructor( val previewUiState: StateFlow = _previewUiState var runningCameraJob: Job? = null + private var recordingJob : Job? = null + init { initializeCamera() } @@ -97,4 +97,36 @@ class PreviewViewModel @Inject constructor( } } } + + fun startVideoRecording() { + Log.d(TAG, "startVideoRecording") + recordingJob = viewModelScope.launch { + + try { + cameraUseCase.startVideoRecording() + _previewUiState.emit( + previewUiState.value.copy( + videoRecordingState = VideoRecordingState.ACTIVE + ) + ) + Log.d(TAG, "cameraUseCase.startRecording success") + } catch (exception: IllegalStateException) { + Log.d(TAG, "cameraUseCase.startVideoRecording error") + Log.d(TAG, exception.toString()) + } + } + } + + fun stopVideoRecording() { + Log.d(TAG, "stopVideoRecording") + viewModelScope.launch { + _previewUiState.emit( + previewUiState.value.copy( + videoRecordingState = VideoRecordingState.INACTIVE + ) + ) + } + cameraUseCase.stopVideoRecording() + recordingJob?.cancel() + } } \ No newline at end of file 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 5480757f..c55848a0 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 @@ -68,6 +68,24 @@ class PreviewViewModelTest { assertEquals(cameraUseCase.numPicturesTaken, 1) } + @Test + fun startVideoRecording() = runTest(StandardTestDispatcher()) { + previewViewModel.runCamera(mock()) + previewViewModel.startVideoRecording() + advanceUntilIdle() + assertEquals(cameraUseCase.recordingInProgress, true) + } + + @Test + fun stopVideoRecording() = runTest(StandardTestDispatcher()) { + previewViewModel.runCamera(mock()) + previewViewModel.startVideoRecording() + advanceUntilIdle() + previewViewModel.stopVideoRecording() + assertEquals(cameraUseCase.recordingInProgress, false) + + } + @Test fun flipCamera() { // TODO(yasith)