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..0bee52b8 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.basic.CameraXBasic 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-basic", + name = "CameraX • Basic Image Capture", + 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 = "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..e6e8442b 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.5.0-beta01" 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,16 @@ 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-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" } +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 +194,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..340ac61a --- /dev/null +++ b/samples/camera/camerax/build.gradle.kts @@ -0,0 +1,65 @@ +/* + * 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.lifecycle.viewmodel.compose) + implementation(libs.androidx.material3) + implementation(project(":shared")) + + // 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) + + // Image loading + implementation(libs.coil) + implementation(libs.coil.compose) + + // Barcode Scanning + implementation(libs.barcode.scanning.common) + implementation(libs.play.services.mlkit.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 new file mode 100644 index 00000000..c9eb3922 --- /dev/null +++ b/samples/camera/camerax/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + \ 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..04f8c71a --- /dev/null +++ b/samples/camera/camerax/src/main/java/com/example/platform/camerax/basic/CameraXBasic.kt @@ -0,0 +1,308 @@ +/* + * 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 -> 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") + } + } +} + +/** + * 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 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")