From 0a9c17c893a2da7245165fcc5a6eff4e8fa3961f Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 12 May 2025 15:09:10 -0400 Subject: [PATCH 01/12] Migrate CameraX MLKit Sample to Platform Samples --- app/build.gradle.kts | 1 + .../com/example/platform/app/ApiSurface.kt | 7 + .../com/example/platform/app/SampleDemo.kt | 51 ++-- gradle/libs.versions.toml | 14 ++ samples/camera/camerax/README.md | 1 + samples/camera/camerax/build.gradle.kts | 60 +++++ .../camerax/src/main/AndroidManifest.xml | 39 +++ .../platform/camerax/CameraXMlKitSample.kt | 223 ++++++++++++++++++ .../platform/camerax/mlkit/QrCodeDrawable.kt | 80 +++++++ .../platform/camerax/mlkit/QrCodeViewModel.kt | 61 +++++ settings.gradle.kts | 1 + 11 files changed, 519 insertions(+), 19 deletions(-) create mode 100644 samples/camera/camerax/README.md create mode 100644 samples/camera/camerax/build.gradle.kts create mode 100644 samples/camera/camerax/src/main/AndroidManifest.xml create mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt create mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeDrawable.kt create mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d750217..aaf59211 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { implementation(project(":shared")) implementation(project(":samples:accessibility")) implementation(project(":samples:camera:camera2")) + implementation(project(":samples:camera:camerax")) implementation(project(":samples:connectivity:audio")) implementation(project(":samples:connectivity:bluetooth:ble")) implementation(project(":samples:connectivity:bluetooth:companion")) diff --git a/app/src/main/java/com/example/platform/app/ApiSurface.kt b/app/src/main/java/com/example/platform/app/ApiSurface.kt index ea995c2d..9ca31ca0 100644 --- a/app/src/main/java/com/example/platform/app/ApiSurface.kt +++ b/app/src/main/java/com/example/platform/app/ApiSurface.kt @@ -33,6 +33,12 @@ val CameraCamera2ApiSurface = ApiSurface( null, ) +val CameraCameraXApiSurface = ApiSurface( + "camera-camerax", + "CameraX", + null, +) + val ConnectivityAudioApiSurface = ApiSurface( "connectivity-audio", "Connectivity Audio", @@ -192,6 +198,7 @@ val UserInterfaceWindowManagerApiSurface = ApiSurface( val API_SURFACES = listOf( AccessiblityApiSurface, CameraCamera2ApiSurface, + CameraCameraXApiSurface, ConnectivityAudioApiSurface, ConnectivityBluetoothBleApiSurface, ConnectivityBluetoothCompanionApiSurface, 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 4b27c359..38d1ca79 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -27,6 +27,7 @@ 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.CameraXMlKitScreen import com.example.platform.connectivity.audio.AudioCommsSample import com.example.platform.connectivity.bluetooth.ble.BLEScanIntentSample import com.example.platform.connectivity.bluetooth.ble.ConnectGATTSample @@ -217,6 +218,18 @@ val SAMPLE_DEMOS by lazy { tags = listOf("Camera2"), content = { AndroidFragment() }, ), + + // CameraX Samples + ComposableSampleDemo( + id = "camerax-mlkit", + name = "CameraX • MLKit Sample", + description = "TBD", + documentation = "https://developer.android.com/training/camerax", + apiSurface = CameraCameraXApiSurface, + tags = listOf("CameraX"), + content = { CameraXMlKitScreen() }, + ), + ComposableSampleDemo( id = "communication-audio-manager", name = "Communication Audio Manager", @@ -956,7 +969,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://source.android.com/docs/core/interaction/haptics", apiSurface = UserInterfaceHapticsApiSurface, tags = listOf("Haptics"), - content = { HapticsBasic() } + content = { HapticsBasic() }, ), ComposableSampleDemo( id = "haptics-2-resist", @@ -965,7 +978,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://source.android.com/docs/core/interaction/haptics", apiSurface = UserInterfaceHapticsApiSurface, tags = listOf("Haptics"), - content = { Resist() } + content = { Resist() }, ), ComposableSampleDemo( id = "haptics-3-expand", @@ -974,7 +987,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://source.android.com/docs/core/interaction/haptics", apiSurface = UserInterfaceHapticsApiSurface, tags = listOf("Haptics"), - content = { Expand() } + content = { Expand() }, ), ComposableSampleDemo( id = "haptics-4-bounce", @@ -983,7 +996,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://source.android.com/docs/core/interaction/haptics", apiSurface = UserInterfaceHapticsApiSurface, tags = listOf("Haptics"), - content = { Bounce() } + content = { Bounce() }, ), ComposableSampleDemo( id = "haptics-5-wobble", @@ -992,7 +1005,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://source.android.com/docs/core/interaction/haptics", apiSurface = UserInterfaceHapticsApiSurface, tags = listOf("Haptics"), - content = { Wobble() } + content = { Wobble() }, ), ComposableSampleDemo( id = "live-updates", @@ -1014,7 +1027,7 @@ val SAMPLE_DEMOS by lazy { description = "Basic usage of Picture-in-Picture mode showcasing video playback", documentation = "https://developer.android.com/develop/ui/views/picture-in-picture", apiSurface = UserInterfacePictureInPictureApiSurface, - content = PiPMovieActivity::class.java + content = PiPMovieActivity::class.java, ), ActivitySampleDemo( id = "picture-in-picture-stopwatch", @@ -1022,7 +1035,7 @@ val SAMPLE_DEMOS by lazy { description = "Basic usage of Picture-in-Picture mode showcasing a stopwatch", documentation = "https://developer.android.com/develop/ui/views/picture-in-picture", apiSurface = UserInterfacePictureInPictureApiSurface, - content = PiPSampleActivity::class.java + content = PiPSampleActivity::class.java, ), ActivitySampleDemo( id = "predictive-back", @@ -1030,7 +1043,7 @@ val SAMPLE_DEMOS by lazy { description = "Shows Predictive Back animations.", documentation = "https://developer.android.com/about/versions/14/features/predictive-back", apiSurface = UserInterfacePredictiveBackApiSurface, - content = PBHostingActivity::class.java + content = PBHostingActivity::class.java, ), ComposableSampleDemo( id = "quick-settings", @@ -1051,7 +1064,7 @@ val SAMPLE_DEMOS by lazy { description = "Receive texts and images from other apps.", documentation = null, apiSurface = UserInterfaceShareApiSurface, - content = ShareReceiverActivity::class.java + content = ShareReceiverActivity::class.java, ), ComposableSampleDemo( id = "send-data-with-sharesheet", @@ -1059,7 +1072,7 @@ val SAMPLE_DEMOS by lazy { description = "Send texts and images to other apps using the Android Sharesheet.", documentation = null, apiSurface = UserInterfaceShareApiSurface, - content = { ShareSender() } + content = { ShareSender() }, ), ComposableSampleDemo( id = "conversion-suggestions", @@ -1068,7 +1081,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://developer.android.com/about/versions/13/features#text-conversion", apiSurface = UserInterfaceTextApiSurface, tags = listOf("Text"), - content = { AndroidFragment() } + content = { AndroidFragment() }, ), ComposableSampleDemo( id = "downloadable-fonts", @@ -1077,7 +1090,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://developer.android.com/develop/ui/views/text-and-emoji/downloadable-fonts", apiSurface = UserInterfaceTextApiSurface, tags = listOf("Text"), - content = { AndroidFragment() } + content = { AndroidFragment() }, ), ComposableSampleDemo( id = "hyphenation", @@ -1086,7 +1099,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://developer.android.com/reference/android/widget/TextView#attr_android:hyphenationFrequency", apiSurface = UserInterfaceTextApiSurface, tags = listOf("Text"), - content = { AndroidFragment() } + content = { AndroidFragment() }, ), ComposableSampleDemo( id = "line-break", @@ -1095,7 +1108,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://developer.android.com/about/versions/13/features#japanese-wrapping", apiSurface = UserInterfaceTextApiSurface, tags = listOf("Text"), - content = { AndroidFragment() } + content = { AndroidFragment() }, ), ComposableSampleDemo( id = "linkify", @@ -1104,7 +1117,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://developer.android.com/reference/kotlin/androidx/core/text/util/LinkifyCompat", apiSurface = UserInterfaceTextApiSurface, tags = listOf("Text"), - content = { AndroidFragment() } + content = { AndroidFragment() }, ), ComposableSampleDemo( id = "text-span", @@ -1113,7 +1126,7 @@ val SAMPLE_DEMOS by lazy { documentation = "https://developer.android.com/kotlin/ktx#core", apiSurface = UserInterfaceTextApiSurface, tags = listOf("Text"), - content = { AndroidFragment() } + content = { AndroidFragment() }, ), ComposableSampleDemo( id = "immersive-mode", @@ -1121,7 +1134,7 @@ val SAMPLE_DEMOS by lazy { description = "Immersive mode enables your app to display full-screen by hiding system bars.", documentation = "https://developer.android.com/develop/ui/views/layout/immersive", apiSurface = UserInterfaceWindowInsetsApiSurface, - content = { ImmersiveMode() } + content = { ImmersiveMode() }, ), ActivitySampleDemo( id = "window-insets-animation", @@ -1129,7 +1142,7 @@ val SAMPLE_DEMOS by lazy { description = "Shows how to react to the on-screen keyboard (IME) changing visibility, and also controlling the IME's visibility.", documentation = "https://developer.android.com/develop/ui/views/layout/sw-keyboard", apiSurface = UserInterfaceWindowInsetsApiSurface, - content = WindowInsetsAnimationActivity::class.java + content = WindowInsetsAnimationActivity::class.java, ), ActivitySampleDemo( id = "window-manager", @@ -1137,7 +1150,7 @@ val SAMPLE_DEMOS by lazy { description = "Demonstrates how to use the Jetpack WindowManager library.", documentation = "https://developer.android.com/jetpack/androidx/releases/window", apiSurface = UserInterfaceWindowManagerApiSurface, - content = WindowDemosActivity::class.java + content = WindowDemosActivity::class.java, ), ).associateBy { it.id } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e3dbbd9..8b4e573b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,7 @@ androidxTestExtTruth = "1.5.0" androidxTestRules = "1.5.0" androidxTestRunner = "1.5.2" androidxUiAutomator = "2.2.0" +camerax = "1.4.2" material3Android = "1.3.2" media3 = "1.5.0" constraintlayout = "2.1.4" @@ -59,6 +60,8 @@ glance = "1.1.0" tensorflowLite = "2.9.0" tensorflowLiteGpuDelegatePlugin = "0.4.4" tensorflowLiteSupport = "0.4.2" +barcodeScanningCommon = "17.0.0" +playServicesMlkitBarcodeScanning = "18.3.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -170,6 +173,15 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi androidx-media3-transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +# CameraX +androidx-camerax-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +androidx-camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +androidx-camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +androidx-camerax-video = { module = "androidx.camera:camera-video", version.ref = "camerax" } +androidx-camerax-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } +androidx-camerax-mlkit-vision = { module = "androidx.camera:camera-mlkit-vision", version.ref = "camerax" } +androidx-camerax-extensions = { module = "androidx.camera:camera-extensions", version.ref = "camerax" } + fresco = "com.facebook.fresco:fresco:3.0.0" fresco-nativeimagetranscoder = "com.facebook.fresco:nativeimagetranscoder:2.6.0!!" glide = "com.github.bumptech.glide:glide:4.15.1" @@ -181,6 +193,8 @@ tensorflow-lite-gpu = { module = "org.tensorflow:tensorflow-lite-gpu", version.r tensorflow-lite-gpu-delegate-plugin = { module = "org.tensorflow:tensorflow-lite-gpu-delegate-plugin", version.ref = "tensorflowLiteGpuDelegatePlugin" } tensorflow-lite-select-tf-ops = { module = "org.tensorflow:tensorflow-lite-select-tf-ops", version.ref = "tensorflowLite" } tensorflow-lite-support = { module = "org.tensorflow:tensorflow-lite-support", version.ref = "tensorflowLiteSupport" } +barcode-scanning-common = { group = "com.google.mlkit", name = "barcode-scanning-common", version.ref = "barcodeScanningCommon" } +play-services-mlkit-barcode-scanning = { group = "com.google.android.gms", name = "play-services-mlkit-barcode-scanning", version.ref = "playServicesMlkitBarcodeScanning" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/samples/camera/camerax/README.md b/samples/camera/camerax/README.md new file mode 100644 index 00000000..2fd9f957 --- /dev/null +++ b/samples/camera/camerax/README.md @@ -0,0 +1 @@ +TBD \ No newline at end of file diff --git a/samples/camera/camerax/build.gradle.kts b/samples/camera/camerax/build.gradle.kts new file mode 100644 index 00000000..528d0719 --- /dev/null +++ b/samples/camera/camerax/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * Copyright 2022 Google LLC + * + * 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. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.example.platform.camerax" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + testOptions.targetSdk = 35 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(project(":shared")) + + // CameraX + implementation(libs.androidx.camerax.core) + implementation(libs.androidx.camerax.camera2) + implementation(libs.androidx.camerax.lifecycle) + implementation(libs.androidx.camerax.video) + implementation(libs.androidx.camerax.view) + implementation(libs.androidx.camerax.mlkit.vision) + implementation(libs.androidx.camerax.mlkit.vision) + implementation(libs.androidx.camerax.extensions) + + // Permissions + implementation(libs.accompanist.permissions) + implementation(libs.barcode.scanning.common) + implementation(libs.play.services.mlkit.barcode.scanning) + + // Barcode Scanning +} diff --git a/samples/camera/camerax/src/main/AndroidManifest.xml b/samples/camera/camerax/src/main/AndroidManifest.xml new file mode 100644 index 00000000..fa86b21a --- /dev/null +++ b/samples/camera/camerax/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt new file mode 100644 index 00000000..6d433437 --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt @@ -0,0 +1,223 @@ +/* + * 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 + +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.example.platform.camerax.mlkit.QrCodeDrawable +import com.example.platform.camerax.mlkit.QrCodeViewModel +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 CameraXMlKitScreen() { + + val context = LocalContext.current + 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/settings.gradle.kts b/settings.gradle.kts index 42c9a1e1..d4f4933a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,6 +41,7 @@ include(":app") include(":shared") include(":samples:accessibility") include(":samples:camera:camera2") +include(":samples:camera:camerax") include(":samples:connectivity:audio") include(":samples:connectivity:bluetooth:ble") include(":samples:connectivity:bluetooth:companion") From 2286d2cf795862dc3ef1be696f8cdd9750f2a521 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 12 May 2025 15:40:28 -0400 Subject: [PATCH 02/12] Migrate CameraXBasic.kt Sample to Platform Samples --- .../com/example/platform/app/SampleDemo.kt | 10 + samples/camera/camerax/build.gradle.kts | 10 +- .../camerax/src/main/AndroidManifest.xml | 18 +- .../example/platform/camerax/CameraXBasic.kt | 306 ++++++++++++++++++ .../platform/camerax/CameraXMlKitSample.kt | 2 +- 5 files changed, 334 insertions(+), 12 deletions(-) create mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXBasic.kt 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 38d1ca79..1e394cf7 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -27,6 +27,7 @@ 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.CameraXBasic import com.example.platform.camerax.CameraXMlKitScreen import com.example.platform.connectivity.audio.AudioCommsSample import com.example.platform.connectivity.bluetooth.ble.BLEScanIntentSample @@ -220,6 +221,15 @@ val SAMPLE_DEMOS by lazy { ), // CameraX Samples + ComposableSampleDemo( + id = "camerax-basic", + name = "CameraX • Basic Image Capture", + description = "TBD", + documentation = "https://developer.android.com/training/camerax", + apiSurface = CameraCameraXApiSurface, + tags = listOf("CameraX"), + content = { CameraXBasic() }, + ), ComposableSampleDemo( id = "camerax-mlkit", name = "CameraX • MLKit Sample", diff --git a/samples/camera/camerax/build.gradle.kts b/samples/camera/camerax/build.gradle.kts index 528d0719..e8eb90e5 100644 --- a/samples/camera/camerax/build.gradle.kts +++ b/samples/camera/camerax/build.gradle.kts @@ -51,10 +51,14 @@ dependencies { implementation(libs.androidx.camerax.mlkit.vision) implementation(libs.androidx.camerax.extensions) - // Permissions - implementation(libs.accompanist.permissions) + // Image loading + implementation(libs.coil) + implementation(libs.coil.compose) + + // Barcode Scanning implementation(libs.barcode.scanning.common) implementation(libs.play.services.mlkit.barcode.scanning) - // Barcode Scanning + // Permissions + implementation(libs.accompanist.permissions) } diff --git a/samples/camera/camerax/src/main/AndroidManifest.xml b/samples/camera/camerax/src/main/AndroidManifest.xml index fa86b21a..c9eb3922 100644 --- a/samples/camera/camerax/src/main/AndroidManifest.xml +++ b/samples/camera/camerax/src/main/AndroidManifest.xml @@ -16,12 +16,16 @@ - - - - - + + + @@ -31,9 +35,7 @@ android:authorities="${applicationId}.provider" android:exported="false" android:grantUriPermissions="true"> - + \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXBasic.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXBasic.kt new file mode 100644 index 00000000..abf0a91a --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXBasic.kt @@ -0,0 +1,306 @@ +/* + * 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 + +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.Toast +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Image +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import coil.compose.rememberAsyncImagePainter +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import java.text.SimpleDateFormat +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraXBasic() { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // State to control whether to show camera preview or captured image + var showCapturedImage by remember { mutableStateOf(null) } + + // Request camera permission + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + + // Executor for camera operations + val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } + + // CameraX use cases + val previewUseCase = remember { Preview.Builder().build() } + val imageCaptureUseCase = remember { ImageCapture.Builder().build() } + + // Get CameraProvider asynchronously + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) } + + // Effect to get the CameraProvider once available + LaunchedEffect(cameraProviderFuture) { + cameraProvider = suspendCoroutine { continuation -> + cameraProviderFuture.addListener( + { + continuation.resume(cameraProviderFuture.get()) + }, + ContextCompat.getMainExecutor(context), + ) + } + } + + // Effect to bind use cases when cameraProvider is available and permission is granted + LaunchedEffect(cameraProvider, cameraPermissionState.status) { + val provider = cameraProvider + if (provider != null && cameraPermissionState.status == PermissionStatus.Granted) { + try { + // Unbind all use cases before rebinding + provider.unbindAll() + + // Select camera + val cameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + + // Bind use cases to the lifecycle + provider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + previewUseCase, + imageCaptureUseCase, + ) + } catch (exc: Exception) { + Log.e("CameraXComposeApp", "Use case binding failed", exc) + Toast.makeText(context, "Failed to initialize camera.", Toast.LENGTH_SHORT).show() + } + } + } + + DisposableEffect(Unit) { + onDispose { + cameraExecutor.shutdown() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + when (cameraPermissionState.status) { + is PermissionStatus.Granted -> { + if (showCapturedImage != null) { + // Show the captured image with a back button + CapturedImageView(uri = showCapturedImage!!) { + showCapturedImage = null // Return to preview on back button click + } + } else { + // Show camera preview + CameraPreview(previewUseCase) + } + } + + is PermissionStatus.Denied -> { + // Show permission request UI + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val textToShow = if (cameraPermissionState.status.shouldShowRationale) { + "The camera is needed to take pictures. Please grant the permission." + } else { + "Camera permission is required to use this feature." + } + Text(text = textToShow) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { + Text("Request Permission") + } + } + } + } + + // Capture button (only visible when showing preview) + if (showCapturedImage == null && cameraPermissionState.status == PermissionStatus.Granted) { + Button( + onClick = { + takePhoto( + context = context, + imageCapture = imageCaptureUseCase, + executor = cameraExecutor, + onImageCaptured = { uri -> showCapturedImage = uri }, + onError = { exc -> + ContextCompat.getMainExecutor(context).execute { + Toast.makeText( + context, + "Photo capture failed: ${exc.message}", + Toast.LENGTH_SHORT, + ).show() + } + Log.e("CameraXComposeApp", "Photo capture failed: ${exc.message}", exc) + }, + ) + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + ) { + Text("Take Photo") + } + } + } +} + +@Composable +fun CameraPreview(previewUseCase: Preview) { + AndroidView( + factory = { context -> + PreviewView(context).apply { + layoutParams = android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + ) + setBackgroundColor(android.graphics.Color.BLACK) + // Bind the Preview use case to the PreviewView's surfaceProvider + previewUseCase.surfaceProvider = this.surfaceProvider + } + }, + modifier = Modifier.fillMaxSize(), + ) +} + +@Composable +fun CapturedImageView(uri: Uri, onDismiss: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + Image( + painter = rememberAsyncImagePainter(uri), + contentDescription = "Captured Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, // Or your preferred scaling + ) + + // Back button + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to Camera", + ) + } + } +} + +private fun takePhoto( + context: Context, + imageCapture: ImageCapture, + executor: ExecutorService, + onImageCaptured: (Uri?) -> Unit, + onError: (ImageCaptureException) -> Unit, +) { + val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", java.util.Locale.US) + .format(System.currentTimeMillis()) + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraXBasic") + } + } + + // Create output options object for saving the file in MediaStore + val outputOptions = ImageCapture.OutputFileOptions + .Builder( + context.contentResolver, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues, + ) + .build() + + // Set up image capture listener, which is triggered after the photo has been taken + imageCapture.takePicture( + outputOptions, + executor, + object : ImageCapture.OnImageSavedCallback { + override fun onError(exc: ImageCaptureException) { + onError(exc) + ContextCompat.getMainExecutor(context).execute { + Toast.makeText( + context, + "Photo capture failed: ${exc.message}", + Toast.LENGTH_SHORT, + ).show() + } + } + + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + onImageCaptured(output.savedUri) + val msg = "Photo capture succeeded: ${output.savedUri}" + ContextCompat.getMainExecutor(context).execute { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + Log.d("CameraXComposeApp", msg) + } + }, + ) +} \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt index 6d433437..0a943981 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. From e5d6ad909310a1a37b270fa101ee990477f31fb1 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 12 May 2025 16:29:53 -0400 Subject: [PATCH 03/12] Migrate Over CameraXExtensions.kt --- .../com/example/platform/app/SampleDemo.kt | 11 + samples/camera/camerax/build.gradle.kts | 1 + .../platform/camerax/CameraXExtensions.kt | 429 +++++++++++++++++ .../platform/camerax/CameraXMlKitSample.kt | 2 - .../viewmodels/CameraExtensionsViewModel.kt | 438 ++++++++++++++++++ 5 files changed, 879 insertions(+), 2 deletions(-) create mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt create mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt 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 1e394cf7..31679ac5 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -28,6 +28,7 @@ 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.CameraXBasic +import com.example.platform.camerax.CameraXExtensions import com.example.platform.camerax.CameraXMlKitScreen import com.example.platform.connectivity.audio.AudioCommsSample import com.example.platform.connectivity.bluetooth.ble.BLEScanIntentSample @@ -230,6 +231,16 @@ val SAMPLE_DEMOS by lazy { tags = listOf("CameraX"), content = { CameraXBasic() }, ), + ComposableSampleDemo( + id = "camerax-extensions", + name = "CameraX • Extensions", + description = "TBD", + documentation = "https://developer.android.com/training/camerax", + apiSurface = CameraCameraXApiSurface, + tags = listOf("CameraX"), + content = { CameraXExtensions() }, + ), + ComposableSampleDemo( id = "camerax-mlkit", name = "CameraX • MLKit Sample", diff --git a/samples/camera/camerax/build.gradle.kts b/samples/camera/camerax/build.gradle.kts index e8eb90e5..da2af049 100644 --- a/samples/camera/camerax/build.gradle.kts +++ b/samples/camera/camerax/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.material3) implementation(project(":shared")) diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt new file mode 100644 index 00000000..b75e68b5 --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt @@ -0,0 +1,429 @@ +/* + * 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 + +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.* +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.Create +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +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.viewmodels.CameraExtensionsState +import com.example.platform.camerax.viewmodels.CameraExtensionsViewModel +import com.example.platform.camerax.viewmodels.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( + uiState.lensFacing, + uiState.selectedExtension, + targetRotation, // Rebind if rotation changes significantly for targetRotation setting + ) { + // Check if camera is initialized in the ViewModel before binding + if (!uiState.isLoading && 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), + + ) { + Icon( + Icons.Filled.Create, + contentDescription = "Capture photo", + tint = Color.White, + modifier = Modifier.size(40.dp), + ) + 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/CameraXMlKitSample.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt index 0a943981..2cf80e1b 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt @@ -62,8 +62,6 @@ import com.google.mlkit.vision.barcode.common.Barcode @OptIn(ExperimentalPermissionsApi::class) @Composable fun CameraXMlKitScreen() { - - val context = LocalContext.current var qrCodeDetected by remember { mutableStateOf(false) } var qrCodeContent by remember { mutableStateOf("") } diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt new file mode 100644 index 00000000..12abb887 --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt @@ -0,0 +1,438 @@ +/* + * 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.viewmodels + +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 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() + + private var cameraProvider: ProcessCameraProvider? = null + private 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) } + // 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 From e92b8d18e3fdf451c837ddcf677f50d78f7b2e65 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 12 May 2025 16:37:33 -0400 Subject: [PATCH 04/12] Migrate Over CameraXExtensions.kt small fixes --- .../example/platform/camerax/CameraXExtensions.kt | 14 +++++--------- .../viewmodels/CameraExtensionsViewModel.kt | 13 +++++++++++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt index b75e68b5..76fc2ad4 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 The Android Open Source Project + * 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. @@ -150,12 +150,14 @@ private fun CameraView( // 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 ) { - // Check if camera is initialized in the ViewModel before binding - if (!uiState.isLoading && uiState.errorMessage == null) { + // 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=${ @@ -290,12 +292,6 @@ fun CaptureButton(isTakingPicture: Boolean, onClick: () -> Unit) { .background(Color.White.copy(alpha = 0.3f), CircleShape), ) { - Icon( - Icons.Filled.Create, - contentDescription = "Capture photo", - tint = Color.White, - modifier = Modifier.size(40.dp), - ) if (isTakingPicture) { CircularProgressIndicator( modifier = Modifier.size(64.dp), // Slightly smaller than button diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt index 12abb887..118a155f 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt @@ -21,6 +21,7 @@ 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 @@ -55,8 +56,8 @@ class CameraExtensionsViewModel : ViewModel() { private val _uiState = MutableStateFlow(CameraExtensionsState()) val uiState: StateFlow = _uiState.asStateFlow() - private var cameraProvider: ProcessCameraProvider? = null - private var extensionsManager: ExtensionsManager? = null + 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 @@ -387,6 +388,14 @@ class CameraExtensionsViewModel : ViewModel() { 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 } }, From 74a06b9fc4bbce4b7ded10fe4ae8cc3931d3d57f Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 12 May 2025 17:18:43 -0400 Subject: [PATCH 05/12] Completed CameraXVideo Sample --- .../com/example/platform/app/SampleDemo.kt | 11 +- samples/camera/camerax/build.gradle.kts | 3 + .../platform/camerax/CameraXExtensions.kt | 2 +- .../example/platform/camerax/CameraXVideo.kt | 495 ++++++++++++++++++ 4 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt 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 31679ac5..47cf1392 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -30,6 +30,7 @@ import com.example.platform.camera.preview.Camera2Preview import com.example.platform.camerax.CameraXBasic import com.example.platform.camerax.CameraXExtensions import com.example.platform.camerax.CameraXMlKitScreen +import com.example.platform.camerax.CameraXVideo import com.example.platform.connectivity.audio.AudioCommsSample import com.example.platform.connectivity.bluetooth.ble.BLEScanIntentSample import com.example.platform.connectivity.bluetooth.ble.ConnectGATTSample @@ -231,6 +232,15 @@ val SAMPLE_DEMOS by lazy { tags = listOf("CameraX"), content = { CameraXBasic() }, ), + ComposableSampleDemo( + id = "camerax-video", + name = "CameraX • Basic Video Capture", + description = "TBD", + documentation = "https://developer.android.com/training/camerax", + apiSurface = CameraCameraXApiSurface, + tags = listOf("CameraX"), + content = { CameraXVideo() }, + ), ComposableSampleDemo( id = "camerax-extensions", name = "CameraX • Extensions", @@ -240,7 +250,6 @@ val SAMPLE_DEMOS by lazy { tags = listOf("CameraX"), content = { CameraXExtensions() }, ), - ComposableSampleDemo( id = "camerax-mlkit", name = "CameraX • MLKit Sample", diff --git a/samples/camera/camerax/build.gradle.kts b/samples/camera/camerax/build.gradle.kts index da2af049..76407cc4 100644 --- a/samples/camera/camerax/build.gradle.kts +++ b/samples/camera/camerax/build.gradle.kts @@ -52,6 +52,9 @@ dependencies { implementation(libs.androidx.camerax.mlkit.vision) implementation(libs.androidx.camerax.extensions) + // Exoplayer + + // Image loading implementation(libs.coil) implementation(libs.coil.compose) diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt index 76fc2ad4..2610c846 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt new file mode 100644 index 00000000..19031208 --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt @@ -0,0 +1,495 @@ +/* + * 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 + +import android.Manifest +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import android.widget.Toast +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.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 + +/** + * The main screen composable for the camera functionality. + * Manages its own state including permissions (using Accompanist), + * camera executor, preview, and recording controls. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraXVideo() { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // --- State Management within the Composable --- + // UI state reflecting whether recording is active + var recordingState by remember { mutableStateOf(RecordingState.Idle) } + // Holds the active VideoCapture use case instance + var videoCapture by remember { mutableStateOf?>(null) } + // Holds the active Recording session instance + var recording by remember { mutableStateOf(null) } + + // --- Camera Executor --- + // Remember a single-threaded executor for camera operations + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + + // --- Cleanup --- + // Use DisposableEffect to shut down the executor when the composable leaves the composition + DisposableEffect(Unit) { + onDispose { + Log.d(TAG, "Shutting down camera executor.") + cameraExecutor.shutdown() + } + } + + // --- Permission Handling with Accompanist --- + val permissionsState = rememberMultiplePermissionsState( + permissions = REQUIRED_PERMISSIONS.toList(), // Use the existing list + ) + + // --- UI --- + Box(modifier = Modifier.fillMaxSize()) { + // Check if all required permissions are granted + if (permissionsState.allPermissionsGranted) { + Log.d(TAG, "All permissions granted, showing camera preview.") + // Display camera preview and controls + CameraContent( + lifecycleOwner = lifecycleOwner, + cameraExecutor = cameraExecutor, + recordingState = recordingState, + onVideoCaptureCreated = { newVideoCapture -> + Log.d(TAG, "VideoCapture instance created.") + videoCapture = newVideoCapture + }, + onRecordClick = { + val currentVideoCapture = videoCapture // Use internal state + if (currentVideoCapture != null) { + if (recordingState == RecordingState.Idle) { + Log.d(TAG, "Start Recording button clicked.") + // Start Recording Action + startRecording( + context = context, + videoCapture = currentVideoCapture, + executor = cameraExecutor, + onRecordingStarted = { activeRec -> + // Update internal recording state + recording = activeRec + recordingState = RecordingState.Recording + }, + onRecordingError = { errorEvent -> + Log.e(TAG, "VideoCapture Error: ${errorEvent.cause}") + // Reset internal state on error + recording = null + recordingState = RecordingState.Idle + }, + onRecordingComplete = { + Log.d(TAG, "Recording complete.") + // Reset internal state on completion + recording = null + recordingState = RecordingState.Idle + }, + ) + } else { + Log.d(TAG, "Stop Recording button clicked.") + // Stop Recording Action + recording?.stop() // Use the internal recording instance to stop + // State is updated via the callbacks passed to startRecording + } + } else { + Log.e(TAG, "Record button clicked but VideoCapture is null.") + Toast.makeText(context, "Camera not ready.", Toast.LENGTH_SHORT).show() + } + }, + ) + } else { + // Permissions are not granted, show rationale or request button + PermissionRationale(permissionsState) + } + } +} + +/** + * Composable responsible for displaying the camera preview and controls + * when permissions are granted. + */ +@Composable +private fun CameraContent( + lifecycleOwner: LifecycleOwner, + cameraExecutor: ExecutorService, + recordingState: RecordingState, + onVideoCaptureCreated: (VideoCapture) -> Unit, + onRecordClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + // Camera Preview + CameraPreview( + lifecycleOwner = lifecycleOwner, + cameraExecutor = cameraExecutor, + onVideoCaptureCreated = onVideoCaptureCreated, + ) + + // Control buttons overlay at the bottom + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RecordButton( + recordingState = recordingState, + onRecordClick = onRecordClick, + ) + // TODO: Add Quality Selector Composable if needed + // TODO: Add Camera Flip Button Composable if needed + } + } +} + +/** + * Composable responsible for displaying the permission rationale or request button + * when permissions are not granted. + */ +@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) { + // If the user has denied the permission but the rationale should be shown, + // explain why the permission is needed + "Camera and Audio access are important for this app. Please grant the permissions." + } else { + // If it's the first time the user sees this or the user has denied the + // permission permanently, explain why the permission is needed + "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 function that hosts the CameraX PreviewView using AndroidView. + * It also handles the binding of CameraX use cases (Preview, VideoCapture) + * to the lifecycle owner. + * + * @param modifier Modifier for the AndroidView. + * @param lifecycleOwner The LifecycleOwner to bind CameraX to. + * @param cameraExecutor The executor for CameraX binding operations. + * @param onVideoCaptureCreated Callback invoked when the VideoCapture use case is created, + * passing the instance back up. + */ +@Composable +fun CameraPreview( + modifier: Modifier = Modifier, + lifecycleOwner: LifecycleOwner, + cameraExecutor: ExecutorService, + onVideoCaptureCreated: (VideoCapture) -> Unit, // Callback still needed to pass VC up +) { + val context = LocalContext.current + // Remember the PreviewView instance + val previewView = remember { PreviewView(context) } + Log.d(TAG, "CameraPreview Composable recomposing/launching.") + + // Use LaunchedEffect to bind the camera. Add lifecycleOwner and context as keys + // so that binding restarts if they change (though context change is unlikely here). + 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.") + + // --- Preview Use Case Setup --- + val preview = Preview.Builder() + .build() + .also { + it.setSurfaceProvider(previewView.surfaceProvider) + Log.d(TAG, "Preview surface provider set.") + } + + // --- VideoCapture Use Case Setup --- + val recorder = Recorder.Builder() + // Consider adding QualitySelector based on requirements + // .setQualitySelector(QualitySelector.from(Quality.HIGHEST)) + .build() + val videoCapture: VideoCapture = VideoCapture.withOutput(recorder) + // Pass the created VideoCapture instance up to the caller (CameraScreen) + Log.d(TAG, "VideoCapture created, invoking callback.") + onVideoCaptureCreated(videoCapture) + + // --- Camera Selector --- + // TODO: Allow selecting between front and back cameras + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + Log.d(TAG, "Using default back camera.") + + // Unbind any existing use cases before rebinding + Log.d(TAG, "Unbinding all previous use cases.") + cameraProvider.unbindAll() + + // Bind the desired use cases (Preview and VideoCapture) to the lifecycle + 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) + // Handle binding failure (e.g., show error message to user) + Toast.makeText( + context, + "Failed to initialize camera: ${exc.message}", + Toast.LENGTH_LONG, + ).show() + } + + }, + ContextCompat.getMainExecutor(context), + ) // Use main executor for CameraX listener setup + Log.d(TAG, "CameraProvider listener added.") + } + + // Embed the Android PreviewView within the Compose UI hierarchy + AndroidView( + { + Log.d(TAG, "AndroidView factory executing.") + previewView + }, + modifier = modifier.fillMaxSize(), + ) +} + +/** + * Composable for the recording button. Changes appearance based on recording state. + * + * @param recordingState The current state of recording (Idle or Recording). + * @param onRecordClick Lambda function to be executed when the button is clicked. + */ +@Composable +fun RecordButton( + recordingState: RecordingState, + onRecordClick: () -> Unit, +) { + Button( + onClick = onRecordClick, + colors = ButtonDefaults.buttonColors( + // Change color based on recording state + containerColor = if (recordingState == RecordingState.Recording) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ), + ) { + Text(if (recordingState == RecordingState.Recording) "Stop Recording" else "Start Recording") + } +} + +// --- Helper Functions & Constants --- + +/** + * Enum to represent the current recording state for UI purposes. + */ +enum class RecordingState { + Idle, Recording +} + +/** + * Starts a new video recording using CameraX VideoCapture. + * + * @param context The application context. + * @param videoCapture The VideoCapture use case instance. + * @param executor The executor to run recording operations on. + * @param onRecordingStarted Callback invoked when recording starts, providing the Recording object. + * @param onRecordingError Callback invoked if recording fails, providing the error event. + * @param onRecordingComplete Callback invoked when recording finishes successfully. + */ +private fun startRecording( + context: Context, + videoCapture: VideoCapture, + executor: ExecutorService, + onRecordingStarted: (Recording) -> Unit, + onRecordingError: (VideoRecordEvent.Finalize) -> Unit, + onRecordingComplete: () -> Unit, +) { + // Create MediaStore output options for saving the video + val mediaStoreOutputOptions = createMediaStoreOutputOptions(context) + Log.d(TAG, "Preparing recording to: $mediaStoreOutputOptions") + + // Prepare the recording + val pendingRecording = videoCapture.output + .prepareRecording(context, mediaStoreOutputOptions) + .apply { + // Enable audio recording if permission is granted + 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.") + } + } + + // Start the recording and get the Recording object + val activeRecording = pendingRecording.start(executor) { recordEvent -> + // Handle recording events + when (recordEvent) { + is VideoRecordEvent.Start -> { + Log.d(TAG, "Recording started successfully.") + } + + is VideoRecordEvent.Finalize -> { + if (!recordEvent.hasError()) { + // Recording succeeded + val msg = "Video capture succeeded: ${recordEvent.outputResults.outputUri}" + Log.d(TAG, msg) + // Dispatch Toast to the main thread + ContextCompat.getMainExecutor(context).execute { + Toast.makeText(context.applicationContext, msg, Toast.LENGTH_SHORT).show() + } + onRecordingComplete() + } else { + // Recording failed + val errorCause = recordEvent.cause ?: "Unknown error" + val errorCode = recordEvent.error + Log.e( + TAG, + "Video capture error ($errorCode): $errorCause", + recordEvent.cause, + ) + // Dispatch Toast to the main thread + ContextCompat.getMainExecutor(context).execute { + Toast.makeText( + context.applicationContext, + "Recording failed: $errorCause", + Toast.LENGTH_LONG, + ).show() + } + onRecordingError(recordEvent) + } + } + + is VideoRecordEvent.Status -> { + // Provides recording progress, duration, file size, etc. + } + + is VideoRecordEvent.Pause -> Log.d(TAG, "Recording paused") + is VideoRecordEvent.Resume -> Log.d(TAG, "Recording resumed") + } + } + + // Pass the obtained Recording object to the callback immediately after starting + onRecordingStarted(activeRecording) + Log.d(TAG, "Recording initiated.") +} + +/** + * Creates MediaStoreOutputOptions for saving the recorded video. + * Defines the filename, MIME type, and storage location. + * + * @param context The application context. + * @return Configured MediaStoreOutputOptions. + */ +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") + // Define storage location (Movies/CameraX-Video directory) for Android Q+ + 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() +} + +// Removed: checkPermissions function is no longer needed with Accompanist + + +// --- Constants --- +private const val TAG = "CameraXComposeAccompanist" // Updated TAG for clarity +private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" + +// Required permissions for camera and video recording +val REQUIRED_PERMISSIONS = + mutableListOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + ).apply { + // Add storage permission for older Android versions if necessary + // (Scoped storage is preferred on newer versions) + 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() + From de7428eef0789d20140160e89a9d8140b69becbe Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 12 May 2025 17:23:10 -0400 Subject: [PATCH 06/12] update to assure video loops --- .../example/platform/camerax/CameraXVideo.kt | 462 +++++++++--------- 1 file changed, 244 insertions(+), 218 deletions(-) diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt index 19031208..ef88b97a 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt @@ -1,34 +1,23 @@ -/* - * 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 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 @@ -49,10 +38,15 @@ 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, and recording controls. + * camera executor, preview, recording controls, and screen navigation. */ @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -60,20 +54,18 @@ fun CameraXVideo() { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - // --- State Management within the Composable --- - // UI state reflecting whether recording is active + // --- State Management --- var recordingState by remember { mutableStateOf(RecordingState.Idle) } - // Holds the active VideoCapture use case instance var videoCapture by remember { mutableStateOf?>(null) } - // Holds the active Recording session instance var recording by remember { mutableStateOf(null) } - // --- Camera Executor --- - // Remember a single-threaded executor for camera operations + // --- Screen navigation and video URI state --- + var currentScreen by remember { mutableStateOf(CameraScreenMode.RECORDING) } + var lastRecordedVideoUri by remember { mutableStateOf(null) } + + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } - // --- Cleanup --- - // Use DisposableEffect to shut down the executor when the composable leaves the composition DisposableEffect(Unit) { onDispose { Log.d(TAG, "Shutting down camera executor.") @@ -81,94 +73,118 @@ fun CameraXVideo() { } } - // --- Permission Handling with Accompanist --- val permissionsState = rememberMultiplePermissionsState( - permissions = REQUIRED_PERMISSIONS.toList(), // Use the existing list + permissions = REQUIRED_PERMISSIONS.toList(), ) - // --- UI --- Box(modifier = Modifier.fillMaxSize()) { - // Check if all required permissions are granted - if (permissionsState.allPermissionsGranted) { - Log.d(TAG, "All permissions granted, showing camera preview.") - // Display camera preview and controls - CameraContent( - lifecycleOwner = lifecycleOwner, - cameraExecutor = cameraExecutor, - recordingState = recordingState, - onVideoCaptureCreated = { newVideoCapture -> - Log.d(TAG, "VideoCapture instance created.") - videoCapture = newVideoCapture - }, - onRecordClick = { - val currentVideoCapture = videoCapture // Use internal state - if (currentVideoCapture != null) { - if (recordingState == RecordingState.Idle) { - Log.d(TAG, "Start Recording button clicked.") - // Start Recording Action - startRecording( - context = context, - videoCapture = currentVideoCapture, - executor = cameraExecutor, - onRecordingStarted = { activeRec -> - // Update internal recording state - recording = activeRec - recordingState = RecordingState.Recording - }, - onRecordingError = { errorEvent -> - Log.e(TAG, "VideoCapture Error: ${errorEvent.cause}") - // Reset internal state on error - recording = null - recordingState = RecordingState.Idle - }, - onRecordingComplete = { - Log.d(TAG, "Recording complete.") - // Reset internal state on completion - recording = null - recordingState = RecordingState.Idle - }, - ) - } else { - Log.d(TAG, "Stop Recording button clicked.") - // Stop Recording Action - recording?.stop() // Use the internal recording instance to stop - // State is updated via the callbacks passed to startRecording + 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") } - } else { - Log.e(TAG, "Record button clicked but VideoCapture is null.") - Toast.makeText(context, "Camera not ready.", Toast.LENGTH_SHORT).show() } - }, - ) - } else { - // Permissions are not granted, show rationale or request button - PermissionRationale(permissionsState) + } + } } } } -/** - * Composable responsible for displaying the camera preview and controls - * when permissions are granted. - */ @Composable private fun CameraContent( lifecycleOwner: LifecycleOwner, - cameraExecutor: ExecutorService, + // 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()) { - // Camera Preview CameraPreview( + // cameraExecutor is needed by CameraPreview for binding lifecycleOwner = lifecycleOwner, - cameraExecutor = cameraExecutor, + // cameraExecutor passed here if CameraPreview handles binding independently + // If binding is managed by CameraXVideo, this might not be needed directly onVideoCaptureCreated = onVideoCaptureCreated, ) - - // Control buttons overlay at the bottom Column( modifier = Modifier .align(Alignment.BottomCenter) @@ -179,16 +195,10 @@ private fun CameraContent( recordingState = recordingState, onRecordClick = onRecordClick, ) - // TODO: Add Quality Selector Composable if needed - // TODO: Add Camera Flip Button Composable if needed } } } -/** - * Composable responsible for displaying the permission rationale or request button - * when permissions are not granted. - */ @OptIn(ExperimentalPermissionsApi::class) @Composable private fun PermissionRationale( @@ -203,12 +213,8 @@ private fun PermissionRationale( verticalArrangement = Arrangement.Center, ) { val textToShow = if (permissionsState.shouldShowRationale) { - // If the user has denied the permission but the rationale should be shown, - // explain why the permission is needed "Camera and Audio access are important for this app. Please grant the permissions." } else { - // If it's the first time the user sees this or the user has denied the - // permission permanently, explain why the permission is needed "Camera and Audio permissions required for this feature to be available. " + "Please grant the permissions." } @@ -229,31 +235,23 @@ private fun PermissionRationale( } -/** - * Composable function that hosts the CameraX PreviewView using AndroidView. - * It also handles the binding of CameraX use cases (Preview, VideoCapture) - * to the lifecycle owner. - * - * @param modifier Modifier for the AndroidView. - * @param lifecycleOwner The LifecycleOwner to bind CameraX to. - * @param cameraExecutor The executor for CameraX binding operations. - * @param onVideoCaptureCreated Callback invoked when the VideoCapture use case is created, - * passing the instance back up. - */ @Composable fun CameraPreview( modifier: Modifier = Modifier, lifecycleOwner: LifecycleOwner, - cameraExecutor: ExecutorService, - onVideoCaptureCreated: (VideoCapture) -> Unit, // Callback still needed to pass VC up + // cameraExecutor: ExecutorService, // This was passed but not used in the original snippet + onVideoCaptureCreated: (VideoCapture) -> Unit, ) { val context = LocalContext.current - // Remember the PreviewView instance 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.") - // Use LaunchedEffect to bind the camera. Add lifecycleOwner and context as keys - // so that binding restarts if they change (though context change is unlikely here). LaunchedEffect(lifecycleOwner, context) { Log.d(TAG, "LaunchedEffect for camera binding starting.") val cameraProviderFuture = ProcessCameraProvider.getInstance(context) @@ -263,7 +261,6 @@ fun CameraPreview( val cameraProvider = cameraProviderFuture.get() Log.d(TAG, "CameraProvider obtained.") - // --- Preview Use Case Setup --- val preview = Preview.Builder() .build() .also { @@ -271,26 +268,24 @@ fun CameraPreview( Log.d(TAG, "Preview surface provider set.") } - // --- VideoCapture Use Case Setup --- val recorder = Recorder.Builder() - // Consider adding QualitySelector based on requirements - // .setQualitySelector(QualitySelector.from(Quality.HIGHEST)) + .setQualitySelector( + QualitySelector.from( + Quality.HIGHEST, + FallbackStrategy.higherQualityOrLowerThan(Quality.SD), + ), + ) .build() val videoCapture: VideoCapture = VideoCapture.withOutput(recorder) - // Pass the created VideoCapture instance up to the caller (CameraScreen) Log.d(TAG, "VideoCapture created, invoking callback.") onVideoCaptureCreated(videoCapture) - // --- Camera Selector --- - // TODO: Allow selecting between front and back cameras val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA Log.d(TAG, "Using default back camera.") - // Unbind any existing use cases before rebinding Log.d(TAG, "Unbinding all previous use cases.") cameraProvider.unbindAll() - // Bind the desired use cases (Preview and VideoCapture) to the lifecycle Log.d(TAG, "Binding Preview and VideoCapture use cases.") cameraProvider.bindToLifecycle( lifecycleOwner, cameraSelector, preview, videoCapture, @@ -299,7 +294,6 @@ fun CameraPreview( } catch (exc: Exception) { Log.e(TAG, "Use case binding failed", exc) - // Handle binding failure (e.g., show error message to user) Toast.makeText( context, "Failed to initialize camera: ${exc.message}", @@ -308,12 +302,20 @@ fun CameraPreview( } }, - ContextCompat.getMainExecutor(context), - ) // Use main executor for CameraX listener setup + ContextCompat.getMainExecutor(context), // For the listener itself + // If ProcessCameraProvider.getInstance needs a specific executor, it's handled internally + // or via its other getInstance method. The addListener's executor is for the callback. + ) Log.d(TAG, "CameraProvider listener added.") } - // Embed the Android PreviewView within the Compose UI hierarchy + DisposableEffect(Unit) { + onDispose { + localCameraExecutor.isShutdown.not() + } + } + + AndroidView( { Log.d(TAG, "AndroidView factory executing.") @@ -323,12 +325,6 @@ fun CameraPreview( ) } -/** - * Composable for the recording button. Changes appearance based on recording state. - * - * @param recordingState The current state of recording (Idle or Recording). - * @param onRecordClick Lambda function to be executed when the button is clicked. - */ @Composable fun RecordButton( recordingState: RecordingState, @@ -337,50 +333,102 @@ fun RecordButton( Button( onClick = onRecordClick, colors = ButtonDefaults.buttonColors( - // Change color based on recording state - containerColor = if (recordingState == RecordingState.Recording) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + containerColor = if (recordingState == RecordingState.Recording) + MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, ), ) { Text(if (recordingState == RecordingState.Recording) "Stop Recording" else "Start Recording") } } -// --- Helper Functions & Constants --- +@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() + + // --- MODIFICATION FOR LOOPING --- + videoView.setOnPreparedListener { mp -> + mp.isLooping = true // Enable looping + Log.d(TAG, "VideoView prepared, looping enabled.") + } + // Start playback after preparing. If setOnPreparedListener is used, + // starting playback there is also an option, but start() here is fine. + videoView.start() + Log.d(TAG, "VideoView playback started with URI: $videoUri") + + + // Alternative looping mechanism using OnCompletionListener: + /* + videoView.setOnCompletionListener { + Log.d(TAG, "VideoView playback completed, restarting for loop.") + videoView.start() // Restart video when it completes + } + videoView.start() // Initial start + */ + // --- END MODIFICATION --- + + 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 to represent the current recording state for UI purposes. - */ enum class RecordingState { Idle, Recording } -/** - * Starts a new video recording using CameraX VideoCapture. - * - * @param context The application context. - * @param videoCapture The VideoCapture use case instance. - * @param executor The executor to run recording operations on. - * @param onRecordingStarted Callback invoked when recording starts, providing the Recording object. - * @param onRecordingError Callback invoked if recording fails, providing the error event. - * @param onRecordingComplete Callback invoked when recording finishes successfully. - */ private fun startRecording( context: Context, videoCapture: VideoCapture, - executor: ExecutorService, + executor: ExecutorService, // This executor is for CameraX recording operations onRecordingStarted: (Recording) -> Unit, onRecordingError: (VideoRecordEvent.Finalize) -> Unit, - onRecordingComplete: () -> Unit, + onRecordingComplete: (Uri) -> Unit, ) { - // Create MediaStore output options for saving the video val mediaStoreOutputOptions = createMediaStoreOutputOptions(context) - Log.d(TAG, "Preparing recording to: $mediaStoreOutputOptions") + Log.d( + TAG, + "Preparing recording to: ${mediaStoreOutputOptions.contentValues.getAsString(MediaStore.MediaColumns.DISPLAY_NAME)}", + ) - // Prepare the recording val pendingRecording = videoCapture.output .prepareRecording(context, mediaStoreOutputOptions) .apply { - // Enable audio recording if permission is granted if (PermissionChecker.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PermissionChecker.PERMISSION_GRANTED ) { @@ -391,66 +439,53 @@ private fun startRecording( } } - // Start the recording and get the Recording object - val activeRecording = pendingRecording.start(executor) { recordEvent -> - // Handle recording events - when (recordEvent) { - is VideoRecordEvent.Start -> { - Log.d(TAG, "Recording started successfully.") - } + val activeRecording = + pendingRecording.start(executor) { recordEvent -> // Uses the passed executor + when (recordEvent) { + is VideoRecordEvent.Start -> { + Log.d(TAG, "Recording started successfully.") + } - is VideoRecordEvent.Finalize -> { - if (!recordEvent.hasError()) { - // Recording succeeded - val msg = "Video capture succeeded: ${recordEvent.outputResults.outputUri}" - Log.d(TAG, msg) - // Dispatch Toast to the main thread - ContextCompat.getMainExecutor(context).execute { - Toast.makeText(context.applicationContext, msg, Toast.LENGTH_SHORT).show() - } - onRecordingComplete() - } else { - // Recording failed - val errorCause = recordEvent.cause ?: "Unknown error" - val errorCode = recordEvent.error - Log.e( - TAG, - "Video capture error ($errorCode): $errorCause", - recordEvent.cause, - ) - // Dispatch Toast to the main thread - ContextCompat.getMainExecutor(context).execute { - Toast.makeText( - context.applicationContext, - "Recording failed: $errorCause", - Toast.LENGTH_LONG, - ).show() + 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) } - onRecordingError(recordEvent) } - } - is VideoRecordEvent.Status -> { - // Provides recording progress, duration, file size, etc. - } + 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") + is VideoRecordEvent.Pause -> Log.d(TAG, "Recording paused") + is VideoRecordEvent.Resume -> Log.d(TAG, "Recording resumed") + } } - } - - // Pass the obtained Recording object to the callback immediately after starting onRecordingStarted(activeRecording) Log.d(TAG, "Recording initiated.") } -/** - * Creates MediaStoreOutputOptions for saving the recorded video. - * Defines the filename, MIME type, and storage location. - * - * @param context The application context. - * @return Configured MediaStoreOutputOptions. - */ private fun createMediaStoreOutputOptions(context: Context): MediaStoreOutputOptions { val name = "CameraX-recording-" + SimpleDateFormat(FILENAME_FORMAT, Locale.US) @@ -459,7 +494,6 @@ private fun createMediaStoreOutputOptions(context: Context): MediaStoreOutputOpt val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, name) put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4") - // Define storage location (Movies/CameraX-Video directory) for Android Q+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/CameraX-Video") } @@ -472,24 +506,16 @@ private fun createMediaStoreOutputOptions(context: Context): MediaStoreOutputOpt .build() } -// Removed: checkPermissions function is no longer needed with Accompanist - - -// --- Constants --- -private const val TAG = "CameraXComposeAccompanist" // Updated TAG for clarity +private const val TAG = "CameraXComposeExtended" private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" -// Required permissions for camera and video recording val REQUIRED_PERMISSIONS = mutableListOf( Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO, ).apply { - // Add storage permission for older Android versions if necessary - // (Scoped storage is preferred on newer versions) 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() - + }.toTypedArray() \ No newline at end of file From ddb5ec4d75212272658c9662c2c0ba447ada9c8b Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 12 May 2025 17:34:41 -0400 Subject: [PATCH 07/12] Fix TBD descriptions --- app/src/main/java/com/example/platform/app/SampleDemo.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 47cf1392..e3f44f54 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -226,7 +226,7 @@ val SAMPLE_DEMOS by lazy { ComposableSampleDemo( id = "camerax-basic", name = "CameraX • Basic Image Capture", - description = "TBD", + description = "This sample demonstrates how to capture an image using CameraX", documentation = "https://developer.android.com/training/camerax", apiSurface = CameraCameraXApiSurface, tags = listOf("CameraX"), @@ -235,7 +235,7 @@ val SAMPLE_DEMOS by lazy { ComposableSampleDemo( id = "camerax-video", name = "CameraX • Basic Video Capture", - description = "TBD", + description = "This sample demonstrates how to capture an video using CameraX", documentation = "https://developer.android.com/training/camerax", apiSurface = CameraCameraXApiSurface, tags = listOf("CameraX"), @@ -244,7 +244,7 @@ val SAMPLE_DEMOS by lazy { ComposableSampleDemo( id = "camerax-extensions", name = "CameraX • Extensions", - description = "TBD", + description = "This sample demonstrates how to check for and utilize CameraX Extensions", documentation = "https://developer.android.com/training/camerax", apiSurface = CameraCameraXApiSurface, tags = listOf("CameraX"), @@ -253,7 +253,7 @@ val SAMPLE_DEMOS by lazy { ComposableSampleDemo( id = "camerax-mlkit", name = "CameraX • MLKit Sample", - description = "TBD", + description = "This sample demonstrates using CameraX in conjunction with MLKit", documentation = "https://developer.android.com/training/camerax", apiSurface = CameraCameraXApiSurface, tags = listOf("CameraX"), From 2db0ee8b60e920ec2571e79692cc45849705016d Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 12 May 2025 17:37:06 -0400 Subject: [PATCH 08/12] Addressing small nits --- samples/camera/camerax/build.gradle.kts | 3 -- .../platform/camerax/CameraXExtensions.kt | 27 +++++++++++++++--- .../example/platform/camerax/CameraXVideo.kt | 28 ++++--------------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/samples/camera/camerax/build.gradle.kts b/samples/camera/camerax/build.gradle.kts index 76407cc4..da2af049 100644 --- a/samples/camera/camerax/build.gradle.kts +++ b/samples/camera/camerax/build.gradle.kts @@ -52,9 +52,6 @@ dependencies { implementation(libs.androidx.camerax.mlkit.vision) implementation(libs.androidx.camerax.extensions) - // Exoplayer - - // Image loading implementation(libs.coil) implementation(libs.coil.compose) diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt index 2610c846..89e8cf26 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt @@ -29,17 +29,36 @@ 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.* +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.Create import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.* -import androidx.compose.runtime.* +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 diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt index ef88b97a..9e80f1fb 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt @@ -239,11 +239,11 @@ private fun PermissionRationale( fun CameraPreview( modifier: Modifier = Modifier, lifecycleOwner: LifecycleOwner, - // cameraExecutor: ExecutorService, // This was passed but not used in the original snippet 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. @@ -302,9 +302,7 @@ fun CameraPreview( } }, - ContextCompat.getMainExecutor(context), // For the listener itself - // If ProcessCameraProvider.getInstance needs a specific executor, it's handled internally - // or via its other getInstance method. The addListener's executor is for the callback. + ContextCompat.getMainExecutor(context), ) Log.d(TAG, "CameraProvider listener added.") } @@ -358,27 +356,12 @@ fun VideoPlaybackScreen( videoView.setMediaController(mediaController) videoView.requestFocus() - // --- MODIFICATION FOR LOOPING --- videoView.setOnPreparedListener { mp -> mp.isLooping = true // Enable looping Log.d(TAG, "VideoView prepared, looping enabled.") } - // Start playback after preparing. If setOnPreparedListener is used, - // starting playback there is also an option, but start() here is fine. videoView.start() Log.d(TAG, "VideoView playback started with URI: $videoUri") - - - // Alternative looping mechanism using OnCompletionListener: - /* - videoView.setOnCompletionListener { - Log.d(TAG, "VideoView playback completed, restarting for loop.") - videoView.start() // Restart video when it completes - } - videoView.start() // Initial start - */ - // --- END MODIFICATION --- - onDispose { Log.d(TAG, "Disposing VideoView, stopping playback.") videoView.stopPlayback() @@ -415,7 +398,7 @@ enum class RecordingState { private fun startRecording( context: Context, videoCapture: VideoCapture, - executor: ExecutorService, // This executor is for CameraX recording operations + executor: ExecutorService, onRecordingStarted: (Recording) -> Unit, onRecordingError: (VideoRecordEvent.Finalize) -> Unit, onRecordingComplete: (Uri) -> Unit, @@ -440,7 +423,7 @@ private fun startRecording( } val activeRecording = - pendingRecording.start(executor) { recordEvent -> // Uses the passed executor + pendingRecording.start(executor) { recordEvent -> when (recordEvent) { is VideoRecordEvent.Start -> { Log.d(TAG, "Recording started successfully.") @@ -475,7 +458,8 @@ private fun startRecording( } } - is VideoRecordEvent.Status -> { /* Log.v(TAG, "Status: ${recordEvent.recordingStats}") */ + is VideoRecordEvent.Status -> { + Log.v(TAG, "Status: ${recordEvent.recordingStats}") } is VideoRecordEvent.Pause -> Log.d(TAG, "Recording paused") From fce97d4db888690564c15aee0cb10e818f0b132e Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 14 May 2025 17:03:40 -0400 Subject: [PATCH 09/12] Update CameraXBasic to use CameraXViewFinder --- .../com/example/platform/app/SampleDemo.kt | 2 +- gradle/libs.versions.toml | 3 +- samples/camera/camerax/build.gradle.kts | 6 +- .../example/platform/camerax/CameraXBasic.kt | 306 ------------------ .../platform/camerax/basic/CameraXBasic.kt | 293 +++++++++++++++++ .../camerax/basic/CameraXBasicViewModel.kt | 193 +++++++++++ 6 files changed, 492 insertions(+), 311 deletions(-) delete mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXBasic.kt create mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt create mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasicViewModel.kt 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 e3f44f54..e0a67fb9 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,7 @@ 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.CameraXBasic +import com.example.platform.camerax.basic.CameraXBasic import com.example.platform.camerax.CameraXExtensions import com.example.platform.camerax.CameraXMlKitScreen import com.example.platform.camerax.CameraXVideo diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8b4e573b..e6e8442b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,7 +51,7 @@ androidxTestExtTruth = "1.5.0" androidxTestRules = "1.5.0" androidxTestRunner = "1.5.2" androidxUiAutomator = "2.2.0" -camerax = "1.4.2" +camerax = "1.5.0-beta01" material3Android = "1.3.2" media3 = "1.5.0" constraintlayout = "2.1.4" @@ -175,6 +175,7 @@ androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "medi # CameraX androidx-camerax-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +androidx-camerax-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" } androidx-camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } androidx-camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } androidx-camerax-video = { module = "androidx.camera:camera-video", version.ref = "camerax" } diff --git a/samples/camera/camerax/build.gradle.kts b/samples/camera/camerax/build.gradle.kts index da2af049..340ac61a 100644 --- a/samples/camera/camerax/build.gradle.kts +++ b/samples/camera/camerax/build.gradle.kts @@ -45,12 +45,12 @@ dependencies { // CameraX implementation(libs.androidx.camerax.core) implementation(libs.androidx.camerax.camera2) + implementation(libs.androidx.camerax.compose) + implementation(libs.androidx.camerax.extensions) implementation(libs.androidx.camerax.lifecycle) + implementation(libs.androidx.camerax.mlkit.vision) implementation(libs.androidx.camerax.video) implementation(libs.androidx.camerax.view) - implementation(libs.androidx.camerax.mlkit.vision) - implementation(libs.androidx.camerax.mlkit.vision) - implementation(libs.androidx.camerax.extensions) // Image loading implementation(libs.coil) diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXBasic.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXBasic.kt deleted file mode 100644 index abf0a91a..00000000 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXBasic.kt +++ /dev/null @@ -1,306 +0,0 @@ -/* - * 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 - -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.Toast -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCaptureException -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.foundation.Image -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.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -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.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.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.lifecycle.compose.LocalLifecycleOwner -import coil.compose.rememberAsyncImagePainter -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.PermissionStatus -import com.google.accompanist.permissions.rememberPermissionState -import com.google.accompanist.permissions.shouldShowRationale -import java.text.SimpleDateFormat -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -fun CameraXBasic() { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - - // State to control whether to show camera preview or captured image - var showCapturedImage by remember { mutableStateOf(null) } - - // Request camera permission - val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) - - // Executor for camera operations - val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } - - // CameraX use cases - val previewUseCase = remember { Preview.Builder().build() } - val imageCaptureUseCase = remember { ImageCapture.Builder().build() } - - // Get CameraProvider asynchronously - val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } - var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) } - - // Effect to get the CameraProvider once available - LaunchedEffect(cameraProviderFuture) { - cameraProvider = suspendCoroutine { continuation -> - cameraProviderFuture.addListener( - { - continuation.resume(cameraProviderFuture.get()) - }, - ContextCompat.getMainExecutor(context), - ) - } - } - - // Effect to bind use cases when cameraProvider is available and permission is granted - LaunchedEffect(cameraProvider, cameraPermissionState.status) { - val provider = cameraProvider - if (provider != null && cameraPermissionState.status == PermissionStatus.Granted) { - try { - // Unbind all use cases before rebinding - provider.unbindAll() - - // Select camera - val cameraSelector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - - // Bind use cases to the lifecycle - provider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - previewUseCase, - imageCaptureUseCase, - ) - } catch (exc: Exception) { - Log.e("CameraXComposeApp", "Use case binding failed", exc) - Toast.makeText(context, "Failed to initialize camera.", Toast.LENGTH_SHORT).show() - } - } - } - - DisposableEffect(Unit) { - onDispose { - cameraExecutor.shutdown() - } - } - - Box(modifier = Modifier.fillMaxSize()) { - when (cameraPermissionState.status) { - is PermissionStatus.Granted -> { - if (showCapturedImage != null) { - // Show the captured image with a back button - CapturedImageView(uri = showCapturedImage!!) { - showCapturedImage = null // Return to preview on back button click - } - } else { - // Show camera preview - CameraPreview(previewUseCase) - } - } - - is PermissionStatus.Denied -> { - // Show permission request UI - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - val textToShow = if (cameraPermissionState.status.shouldShowRationale) { - "The camera is needed to take pictures. Please grant the permission." - } else { - "Camera permission is required to use this feature." - } - Text(text = textToShow) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { - Text("Request Permission") - } - } - } - } - - // Capture button (only visible when showing preview) - if (showCapturedImage == null && cameraPermissionState.status == PermissionStatus.Granted) { - Button( - onClick = { - takePhoto( - context = context, - imageCapture = imageCaptureUseCase, - executor = cameraExecutor, - onImageCaptured = { uri -> showCapturedImage = uri }, - onError = { exc -> - ContextCompat.getMainExecutor(context).execute { - Toast.makeText( - context, - "Photo capture failed: ${exc.message}", - Toast.LENGTH_SHORT, - ).show() - } - Log.e("CameraXComposeApp", "Photo capture failed: ${exc.message}", exc) - }, - ) - }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(16.dp), - ) { - Text("Take Photo") - } - } - } -} - -@Composable -fun CameraPreview(previewUseCase: Preview) { - AndroidView( - factory = { context -> - PreviewView(context).apply { - layoutParams = android.view.ViewGroup.LayoutParams( - android.view.ViewGroup.LayoutParams.MATCH_PARENT, - android.view.ViewGroup.LayoutParams.MATCH_PARENT, - ) - setBackgroundColor(android.graphics.Color.BLACK) - // Bind the Preview use case to the PreviewView's surfaceProvider - previewUseCase.surfaceProvider = this.surfaceProvider - } - }, - modifier = Modifier.fillMaxSize(), - ) -} - -@Composable -fun CapturedImageView(uri: Uri, onDismiss: () -> Unit) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - Image( - painter = rememberAsyncImagePainter(uri), - contentDescription = "Captured Image", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, // Or your preferred scaling - ) - - // Back button - IconButton( - onClick = onDismiss, - modifier = Modifier - .align(Alignment.TopStart) - .padding(16.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back to Camera", - ) - } - } -} - -private fun takePhoto( - context: Context, - imageCapture: ImageCapture, - executor: ExecutorService, - onImageCaptured: (Uri?) -> Unit, - onError: (ImageCaptureException) -> Unit, -) { - val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", java.util.Locale.US) - .format(System.currentTimeMillis()) - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, name) - put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { - put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraXBasic") - } - } - - // Create output options object for saving the file in MediaStore - val outputOptions = ImageCapture.OutputFileOptions - .Builder( - context.contentResolver, - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - contentValues, - ) - .build() - - // Set up image capture listener, which is triggered after the photo has been taken - imageCapture.takePicture( - outputOptions, - executor, - object : ImageCapture.OnImageSavedCallback { - override fun onError(exc: ImageCaptureException) { - onError(exc) - ContextCompat.getMainExecutor(context).execute { - Toast.makeText( - context, - "Photo capture failed: ${exc.message}", - Toast.LENGTH_SHORT, - ).show() - } - } - - override fun onImageSaved(output: ImageCapture.OutputFileResults) { - onImageCaptured(output.savedUri) - val msg = "Photo capture succeeded: ${output.savedUri}" - ContextCompat.getMainExecutor(context).execute { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - Log.d("CameraXComposeApp", msg) - } - }, - ) -} \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt new file mode 100644 index 00000000..d9496278 --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt @@ -0,0 +1,293 @@ +/* + * 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.basic + +import android.Manifest +import android.net.Uri +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.viewfinder.compose.MutableCoordinateTransformer +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.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.geometry.Offset +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.geometry.takeOrElse +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.rememberAsyncImagePainter +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import kotlinx.coroutines.delay +import java.util.UUID.randomUUID +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * This is a basic CameraX sample demonstrating how to use CameraX with Compose. It handles camera + * permissions, displays a camera preview, and captures photos. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraXBasic(modifier: Modifier = Modifier) { + var showCapturedImage by remember { mutableStateOf(null) } + + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + val imageCaptureCallbackExecutor: ExecutorService = + remember { Executors.newSingleThreadExecutor() } + + val viewModel = remember { CameraXBasicViewModel() } + + DisposableEffect(Unit) { + onDispose { + imageCaptureCallbackExecutor.shutdown() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + ContentWithPermissionHandling( + cameraPermissionState = cameraPermissionState, + showCapturedImage = showCapturedImage, + onShowCapturedImageChange = { showCapturedImage = it }, + viewModel = viewModel, + imageCaptureCallbackExecutor = imageCaptureCallbackExecutor, + modifier = modifier, + ) + } +} + +/** + * Handles camera permission status and displays content accordingly. + * + * If permission is granted, it shows either the camera preview or the captured image. + * If permission is denied, it displays a message and a button to request permission. + * + * @param cameraPermissionState The state of the camera permission. + * @param showCapturedImage The URI of the captured image to display, if any. + * @param onShowCapturedImageChange Callback function to update the [showCapturedImage] state. + * @param viewModel The [CameraXBasicViewModel] for handling camera operations. + * @param imageCaptureCallbackExecutor The executor service for image capture callbacks. + * @param modifier Modifier to be applied to the layout. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun ContentWithPermissionHandling( + cameraPermissionState: PermissionState, + showCapturedImage: Uri?, + onShowCapturedImageChange: (Uri?) -> Unit, + viewModel: CameraXBasicViewModel, + imageCaptureCallbackExecutor: ExecutorService, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + when (cameraPermissionState.status) { + is PermissionStatus.Granted -> { + if (showCapturedImage != null) { + CapturedImageView(uri = showCapturedImage) { + onShowCapturedImageChange(null) + } + } else { + CameraPreviewContent( + viewModel = viewModel, + modifier = modifier, + lifecycleOwner = LocalLifecycleOwner.current, + onTakePhotoClick = { + viewModel.takePhoto( + context = context, + callbackExecutor = imageCaptureCallbackExecutor, + onImageCaptured = { uri -> onShowCapturedImageChange(uri) }, + onError = { /* Error logging/toast handled within takePhoto */ }, + ) + }, + ) + } + } + + is PermissionStatus.Denied -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val textToShow = if (cameraPermissionState.status.shouldShowRationale) { + "The camera is needed to take pictures. Please grant the permission." + } else { + "Camera permission is required to use this feature." + } + Text(text = textToShow) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { + Text("Request Permission") + } + } + } + } +} + +/** + * Composable function that displays the camera preview. + * + * @param viewModel The [CameraXBasicViewModel] for accessing the camera preview surface request. + * @param modifier Modifier to be applied to the layout. + * @param onTakePhotoClick Callback function to be invoked when the "Take Photo" button is clicked. + * @param lifecycleOwner The lifecycle owner to bind the camera to. + */ +@Composable +private fun CameraPreviewContent( + viewModel: CameraXBasicViewModel, + modifier: Modifier = Modifier, + onTakePhotoClick: () -> Unit, + lifecycleOwner: LifecycleOwner, +) { + val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle() + val context = LocalContext.current + LaunchedEffect(lifecycleOwner) { + viewModel.bindToCamera(context.applicationContext, lifecycleOwner) + } + + // State for managing the autofocus indicator + var autofocusRequest by remember { mutableStateOf(randomUUID() to Offset.Unspecified) } + + // Extracting values from the autofocusRequest state + val autofocusRequestId = autofocusRequest.first + val showAutofocusIndicator = autofocusRequest.second.isSpecified + val autofocusLocation = remember(autofocusRequestId) { autofocusRequest.second } + + // Effect to hide the autofocus indicator after a delay + if (showAutofocusIndicator) { + LaunchedEffect(autofocusRequestId) { + delay(1000) + // Clear the offset to finish the request and hide the indicator + autofocusRequest = autofocusRequestId to Offset.Unspecified + } + } + + surfaceRequest?.let { request -> + val coordinateTransformer = remember { MutableCoordinateTransformer() } + Box(modifier = Modifier.fillMaxSize()) { + CameraXViewfinder( + surfaceRequest = request, + coordinateTransformer = coordinateTransformer, + modifier = Modifier + .fillMaxSize() // Ensure CameraXViewfinder fills the Box + .pointerInput(viewModel, coordinateTransformer) { + detectTapGestures { tapCoords -> + with(coordinateTransformer) { + viewModel.tapToFocus(tapCoords.transform()) + } + // Update the state to show the autofocus indicator + autofocusRequest = randomUUID() to tapCoords + } + }, + ) + + AnimatedVisibility( + visible = showAutofocusIndicator, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .offset { autofocusLocation.takeOrElse { Offset.Zero }.round() } + .offset((-24).dp, (-24).dp), // Adjust offset to center the indicator + ) { + Spacer( + Modifier + .border(2.dp, Color.White, CircleShape) + .size(48.dp), + ) + } + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button(onClick = onTakePhotoClick) { Text("Take Photo") } + } + } + } +} + +/** + * Composable function that displays a captured image. + * + * @param uri The URI of the image to display. + * @param onDismiss Callback function to be invoked when the user dismisses the image view + * (e.g., clicks the back button). + */ +@Composable +fun CapturedImageView(uri: Uri, onDismiss: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + Image( + painter = rememberAsyncImagePainter(uri), + contentDescription = "Captured Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to Camera", + ) + } + } +} \ No newline at end of file diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasicViewModel.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasicViewModel.kt new file mode 100644 index 00000000..3b11bc18 --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasicViewModel.kt @@ -0,0 +1,193 @@ +/* + * 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.basic + +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.CameraControl +import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA +import androidx.camera.core.FocusMeteringAction +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceOrientedMeteringPointFactory +import androidx.camera.core.SurfaceRequest +import androidx.camera.core.UseCaseGroup +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.lifecycle.awaitInstance +import androidx.compose.ui.geometry.Offset +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.ExecutorService + +/** + * ViewModel for the CameraX Basic sample. + * + * This ViewModel handles the camera setup, preview display, tap to focus, and photo capture + * functionality using CameraX. It exposes a [StateFlow] for the camera preview [SurfaceRequest] + * to be used in a composable. + */ +class CameraXBasicViewModel { + private val _surfaceRequest = MutableStateFlow(null) + val surfaceRequest: StateFlow = _surfaceRequest + private var surfaceMeteringPointFactory: SurfaceOrientedMeteringPointFactory? = null + + private var cameraControl: CameraControl? = null + + /** + * CameraX Preview use case configuration. + * Sets up a surface provider that updates the [_surfaceRequest] StateFlow. + */ + private val previewUseCase = Preview.Builder().build().apply { + setSurfaceProvider { newSurfaceRequest -> + _surfaceRequest.update { newSurfaceRequest } + surfaceMeteringPointFactory = SurfaceOrientedMeteringPointFactory( + newSurfaceRequest.resolution.width.toFloat(), + newSurfaceRequest.resolution.height.toFloat(), + ) + } + } + + /** + * CameraX ImageCapture use case configuration. + */ + private val imageCaptureUseCase = ImageCapture.Builder().build() + + /** + * Binds the camera to the lifecycle owner and selected use cases. + * + * @param appContext The application context. + * @param lifecycleOwner The lifecycle owner to which the camera will be bound. + */ + suspend fun bindToCamera(appContext: Context, lifecycleOwner: LifecycleOwner) { + val processCameraProvider = ProcessCameraProvider.awaitInstance(appContext) + val useCaseGroup = UseCaseGroup.Builder() + .addUseCase(previewUseCase) // Add Preview UseCase + .addUseCase(imageCaptureUseCase) // Add Image Capture UseCase + .build() + + val camera = processCameraProvider.bindToLifecycle( + lifecycleOwner = lifecycleOwner, + cameraSelector = DEFAULT_BACK_CAMERA, // Default to the back camera + useCaseGroup = useCaseGroup, + ) + + // Assign camera control for tap-to-focus functionality. + cameraControl = camera.cameraControl + + // Cancellation signals we're done with the camera + try { + awaitCancellation() + } finally { + processCameraProvider.unbindAll() + cameraControl = null + } + } + + /** + * Initiates tap-to-focus at the given coordinates on the preview surface. + * + * @param coordinates The coordinates of the tap relative to the preview surface. + */ + fun tapToFocus(coordinates: Offset) { + val point = surfaceMeteringPointFactory?.createPoint(coordinates.x, coordinates.y) + if (point != null) { + val meteringAction = FocusMeteringAction.Builder(point).build() + cameraControl?.startFocusAndMetering(meteringAction) + } + } + + /** + * Takes a photo and saves it to external storage. + * + * @param context The application context. + * @param callbackExecutor The executor to run the image capture callbacks on. + * @param onImageCaptured Callback invoked when the image is successfully captured and saved. + * @param onError Callback invoked if an error occurs during image capture. + */ + fun takePhoto( + context: Context, + callbackExecutor: ExecutorService, + onImageCaptured: (Uri?) -> Unit, + onError: (ImageCaptureException) -> Unit, + ) { + val name = SimpleDateFormat( + "yyyy-MM-dd-HH-mm-ss-SSS", + Locale.US, + ).format(System.currentTimeMillis()) + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraXBasic") + } + } + + val outputOptions = ImageCapture.OutputFileOptions + .Builder( + context.contentResolver, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues, + ) + .build() + + imageCaptureUseCase.takePicture( + outputOptions, + callbackExecutor, + ImageSavedCallback(context, onImageCaptured, onError), + ) + } +} + +/** + * Callback for handling image capture results. + * + * @param context The application context. + * @param onImageCaptured Callback invoked when the image is successfully captured and saved. + * @param onError Callback invoked if an error occurs during image capture. + */ +private class ImageSavedCallback( + private val context: Context, + private val onImageCaptured: (Uri?) -> Unit, + private val onErrorA: (ImageCaptureException) -> Unit, +) : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + val savedUri = output.savedUri + Log.d("CameraXComposeApp", "Photo capture succeeded: $savedUri") + onImageCaptured(savedUri) + } + + override fun onError(exc: ImageCaptureException) { + Log.e("CameraXComposeApp", "Photo capture failed: ${exc.message}", exc) + ContextCompat.getMainExecutor(context).execute { + Toast.makeText(context, "Photo capture failed: ${exc.message}", Toast.LENGTH_SHORT) + .show() + } + onErrorA(exc) + } +} \ No newline at end of file From c3165945149cdbeecef35abc6884360939b73a35 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Fri, 16 May 2025 14:09:37 -0400 Subject: [PATCH 10/12] Simplified PR to just CameraXBasic to address issues with other samples using CameraXViewFinder --- .../com/example/platform/app/SampleDemo.kt | 32 +- .../platform/camerax/CameraXExtensions.kt | 444 --------------- .../platform/camerax/CameraXMlKitSample.kt | 221 -------- .../example/platform/camerax/CameraXVideo.kt | 505 ------------------ .../platform/camerax/mlkit/QrCodeDrawable.kt | 80 --- .../platform/camerax/mlkit/QrCodeViewModel.kt | 61 --- .../viewmodels/CameraExtensionsViewModel.kt | 447 ---------------- 7 files changed, 1 insertion(+), 1789 deletions(-) delete mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt delete mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt delete mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt delete mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeDrawable.kt delete mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeViewModel.kt delete mode 100644 samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt 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 e0a67fb9..0bee52b8 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -28,9 +28,6 @@ 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.basic.CameraXBasic -import com.example.platform.camerax.CameraXExtensions -import com.example.platform.camerax.CameraXMlKitScreen -import com.example.platform.camerax.CameraXVideo import com.example.platform.connectivity.audio.AudioCommsSample import com.example.platform.connectivity.bluetooth.ble.BLEScanIntentSample import com.example.platform.connectivity.bluetooth.ble.ConnectGATTSample @@ -226,39 +223,12 @@ val SAMPLE_DEMOS by lazy { ComposableSampleDemo( id = "camerax-basic", name = "CameraX • Basic Image Capture", - description = "This sample demonstrates how to capture an image using CameraX", + description = "This sample demonstrates how to capture an image & tap-to-focus using CameraX", documentation = "https://developer.android.com/training/camerax", apiSurface = CameraCameraXApiSurface, tags = listOf("CameraX"), content = { CameraXBasic() }, ), - ComposableSampleDemo( - id = "camerax-video", - name = "CameraX • Basic Video Capture", - description = "This sample demonstrates how to capture an 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-mlkit", - name = "CameraX • MLKit Sample", - description = "This sample demonstrates using CameraX in conjunction with MLKit", - documentation = "https://developer.android.com/training/camerax", - apiSurface = CameraCameraXApiSurface, - tags = listOf("CameraX"), - content = { CameraXMlKitScreen() }, - ), ComposableSampleDemo( id = "communication-audio-manager", diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt deleted file mode 100644 index 89e8cf26..00000000 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXExtensions.kt +++ /dev/null @@ -1,444 +0,0 @@ -/* - * 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 - -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.viewmodels.CameraExtensionsState -import com.example.platform.camerax.viewmodels.CameraExtensionsViewModel -import com.example.platform.camerax.viewmodels.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/CameraXMlKitSample.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt deleted file mode 100644 index 2cf80e1b..00000000 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXMlKitSample.kt +++ /dev/null @@ -1,221 +0,0 @@ -/* - * 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 - -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.example.platform.camerax.mlkit.QrCodeDrawable -import com.example.platform.camerax.mlkit.QrCodeViewModel -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 CameraXMlKitScreen() { - 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/CameraXVideo.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt deleted file mode 100644 index 9e80f1fb..00000000 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/CameraXVideo.kt +++ /dev/null @@ -1,505 +0,0 @@ -package com.example.platform.camerax - -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 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 deleted file mode 100644 index 352c134c..00000000 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeDrawable.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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 deleted file mode 100644 index fa59f63e..00000000 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/mlkit/QrCodeViewModel.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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/viewmodels/CameraExtensionsViewModel.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt deleted file mode 100644 index 118a155f..00000000 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/viewmodels/CameraExtensionsViewModel.kt +++ /dev/null @@ -1,447 +0,0 @@ -/* - * 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.viewmodels - -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 From 62d732440b3c45bdd65bf4d54007927cc08940cd Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Fri, 16 May 2025 14:28:12 -0400 Subject: [PATCH 11/12] Extracted new composible for permission denied --- .../platform/camerax/basic/CameraXBasic.kt | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt index d9496278..bed56b33 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt @@ -154,25 +154,43 @@ private fun ContentWithPermissionHandling( } } - is PermissionStatus.Denied -> { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - val textToShow = if (cameraPermissionState.status.shouldShowRationale) { - "The camera is needed to take pictures. Please grant the permission." - } else { - "Camera permission is required to use this feature." - } - Text(text = textToShow) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { - Text("Request Permission") - } - } + is PermissionStatus.Denied -> CameraPermissionDeniedView( + cameraPermissionState.status, + cameraPermissionState, + ) + } +} + +/** + * Composable function that displays a message when camera permission is denied. + * + * It provides an option to request the permission again. + * + * @param status The current [PermissionStatus] of the camera permission. + * @param cameraPermissionState The [PermissionState] for the camera permission, used to request permission again. + */ +@Composable +@OptIn(ExperimentalPermissionsApi::class) +private fun CameraPermissionDeniedView( + status: PermissionStatus, + cameraPermissionState: PermissionState, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val textToShow = if (status.shouldShowRationale) { + "The camera is needed to take pictures. Please grant the permission." + } else { + "Camera permission is required to use this feature." + } + Text(text = textToShow) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { + Text("Request Permission") } } } From 2b847767cc1eb77269dafb4bb5dff9b395ceefdf Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 19 May 2025 16:15:13 -0700 Subject: [PATCH 12/12] small nits on formating --- .../java/com/example/platform/camerax/basic/CameraXBasic.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt b/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt index bed56b33..04f8c71a 100644 --- a/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt @@ -82,11 +82,8 @@ import java.util.concurrent.Executors @Composable fun CameraXBasic(modifier: Modifier = Modifier) { var showCapturedImage by remember { mutableStateOf(null) } - val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) - val imageCaptureCallbackExecutor: ExecutorService = - remember { Executors.newSingleThreadExecutor() } - + val imageCaptureCallbackExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } val viewModel = remember { CameraXBasicViewModel() } DisposableEffect(Unit) {