diff --git a/buildSrc/src/main/kotlin/ujizin/camposer/Config.kt b/buildSrc/src/main/kotlin/ujizin/camposer/Config.kt index addab02..efbcdfb 100644 --- a/buildSrc/src/main/kotlin/ujizin/camposer/Config.kt +++ b/buildSrc/src/main/kotlin/ujizin/camposer/Config.kt @@ -1,8 +1,8 @@ -package com.ujizin.camposer +package ujizin.camposer object Config { - const val compileSdk = 33 - const val targetSdk = 33 + const val compileSdk = 34 + const val targetSdk = 34 const val minSdk = 21 const val versionCode = 8 const val versionName = "0.3.2" diff --git a/camposer/build.gradle.kts b/camposer/build.gradle.kts index 53fc35e..5a3977d 100644 --- a/camposer/build.gradle.kts +++ b/camposer/build.gradle.kts @@ -1,4 +1,4 @@ -import com.ujizin.camposer.Config +import ujizin.camposer.Config plugins { id("com.android.library") diff --git a/camposer/src/androidTest/java/com/ujizin/camposer/CameraTest.kt b/camposer/src/androidTest/java/com/ujizin/camposer/CameraTest.kt index 61f4af2..60bf058 100644 --- a/camposer/src/androidTest/java/com/ujizin/camposer/CameraTest.kt +++ b/camposer/src/androidTest/java/com/ujizin/camposer/CameraTest.kt @@ -49,6 +49,6 @@ internal abstract class CameraTest { } private companion object { - private const val CAMERA_TIMEOUT = 2_500L + private const val CAMERA_TIMEOUT = 10_000L } } diff --git a/camposer/src/androidTest/java/com/ujizin/camposer/CaptureModeTest.kt b/camposer/src/androidTest/java/com/ujizin/camposer/CaptureModeTest.kt index 4832f66..199269e 100644 --- a/camposer/src/androidTest/java/com/ujizin/camposer/CaptureModeTest.kt +++ b/camposer/src/androidTest/java/com/ujizin/camposer/CaptureModeTest.kt @@ -2,8 +2,9 @@ package com.ujizin.camposer import android.content.Context import android.net.Uri -import android.os.Build import androidx.camera.core.ImageProxy +import androidx.camera.video.FileOutputOptions +import androidx.camera.view.video.AudioConfig import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest @@ -15,7 +16,6 @@ import com.ujizin.camposer.state.rememberImageAnalyzer import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import java.io.File @@ -28,74 +28,80 @@ internal class CaptureModeTest : CameraTest() { get() = InstrumentationRegistry.getInstrumentation().context @Test - @Ignore("Flaky test, sometimes throw exception \"Camera closed\"") fun test_captureMode() = with(composeTestRule) { initCaptureModeCamera(CaptureMode.Image) + var isFinalized = false runOnIdle { val imageFile = File(context.filesDir, IMAGE_TEST_FILENAME).apply { createNewFile() } - cameraState.takePicture(imageFile) { result -> when (result) { is ImageCaptureResult.Error -> throw result.throwable is ImageCaptureResult.Success -> { assertEquals(Uri.fromFile(imageFile), result.savedUri) assertEquals(CaptureMode.Image, cameraState.captureMode) + isFinalized = true } } } } + + waitUntil(CAPTURE_MODE_TIMEOUT) { isFinalized } } @Test fun test_videoCaptureMode() = with(composeTestRule) { - // No support for API Level 23 below - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return - initCaptureModeCamera(CaptureMode.Video) + if (!cameraState.isVideoSupported) return + + // Create file + val videoFile = File(context.filesDir, VIDEO_TEST_FILENAME).apply { + delete() + createNewFile() + } + + var isFinished = false runOnIdle { - val videoFile = File(context.filesDir, VIDEO_TEST_FILENAME).apply { createNewFile() } - cameraState.startRecording(videoFile) { result -> + cameraState.startRecording(FileOutputOptions.Builder(videoFile).build(), AudioConfig.AUDIO_DISABLED) { result -> when (result) { - is VideoCaptureResult.Error -> { - throw result.throwable ?: Exception(result.message) - } - + is VideoCaptureResult.Error -> throw result.throwable ?: error(result.message) is VideoCaptureResult.Success -> { assertEquals(Uri.fromFile(videoFile), result.savedUri) assertEquals(CaptureMode.Video, cameraState.captureMode) + isFinished = true } } } - runBlocking { - delay(RECORD_VIDEO_DELAY) - cameraState.stopRecording() - } } + + runBlocking { + delay(RECORD_VIDEO_DELAY) + cameraState.stopRecording() + } + + waitUntil(CAPTURE_MODE_TIMEOUT) { isFinished } } @Test fun test_videoCaptureModeWithAnalysis() = with(composeTestRule) { - // No support for API Level 23 below - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + // Create file + val videoFile = File(context.filesDir, VIDEO_TEST_FILENAME).apply { + delete() + createNewFile() + } var isAnalyzeCalled = false - initCaptureModeCamera(CaptureMode.Video) { - isAnalyzeCalled = true - } + initCaptureModeCamera(CaptureMode.Video) { isAnalyzeCalled = true } - if (!cameraState.isImageAnalysisSupported) return + if (!cameraState.isImageAnalysisSupported || !cameraState.isVideoSupported) return - runOnIdle { - val videoFile = File(context.filesDir, VIDEO_TEST_FILENAME).apply { createNewFile() } + var isFinished = false - cameraState.startRecording(videoFile) { result -> + runOnIdle { + cameraState.startRecording(FileOutputOptions.Builder(videoFile).build()) { result -> when (result) { - is VideoCaptureResult.Error -> { - throw result.throwable ?: Exception(result.message) - } - + is VideoCaptureResult.Error -> throw result.throwable ?: error(result.message) is VideoCaptureResult.Success -> { assertEquals(Uri.fromFile(videoFile), result.savedUri) assertEquals(CaptureMode.Video, cameraState.captureMode) @@ -103,14 +109,18 @@ internal class CaptureModeTest : CameraTest() { assertEquals(true, cameraState.isImageAnalysisEnabled) assertEquals(true, isAnalyzeCalled) } + isFinished = true } } } - runBlocking { - delay(RECORD_VIDEO_DELAY) - cameraState.stopRecording() - } } + + runBlocking { + delay(RECORD_VIDEO_DELAY) + cameraState.stopRecording() + } + + waitUntil(CAPTURE_MODE_TIMEOUT) { isFinished } } private fun ComposeContentTestRule.initCaptureModeCamera( @@ -129,6 +139,7 @@ internal class CaptureModeTest : CameraTest() { private companion object { private const val RECORD_VIDEO_DELAY = 2000L + private const val CAPTURE_MODE_TIMEOUT = 10000L private const val IMAGE_TEST_FILENAME = "capture_mode_test.jpg" private const val VIDEO_TEST_FILENAME = "capture_mode_test.mp4" } diff --git a/camposer/src/androidTest/java/com/ujizin/camposer/FlashModeTest.kt b/camposer/src/androidTest/java/com/ujizin/camposer/FlashModeTest.kt index d4eb027..18805a5 100644 --- a/camposer/src/androidTest/java/com/ujizin/camposer/FlashModeTest.kt +++ b/camposer/src/androidTest/java/com/ujizin/camposer/FlashModeTest.kt @@ -26,7 +26,7 @@ internal class FlashModeTest : CameraTest() { initFlashCamera(camSelector = CamSelector.Back) if (!cameraState.hasFlashUnit) return - FlashMode.values().forEach { mode -> + FlashMode.entries.forEach { mode -> flashMode.value = mode onNodeWithTag("${flashMode.value}").assertIsDisplayed() runOnIdle { assertEquals(mode, cameraState.flashMode) } diff --git a/camposer/src/main/java/com/ujizin/camposer/CameraPreview.kt b/camposer/src/main/java/com/ujizin/camposer/CameraPreview.kt index 2eb9a22..e442191 100644 --- a/camposer/src/main/java/com/ujizin/camposer/CameraPreview.kt +++ b/camposer/src/main/java/com/ujizin/camposer/CameraPreview.kt @@ -3,6 +3,7 @@ package com.ujizin.camposer import android.annotation.SuppressLint import android.graphics.Bitmap import android.view.ViewGroup +import androidx.camera.video.QualitySelector import androidx.camera.view.PreviewView import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -51,6 +52,7 @@ import androidx.camera.core.CameraSelector as CameraXSelector * @param isImageAnalysisEnabled enable or disable image analysis * @param isFocusOnTapEnabled turn on feature focus on tap if true * @param isPinchToZoomEnabled turn on feature pinch to zoom if true + * @param videoQualitySelector quality selector to the video capture * @param onPreviewStreamChanged dispatch when preview is switching to front or back * @param onSwitchToFront composable preview when change camera to front and it's not been streaming yet * @param onSwitchToBack composable preview when change camera to back and it's not been streaming yet @@ -79,6 +81,7 @@ public fun CameraPreview( isImageAnalysisEnabled: Boolean = cameraState.isImageAnalysisEnabled, isFocusOnTapEnabled: Boolean = cameraState.isFocusOnTapEnabled, isPinchToZoomEnabled: Boolean = cameraState.isZoomSupported, + videoQualitySelector: QualitySelector = cameraState.videoQualitySelector, onPreviewStreamChanged: () -> Unit = {}, onSwitchToFront: @Composable (Bitmap) -> Unit = {}, onSwitchToBack: @Composable (Bitmap) -> Unit = {}, @@ -107,6 +110,7 @@ public fun CameraPreview( implementationMode = implementationMode, isFocusOnTapEnabled = isFocusOnTapEnabled, isPinchToZoomEnabled = isPinchToZoomEnabled, + videoQualitySelector = videoQualitySelector, onZoomRatioChanged = onZoomRatioChanged, focusTapContent = focusTapContent, onFocus = onFocus, @@ -136,6 +140,7 @@ internal fun CameraPreviewImpl( isImageAnalysisEnabled: Boolean, isFocusOnTapEnabled: Boolean, isPinchToZoomEnabled: Boolean, + videoQualitySelector: QualitySelector, onZoomRatioChanged: (Float) -> Unit, onPreviewStreamChanged: () -> Unit, onFocus: suspend (() -> Unit) -> Unit, @@ -200,6 +205,7 @@ internal fun CameraPreviewImpl( zoomRatio = zoomRatio, imageCaptureMode = imageCaptureMode, meteringPoint = meteringPointFactory.createPoint(x, y), + videoQualitySelector = videoQualitySelector, exposureCompensation = exposureCompensation, ) } diff --git a/camposer/src/main/java/com/ujizin/camposer/extensions/CameraStateExtensions.kt b/camposer/src/main/java/com/ujizin/camposer/extensions/CameraStateExtensions.kt index cd408ff..2c56bae 100644 --- a/camposer/src/main/java/com/ujizin/camposer/extensions/CameraStateExtensions.kt +++ b/camposer/src/main/java/com/ujizin/camposer/extensions/CameraStateExtensions.kt @@ -1,12 +1,17 @@ package com.ujizin.camposer.extensions +import android.Manifest import android.content.ContentValues import android.net.Uri import android.os.Build import android.provider.MediaStore import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission import androidx.camera.core.ImageCapture -import androidx.camera.view.video.OutputFileOptions +import androidx.camera.video.FileDescriptorOutputOptions +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.MediaStoreOutputOptions +import androidx.camera.view.video.AudioConfig import com.ujizin.camposer.state.CameraState import com.ujizin.camposer.state.ImageCaptureResult import com.ujizin.camposer.state.VideoCaptureResult @@ -45,30 +50,44 @@ public suspend fun CameraState.takePicture( /** * Transform toggle recording file to suspend function * */ -@RequiresApi(Build.VERSION_CODES.M) -public suspend fun CameraState.toggleRecording(file: File): Uri? = suspendCancellableCoroutine { cont -> - with(cont) { toggleRecording(file, ::toggleRecordContinuation) } -} +@RequiresApi(Build.VERSION_CODES.O) +@RequiresPermission(Manifest.permission.RECORD_AUDIO) +public suspend fun CameraState.toggleRecording( + fileOutputOptions: FileOutputOptions, + audioConfig: AudioConfig = AudioConfig.create(true), +): Uri? = + suspendCancellableCoroutine { cont -> + with(cont) { toggleRecording(fileOutputOptions, audioConfig, ::toggleRecordContinuation) } + } /** * Transform toggle recording content values options to suspend function * */ -@RequiresApi(Build.VERSION_CODES.M) +@RequiresApi(Build.VERSION_CODES.N) +@RequiresPermission(Manifest.permission.RECORD_AUDIO) public suspend fun CameraState.toggleRecording( - contentValues: ContentValues, - saveCollection: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + mediaStoreOutputOptions: MediaStoreOutputOptions, + audioConfig: AudioConfig = AudioConfig.create(true), ): Uri? = suspendCancellableCoroutine { cont -> - with(cont) { toggleRecording(contentValues, saveCollection, ::toggleRecordContinuation) } + with(cont) { toggleRecording(mediaStoreOutputOptions, audioConfig, ::toggleRecordContinuation) } } /** * Transform toggle recording output files options to suspend function * */ -@RequiresApi(Build.VERSION_CODES.M) +@RequiresApi(Build.VERSION_CODES.O) +@RequiresPermission(Manifest.permission.RECORD_AUDIO) public suspend fun CameraState.toggleRecording( - outputFileOptions: OutputFileOptions + fileDescriptorOutputOptions: FileDescriptorOutputOptions, + audioConfig: AudioConfig = AudioConfig.create(true), ): Uri? = suspendCancellableCoroutine { cont -> - with(cont) { toggleRecording(outputFileOptions, ::toggleRecordContinuation) } + with(cont) { + toggleRecording( + fileDescriptorOutputOptions, + audioConfig, + ::toggleRecordContinuation + ) + } } private fun Continuation.takePictureContinuation(result: ImageCaptureResult) { diff --git a/camposer/src/main/java/com/ujizin/camposer/state/CameraState.kt b/camposer/src/main/java/com/ujizin/camposer/state/CameraState.kt index 75c5833..a251558 100644 --- a/camposer/src/main/java/com/ujizin/camposer/state/CameraState.kt +++ b/camposer/src/main/java/com/ujizin/camposer/state/CameraState.kt @@ -1,5 +1,6 @@ package com.ujizin.camposer.state +import android.Manifest import android.annotation.SuppressLint import android.content.ContentResolver import android.content.ContentValues @@ -10,27 +11,33 @@ import android.os.Build import android.provider.MediaStore import android.util.Log import androidx.annotation.ChecksSdkIntAtLeast -import androidx.annotation.OptIn import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission import androidx.annotation.VisibleForTesting +import androidx.camera.core.CameraEffect import androidx.camera.core.FocusMeteringAction import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.MeteringPoint import androidx.camera.core.TorchState +import androidx.camera.video.FileDescriptorOutputOptions +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.MediaStoreOutputOptions +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recording +import androidx.camera.video.VideoRecordEvent import androidx.camera.view.CameraController.IMAGE_ANALYSIS import androidx.camera.view.CameraController.OutputSize import androidx.camera.view.CameraController.OutputSize.UNASSIGNED_ASPECT_RATIO import androidx.camera.view.LifecycleCameraController -import androidx.camera.view.video.ExperimentalVideo -import androidx.camera.view.video.OnVideoSavedCallback -import androidx.camera.view.video.OutputFileOptions -import androidx.camera.view.video.OutputFileResults +import androidx.camera.view.video.AudioConfig import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.util.Consumer import com.ujizin.camposer.extensions.compatMainExecutor import com.ujizin.camposer.extensions.isImageAnalysisSupported import java.io.File @@ -66,10 +73,15 @@ public class CameraState(context: Context) { * */ public val controller: LifecycleCameraController = LifecycleCameraController(context) + /** + * Record controller to video Capture + * */ + private var recordController: Recording? = null + /** * Get max zoom from camera. * */ - public var maxZoom: Float by mutableStateOf( + public var maxZoom: Float by mutableFloatStateOf( controller.zoomState.value?.maxZoomRatio ?: INITIAL_ZOOM_VALUE ) internal set @@ -174,6 +186,12 @@ public class CameraState(context: Context) { * */ internal var implementationMode: ImplementationMode = ImplementationMode.Performance + internal var videoQualitySelector: QualitySelector + get() = controller.videoCaptureQualitySelector + set(value) { + controller.videoCaptureQualitySelector = value + } + /** * Camera mode, it can be front or back. * @see CamSelector @@ -223,7 +241,11 @@ public class CameraState(context: Context) { /** * Check if image analysis is supported by camera hardware level. * */ - public var isImageAnalysisSupported: Boolean by mutableStateOf(isImageAnalysisSupported(camSelector)) + public var isImageAnalysisSupported: Boolean by mutableStateOf( + isImageAnalysisSupported( + camSelector + ) + ) private set /** @@ -331,13 +353,12 @@ public class CameraState(context: Context) { * Return if video is supported. * */ - @ChecksSdkIntAtLeast(Build.VERSION_CODES.M) - public var isVideoSupported: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + @ChecksSdkIntAtLeast(Build.VERSION_CODES.N) + public var isVideoSupported: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N /** * Return true if it's recording. * */ - @ExperimentalVideo public var isRecording: Boolean by mutableStateOf(controller.isRecording) private set @@ -425,70 +446,108 @@ public class CameraState(context: Context) { /** * Start recording camera. * - * @param file file where the video will be saved + * @param fileOutputOptions file output options where the video will be saved * @param onResult Callback called when [VideoCaptureResult] is ready * */ - @OptIn(markerClass = [ExperimentalVideo::class]) - @RequiresApi(Build.VERSION_CODES.M) - public fun startRecording(file: File, onResult: (VideoCaptureResult) -> Unit) { - startRecording(OutputFileOptions.builder(file).build(), onResult) + @RequiresApi(Build.VERSION_CODES.N) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + public fun startRecording( + fileOutputOptions: FileOutputOptions, + audioConfig: AudioConfig = AudioConfig.create(true), + onResult: (VideoCaptureResult) -> Unit, + ): Unit = prepareRecording(onResult) { + Log.i(TAG, "Start recording") + controller.startRecording( + fileOutputOptions, + audioConfig, + mainExecutor, + getConsumerEvent(onResult) + ) + } + + /** + * Start recording camera. + * + * @param fileDescriptorOutputOptions file output options where the video will be saved + * @param onResult Callback called when [VideoCaptureResult] is ready + * */ + @RequiresApi(Build.VERSION_CODES.O) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + public fun startRecording( + fileDescriptorOutputOptions: FileDescriptorOutputOptions, + audioConfig: AudioConfig = AudioConfig.create(true), + onResult: (VideoCaptureResult) -> Unit, + ): Unit = prepareRecording(onResult) { + controller.startRecording( + fileDescriptorOutputOptions, + audioConfig, + mainExecutor, + getConsumerEvent(onResult) + ) } /** * Start recording camera. * - * @param saveCollection Uri collection where the video will be saved. - * @param contentValues Content values of the video. + * @param mediaStoreOutputOptions media store output options to the video to be saved. * @param onResult Callback called when [VideoCaptureResult] is ready * */ - @OptIn(markerClass = [ExperimentalVideo::class]) - @RequiresApi(Build.VERSION_CODES.M) + @RequiresApi(Build.VERSION_CODES.N) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) public fun startRecording( - saveCollection: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI, - contentValues: ContentValues, + mediaStoreOutputOptions: MediaStoreOutputOptions, + audioConfig: AudioConfig = AudioConfig.create(true), onResult: (VideoCaptureResult) -> Unit, - ) { - startRecording( - OutputFileOptions.builder(contentResolver, saveCollection, contentValues).build(), - onResult + ): Unit = prepareRecording(onError = onResult) { + controller.startRecording( + mediaStoreOutputOptions, + audioConfig, + mainExecutor, + getConsumerEvent(onResult) ) } + @RequiresApi(Build.VERSION_CODES.N) + private fun getConsumerEvent( + onResult: (VideoCaptureResult) -> Unit + ): Consumer = Consumer { event -> + Log.i(TAG, "Video Recorder Event - $event") + if (event is VideoRecordEvent.Finalize) { + isRecording = false + val result = when { + !event.hasError() -> VideoCaptureResult.Success(event.outputResults.outputUri) + else -> VideoCaptureResult.Error( + "Video error code: ${event.error}", + event.cause + ) + } + recordController = null + onResult(result) + } + } + /** - * Start recording camera. + * Prepare recording camera. * - * @param outputFileOptions Output file options of the video. - * @param onResult Callback called when [VideoCaptureResult] is ready + * @param onRecordBuild lambda to retrieve record controller + * @param onError Callback called when thrown error * */ - @ExperimentalVideo @RequiresApi(Build.VERSION_CODES.M) - public fun startRecording( - outputFileOptions: OutputFileOptions, - onResult: (VideoCaptureResult) -> Unit, + private fun prepareRecording( + onError: (VideoCaptureResult.Error) -> Unit, + onRecordBuild: () -> Recording, ) { try { + Log.i(TAG, "Prepare recording") isRecording = true - controller.startRecording(outputFileOptions, - mainExecutor, - object : OnVideoSavedCallback { - override fun onVideoSaved(outputFileResults: OutputFileResults) { - isRecording = false - onResult(VideoCaptureResult.Success(outputFileResults.savedUri)) - } - - override fun onError( - videoCaptureError: Int, message: String, cause: Throwable? - ) { - isRecording = false - onResult(VideoCaptureResult.Error(videoCaptureError, message, cause)) - } - }) + recordController = onRecordBuild() } catch (exception: Exception) { + Log.i(TAG, "Fail to record! - $exception") isRecording = false - onResult( + onError( VideoCaptureResult.Error( - OnVideoSavedCallback.ERROR_UNKNOWN, if (!controller.isVideoCaptureEnabled) { - "Video capture is not enabled, please set captureMode as CaptureMode.Video" + if (!controller.isVideoCaptureEnabled) { + "Video capture is not enabled, please set captureMode as CaptureMode.Video - ${exception.message}" } else "${exception.message}", exception ) ) @@ -498,53 +557,76 @@ public class CameraState(context: Context) { /** * Stop recording camera. * */ - @OptIn(markerClass = [ExperimentalVideo::class]) @RequiresApi(Build.VERSION_CODES.M) public fun stopRecording() { - controller.stopRecording() + Log.i(TAG, "Stop recording") + recordController?.stop()?.also { + isRecording = false + } + } + + @RequiresApi(Build.VERSION_CODES.M) + public fun pauseRecording() { + Log.i(TAG, "Pause recording") + recordController?.pause() + } + + @RequiresApi(Build.VERSION_CODES.M) + public fun resumeRecording() { + Log.i(TAG, "Resume recording") + recordController?.resume() + } + + @RequiresApi(Build.VERSION_CODES.M) + public fun muteRecording(muted: Boolean) { + recordController?.mute(muted) } /** * Toggle recording camera. * */ - @RequiresApi(Build.VERSION_CODES.M) + @RequiresApi(Build.VERSION_CODES.O) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) public fun toggleRecording( - file: File, + fileDescriptorOutputOptions: FileDescriptorOutputOptions, + audioConfig: AudioConfig = AudioConfig.create(true), onResult: (VideoCaptureResult) -> Unit ) { when (isRecording) { true -> stopRecording() - false -> startRecording(file, onResult) + false -> startRecording(fileDescriptorOutputOptions, audioConfig, onResult) } } /** * Toggle recording camera. * */ - @RequiresApi(Build.VERSION_CODES.M) + @RequiresApi(Build.VERSION_CODES.N) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) public fun toggleRecording( - contentValues: ContentValues, - saveCollection: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + mediaStoreOutputOptions: MediaStoreOutputOptions, + audioConfig: AudioConfig = AudioConfig.create(true), onResult: (VideoCaptureResult) -> Unit ) { when (isRecording) { true -> stopRecording() - false -> startRecording(saveCollection, contentValues, onResult) + false -> startRecording(mediaStoreOutputOptions, audioConfig, onResult) } } /** * Toggle recording camera. * */ - @RequiresApi(Build.VERSION_CODES.M) - @OptIn(markerClass = [ExperimentalVideo::class]) + @RequiresApi(Build.VERSION_CODES.N) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) public fun toggleRecording( - outputFileOptions: OutputFileOptions, + fileOutputOptions: FileOutputOptions, + audioConfig: AudioConfig = AudioConfig.create(true), onResult: (VideoCaptureResult) -> Unit ) { when (isRecording) { true -> stopRecording() - false -> startRecording(outputFileOptions, onResult) + false -> startRecording(fileOutputOptions, audioConfig, onResult) } } @@ -570,6 +652,20 @@ public class CameraState(context: Context) { startExposure() } + /** + * Set effects on camera + * */ + public fun setEffects(effects: Set) { + controller.setEffects(effects) + } + + /** + * Set effects on camera + * */ + public fun clearEffects() { + controller.clearEffects() + } + private fun Set.sumOr(initial: Int = 0): Int = fold(initial) { acc, current -> acc or current } @@ -578,7 +674,9 @@ public class CameraState(context: Context) { @VisibleForTesting internal fun isImageAnalysisSupported( cameraSelector: CamSelector = camSelector - ): Boolean = cameraManager?.isImageAnalysisSupported(cameraSelector.selector.lensFacing) ?: false + ): Boolean = cameraManager?.isImageAnalysisSupported( + cameraSelector.selector.lensFacing + ) ?: false /** * Update all values from camera state. @@ -597,7 +695,8 @@ public class CameraState(context: Context) { imageCaptureMode: ImageCaptureMode, enableTorch: Boolean, meteringPoint: MeteringPoint, - exposureCompensation: Int + exposureCompensation: Int, + videoQualitySelector: QualitySelector, ) { this.camSelector = camSelector this.captureMode = captureMode @@ -611,6 +710,7 @@ public class CameraState(context: Context) { this.enableTorch = enableTorch this.isFocusOnTapSupported = meteringPoint.isFocusMeteringSupported this.imageCaptureMode = imageCaptureMode + this.videoQualitySelector = videoQualitySelector setExposureCompensation(exposureCompensation) setZoomRatio(zoomRatio) } diff --git a/camposer/src/main/java/com/ujizin/camposer/state/CaptureMode.kt b/camposer/src/main/java/com/ujizin/camposer/state/CaptureMode.kt index f9599ff..55463e4 100644 --- a/camposer/src/main/java/com/ujizin/camposer/state/CaptureMode.kt +++ b/camposer/src/main/java/com/ujizin/camposer/state/CaptureMode.kt @@ -1,11 +1,9 @@ package com.ujizin.camposer.state import android.os.Build -import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.camera.view.CameraController.IMAGE_CAPTURE import androidx.camera.view.CameraController.VIDEO_CAPTURE -import androidx.camera.view.video.ExperimentalVideo /** * Camera Capture mode. @@ -14,9 +12,9 @@ import androidx.camera.view.video.ExperimentalVideo * @see IMAGE_CAPTURE * @see VIDEO_CAPTURE * */ -@OptIn(markerClass = [ExperimentalVideo::class]) public enum class CaptureMode(internal val value: Int) { Image(IMAGE_CAPTURE), - @RequiresApi(Build.VERSION_CODES.M) + + @RequiresApi(Build.VERSION_CODES.N) Video(VIDEO_CAPTURE), } diff --git a/camposer/src/main/java/com/ujizin/camposer/state/VideoCaptureResult.kt b/camposer/src/main/java/com/ujizin/camposer/state/VideoCaptureResult.kt index 0ce4b83..13b9182 100644 --- a/camposer/src/main/java/com/ujizin/camposer/state/VideoCaptureResult.kt +++ b/camposer/src/main/java/com/ujizin/camposer/state/VideoCaptureResult.kt @@ -15,7 +15,6 @@ public sealed interface VideoCaptureResult { @Immutable public data class Error( - val videoCaptureError: Int, val message: String, val throwable: Throwable? ) : VideoCaptureResult diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f87397c..8410320 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,11 +7,11 @@ androidx-core = "1.12.0" androidx-test-rules = "1.5.0" camerax = "1.3.1" compose-bom = "2023.10.01" -compose-compiler = "1.5.6" +compose-compiler = "1.5.3" appcompat = "1.8.1" -material = "1.11.1" +material = "1.11.0" lifecycle = "2.6.2" -navigation = "2.7.5" +navigation = "2.5.2" accompanist = "0.32.0" coil = "2.5.0" koin = "3.3.0" diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 161cad4..0fb2257 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -1,4 +1,4 @@ -import com.ujizin.camposer.Config +import ujizin.camposer.Config plugins { id("com.android.application") diff --git a/sample/src/main/java/com/ujizin/sample/feature/camera/CameraScreen.kt b/sample/src/main/java/com/ujizin/sample/feature/camera/CameraScreen.kt index 220b9bc..9ba72b0 100644 --- a/sample/src/main/java/com/ujizin/sample/feature/camera/CameraScreen.kt +++ b/sample/src/main/java/com/ujizin/sample/feature/camera/CameraScreen.kt @@ -50,6 +50,7 @@ fun CameraScreen( when (val result: CameraUiState = uiState) { is CameraUiState.Ready -> { val cameraState = rememberCameraState() + val context = LocalContext.current CameraSection( cameraState = cameraState, useFrontCamera = result.user.useCamFront, @@ -59,12 +60,11 @@ fun CameraScreen( qrCodeText = result.qrCodeText, onGalleryClick = onGalleryClick, onConfigurationClick = onConfigurationClick, - onRecording = { viewModel.toggleRecording(cameraState) }, + onRecording = { viewModel.toggleRecording(context.contentResolver, cameraState) }, onTakePicture = { viewModel.takePicture(cameraState) }, onAnalyzeImage = viewModel::analyzeImage ) - val context = LocalContext.current LaunchedEffect(result.throwable) { if (result.throwable != null) { Toast.makeText(context, result.throwable.message, Toast.LENGTH_SHORT).show() diff --git a/sample/src/main/java/com/ujizin/sample/feature/camera/CameraViewModel.kt b/sample/src/main/java/com/ujizin/sample/feature/camera/CameraViewModel.kt index a32dada..0dae8e2 100644 --- a/sample/src/main/java/com/ujizin/sample/feature/camera/CameraViewModel.kt +++ b/sample/src/main/java/com/ujizin/sample/feature/camera/CameraViewModel.kt @@ -1,11 +1,16 @@ package com.ujizin.sample.feature.camera +import android.annotation.SuppressLint +import android.content.ContentResolver import android.graphics.ImageFormat.YUV_420_888 import android.graphics.ImageFormat.YUV_422_888 import android.graphics.ImageFormat.YUV_444_888 import android.os.Build +import android.provider.MediaStore import android.util.Log import androidx.camera.core.ImageProxy +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.MediaStoreOutputOptions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.zxing.BarcodeFormat @@ -72,23 +77,28 @@ class CameraViewModel( } } - fun toggleRecording(cameraState: CameraState) = with(cameraState) { - viewModelScope.launch { - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> toggleRecording( - fileDataSource.videoContentValues, - onResult = ::onVideoResult - ) - - Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { - toggleRecording( - fileDataSource.getFile("mp4"), + @SuppressLint("MissingPermission") + fun toggleRecording(contentResolver: ContentResolver, cameraState: CameraState) = + with(cameraState) { + viewModelScope.launch { + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> toggleRecording( + MediaStoreOutputOptions.Builder( + contentResolver, + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + ).setContentValues(fileDataSource.videoContentValues).build(), onResult = ::onVideoResult ) + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> { + toggleRecording( + FileOutputOptions.Builder(fileDataSource.getFile("mp4")).build(), + onResult = ::onVideoResult + ) + } } } } - } fun analyzeImage(image: ImageProxy) { viewModelScope.launch {