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