Embedded Android Photo Picker in Jetpack Compose
Embed Android's system photo picker in a Jetpack Compose UI while keeping URI permissions and selection state under your app's control.
The embedded photo picker is not “a custom gallery UI.” It is the system photo picker rendered inside your UI hierarchy, with the same security and privacy properties as the classic picker, because the system draws it into a dedicated SurfaceView (internally attached via SurfaceView.setChildSurfacePackage). That architectural choice is what unlocks the core product shift: the user stays in your screen while browsing and selecting, and your app can react to selection updates in real time because your activity remains resumed.
The embedded picker is currently supported on Android 14 (API 34) devices with SDK Extensions 15+. On devices that do not meet that bar, Android’s guidance is to rely on the classic photo picker, including backported availability through Google Play services where applicable.
Here is a minimal availability helper you can keep near your UI entrypoint:
import android.os.Buildimport android.os.ext.SdkExtensions
fun isEmbeddedPhotoPickerAvailable(): Boolean { // Embedded picker requires Android 14+ anyway. if (Build.VERSION.SDK_INT < 34) return false
// SDK Extensions are the actual gate for embedded support. return SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 15}Compose integration
The Jetpack integration for embedded is shipped as androidx.photopicker, with a Compose artifact (photopicker-compose).
dependencies { implementation("androidx.photopicker:photopicker-compose:1.0.0-alpha01")}The Compose entrypoint is the EmbeddedPhotoPicker composable and a state holder created via rememberEmbeddedPhotoPickerState. The official docs describe the composable as creating a SurfaceView, managing the service connection, and plumbing selected URIs back via callbacks.
Your minSdk has to be at least 34.
Below is a Compose-first scaffold that keeps the picker logic isolated and makes the rest of the screen testable. The key points are: keep selected URIs in your own state, let the picker grant or revoke URI permissions through callbacks, and explicitly inform the picker when your container expands or collapses.
import android.net.Uriimport androidx.compose.foundation.clickableimport androidx.compose.foundation.layout.*import androidx.compose.foundation.lazy.grid.GridCellsimport androidx.compose.foundation.lazy.grid.LazyVerticalGridimport androidx.compose.foundation.lazy.grid.itemsimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)@Composablefun EmbeddedPickerHost( maxSelection: Int = 5, onDone: (List<Uri>) -> Unit,) { var attachments by remember { mutableStateOf(emptyList<Uri>()) } val scope = rememberCoroutineScope()
val sheetState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( initialValue = SheetValue.Hidden, skipHiddenState = false ) )
// Feature configuration is explicit in the embedded picker sample: // max limit + ordered selection + accent color. // Keep this object stable. val featureInfo = remember { EmbeddedPhotoPickerFeatureInfo.Builder() .setMaxSelectionLimit(maxSelection) .setOrderedSelection(true) .build() }
val pickerState = rememberEmbeddedPhotoPickerState( onSelectionComplete = { scope.launch { sheetState.bottomSheetState.hide() } onDone(attachments) }, onUriPermissionGranted = { granted -> attachments = attachments + granted }, onUriPermissionRevoked = { revoked -> attachments = attachments - revoked.toSet() } )
// Keep picker expansion in sync with the container. SideEffect { val expanded = sheetState.bottomSheetState.targetValue == SheetValue.Expanded pickerState.setCurrentExpanded(expanded) }
BottomSheetScaffold( scaffoldState = sheetState, sheetPeekHeight = if (sheetState.bottomSheetState.isVisible) 400.dp else 0.dp, sheetContent = { // Dedicated picker surface area. EmbeddedPhotoPicker( modifier = Modifier .fillMaxWidth() .heightIn(min = 240.dp), state = pickerState, embeddedPhotoPickerFeatureInfo = featureInfo ) }, topBar = { TopAppBar(title = { Text("Composer") }) } ) { innerPadding -> Column(Modifier.padding(innerPadding).padding(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button(onClick = { scope.launch { sheetState.bottomSheetState.partialExpand() } }) { Text("Add from gallery") } Button( enabled = attachments.isNotEmpty(), onClick = { onDone(attachments) } ) { Text("Send") } }
Spacer(Modifier.height(16.dp))
// Your own attachment UI is separate from the picker surface. LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 88.dp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(attachments) { uri -> AttachmentTile( uri = uri, onRemove = { scope.launch { // Inform the picker that the host UI removed something. pickerState.deselectUri(uri) // Keep host state consistent (deselectUri won't auto-update your list). attachments = attachments - uri } } ) } } } }}
@Composableprivate fun AttachmentTile( uri: Uri, onRemove: () -> Unit) { // Replace with your image loader. Keep it simple here. Surface( tonalElevation = 2.dp, modifier = Modifier .size(88.dp) .clickable { onRemove() } ) { /* preview */ }}Everything in this skeleton is aligned with the platform guidance: the embedded picker is intended to sit inside your UI, selection changes are continuous, and the API surface is built around permission grants and revocations for content URIs instead of broad media permissions.
Selection synchronization, URI lifetimes, and background work
The embedded picker UX is at its best when your host UI and the picker behave like a single selection model. Android’s embedded picker docs call this out explicitly: when a user deselects in your UI, you should notify the picker via deselectUri or deselectUris. There is a crucial gotcha: these calls do not automatically trigger your onUriPermissionRevoked callback, so you must update your own state explicitly.
That behavior is not accidental. It forces you to define ownership: the picker owns what is selectable; your app owns how selection is represented and persisted in your UI. In a messaging composer, the picker is a panel, not the source of truth.
The other non-obvious “this will bite you later” concern is URI access lifetime. For the photo picker in general, Android documents that access is granted until the device restarts or until your app stops, and you can persist access longer by calling takePersistableUriPermission(). If your UX lets users queue uploads or drafts, this matters immediately.
Be aware of the platform cap: Android notes that an app can have up to 5,000 media grants at a time, after which older grants are evicted. That is high enough for typical consumer flows, but it is relevant for apps that treat the picker as an ingestion pipeline.
On the test side, androidx.photopicker has a dedicated testing artifact and a TestEmbeddedPhotoPickerProvider to support testing flows that rely on the embedded picker.