From 1c77eb343d57962e59c95d1bbfe6bbe465937d19 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Fri, 17 Jul 2026 10:35:14 -0400 Subject: [PATCH 1/4] Add sensor resolution selection UI and controller support for RAW capture modes --- .../rawcapture/CameraXRawCaptureController.kt | 197 +++++++++- .../rawcapture/CameraXRawCaptureScreen.kt | 336 +++++++++++++++++- .../rawcapture/CameraXRawCaptureUiState.kt | 14 +- .../rawcapture/CameraXRawCaptureViewModel.kt | 52 ++- .../src/main/res/values/strings.xml | 22 ++ 5 files changed, 592 insertions(+), 29 deletions(-) diff --git a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureController.kt b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureController.kt index f8afe46e..8cfda15b 100644 --- a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureController.kt +++ b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureController.kt @@ -18,15 +18,26 @@ package com.android.camerax.rawcapture import android.content.ContentValues import android.content.Context import android.graphics.ImageFormat +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.hardware.camera2.CameraMetadata +import android.hardware.camera2.CaptureRequest import android.net.Uri import android.os.Build import android.provider.MediaStore import android.util.Log +import androidx.annotation.OptIn +import androidx.camera.camera2.interop.Camera2CameraInfo +import androidx.camera.camera2.interop.Camera2Interop +import androidx.camera.camera2.interop.ExperimentalCamera2Interop +import androidx.camera.core.CameraInfo import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -55,15 +66,20 @@ fun rememberCameraXRawCaptureController( context: Context, lifecycleOwner: LifecycleOwner, onCaptured: (dngUri: Uri, jpegUri: Uri) -> Unit, + onCapabilitiesReady: (isFullSensorSupported: Boolean, pixelBinLabel: String, fullSensorLabel: String) -> Unit, onUnsupported: () -> Unit, ): CameraXRawCaptureController { val latestOnCaptured by rememberUpdatedState(onCaptured) + val latestOnCapabilitiesReady by rememberUpdatedState(onCapabilitiesReady) val latestOnUnsupported by rememberUpdatedState(onUnsupported) return remember(context, lifecycleOwner) { CameraXRawCaptureController( context, lifecycleOwner, onCaptured = { dng, jpeg -> latestOnCaptured(dng, jpeg) }, + onCapabilitiesReady = { isFullSupported, pixelBin, fullSensor -> + latestOnCapabilitiesReady(isFullSupported, pixelBin, fullSensor) + }, onUnsupported = { latestOnUnsupported() }, ) } @@ -72,17 +88,15 @@ fun rememberCameraXRawCaptureController( /** * Captures a RAW (DNG) frame and a companion JPEG in one shot with CameraX. After resolving the back * camera it checks [ImageCapture.getImageCaptureCapabilities]; if the camera does not advertise - * [ImageCapture.OUTPUT_FORMAT_RAW_JPEG] it reports [onUnsupported] instead of binding. The - * [ImageCapture] is built with that output format, and capture uses the two-[OutputFileOptions] - * `takePicture` overload — its callback fires twice, once for the DNG (`image/x-adobe-dng`) and once - * for the JPEG (`image/jpeg`), both written to `DCIM/Camera` via MediaStore. Once both URIs arrive - * they are emitted together through [onCaptured]. + * [ImageCapture.OUTPUT_FORMAT_RAW_JPEG] it reports [onUnsupported] instead of binding. Supports + * toggling between standard pixel-binned mode and full sensor native resolution mode via Camera2Interop. */ @Stable class CameraXRawCaptureController( private val context: Context, private val lifecycleOwner: LifecycleOwner, private val onCaptured: (dngUri: Uri, jpegUri: Uri) -> Unit, + private val onCapabilitiesReady: (isFullSensorSupported: Boolean, pixelBinLabel: String, fullSensorLabel: String) -> Unit, private val onUnsupported: () -> Unit, ) { private val appContext = context.applicationContext @@ -96,6 +110,9 @@ class CameraXRawCaptureController( private var imageCapture: ImageCapture? = null private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private var currentMode: RawSensorMode = RawSensorMode.PIXEL_BIN + private var isFullSensorSupported: Boolean = false + // The dual-format callback fires twice; hold each URI until its partner arrives. private var pendingDngUri: Uri? = null private var pendingJpegUri: Uri? = null @@ -107,7 +124,8 @@ class CameraXRawCaptureController( private val cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - fun openCamera() { + fun openCamera(mode: RawSensorMode = currentMode) { + currentMode = mode providerScope.launch { val provider = ProcessCameraProvider.getInstance(appContext).await() cameraProvider = provider @@ -124,15 +142,11 @@ class CameraXRawCaptureController( return@launch } - val capture = - ImageCapture - .Builder() - .setOutputFormat(ImageCapture.OUTPUT_FORMAT_RAW_JPEG) - .build() - imageCapture = capture + val eval = evaluateSensorCapabilities(cameraInfo) + isFullSensorSupported = eval.isFullSensorSupported + onCapabilitiesReady(eval.isFullSensorSupported, eval.pixelBinLabel, eval.fullSensorLabel) - provider.unbindAll() - provider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, capture) + bindImageCapture(provider, mode) } catch (e: Exception) { Log.e(TAG, "Use case binding failed", e) onUnsupported() @@ -140,6 +154,155 @@ class CameraXRawCaptureController( } } + fun setSensorMode(mode: RawSensorMode) { + if (currentMode == mode) return + currentMode = mode + val provider = cameraProvider ?: return + bindImageCapture(provider, mode) + } + + @OptIn(ExperimentalCamera2Interop::class) + private fun bindImageCapture( + provider: ProcessCameraProvider, + mode: RawSensorMode, + ) { + val captureBuilder = + ImageCapture + .Builder() + .setOutputFormat(ImageCapture.OUTPUT_FORMAT_RAW_JPEG) + + if (mode == RawSensorMode.FULL_SENSOR && isFullSensorSupported) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Camera2Interop.Extender(captureBuilder).setCaptureRequestOption( + CaptureRequest.SENSOR_PIXEL_MODE, + CameraMetadata.SENSOR_PIXEL_MODE_MAXIMUM_RESOLUTION, + ) + } + captureBuilder.setResolutionSelector( + ResolutionSelector + .Builder() + .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY) + .build(), + ) + } + + val capture = captureBuilder.build() + imageCapture = capture + + provider.unbindAll() + provider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, capture) + } + + @OptIn(ExperimentalCamera2Interop::class) + private fun evaluateSensorCapabilities(cameraInfo: CameraInfo): SensorCapabilities { + val camera2Info = Camera2CameraInfo.from(cameraInfo) + val cameraManager = appContext.getSystemService(Context.CAMERA_SERVICE) as? CameraManager + val logicalChars = + try { + cameraManager?.getCameraCharacteristics(camera2Info.cameraId) + } catch (e: Exception) { + null + } + + val caps = + logicalChars?.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) + ?: camera2Info.getCameraCharacteristic(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) + var fullSupported = + caps?.contains( + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_ULTRA_HIGH_RESOLUTION_SENSOR, + ) == true + + val streamMap = + logicalChars?.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) + ?: camera2Info.getCameraCharacteristic(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) + val defaultRawSize = + streamMap?.getOutputSizes(ImageFormat.RAW_SENSOR)?.maxByOrNull { it.width * it.height } + val pixelBinLabel = defaultRawSize?.let { formatMegapixels(it.width, it.height) } ?: "" + + var maxRawSize = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val maxResStreamMap = + logicalChars?.get( + CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP_MAXIMUM_RESOLUTION, + ) ?: camera2Info.getCameraCharacteristic( + CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP_MAXIMUM_RESOLUTION, + ) + maxResStreamMap?.getOutputSizes(ImageFormat.RAW_SENSOR)?.maxByOrNull { + it.width * it.height + } + } else { + null + } + + if (maxRawSize != null) { + fullSupported = true + } + + // On Google Pixel and multi-camera devices, the default logical camera ID ("0") might hide + // ULTRA_HIGH_RESOLUTION_SENSOR. Query the underlying physical camera IDs to discover native 50MP streams. + if (!fullSupported && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && logicalChars != null && cameraManager != null) { + val physicalIds = logicalChars.physicalCameraIds + for (physicalId in physicalIds) { + try { + val physicalChars = cameraManager.getCameraCharacteristics(physicalId) + val physicalCaps = + physicalChars.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) + val physicalHasUltra = + physicalCaps?.contains( + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_ULTRA_HIGH_RESOLUTION_SENSOR, + ) == true + + val physicalMaxMap = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + physicalChars.get( + CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP_MAXIMUM_RESOLUTION, + ) + } else { + null + } + val physicalMaxSize = + physicalMaxMap?.getOutputSizes(ImageFormat.RAW_SENSOR)?.maxByOrNull { + it.width * it.height + } + + if (physicalHasUltra || physicalMaxSize != null) { + fullSupported = true + if (maxRawSize == null || + ( + physicalMaxSize != null && + physicalMaxSize.width * physicalMaxSize.height > maxRawSize.width * maxRawSize.height + ) + ) { + maxRawSize = physicalMaxSize + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to query physical camera $physicalId", e) + } + } + } + + val fullSensorLabel = maxRawSize?.let { formatMegapixels(it.width, it.height) } ?: "" + + return SensorCapabilities( + isFullSensorSupported = fullSupported, + pixelBinLabel = pixelBinLabel, + fullSensorLabel = fullSensorLabel, + ) + } + + private fun formatMegapixels( + width: Int, + height: Int, + ): String { + val mp = (width.toLong() * height) / 1_000_000.0 + return if (mp >= 10.0 && mp % 1.0 < 0.05) { + String.format(Locale.US, "%.0f MP", mp) + } else { + String.format(Locale.US, "%.1f MP", mp) + } + } + fun updateTargetRotation(rotation: Int) { imageCapture?.targetRotation = rotation preview.targetRotation = rotation @@ -213,3 +376,9 @@ class CameraXRawCaptureController( cameraExecutor.shutdown() } } + +private data class SensorCapabilities( + val isFullSensorSupported: Boolean, + val pixelBinLabel: String, + val fullSensorLabel: String, +) diff --git a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureScreen.kt b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureScreen.kt index 0a011763..5a3cda13 100644 --- a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureScreen.kt +++ b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureScreen.kt @@ -22,18 +22,61 @@ import android.graphics.ImageDecoder import android.net.Uri import android.os.Build import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +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.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState +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.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -42,12 +85,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.camera.core.camerax.CameraXPreview import com.android.camera.core.display.rememberDisplayRotation import com.android.camera.core.permissions.CameraPermissions +import com.android.camera.coretheme.bodyFontFamily +import com.android.camera.coretheme.monoFontFamily import com.android.camera.coreui.controls.CameraControlsBar +import com.android.camera.coreui.controls.ScrimIconButton import com.android.camera.coreui.controls.ShutterButton import com.android.camera.coreui.feedback.ObserveSaveEvents import com.android.camera.coreui.overlay.RuleOfThirdsGrid +import com.android.camera.coreui.overlay.SettingsDropdown +import com.android.camera.coreui.overlay.SettingsHeader +import com.android.camera.coreui.overlay.SettingsOverlay import com.android.camera.coreui.overlay.ViewfinderTopBar -import com.android.camera.coreui.preview.CapturedImagePreview import com.android.camera.coreui.scaffold.CameraApi import com.android.camera.coreui.scaffold.CameraSampleScaffold import com.android.camera.coreui.state.ErrorView @@ -55,12 +103,10 @@ import com.android.camera.coreui.state.LoadingView import com.android.camera.coreui.state.UnsupportedView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.util.Locale @Composable -fun CameraXRawCaptureScreen( - viewModel: CameraXRawCaptureViewModel = - hiltViewModel(), -) { +fun CameraXRawCaptureScreen(viewModel: CameraXRawCaptureViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val onBack = { backDispatcher?.onBackPressed() ?: Unit } @@ -83,8 +129,10 @@ fun CameraXRawCaptureScreen( ErrorView(errorMessage = state.errorMessage, onRetry = viewModel::resetError) } - CameraXRawCaptureUiState.Previewing -> { + is CameraXRawCaptureUiState.Previewing -> { PreviewingContent( + state = state, + viewModel = viewModel, onCaptured = viewModel::captured, onUnsupported = viewModel::setUnsupported, onBack = onBack, @@ -104,6 +152,8 @@ fun CameraXRawCaptureScreen( @Composable private fun BoxScope.PreviewingContent( + state: CameraXRawCaptureUiState.Previewing, + viewModel: CameraXRawCaptureViewModel, onCaptured: (dngUri: Uri, jpegUri: Uri) -> Unit, onUnsupported: () -> Unit, onBack: () -> Unit, @@ -115,6 +165,7 @@ private fun BoxScope.PreviewingContent( context = context, lifecycleOwner = lifecycleOwner, onCaptured = onCaptured, + onCapabilitiesReady = viewModel::onCapabilitiesEvaluated, onUnsupported = onUnsupported, ) val displayRotation = rememberDisplayRotation() @@ -128,7 +179,7 @@ private fun BoxScope.PreviewingContent( LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_CREATE, Lifecycle.Event.ON_RESUME -> { - controller.openCamera() + controller.openCamera(state.selectedMode) } Lifecycle.Event.ON_PAUSE -> { @@ -155,17 +206,68 @@ private fun BoxScope.PreviewingContent( title = stringResource(R.string.rawcapture_title), onClose = onBack, closeIcon = Icons.AutoMirrored.Filled.ArrowBack, + actions = { + ScrimIconButton( + onClick = { viewModel.setSettingsVisible(true) }, + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(R.string.rawcapture_settings_button), + size = 34.dp, + iconSize = 18.dp, + ) + }, ) CameraControlsBar( modifier = Modifier.align(Alignment.BottomCenter), center = { ShutterButton(onClick = controller::takePicture) }, ) + + val pixelBinSummary = + state.pixelBinResolutionLabel.ifEmpty { + context.getString(R.string.rawcapture_mode_default_fallback) + } + val fullSensorSummary = + state.fullSensorResolutionLabel.ifEmpty { + context.getString(R.string.rawcapture_mode_max_fallback) + } + + val options = + if (state.isFullSensorSupported) { + listOf(RawSensorMode.PIXEL_BIN, RawSensorMode.FULL_SENSOR) + } else { + listOf(RawSensorMode.PIXEL_BIN) + } + + SettingsOverlay( + visible = state.showSettings, + onDismiss = { viewModel.setSettingsVisible(false) }, + ) { + SettingsHeader(text = stringResource(R.string.rawcapture_settings_title)) + SettingsDropdown( + label = stringResource(R.string.rawcapture_mode_label), + options = options, + selected = state.selectedMode, + onSelected = { mode -> + viewModel.selectMode(mode) + controller.setSensorMode(mode) + }, + optionLabel = { mode -> + when (mode) { + RawSensorMode.PIXEL_BIN -> { + context.getString(R.string.rawcapture_mode_pixel_bin, pixelBinSummary) + } + + RawSensorMode.FULL_SENSOR -> { + context.getString(R.string.rawcapture_mode_full_sensor, fullSensorSummary) + } + } + }, + ) + } } /** - * Reviews the capture by decoding the companion JPEG to a [Bitmap] (the DNG is preserved on disk but - * not all devices can decode it in-app). Retake deletes both saved files; Done leaves the sample. + * Reviews the capture with interactive pinch-to-zoom and panning. */ @Composable private fun BoxScope.CapturedReview( @@ -182,7 +284,7 @@ private fun BoxScope.CapturedReview( if (current == null) { LoadingView() } else { - CapturedImagePreview( + ZoomableCapturedImagePreview( bitmap = current, onRetake = onRetake, onDone = onDone, @@ -190,6 +292,220 @@ private fun BoxScope.CapturedReview( } } +@Composable +private fun ZoomableCapturedImagePreview( + bitmap: Bitmap, + onRetake: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + val mp = (bitmap.width.toLong() * bitmap.height) / 1_000_000.0 + val mpLabel = + if (mp >= 10.0 && mp % 1.0 < 0.05) { + String.format(Locale.US, "%.0f MP", mp) + } else { + String.format(Locale.US, "%.1f MP", mp) + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(Color.Black), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .clipToBounds() + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { tapOffset -> + if (scale > 1.2f) { + scale = 1f + offset = Offset.Zero + } else { + scale = 3.5f + val maxOffsetX = (size.width * (3.5f - 1f)) / 2f + val maxOffsetY = (size.height * (3.5f - 1f)) / 2f + val centerX = size.width / 2f + val centerY = size.height / 2f + offset = + Offset( + x = + ((centerX - tapOffset.x) * (3.5f - 1f)).coerceIn( + -maxOffsetX, + maxOffsetX, + ), + y = + ((centerY - tapOffset.y) * (3.5f - 1f)).coerceIn( + -maxOffsetY, + maxOffsetY, + ), + ) + } + }, + ) + }.pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + val newScale = (scale * zoom).coerceIn(1f, 8f) + scale = newScale + val maxOffsetX = (size.width * (newScale - 1f)) / 2f + val maxOffsetY = (size.height * (newScale - 1f)) / 2f + offset = + if (newScale > 1f) { + Offset( + x = (offset.x + pan.x).coerceIn(-maxOffsetX, maxOffsetX), + y = (offset.y + pan.y).coerceIn(-maxOffsetY, maxOffsetY), + ) + } else { + Offset.Zero + } + } + }, + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Captured Photo", + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + }, + contentScale = ContentScale.Fit, + ) + } + + ScrimIconButton( + onClick = onDone, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + size = 34.dp, + iconSize = 18.dp, + modifier = + Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(16.dp), + ) + + if (scale > 1.05f) { + Text( + text = String.format(Locale.US, "%.1fx", scale), + style = + TextStyle( + fontFamily = monoFontFamily, + fontSize = 12.sp, + letterSpacing = 0.06.em, + ), + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(16.dp), + ) + } + + Text( + text = "${bitmap.width} × ${bitmap.height} • $mpLabel", + style = + TextStyle( + fontFamily = monoFontFamily, + fontSize = 10.sp, + letterSpacing = 0.06.em, + ), + color = Color.White.copy(alpha = 0.55f), + modifier = + Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .padding(start = 20.dp, bottom = 100.dp), + ) + + Row( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ReviewActionPill( + modifier = Modifier.weight(1f), + icon = Icons.Filled.Refresh, + label = "Retake", + filled = false, + onClick = onRetake, + ) + ReviewActionPill( + modifier = Modifier.weight(1f), + icon = Icons.Filled.Check, + label = "Done", + filled = true, + onClick = onDone, + ) + } + } +} + +@Composable +private fun ReviewActionPill( + icon: ImageVector, + label: String, + filled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val accent = MaterialTheme.colorScheme.primary + val shape = RoundedCornerShape(26.dp) + val contentColor = if (filled) MaterialTheme.colorScheme.onPrimary else Color.White + val base = + modifier + .height(52.dp) + .clip(shape) + val styled = + if (filled) { + base.background(accent) + } else { + base + .background(Color.White.copy(alpha = 0.04f)) + .border(1.dp, Color.White.copy(alpha = 0.22f), shape) + } + Row( + modifier = styled.clickable(onClick = onClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + style = + TextStyle( + fontFamily = bodyFontFamily, + fontSize = 14.sp, + fontWeight = if (filled) FontWeight.Bold else FontWeight.Medium, + ), + color = contentColor, + ) + } +} + private fun decodeJpeg( context: Context, uri: Uri, diff --git a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureUiState.kt b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureUiState.kt index ac987ff4..7452e850 100644 --- a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureUiState.kt +++ b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureUiState.kt @@ -17,10 +17,21 @@ package com.android.camerax.rawcapture import android.net.Uri +enum class RawSensorMode { + PIXEL_BIN, + FULL_SENSOR, +} + sealed interface CameraXRawCaptureUiState { data object Initial : CameraXRawCaptureUiState - data object Previewing : CameraXRawCaptureUiState + data class Previewing( + val selectedMode: RawSensorMode = RawSensorMode.PIXEL_BIN, + val isFullSensorSupported: Boolean = false, + val pixelBinResolutionLabel: String = "", + val fullSensorResolutionLabel: String = "", + val showSettings: Boolean = false, + ) : CameraXRawCaptureUiState /** * A single shutter press produced two files: a RAW [dngUri] (`image/x-adobe-dng`) and a @@ -30,6 +41,7 @@ sealed interface CameraXRawCaptureUiState { data class Captured( val dngUri: Uri, val jpegUri: Uri, + val mode: RawSensorMode = RawSensorMode.PIXEL_BIN, ) : CameraXRawCaptureUiState /** The camera does not advertise RAW + JPEG simultaneous output (e.g. most emulators). */ diff --git a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureViewModel.kt b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureViewModel.kt index c1ca4555..c3beaafb 100644 --- a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureViewModel.kt +++ b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import javax.inject.Inject @HiltViewModel @@ -44,7 +45,45 @@ class CameraXRawCaptureViewModel fun initialize() { if (_uiState.value is CameraXRawCaptureUiState.Initial) { - _uiState.value = CameraXRawCaptureUiState.Previewing + _uiState.value = CameraXRawCaptureUiState.Previewing() + } + } + + fun onCapabilitiesEvaluated( + isFullSensorSupported: Boolean, + pixelBinLabel: String, + fullSensorLabel: String, + ) { + _uiState.update { current -> + if (current is CameraXRawCaptureUiState.Previewing) { + current.copy( + isFullSensorSupported = isFullSensorSupported, + pixelBinResolutionLabel = pixelBinLabel, + fullSensorResolutionLabel = fullSensorLabel, + ) + } else { + current + } + } + } + + fun selectMode(mode: RawSensorMode) { + _uiState.update { current -> + if (current is CameraXRawCaptureUiState.Previewing) { + current.copy(selectedMode = mode, showSettings = false) + } else { + current + } + } + } + + fun setSettingsVisible(visible: Boolean) { + _uiState.update { current -> + if (current is CameraXRawCaptureUiState.Previewing) { + current.copy(showSettings = visible) + } else { + current + } } } @@ -56,7 +95,10 @@ class CameraXRawCaptureViewModel dngUri: Uri, jpegUri: Uri, ) { - _uiState.value = CameraXRawCaptureUiState.Captured(dngUri, jpegUri) + val currentMode = + (_uiState.value as? CameraXRawCaptureUiState.Previewing)?.selectedMode + ?: RawSensorMode.PIXEL_BIN + _uiState.value = CameraXRawCaptureUiState.Captured(dngUri, jpegUri, currentMode) _events.trySend(SaveEvent.Saved) } @@ -70,10 +112,12 @@ class CameraXRawCaptureViewModel } fun resetToCamera() { - _uiState.value = CameraXRawCaptureUiState.Previewing + val previous = (_uiState.value as? CameraXRawCaptureUiState.Captured) + val previousMode = previous?.mode ?: RawSensorMode.PIXEL_BIN + _uiState.value = CameraXRawCaptureUiState.Previewing(selectedMode = previousMode) } fun resetError() { - _uiState.value = CameraXRawCaptureUiState.Previewing + _uiState.value = CameraXRawCaptureUiState.Previewing() } } diff --git a/samples/camerax-rawcapture/src/main/res/values/strings.xml b/samples/camerax-rawcapture/src/main/res/values/strings.xml index a69f8e27..a35069c5 100644 --- a/samples/camerax-rawcapture/src/main/res/values/strings.xml +++ b/samples/camerax-rawcapture/src/main/res/values/strings.xml @@ -1,5 +1,27 @@ + RAW / DNG This device\'s camera doesn\'t support simultaneous RAW (DNG) + JPEG capture. + Sensor Settings + Sensor Settings + Sensor Resolution + Pixel Bin (%1$s) + Full Sensor (%1$s) + Default + Max From ad0ac29fe01c9a9baf72b566512461e670f9665e Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 10 Aug 2026 14:36:04 -0400 Subject: [PATCH 2/4] Implement full sensor resolution mode and settings in Camera2 RAW capture --- gradle.properties | 4 +- .../rawcapture/Camera2RawCaptureController.kt | 193 +++++++++++++++++- .../rawcapture/Camera2RawCaptureScreen.kt | 72 ++++++- .../rawcapture/Camera2RawCaptureUiState.kt | 14 +- .../rawcapture/Camera2RawCaptureViewModel.kt | 52 ++++- .../src/main/res/values/strings.xml | 22 ++ 6 files changed, 336 insertions(+), 21 deletions(-) diff --git a/gradle.properties b/gradle.properties index c4d42b82..00bee911 100644 --- a/gradle.properties +++ b/gradle.properties @@ -33,4 +33,6 @@ android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false android.r8.optimizedResourceShrinking=false android.builtInKotlin=false -android.newDsl=false \ No newline at end of file +android.newDsl=false +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureController.kt b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureController.kt index c40d021f..88e7152f 100644 --- a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureController.kt +++ b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureController.kt @@ -34,12 +34,14 @@ import android.os.Build import android.os.Environment import android.provider.MediaStore import android.util.Log +import android.util.Size import android.view.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import com.android.camera.core.camera2.BaseCamera2Controller import com.android.camera.core.media.MediaStoreSaver import java.io.File import java.io.FileOutputStream @@ -56,15 +58,20 @@ fun rememberCamera2RawCaptureController( context: Context, isFrontCamera: Boolean, onDngSaved: (uri: Uri, rotationDegrees: Int) -> Unit, + onCapabilitiesReady: (isFullSensorSupported: Boolean, pixelBinLabel: String, fullSensorLabel: String) -> Unit, onUnsupported: () -> Unit, ): Camera2RawCaptureController { val latestOnDngSaved by rememberUpdatedState(onDngSaved) + val latestOnCapabilitiesReady by rememberUpdatedState(onCapabilitiesReady) val latestOnUnsupported by rememberUpdatedState(onUnsupported) return remember(context, isFrontCamera) { Camera2RawCaptureController( context, isFrontCamera, onDngSaved = { uri, rotationDegrees -> latestOnDngSaved(uri, rotationDegrees) }, + onCapabilitiesReady = { isFullSupported, pixelBin, fullSensor -> + latestOnCapabilitiesReady(isFullSupported, pixelBin, fullSensor) + }, onUnsupported = { latestOnUnsupported() }, ) } @@ -72,21 +79,29 @@ fun rememberCamera2RawCaptureController( /** * Captures a single `RAW_SENSOR` frame and writes it as a DNG via [DngCreator]. The shared - * open/close/transform plumbing lives in [com.android.camera.core.camera2.BaseCamera2Controller]; + * open/close/transform plumbing lives in [BaseCamera2Controller]; * this class gates on the camera's RAW capability, adds a `RAW_SENSOR` [ImageReader], and pairs each - * captured [Image] with its [TotalCaptureResult] (DngCreator needs both) before saving. All work - * happens on the controller's background handler. + * captured [Image] with its [TotalCaptureResult] (DngCreator needs both) before saving. + * Supports toggling between standard pixel-binned mode and full sensor native resolution mode. + * All work happens on the controller's background handler. */ @Stable class Camera2RawCaptureController( context: Context, isFrontCamera: Boolean, private val onDngSaved: (uri: Uri, rotationDegrees: Int) -> Unit, + private val onCapabilitiesReady: (isFullSensorSupported: Boolean, pixelBinLabel: String, fullSensorLabel: String) -> Unit, private val onUnsupported: () -> Unit, -) : com.android.camera.core.camera2.BaseCamera2Controller(context, isFrontCamera) { +) : BaseCamera2Controller(context, isFrontCamera) { private var rawReader: ImageReader? = null private var sensorOrientation: Int = 90 + private var currentMode: RawSensorMode = RawSensorMode.PIXEL_BIN + private var isFullSensorSupported: Boolean = false + private var defaultRawSize: Size? = null + private var maxRawSize: Size? = null + private var currentPreviewSurface: Surface? = null + // DngCreator needs the RAW Image and the TotalCaptureResult that produced it; they arrive on two // callbacks (both on the background handler), so hold each until its partner is ready. private var pendingImage: Image? = null @@ -106,16 +121,32 @@ class Camera2RawCaptureController( return } - val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) - val rawSize = - map?.getOutputSizes(ImageFormat.RAW_SENSOR)?.maxByOrNull { it.width.toLong() * it.height } - if (rawSize == null) { + val eval = evaluateSensorCapabilities(characteristics) + isFullSensorSupported = eval.isFullSensorSupported + defaultRawSize = eval.defaultRawSize + maxRawSize = eval.maxRawSize + onCapabilitiesReady(eval.isFullSensorSupported, eval.pixelBinLabel, eval.fullSensorLabel) + + val size = activeRawSize() + if (size == null) { onUnsupported() return } + createRawReader(size) + } + + private fun activeRawSize(): Size? = + if (currentMode == RawSensorMode.FULL_SENSOR && isFullSensorSupported) { + maxRawSize ?: defaultRawSize + } else { + defaultRawSize + } + + private fun createRawReader(size: Size) { + rawReader?.close() rawReader = - ImageReader.newInstance(rawSize.width, rawSize.height, ImageFormat.RAW_SENSOR, 2).apply { + ImageReader.newInstance(size.width, size.height, ImageFormat.RAW_SENSOR, 2).apply { setOnImageAvailableListener({ reader -> pendingImage = reader.acquireNextImage() tryWriteDng() @@ -127,6 +158,7 @@ class Camera2RawCaptureController( camera: CameraDevice, surface: Surface, ) { + currentPreviewSurface = surface val targets = mutableListOf(surface) rawReader?.surface?.let { targets.add(it) } @@ -138,6 +170,23 @@ class Camera2RawCaptureController( createCaptureSession(camera, targets) { startRepeatingRequest() } } + fun setSensorMode(mode: RawSensorMode) { + if (currentMode == mode) return + currentMode = mode + backgroundHandler.post { + val camera = cameraDevice ?: return@post + val surface = currentPreviewSurface ?: return@post + val size = activeRawSize() ?: return@post + + createRawReader(size) + + val targets = mutableListOf(surface) + rawReader?.surface?.let { targets.add(it) } + + createCaptureSession(camera, targets) { startRepeatingRequest() } + } + } + private fun startRepeatingRequest() { try { val builder = previewRequestBuilder ?: return @@ -163,6 +212,19 @@ class Camera2RawCaptureController( device.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { addTarget(reader.surface) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + if (currentMode == RawSensorMode.FULL_SENSOR && isFullSensorSupported) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + set( + CaptureRequest.SENSOR_PIXEL_MODE, + CameraMetadata.SENSOR_PIXEL_MODE_MAXIMUM_RESOLUTION, + ) + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + set( + CaptureRequest.SENSOR_PIXEL_MODE, + CameraMetadata.SENSOR_PIXEL_MODE_DEFAULT, + ) + } } session.capture( captureBuilder.build(), @@ -183,6 +245,110 @@ class Camera2RawCaptureController( } } + private fun evaluateSensorCapabilities(characteristics: CameraCharacteristics): SensorCapabilities { + val caps = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) + var fullSupported = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + caps?.contains( + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_ULTRA_HIGH_RESOLUTION_SENSOR, + ) == true + } else { + false + } + + val streamMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) + val defaultRawSize = + streamMap?.getOutputSizes(ImageFormat.RAW_SENSOR)?.maxByOrNull { it.width.toLong() * it.height } + val pixelBinLabel = defaultRawSize?.let { formatMegapixels(it.width, it.height) } ?: "" + + var maxRawSize = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val maxResStreamMap = + characteristics.get( + CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP_MAXIMUM_RESOLUTION, + ) + maxResStreamMap?.getOutputSizes(ImageFormat.RAW_SENSOR)?.maxByOrNull { + it.width.toLong() * it.height + } + } else { + null + } + + if (maxRawSize != null) { + fullSupported = true + } + + // On Google Pixel and multi-camera devices, the default logical camera ID ("0") might hide + // ULTRA_HIGH_RESOLUTION_SENSOR. Query the underlying physical camera IDs to discover native 50MP streams. + if (!fullSupported && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val physicalIds = characteristics.physicalCameraIds + for (physicalId in physicalIds) { + try { + val physicalChars = cameraManager.getCameraCharacteristics(physicalId) + val physicalCaps = + physicalChars.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) + val physicalHasUltra = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + physicalCaps?.contains( + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_ULTRA_HIGH_RESOLUTION_SENSOR, + ) == true + } else { + false + } + + val physicalMaxMap = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + physicalChars.get( + CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP_MAXIMUM_RESOLUTION, + ) + } else { + null + } + val physicalMaxSize = + physicalMaxMap?.getOutputSizes(ImageFormat.RAW_SENSOR)?.maxByOrNull { + it.width.toLong() * it.height + } + + if (physicalHasUltra || physicalMaxSize != null) { + fullSupported = true + if (maxRawSize == null || + ( + physicalMaxSize != null && + physicalMaxSize.width.toLong() * physicalMaxSize.height > maxRawSize.width.toLong() * maxRawSize.height + ) + ) { + maxRawSize = physicalMaxSize + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to query physical camera $physicalId", e) + } + } + } + + val fullSensorLabel = maxRawSize?.let { formatMegapixels(it.width, it.height) } ?: "" + + return SensorCapabilities( + isFullSensorSupported = fullSupported, + defaultRawSize = defaultRawSize, + maxRawSize = maxRawSize, + pixelBinLabel = pixelBinLabel, + fullSensorLabel = fullSensorLabel, + ) + } + + private fun formatMegapixels( + width: Int, + height: Int, + ): String { + val mp = (width.toLong() * height) / 1_000_000.0 + return if (mp >= 10.0 && mp % 1.0 < 0.05) { + String.format(Locale.US, "%.0f MP", mp) + } else { + String.format(Locale.US, "%.1f MP", mp) + } + } + /** Runs on the background handler; writes the DNG once both the image and its result exist. */ private fun tryWriteDng() { val image = pendingImage ?: return @@ -274,5 +440,14 @@ class Camera2RawCaptureController( pendingResult = null rawReader?.close() rawReader = null + currentPreviewSurface = null } } + +private data class SensorCapabilities( + val isFullSensorSupported: Boolean, + val defaultRawSize: Size?, + val maxRawSize: Size?, + val pixelBinLabel: String, + val fullSensorLabel: String, +) diff --git a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureScreen.kt b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureScreen.kt index e2e9104c..16211816 100644 --- a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureScreen.kt +++ b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureScreen.kt @@ -15,10 +15,12 @@ */ package com.android.camera2.rawcapture +import android.net.Uri import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.BoxScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -33,8 +35,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.camera.core.camera2.Camera2Preview import com.android.camera.core.permissions.CameraPermissions import com.android.camera.coreui.controls.CameraControlsBar +import com.android.camera.coreui.controls.ScrimIconButton import com.android.camera.coreui.controls.ShutterButton import com.android.camera.coreui.overlay.RuleOfThirdsGrid +import com.android.camera.coreui.overlay.SettingsDropdown +import com.android.camera.coreui.overlay.SettingsHeader +import com.android.camera.coreui.overlay.SettingsOverlay import com.android.camera.coreui.overlay.ViewfinderTopBar import com.android.camera.coreui.scaffold.CameraApi import com.android.camera.coreui.scaffold.CameraSampleScaffold @@ -43,10 +49,7 @@ import com.android.camera.coreui.state.LoadingView import com.android.camera.coreui.state.UnsupportedView @Composable -fun Camera2RawCaptureScreen( - viewModel: Camera2RawCaptureViewModel = - hiltViewModel(), -) { +fun Camera2RawCaptureScreen(viewModel: Camera2RawCaptureViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val onBack = { backDispatcher?.onBackPressed() ?: Unit } @@ -67,8 +70,10 @@ fun Camera2RawCaptureScreen( ErrorView(errorMessage = state.errorMessage, onRetry = viewModel::resetError) } - Camera2RawCaptureUiState.Previewing -> { + is Camera2RawCaptureUiState.Previewing -> { PreviewingContent( + state = state, + viewModel = viewModel, onDngSaved = viewModel::onDngSaved, onUnsupported = viewModel::setUnsupported, onBack = onBack, @@ -88,7 +93,9 @@ fun Camera2RawCaptureScreen( @Composable private fun BoxScope.PreviewingContent( - onDngSaved: (android.net.Uri, Int) -> Unit, + state: Camera2RawCaptureUiState.Previewing, + viewModel: Camera2RawCaptureViewModel, + onDngSaved: (Uri, Int) -> Unit, onUnsupported: () -> Unit, onBack: () -> Unit, ) { @@ -98,6 +105,7 @@ private fun BoxScope.PreviewingContent( context = context, isFrontCamera = false, onDngSaved = onDngSaved, + onCapabilitiesReady = viewModel::onCapabilitiesEvaluated, onUnsupported = onUnsupported, ) @@ -113,10 +121,62 @@ private fun BoxScope.PreviewingContent( title = stringResource(R.string.rawcapture_title), onClose = onBack, closeIcon = Icons.AutoMirrored.Filled.ArrowBack, + actions = { + ScrimIconButton( + onClick = { viewModel.setSettingsVisible(true) }, + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(R.string.rawcapture_settings_button), + size = 34.dp, + iconSize = 18.dp, + ) + }, ) CameraControlsBar( modifier = Modifier.align(Alignment.BottomCenter), center = { ShutterButton(onClick = controller::captureRaw) }, ) + + val pixelBinSummary = + state.pixelBinResolutionLabel.ifEmpty { + context.getString(R.string.rawcapture_mode_default_fallback) + } + val fullSensorSummary = + state.fullSensorResolutionLabel.ifEmpty { + context.getString(R.string.rawcapture_mode_max_fallback) + } + + val options = + if (state.isFullSensorSupported) { + listOf(RawSensorMode.PIXEL_BIN, RawSensorMode.FULL_SENSOR) + } else { + listOf(RawSensorMode.PIXEL_BIN) + } + + SettingsOverlay( + visible = state.showSettings, + onDismiss = { viewModel.setSettingsVisible(false) }, + ) { + SettingsHeader(text = stringResource(R.string.rawcapture_settings_title)) + SettingsDropdown( + label = stringResource(R.string.rawcapture_mode_label), + options = options, + selected = state.selectedMode, + onSelected = { mode -> + viewModel.selectMode(mode) + controller.setSensorMode(mode) + }, + optionLabel = { mode -> + when (mode) { + RawSensorMode.PIXEL_BIN -> { + context.getString(R.string.rawcapture_mode_pixel_bin, pixelBinSummary) + } + + RawSensorMode.FULL_SENSOR -> { + context.getString(R.string.rawcapture_mode_full_sensor, fullSensorSummary) + } + } + }, + ) + } } diff --git a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureUiState.kt b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureUiState.kt index 1dad9107..3ff278c8 100644 --- a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureUiState.kt +++ b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureUiState.kt @@ -17,10 +17,21 @@ package com.android.camera2.rawcapture import android.net.Uri +enum class RawSensorMode { + PIXEL_BIN, + FULL_SENSOR, +} + sealed interface Camera2RawCaptureUiState { data object Initial : Camera2RawCaptureUiState - data object Previewing : Camera2RawCaptureUiState + data class Previewing( + val selectedMode: RawSensorMode = RawSensorMode.PIXEL_BIN, + val isFullSensorSupported: Boolean = false, + val pixelBinResolutionLabel: String = "", + val fullSensorResolutionLabel: String = "", + val showSettings: Boolean = false, + ) : Camera2RawCaptureUiState /** * Reviewing/editing a just-captured DNG. [dngUri] is the saved file and [rotationDegrees] is the @@ -29,6 +40,7 @@ sealed interface Camera2RawCaptureUiState { data class Editing( val dngUri: Uri, val rotationDegrees: Int, + val mode: RawSensorMode = RawSensorMode.PIXEL_BIN, ) : Camera2RawCaptureUiState /** Shown when the camera does not advertise the RAW capability. */ diff --git a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureViewModel.kt b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureViewModel.kt index 2ad30756..b5a26964 100644 --- a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureViewModel.kt +++ b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureViewModel.kt @@ -21,6 +21,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import javax.inject.Inject @HiltViewModel @@ -33,7 +34,45 @@ class Camera2RawCaptureViewModel fun initialize() { if (_uiState.value is Camera2RawCaptureUiState.Initial) { - _uiState.value = Camera2RawCaptureUiState.Previewing + _uiState.value = Camera2RawCaptureUiState.Previewing() + } + } + + fun onCapabilitiesEvaluated( + isFullSensorSupported: Boolean, + pixelBinLabel: String, + fullSensorLabel: String, + ) { + _uiState.update { current -> + if (current is Camera2RawCaptureUiState.Previewing) { + current.copy( + isFullSensorSupported = isFullSensorSupported, + pixelBinResolutionLabel = pixelBinLabel, + fullSensorResolutionLabel = fullSensorLabel, + ) + } else { + current + } + } + } + + fun selectMode(mode: RawSensorMode) { + _uiState.update { current -> + if (current is Camera2RawCaptureUiState.Previewing) { + current.copy(selectedMode = mode, showSettings = false) + } else { + current + } + } + } + + fun setSettingsVisible(visible: Boolean) { + _uiState.update { current -> + if (current is Camera2RawCaptureUiState.Previewing) { + current.copy(showSettings = visible) + } else { + current + } } } @@ -45,11 +84,16 @@ class Camera2RawCaptureViewModel uri: Uri, rotationDegrees: Int, ) { - _uiState.value = Camera2RawCaptureUiState.Editing(uri, rotationDegrees) + val currentMode = + (_uiState.value as? Camera2RawCaptureUiState.Previewing)?.selectedMode + ?: RawSensorMode.PIXEL_BIN + _uiState.value = Camera2RawCaptureUiState.Editing(uri, rotationDegrees, currentMode) } fun backToCamera() { - _uiState.value = Camera2RawCaptureUiState.Previewing + val previous = (_uiState.value as? Camera2RawCaptureUiState.Editing) + val previousMode = previous?.mode ?: RawSensorMode.PIXEL_BIN + _uiState.value = Camera2RawCaptureUiState.Previewing(selectedMode = previousMode) } fun showError(message: String) { @@ -57,6 +101,6 @@ class Camera2RawCaptureViewModel } fun resetError() { - _uiState.value = Camera2RawCaptureUiState.Previewing + _uiState.value = Camera2RawCaptureUiState.Previewing() } } diff --git a/samples/camera2-rawcapture/src/main/res/values/strings.xml b/samples/camera2-rawcapture/src/main/res/values/strings.xml index b6688ebb..4ee8805b 100644 --- a/samples/camera2-rawcapture/src/main/res/values/strings.xml +++ b/samples/camera2-rawcapture/src/main/res/values/strings.xml @@ -1,4 +1,19 @@ + Back RAW / DNG @@ -16,4 +31,11 @@ Warm %1$d Cool %1$d RESET + Sensor Settings + Sensor Settings + Sensor Resolution + Pixel Bin (%1$s) + Full Sensor (%1$s) + Default + Max From d055e8bce803c9052e5a47d7aa8df14738af1c3a Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 10 Aug 2026 14:37:54 -0400 Subject: [PATCH 3/4] Remove obsolete Pixel comment from Camera2RawCaptureController --- .../android/camera2/rawcapture/Camera2RawCaptureController.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureController.kt b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureController.kt index 88e7152f..dec883ad 100644 --- a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureController.kt +++ b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureController.kt @@ -278,8 +278,6 @@ class Camera2RawCaptureController( fullSupported = true } - // On Google Pixel and multi-camera devices, the default logical camera ID ("0") might hide - // ULTRA_HIGH_RESOLUTION_SENSOR. Query the underlying physical camera IDs to discover native 50MP streams. if (!fullSupported && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val physicalIds = characteristics.physicalCameraIds for (physicalId in physicalIds) { From 97f880ddda8929caeb983431c5b157c22a414e50 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Mon, 10 Aug 2026 14:38:21 -0400 Subject: [PATCH 4/4] Remove obsolete Pixel comment from CameraXRawCaptureController --- .../android/camerax/rawcapture/CameraXRawCaptureController.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureController.kt b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureController.kt index 8cfda15b..28b8542a 100644 --- a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureController.kt +++ b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureController.kt @@ -238,8 +238,6 @@ class CameraXRawCaptureController( fullSupported = true } - // On Google Pixel and multi-camera devices, the default logical camera ID ("0") might hide - // ULTRA_HIGH_RESOLUTION_SENSOR. Query the underlying physical camera IDs to discover native 50MP streams. if (!fullSupported && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && logicalChars != null && cameraManager != null) { val physicalIds = logicalChars.physicalCameraIds for (physicalId in physicalIds) {