diff --git a/app/src/main/java/com/example/platform/app/SampleDemo.kt b/app/src/main/java/com/example/platform/app/SampleDemo.kt index 0bee52b8..f406b1e3 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -27,7 +27,10 @@ import com.example.platform.accessibility.SpeakableText import com.example.platform.camera.imagecapture.Camera2ImageCapture import com.example.platform.camera.imagecapture.Camera2UltraHDRCapture import com.example.platform.camera.preview.Camera2Preview +import com.example.platform.camerax.video.CameraXVideo import com.example.platform.camerax.basic.CameraXBasic +import com.example.platform.camerax.extensions.CameraXExtensions +import com.example.platform.camerax.mlkit.CameraXMlKit import com.example.platform.connectivity.audio.AudioCommsSample import com.example.platform.connectivity.bluetooth.ble.BLEScanIntentSample import com.example.platform.connectivity.bluetooth.ble.ConnectGATTSample @@ -229,6 +232,33 @@ val SAMPLE_DEMOS by lazy { tags = listOf("CameraX"), content = { CameraXBasic() }, ), + ComposableSampleDemo( + id = "camerax-video-capture", + name = "CameraX • Basic Video Capture", + description = "This sample demonstrates how to capture a video using CameraX", + documentation = "https://developer.android.com/training/camerax", + apiSurface = CameraCameraXApiSurface, + tags = listOf("CameraX"), + content = { CameraXVideo() }, + ), + ComposableSampleDemo( + id = "camerax-extensions", + name = "CameraX • Extensions", + description = "This sample demonstrates how to check for and utilize CameraX Extensions", + documentation = "https://developer.android.com/training/camerax", + apiSurface = CameraCameraXApiSurface, + tags = listOf("CameraX"), + content = { CameraXExtensions() }, + ), + ComposableSampleDemo( + id = "camerax-ml-kit", + name = "CameraX • MLKit Sample", + description = "This sample demonstrates how to use MLKit with CameraX", + documentation = "https://developer.android.com/training/camerax", + apiSurface = CameraCameraXApiSurface, + tags = listOf("CameraX"), + content = { CameraXMlKit() }, + ), ComposableSampleDemo( id = "communication-audio-manager", diff --git a/samples/camera/camerax/src/main/AndroidManifest.xml b/samples/camera/camerax/src/main/AndroidManifest.xml index c9eb3922..1abd3ad4 100644 --- a/samples/camera/camerax/src/main/AndroidManifest.xml +++ b/samples/camera/camerax/src/main/AndroidManifest.xml @@ -23,6 +23,8 @@ + + diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/extensions/CameraExtensionsViewModel.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/extensions/CameraExtensionsViewModel.kt new file mode 100644 index 00000000..bd2c58c7 --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/extensions/CameraExtensionsViewModel.kt @@ -0,0 +1,447 @@ +/* + * Copyright 2023 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 + * + * https://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.example.platform.camerax.extensions + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import android.widget.Toast +import androidx.camera.core.* +import androidx.camera.extensions.ExtensionMode +import androidx.camera.extensions.ExtensionsManager +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +// Helper data class for UI state +data class CameraExtensionsState( + val cameraPermissionGranted: Boolean = false, + val isLoading: Boolean = true, + val errorMessage: String? = null, + val lensFacing: Int = CameraSelector.LENS_FACING_BACK, + val availableExtensions: Map> = emptyMap(), // Map> + val selectedExtension: Int = ExtensionMode.NONE, + val isTakingPicture: Boolean = false, + val lastCapturedUri: Uri? = null, // Optional: for showing thumbnail +) + +class CameraExtensionsViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(CameraExtensionsState()) + val uiState: StateFlow = _uiState.asStateFlow() + + internal var cameraProvider: ProcessCameraProvider? = null + internal var extensionsManager: ExtensionsManager? = null + private var imageCapture: ImageCapture? = null + private var preview: Preview? = null + private var camera: Camera? = null + + /** Executor for background camera operations */ + private lateinit var cameraExecutor: ExecutorService + + // Deferred objects to wait for async initialization + private var cameraProviderDeferred = CompletableDeferred() + private var extensionsManagerDeferred = CompletableDeferred() + + // --- Initialization and Setup --- + + fun initialize(context: Context) { + if (this::cameraExecutor.isInitialized) return // Avoid re-initialization + + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + cameraExecutor = Executors.newSingleThreadExecutor() + + viewModelScope.launch(Dispatchers.IO) { + try { + // Initialize CameraProvider and ExtensionsManager concurrently + val providerFuture = ProcessCameraProvider.getInstance(context) + providerFuture.addListener( + { + try { + cameraProvider = providerFuture.get() + cameraProviderDeferred.complete(cameraProvider!!) + + // Now initialize ExtensionsManager after getting provider + val extensionsFuture = + ExtensionsManager.getInstanceAsync(context, cameraProvider!!) + extensionsFuture.addListener( + { + try { + extensionsManager = extensionsFuture.get() + extensionsManagerDeferred.complete(extensionsManager!!) + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize ExtensionsManager", e) + extensionsManagerDeferred.completeExceptionally(e) + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to initialize Camera Extensions: ${e.localizedMessage}", + ) + } + } + }, + ContextCompat.getMainExecutor(context), + ) + + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize CameraProvider", e) + cameraProviderDeferred.completeExceptionally(e) + extensionsManagerDeferred.completeExceptionally(e) // Fail extensions too + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to initialize Camera Provider: ${e.localizedMessage}", + ) + } + } + }, + ContextCompat.getMainExecutor(context), + ) + + // Wait for both to complete + cameraProvider = cameraProviderDeferred.await() + extensionsManager = extensionsManagerDeferred.await() + + // Check available extensions after initialization + checkAvailableExtensions() + _uiState.update { it.copy(isLoading = false) } + + } catch (e: Exception) { + Log.e(TAG, "Initialization failed", e) + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Camera initialization failed: ${e.localizedMessage}", + ) + } + } + } + } + + private fun checkAvailableExtensions() { + val provider = cameraProvider ?: return + val manager = extensionsManager ?: return + val allExtensionModes = listOf( + ExtensionMode.BOKEH, + ExtensionMode.HDR, + ExtensionMode.NIGHT, + ExtensionMode.FACE_RETOUCH, + ExtensionMode.AUTO, + ) + + val available: MutableMap> = mutableMapOf() + + // Check for Back Camera + val backCameraSelector = CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() + if (provider.hasCamera(backCameraSelector)) { + val backExtensions = allExtensionModes.filter { manager.isExtensionAvailable(backCameraSelector, it) } + available[CameraSelector.LENS_FACING_BACK] = listOf(ExtensionMode.NONE) + backExtensions + } else { + available[CameraSelector.LENS_FACING_BACK] = listOf(ExtensionMode.NONE) + } + + // Check for Front Camera + val frontCameraSelector = + CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_FRONT).build() + if (provider.hasCamera(frontCameraSelector)) { + val frontExtensions = allExtensionModes + .filter { manager.isExtensionAvailable(frontCameraSelector, it) } + available[CameraSelector.LENS_FACING_FRONT] = + listOf(ExtensionMode.NONE) + frontExtensions + } else { + available[CameraSelector.LENS_FACING_FRONT] = listOf(ExtensionMode.NONE) + } + + Log.d(TAG, "Available extensions: $available") + _uiState.update { + val currentLensExtensions = available[it.lensFacing] ?: listOf(ExtensionMode.NONE) + // Reset selected extension if it's not available for the current lens + val newSelectedExtension = if (currentLensExtensions.contains(it.selectedExtension)) { + it.selectedExtension + } else { + ExtensionMode.NONE + } + it.copy(availableExtensions = available, selectedExtension = newSelectedExtension) + } + } + + // --- Camera Binding --- + + fun bindUseCases( + context: Context, + lifecycleOwner: LifecycleOwner, + surfaceProvider: Preview.SurfaceProvider, + targetRotation: Int, + ) { + val provider = cameraProvider ?: run { Log.e(TAG, "CameraProvider not ready"); return } + val manager = extensionsManager ?: run { Log.e(TAG, "ExtensionsManager not ready"); return } + val lensFacing = _uiState.value.lensFacing + val selectedExtension = _uiState.value.selectedExtension + + viewModelScope.launch(Dispatchers.Main) { // Ensure binding happens on the main thread + try { + // 1. Create CameraSelector (base or extension-enabled) + val baseCameraSelector = + CameraSelector.Builder().requireLensFacing(lensFacing).build() + + val cameraSelector = if (selectedExtension != ExtensionMode.NONE && + manager.isExtensionAvailable(baseCameraSelector, selectedExtension) + ) { + Log.d( + TAG, + "Binding with extension: ${extensionModeToString(selectedExtension)}", + ) + manager.getExtensionEnabledCameraSelector(baseCameraSelector, selectedExtension) + } else { + Log.d( + TAG, + "Binding without extension (Mode: ${extensionModeToString(selectedExtension)})", + ) + baseCameraSelector + } + + // 2. Build Use Cases (Preview and ImageCapture) + // Aspect ratio can be determined more dynamically if needed + val aspectRatio = AspectRatio.RATIO_16_9 // Or AspectRatio.RATIO_4_3 + + preview = Preview.Builder() + .setTargetRotation(targetRotation) + .setTargetAspectRatio(aspectRatio) + .build() + + imageCapture = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .setTargetRotation(targetRotation) + .setTargetAspectRatio(aspectRatio) + .build() + + // 3. Unbind existing use cases before rebinding + provider.unbindAll() + + // 4. Bind new use cases + camera = provider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageCapture, + ) + + // 5. Attach SurfaceProvider + preview?.setSurfaceProvider(surfaceProvider) + Log.d( + TAG, + "Use cases bound successfully for lens $lensFacing, extension ${ + extensionModeToString(selectedExtension) + }", + ) + + } catch (exc: Exception) { + Log.e(TAG, "Use case binding failed", exc) + _uiState.update { it.copy(errorMessage = "Failed to bind camera: ${exc.localizedMessage}") } + // Attempt to fallback to NONE mode if extension binding failed? + if (selectedExtension != ExtensionMode.NONE) { + Log.w(TAG, "Falling back to ExtensionMode.NONE") + selectExtension(ExtensionMode.NONE) + // Recursive call might be risky, maybe just signal UI to retry/reset? + } + } + } + } + + // --- User Actions --- + + fun onPermissionResult(granted: Boolean) { + _uiState.update { it.copy(cameraPermissionGranted = granted) } + if (!granted) { + _uiState.update { + it.copy( + errorMessage = "Camera permission is required.", + isLoading = false, + ) + } + } + // Initialization might depend on permission, trigger if needed, + // but `initialize` is usually called once from the Composable's LaunchedEffect. + // If permission is granted later, the Composable's effect should re-trigger binding. + } + + fun switchCamera() { + val currentLensFacing = _uiState.value.lensFacing + val newLensFacing = if (currentLensFacing == CameraSelector.LENS_FACING_BACK) { + CameraSelector.LENS_FACING_FRONT + } else { + CameraSelector.LENS_FACING_BACK + } + + // Check if the new lens facing has any available extensions (including NONE) + if (_uiState.value.availableExtensions[newLensFacing]?.isNotEmpty() == true) { + Log.d(TAG, "Switching camera to $newLensFacing") + // Reset selected extension if it's not supported by the new lens + val newLensExtensions = + _uiState.value.availableExtensions[newLensFacing] ?: listOf(ExtensionMode.NONE) + val newSelectedExtension = + if (newLensExtensions.contains(_uiState.value.selectedExtension)) { + _uiState.value.selectedExtension + } else { + ExtensionMode.NONE // Default to NONE if current extension not supported + } + _uiState.update { + it.copy( + lensFacing = newLensFacing, + selectedExtension = newSelectedExtension, + ) + } + // Rebinding will be triggered by the Composable observing these state changes + } else { + Log.w( + TAG, + "Cannot switch camera: Lens facing $newLensFacing not available or has no modes.", + ) + _uiState.update { it.copy(errorMessage = "Cannot switch to other camera.") } + } + } + + fun selectExtension(extensionMode: Int) { + val currentLens = _uiState.value.lensFacing + val availableForLens = _uiState.value.availableExtensions[currentLens] ?: listOf() + + if (availableForLens.contains(extensionMode)) { + if (_uiState.value.selectedExtension != extensionMode) { + Log.d(TAG, "Selecting extension: ${extensionModeToString(extensionMode)}") + _uiState.update { it.copy(selectedExtension = extensionMode) } + // Rebinding will be triggered by the Composable observing this state change + } + } else { + Log.w( + TAG, + "Extension ${extensionModeToString(extensionMode)} not available for lens $currentLens", + ) + } + } + + fun takePicture(context: Context) { + val imageCapture = this.imageCapture ?: run { + _uiState.update { it.copy(errorMessage = "Camera not ready for capture.") } + return + } + if (_uiState.value.isTakingPicture) return // Prevent multiple captures + + _uiState.update { it.copy(isTakingPicture = true, errorMessage = null) } + + val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US).format(System.currentTimeMillis()) + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, PHOTO_TYPE) + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + val appName = "CameraXExtensions" // Ensure this string exists + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/$appName") + } + } + + val outputOptions = ImageCapture.OutputFileOptions.Builder( + context.contentResolver, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues, + ).build() + + imageCapture.takePicture( + outputOptions, + ContextCompat.getMainExecutor(context), // Callback on main thread + object : ImageCapture.OnImageSavedCallback { + override fun onError(exc: ImageCaptureException) { + Log.e(TAG, "Photo capture failed: ${exc.message}", exc) + _uiState.update { + it.copy( + isTakingPicture = false, + errorMessage = "Capture failed: ${exc.message} (Code: ${exc.imageCaptureError})", + ) + } + } + + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + val savedUri = output.savedUri + Log.d(TAG, "Photo capture succeeded: $savedUri") + _uiState.update { it.copy(isTakingPicture = false, lastCapturedUri = savedUri) } + + // Show a Toast with the saved image location + Toast.makeText( + context, + "Photo saved to: $savedUri", + Toast.LENGTH_SHORT + ).show() + + // Optionally trigger flash animation or sound here via state update + } + }, + ) + } + + fun updateTargetRotation(rotation: Int) { + imageCapture?.targetRotation = rotation + preview?.targetRotation = rotation + } + + fun clearErrorMessage() { + _uiState.update { it.copy(errorMessage = null) } + } + + // --- Cleanup --- + + override fun onCleared() { + super.onCleared() + try { + cameraProvider?.unbindAll() + } catch (e: Exception) { + Log.e(TAG, "Error unbinding camera provider on clear", e) + } + if (this::cameraExecutor.isInitialized) { + cameraExecutor.shutdown() + } + Log.d(TAG, "ViewModel cleared and resources released.") + } + + companion object { + private const val TAG = "CameraExtViewModel" + private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" + private const val PHOTO_TYPE = "image/jpeg" + + // Helper to convert ExtensionMode Int to String for logging/display + fun extensionModeToString(mode: Int): String { + return when (mode) { + ExtensionMode.NONE -> "NONE" + ExtensionMode.BOKEH -> "BOKEH" + ExtensionMode.HDR -> "HDR" + ExtensionMode.NIGHT -> "NIGHT" + ExtensionMode.FACE_RETOUCH -> "FACE_RETOUCH" + ExtensionMode.AUTO -> "AUTO" + else -> "UNKNOWN ($mode)" + } + } + } +} \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/extensions/CameraXExtensions.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/extensions/CameraXExtensions.kt new file mode 100644 index 00000000..32629edb --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/extensions/CameraXExtensions.kt @@ -0,0 +1,442 @@ +/* + * Copyright 2023 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 + * + * https://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.example.platform.camerax.extensions + +import android.Manifest +import android.content.Context +import android.hardware.display.DisplayManager +import android.os.Build +import android.util.Log +import android.view.ViewGroup +import android.view.WindowManager +import androidx.camera.core.CameraSelector +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.platform.camerax.extensions.CameraExtensionsViewModel.Companion.extensionModeToString +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraXExtensions( + viewModel: CameraExtensionsViewModel = viewModel(), +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // Request camera permission using Accompanist + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + + // --- Initialization --- + // Initialize the ViewModel when permission is granted + LaunchedEffect(cameraPermissionState.status) { + if (cameraPermissionState.status == PermissionStatus.Granted) { + viewModel.initialize(context) + } + } + + // --- Display Rotation Listener --- + // Use WindowManager for API levels below 30 to get the display rotation + val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + var currentRotation by remember { mutableIntStateOf(windowManager.defaultDisplay.rotation) } + DisposableEffect(context) { + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = Unit + override fun onDisplayRemoved(displayId: Int) = Unit + override fun onDisplayChanged(displayId: Int) { + // Use WindowManager for API levels below 30 + @Suppress("DEPRECATION") + val newRotation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.display.rotation + } else { + windowManager.defaultDisplay.rotation + } + + if (newRotation != currentRotation) { + Log.d("CameraExtScreen", "Rotation changed: $newRotation") + currentRotation = newRotation + viewModel.updateTargetRotation(newRotation) // Inform ViewModel + } + } + } + displayManager.registerDisplayListener(displayListener, null) + onDispose { displayManager.unregisterDisplayListener(displayListener) } + } + + // --- UI Structure --- + Box(modifier = Modifier.fillMaxSize()) { + when (cameraPermissionState.status) { + PermissionStatus.Granted -> { + // Permission is granted, show the camera view + CameraView( + viewModel = viewModel, + uiState = uiState, + lifecycleOwner = lifecycleOwner, + targetRotation = currentRotation, + ) + } + + is PermissionStatus.Denied -> { + // Permission is denied, show a message and a button to request permission + PermissionRequestScreen( + status = cameraPermissionState.status, + onRequestPermission = { cameraPermissionState.launchPermissionRequest() }, + ) + } + } + } +} + + +// --- UI Components --- + +@Composable +private fun CameraView( + viewModel: CameraExtensionsViewModel, + uiState: CameraExtensionsState, + lifecycleOwner: LifecycleOwner, + targetRotation: Int, +) { + val context = LocalContext.current + val previewView = remember { PreviewView(context) } + + // Effect to bind use cases when permission, lens, extension, or rotation changes + LaunchedEffect( + viewModel.cameraProvider, // Add dependency on cameraProvider + viewModel.extensionsManager, // Add dependency on extensionsManager + uiState.lensFacing, + uiState.selectedExtension, + targetRotation, // Rebind if rotation changes significantly for targetRotation setting + ) { + // Trigger binding once cameraProvider and extensionsManager are ready + if (viewModel.cameraProvider != null && viewModel.extensionsManager != null && uiState.errorMessage == null) { + Log.d( + "CameraExtScreen", + "Triggering bindUseCases: Lens=${uiState.lensFacing}, Ext=${ + extensionModeToString(uiState.selectedExtension) + }, Rot=$targetRotation", + ) + viewModel.bindUseCases( + context = context, + lifecycleOwner = lifecycleOwner, + surfaceProvider = previewView.surfaceProvider, + targetRotation = targetRotation, + ) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + // Camera Preview + AndroidView( + factory = { + previewView.apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + scaleType = PreviewView.ScaleType.FILL_CENTER // Adjust as needed + implementationMode = PreviewView.ImplementationMode.PERFORMANCE + } + }, + modifier = Modifier.fillMaxSize(), + // No update block needed here as LaunchedEffect handles rebinding + ) + + // Controls Overlay + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // Top Row: Extension Selection + ExtensionSelector( + availableExtensions = uiState.availableExtensions[uiState.lensFacing] ?: listOf(), + selectedExtension = uiState.selectedExtension, + onExtensionSelected = { viewModel.selectExtension(it) }, + modifier = Modifier.fillMaxWidth(), + ) + + // Bottom Row: Capture and Switch Camera + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() // Add padding for navigation bar + .padding(bottom = 20.dp), // Extra padding from bottom + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround, // Space out buttons + ) { + // Placeholder for gallery button if needed + Spacer(modifier = Modifier.size(60.dp)) + + // Capture Button + CaptureButton( + isTakingPicture = uiState.isTakingPicture, + onClick = { viewModel.takePicture(context) }, + ) + + // Switch Camera Button + SwitchCameraButton( + availableExtensions = uiState.availableExtensions, + currentLensFacing = uiState.lensFacing, + onClick = { viewModel.switchCamera() }, + ) + } + } + } + + // Handle Loading and Error states within the CameraView if permission is granted + when { + uiState.isLoading -> LoadingScreen() + uiState.errorMessage != null -> ErrorScreen(message = uiState.errorMessage) + } +} + +@Composable +fun ExtensionSelector( + availableExtensions: List, + selectedExtension: Int, + onExtensionSelected: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + if (availableExtensions.size <= 1) { // Only show if there's more than NONE + return + } + + Row( + modifier = modifier + .statusBarsPadding() // Add padding for status bar + .horizontalScroll(rememberScrollState()) + .background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + availableExtensions.forEach { mode -> + val isSelected = mode == selectedExtension + Text( + text = extensionModeToString(mode), + color = if (isSelected) MaterialTheme.colorScheme.primary else Color.White, + fontSize = 12.sp, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { onExtensionSelected(mode) } + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f) else Color.Transparent, + RoundedCornerShape(12.dp), + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } +} + + +@Composable +fun CaptureButton(isTakingPicture: Boolean, onClick: () -> Unit) { + IconButton( + onClick = onClick, + enabled = !isTakingPicture, + modifier = Modifier + .size(72.dp) + .border(4.dp, Color.White, CircleShape) + .padding(4.dp) // Padding inside the border + .background(Color.White.copy(alpha = 0.3f), CircleShape), + + ) { + if (isTakingPicture) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp), // Slightly smaller than button + color = MaterialTheme.colorScheme.primary, + strokeWidth = 4.dp, + ) + } + } +} + +@Composable +fun SwitchCameraButton( + availableExtensions: Map>, + currentLensFacing: Int, + onClick: () -> Unit, +) { + val otherLens = if (currentLensFacing == CameraSelector.LENS_FACING_BACK) { + CameraSelector.LENS_FACING_FRONT + } else { + CameraSelector.LENS_FACING_BACK + } + // Enable switch if the other lens exists in the available extensions map + val isEnabled = availableExtensions.containsKey(otherLens) + + IconButton( + onClick = onClick, + enabled = isEnabled, + modifier = Modifier.size(60.dp), + ) { + Icon( + Icons.Filled.Refresh, + contentDescription = "Switch camera", + tint = if (isEnabled) Color.White else Color.Gray, + modifier = Modifier.size(36.dp), + ) + } +} + + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun PermissionRequestScreen( + status: PermissionStatus, + onRequestPermission: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Clear, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(64.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + + val textToShow = if (status.shouldShowRationale) { + "The camera is important for this feature. Please grant the permission." + } else { + "Camera permission is required for this feature to be available. " + + "Please grant the permission" + } + + Text( + textToShow, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Please grant the permission to continue.", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onRequestPermission) { + Text("Grant Permission") + } + } +} + +@Composable +fun LoadingScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = Color.White) + Spacer(modifier = Modifier.height(16.dp)) + Text("Initializing Camera...", color = Color.White) + } + } +} + +@Composable +fun ErrorScreen(message: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Clear, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(64.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Camera Error", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + message, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } +} \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/CameraXMlKitSample.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/CameraXMlKitSample.kt new file mode 100644 index 00000000..17c3983a --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/CameraXMlKitSample.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2023 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 + * + * https://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.example.platform.camerax.mlkit + +import android.Manifest +import android.util.Log +import android.util.Size +import androidx.camera.core.CameraSelector +import androidx.camera.core.CameraSelector.LENS_FACING_BACK +import androidx.camera.core.ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.mlkit.vision.MlKitAnalyzer +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraXMlKit() { + var qrCodeDetected by remember { mutableStateOf(false) } + var qrCodeContent by remember { mutableStateOf("") } + + // Request camera permission + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + var barcodeScanner = remember { + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build(), + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + when (cameraPermissionState.status) { + PermissionStatus.Granted -> { + // Permission is granted, show the camera preview + CameraPreview( + barcodeScanner, + { detected -> qrCodeDetected = detected }, + { content -> qrCodeContent = content }, + ) + } + + is PermissionStatus.Denied -> { + // Permission is denied, show a message and a button to request permission + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val textToShow = + if ((cameraPermissionState.status as PermissionStatus.Denied).shouldShowRationale) { + "The camera is important for this feature. Please grant the permission." + } else { + "Camera permission is required for this feature to be available. " + + "Please grant the permission" + } + Text( + textToShow, + modifier = Modifier.align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { + Text("Request permission") + } + } + } + } + + QrCodeText(qrCodeDetected, qrCodeContent) + } +} + +@Composable +fun QrCodeText(qrCodeDetected: Boolean, qrCodeContent: String) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + Text( + text = if (qrCodeDetected) "QR Code Detected: $qrCodeContent" else "No QR Code Detected", + modifier = Modifier.padding(16.dp), + ) + } +} + +@Composable +fun CameraPreview( + barcodeScanner: BarcodeScanner, + setQrCodeDetected: (Boolean) -> Unit, + setQrCodeContent: (String) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + var cameraError by remember { mutableStateOf(false) } + val cameraController = remember { LifecycleCameraController(context) } + val previewView = remember { PreviewView(context) } + cameraController.cameraSelector = + CameraSelector.Builder().requireLensFacing(LENS_FACING_BACK).build() + + //Throttle the analysis to avoid constant checks. + val resolutionStrategy = ResolutionStrategy( + Size(500, 500), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER, + ) + val resolutionSelector = + ResolutionSelector.Builder().setResolutionStrategy(resolutionStrategy).build() + cameraController.setImageAnalysisResolutionSelector(resolutionSelector) + + cameraController.setImageAnalysisAnalyzer( + ContextCompat.getMainExecutor(context), + MlKitAnalyzer( + listOf(barcodeScanner), + COORDINATE_SYSTEM_VIEW_REFERENCED, + ContextCompat.getMainExecutor(context), + ) { result: MlKitAnalyzer.Result? -> + val barcodeResults = result?.getValue(barcodeScanner) + if ((barcodeResults == null) || + (barcodeResults.isEmpty()) || + (barcodeResults.first() == null) + ) { + setQrCodeDetected(false) + setQrCodeContent("") // Clear the text. + previewView.overlay.clear() + previewView.setOnTouchListener { _, _ -> false } + return@MlKitAnalyzer + } + val qrCode = barcodeResults[0] + val qrCodeViewModel = QrCodeViewModel(qrCode) + val qrCodeDrawable = QrCodeDrawable(qrCodeViewModel) + setQrCodeContent(qrCode.rawValue ?: "") // Display the content. + setQrCodeDetected(true) + previewView.setOnTouchListener(qrCodeViewModel.qrCodeTouchCallback) + previewView.overlay.clear() + previewView.overlay.add(qrCodeDrawable) + + }, + ) + + cameraController.bindToLifecycle(lifecycleOwner).also { + //Check if the camera was able to start or if there is a problem. + try { + cameraController.cameraInfo + } catch (e: Exception) { + Log.e("Test", "Camera error: $e") + cameraError = true + } + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (cameraError) { + Text( + text = "Error: could not initialize camera", + modifier = Modifier + .padding(16.dp), + ) + } else { + AndroidView( + factory = { + previewView.apply { + this.controller = cameraController + scaleType = PreviewView.ScaleType.FILL_CENTER + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + } +} \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeDrawable.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeDrawable.kt new file mode 100644 index 00000000..352c134c --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeDrawable.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2025 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 + * + * https://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.example.platform.camerax.mlkit + +import android.graphics.* +import android.graphics.drawable.Drawable + +/** + * A Drawable that handles displaying a QR Code's data and a bounding box around the QR code. + */ +class QrCodeDrawable(private val qrCodeViewModel: QrCodeViewModel) : Drawable() { + private val boundingRectPaint = Paint().apply { + style = Paint.Style.STROKE + color = Color.YELLOW + strokeWidth = 5F + alpha = 200 + } + + private val contentRectPaint = Paint().apply { + style = Paint.Style.FILL + color = Color.YELLOW + alpha = 255 + } + + private val contentTextPaint = Paint().apply { + color = Color.DKGRAY + alpha = 255 + textSize = 36F + } + + private val contentPadding = 25 + private var textWidth = contentTextPaint.measureText(qrCodeViewModel.qrContent).toInt() + + override fun draw(canvas: Canvas) { + canvas.drawRect(qrCodeViewModel.boundingRect, boundingRectPaint) + canvas.drawRect( + Rect( + qrCodeViewModel.boundingRect.left, + qrCodeViewModel.boundingRect.bottom + contentPadding/2, + qrCodeViewModel.boundingRect.left + textWidth + contentPadding*2, + qrCodeViewModel.boundingRect.bottom + contentTextPaint.textSize.toInt() + contentPadding), + contentRectPaint + ) + canvas.drawText( + qrCodeViewModel.qrContent, + (qrCodeViewModel.boundingRect.left + contentPadding).toFloat(), + (qrCodeViewModel.boundingRect.bottom + contentPadding*2).toFloat(), + contentTextPaint + ) + } + + override fun setAlpha(alpha: Int) { + boundingRectPaint.alpha = alpha + contentRectPaint.alpha = alpha + contentTextPaint.alpha = alpha + } + + override fun setColorFilter(colorFiter: ColorFilter?) { + boundingRectPaint.colorFilter = colorFilter + contentRectPaint.colorFilter = colorFilter + contentTextPaint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT +} \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeViewModel.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeViewModel.kt new file mode 100644 index 00000000..fa59f63e --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeViewModel.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2025 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 + * + * https://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.example.platform.camerax.mlkit + +import android.content.Intent +import android.graphics.Rect +import android.view.MotionEvent +import android.view.View +import androidx.core.net.toUri +import com.google.mlkit.vision.barcode.common.Barcode + +/** + * A ViewModel for encapsulating the data for a QR Code, including the encoded data, the bounding + * box, and the touch behavior on the QR Code. + * + * As is, this class only handles displaying the QR Code data if it's a URL. Other data types + * can be handled by adding more cases of Barcode.TYPE_URL in the init block. + */ +class QrCodeViewModel(barcode: Barcode) { + var boundingRect: Rect = barcode.boundingBox!! + var qrContent: String = "" + var qrCodeTouchCallback = { v: View, e: MotionEvent -> false } //no-op + + init { + when (barcode.valueType) { + Barcode.TYPE_URL -> { + qrContent = barcode.url!!.url!! + qrCodeTouchCallback = { v: View, e: MotionEvent -> + if (e.action == MotionEvent.ACTION_DOWN && boundingRect.contains( + e.x.toInt(), e.y.toInt(), + ) + ) { + val openBrowserIntent = Intent(Intent.ACTION_VIEW) + openBrowserIntent.data = qrContent.toUri() + v.context.startActivity(openBrowserIntent) + } + true // return true from the callback to signify the event was handled + } + } + // Add other QR Code types here to handle other types of data, + // like Wifi credentials. + else -> { + qrContent = "Unsupported data type: ${barcode.rawValue.toString()}" + } + } + } +} \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/video/CameraXVideo.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/video/CameraXVideo.kt new file mode 100644 index 00000000..657c9323 --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/video/CameraXVideo.kt @@ -0,0 +1,505 @@ +package com.example.platform.camerax.video + +import android.Manifest +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import android.widget.MediaController +import android.widget.Toast +import android.widget.VideoView +import androidx.camera.core.CameraSelector +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.* +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack // Corrected import +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +private enum class CameraScreenMode { + RECORDING, + PLAYBACK +} + +/** + * The main screen composable for the camera functionality. + * Manages its own state including permissions (using Accompanist), + * camera executor, preview, recording controls, and screen navigation. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraXVideo() { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // --- State Management --- + var recordingState by remember { mutableStateOf(RecordingState.Idle) } + var videoCapture by remember { mutableStateOf?>(null) } + var recording by remember { mutableStateOf(null) } + + // --- Screen navigation and video URI state --- + var currentScreen by remember { mutableStateOf(CameraScreenMode.RECORDING) } + var lastRecordedVideoUri by remember { mutableStateOf(null) } + + + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + + DisposableEffect(Unit) { + onDispose { + Log.d(TAG, "Shutting down camera executor.") + cameraExecutor.shutdown() + } + } + + val permissionsState = rememberMultiplePermissionsState( + permissions = REQUIRED_PERMISSIONS.toList(), + ) + + Box(modifier = Modifier.fillMaxSize()) { + when (currentScreen) { + CameraScreenMode.RECORDING -> { + if (permissionsState.allPermissionsGranted) { + Log.d(TAG, "All permissions granted, showing camera preview.") + CameraContent( + lifecycleOwner = lifecycleOwner, + // cameraExecutor removed as it's not directly used by CameraContent + recordingState = recordingState, + onVideoCaptureCreated = { newVideoCapture -> + Log.d(TAG, "VideoCapture instance created.") + videoCapture = newVideoCapture + }, + onRecordClick = { + val currentVideoCapture = videoCapture + if (currentVideoCapture != null) { + if (recordingState == RecordingState.Idle) { + Log.d(TAG, "Start Recording button clicked.") + startRecording( + context = context, + videoCapture = currentVideoCapture, + executor = cameraExecutor, // cameraExecutor passed here + onRecordingStarted = { activeRec -> + recording = activeRec + recordingState = RecordingState.Recording + }, + onRecordingError = { errorEvent -> + Log.e(TAG, "VideoCapture Error: ${errorEvent.cause}") + recording = null + recordingState = RecordingState.Idle + }, + onRecordingComplete = { uri -> + Log.d(TAG, "Recording complete. URI: $uri") + lastRecordedVideoUri = uri + currentScreen = + CameraScreenMode.PLAYBACK + recording = null + recordingState = RecordingState.Idle + }, + ) + } else { + Log.d(TAG, "Stop Recording button clicked.") + recording?.stop() + } + } else { + Log.e(TAG, "Record button clicked but VideoCapture is null.") + Toast.makeText(context, "Camera not ready.", Toast.LENGTH_SHORT) + .show() + } + }, + ) + } else { + PermissionRationale(permissionsState) + } + } + + CameraScreenMode.PLAYBACK -> { + lastRecordedVideoUri?.let { uri -> + VideoPlaybackScreen( + // Loop enabled here + videoUri = uri, + onBackToRecord = { + currentScreen = CameraScreenMode.RECORDING + lastRecordedVideoUri = null + }, + ) + } ?: run { + Log.e(TAG, "VideoPlaybackScreen requested but videoUri is null.") + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Error: Video URI not available. Please record again.") + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + currentScreen = CameraScreenMode.RECORDING + }, + ) { + Text("Go Back to Record") + } + } + } + } + } + } +} + +@Composable +private fun CameraContent( + lifecycleOwner: LifecycleOwner, + // cameraExecutor: ExecutorService, // Not needed here if startRecording is called from parent + recordingState: RecordingState, + onVideoCaptureCreated: (VideoCapture) -> Unit, + onRecordClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + CameraPreview( + // cameraExecutor is needed by CameraPreview for binding + lifecycleOwner = lifecycleOwner, + // cameraExecutor passed here if CameraPreview handles binding independently + // If binding is managed by CameraXVideo, this might not be needed directly + onVideoCaptureCreated = onVideoCaptureCreated, + ) + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RecordButton( + recordingState = recordingState, + onRecordClick = onRecordClick, + ) + } + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun PermissionRationale( + permissionsState: MultiplePermissionsState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val textToShow = if (permissionsState.shouldShowRationale) { + "Camera and Audio access are important for this app. Please grant the permissions." + } else { + "Camera and Audio permissions required for this feature to be available. " + + "Please grant the permissions." + } + Text( + textToShow, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 8.dp), + ) + Button( + onClick = { + Log.d(TAG, "Request Permissions button clicked.") + permissionsState.launchMultiplePermissionRequest() + }, + ) { + Text("Request permissions") + } + } +} + + +@Composable +fun CameraPreview( + modifier: Modifier = Modifier, + lifecycleOwner: LifecycleOwner, + onVideoCaptureCreated: (VideoCapture) -> Unit, +) { + val context = LocalContext.current + val previewView = remember { PreviewView(context) } + + // If CameraX binding needs a specific executor, it should be sourced or passed here. + // Using ContextCompat.getMainExecutor(context) is common for listeners. + // The original 'cameraExecutor' from CameraXVideo could be passed if needed for binding. + val localCameraExecutor = + remember { Executors.newSingleThreadExecutor() } // Or pass from parent + + Log.d(TAG, "CameraPreview Composable recomposing/launching.") + + LaunchedEffect(lifecycleOwner, context) { + Log.d(TAG, "LaunchedEffect for camera binding starting.") + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener( + { + try { + val cameraProvider = cameraProviderFuture.get() + Log.d(TAG, "CameraProvider obtained.") + + val preview = Preview.Builder() + .build() + .also { + it.setSurfaceProvider(previewView.surfaceProvider) + Log.d(TAG, "Preview surface provider set.") + } + + val recorder = Recorder.Builder() + .setQualitySelector( + QualitySelector.from( + Quality.HIGHEST, + FallbackStrategy.higherQualityOrLowerThan(Quality.SD), + ), + ) + .build() + val videoCapture: VideoCapture = VideoCapture.withOutput(recorder) + Log.d(TAG, "VideoCapture created, invoking callback.") + onVideoCaptureCreated(videoCapture) + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + Log.d(TAG, "Using default back camera.") + + Log.d(TAG, "Unbinding all previous use cases.") + cameraProvider.unbindAll() + + Log.d(TAG, "Binding Preview and VideoCapture use cases.") + cameraProvider.bindToLifecycle( + lifecycleOwner, cameraSelector, preview, videoCapture, + ) + Log.d(TAG, "CameraX Use cases bound successfully.") + + } catch (exc: Exception) { + Log.e(TAG, "Use case binding failed", exc) + Toast.makeText( + context, + "Failed to initialize camera: ${exc.message}", + Toast.LENGTH_LONG, + ).show() + } + + }, + ContextCompat.getMainExecutor(context), + ) + Log.d(TAG, "CameraProvider listener added.") + } + + DisposableEffect(Unit) { + onDispose { + localCameraExecutor.isShutdown.not() + } + } + + + AndroidView( + { + Log.d(TAG, "AndroidView factory executing.") + previewView + }, + modifier = modifier.fillMaxSize(), + ) +} + +@Composable +fun RecordButton( + recordingState: RecordingState, + onRecordClick: () -> Unit, +) { + Button( + onClick = onRecordClick, + colors = ButtonDefaults.buttonColors( + containerColor = if (recordingState == RecordingState.Recording) + MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ), + ) { + Text(if (recordingState == RecordingState.Recording) "Stop Recording" else "Start Recording") + } +} + +@Composable +fun VideoPlaybackScreen( + videoUri: Uri, + onBackToRecord: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val videoView = remember { VideoView(context) } + + DisposableEffect(videoUri) { + val mediaController = MediaController(context) + mediaController.setAnchorView(videoView) + + videoView.setVideoURI(videoUri) + videoView.setMediaController(mediaController) + videoView.requestFocus() + + videoView.setOnPreparedListener { mp -> + mp.isLooping = true // Enable looping + Log.d(TAG, "VideoView prepared, looping enabled.") + } + videoView.start() + Log.d(TAG, "VideoView playback started with URI: $videoUri") + onDispose { + Log.d(TAG, "Disposing VideoView, stopping playback.") + videoView.stopPlayback() + videoView.setOnCompletionListener(null) // Clean up listener + videoView.setOnPreparedListener(null) // Clean up listener + } + } + + Box(modifier = modifier.fillMaxSize()) { + AndroidView( + factory = { videoView }, + modifier = Modifier.fillMaxSize(), + ) + IconButton( + onClick = onBackToRecord, + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, // Updated icon + contentDescription = "Record New Video", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + + +enum class RecordingState { + Idle, Recording +} + +private fun startRecording( + context: Context, + videoCapture: VideoCapture, + executor: ExecutorService, + onRecordingStarted: (Recording) -> Unit, + onRecordingError: (VideoRecordEvent.Finalize) -> Unit, + onRecordingComplete: (Uri) -> Unit, +) { + val mediaStoreOutputOptions = createMediaStoreOutputOptions(context) + Log.d( + TAG, + "Preparing recording to: ${mediaStoreOutputOptions.contentValues.getAsString(MediaStore.MediaColumns.DISPLAY_NAME)}", + ) + + val pendingRecording = videoCapture.output + .prepareRecording(context, mediaStoreOutputOptions) + .apply { + if (PermissionChecker.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PermissionChecker.PERMISSION_GRANTED + ) { + Log.d(TAG, "Audio permission granted, enabling audio.") + withAudioEnabled() + } else { + Log.d(TAG, "Audio permission denied, recording without audio.") + } + } + + val activeRecording = + pendingRecording.start(executor) { recordEvent -> + when (recordEvent) { + is VideoRecordEvent.Start -> { + Log.d(TAG, "Recording started successfully.") + } + + is VideoRecordEvent.Finalize -> { + if (!recordEvent.hasError()) { + val outputUri = recordEvent.outputResults.outputUri + val msg = "Video capture succeeded: $outputUri" + Log.d(TAG, msg) + ContextCompat.getMainExecutor(context).execute { + Toast.makeText(context.applicationContext, msg, Toast.LENGTH_SHORT) + .show() + } + onRecordingComplete(outputUri) + } else { + val errorCause = recordEvent.cause ?: "Unknown error" + val errorCode = recordEvent.error + Log.e( + TAG, + "Video capture error ($errorCode): $errorCause", + recordEvent.cause, + ) + ContextCompat.getMainExecutor(context).execute { + Toast.makeText( + context.applicationContext, + "Recording failed: $errorCause", + Toast.LENGTH_LONG, + ).show() + } + onRecordingError(recordEvent) + } + } + + is VideoRecordEvent.Status -> { + Log.v(TAG, "Status: ${recordEvent.recordingStats}") + } + + is VideoRecordEvent.Pause -> Log.d(TAG, "Recording paused") + is VideoRecordEvent.Resume -> Log.d(TAG, "Recording resumed") + } + } + onRecordingStarted(activeRecording) + Log.d(TAG, "Recording initiated.") +} + +private fun createMediaStoreOutputOptions(context: Context): MediaStoreOutputOptions { + val name = "CameraX-recording-" + + SimpleDateFormat(FILENAME_FORMAT, Locale.US) + .format(System.currentTimeMillis()) + ".mp4" + + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4") + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/CameraX-Video") + } + } + Log.d(TAG, "Creating MediaStoreOutputOptions with name: $name") + + return MediaStoreOutputOptions + .Builder(context.contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI) + .setContentValues(contentValues) + .build() +} + +private const val TAG = "CameraXComposeExtended" +private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" + +val REQUIRED_PERMISSIONS = + mutableListOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + ).apply { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + Log.d(TAG, "Adding WRITE_EXTERNAL_STORAGE permission for API <= P.") + } + }.toTypedArray() \ No newline at end of file