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 don’t meet that bar, Android’s guidance is to rely on the classic photo picker (including backported availability via Google Play services where applicable).
Here is a minimal availability helper you can keep near your UI entrypoint:
import android.os.Build
import 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.
You 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/revoke URI permissions through callbacks, and explicitly inform the picker when your container expands/collapses.
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun 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
}
}
)
}
}
}
}
}
@Composable
private 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: embedded picker is intended to sit inside your UI, selection changes are continuous, and the API surface is built around permission grants/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 / deselectUris. There’s 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, the androidx.photopicker has a dedicated testing artifact and a TestEmbeddedPhotoPickerProvider to support testing flows that rely on the embedded picker.
Photo by Ruben Mavarez on Unsplash