gallery(android): Photo Picker Manage flow + per-type MediaStore queries

handleAssets queries MediaStore.Images.Media.EXTERNAL_CONTENT_URI and
MediaStore.Video.Media.EXTERNAL_CONTENT_URI separately and merges. The
previous unified MediaStore.Files query under-reported rows under
READ_MEDIA_VISUAL_USER_SELECTED — the Files URI requires the legacy
READ_EXTERNAL_STORAGE for full visibility, so limited-mode users saw
the wrong subset.

presentLimitedLibraryPicker on API 33+ now launches
MediaStore.ACTION_PICK_IMAGES (multi-select up to 100) instead of
re-calling requestPermissions. The latter falls back to the Photo
Picker UI when READ_MEDIA_IMAGES/VIDEO are USER_FIXED, but its Done
press doesn't write back to the USER_SELECTED grant; only an explicit
ACTION_PICK_IMAGES + onActivityResult path reliably grows the
accessible subset.

Selected URIs are pinned via takePersistableUriPermission and stored
in SharedPreferences (ordered, union semantics, capped at 400 with
oldest-first eviction). handleAssets folds them in as a third source
in Recents; onAttachedToEngine reconciles persisted entries against
ContentResolver.persistedUriPermissions so Settings-side revocations
between sessions are caught.

handleAlbums also drops unnamed buckets from the album dropdown —
they were rendering as blank rows; their photos remain reachable via
Recents.
This commit is contained in:
agra
2026-05-21 21:47:34 +03:00
parent 8fcb2b4af7
commit 30a2933e7b

View File

@@ -32,7 +32,9 @@ import java.io.FileOutputStream
/// resolution into the app cache so `dart:io` can read what the /// resolution into the app cache so `dart:io` can read what the
/// system holds behind a `content://` URI. /// system holds behind a `content://` URI.
class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler, class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
PluginRegistry.RequestPermissionsResultListener, EventChannel.StreamHandler { PluginRegistry.RequestPermissionsResultListener,
PluginRegistry.ActivityResultListener,
EventChannel.StreamHandler {
private var methodChannel: MethodChannel? = null private var methodChannel: MethodChannel? = null
private var changesChannel: EventChannel? = null private var changesChannel: EventChannel? = null
private var context: Context? = null private var context: Context? = null
@@ -44,6 +46,11 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
/// is rejected. /// is rejected.
private var pendingPermissionResult: MethodChannel.Result? = null private var pendingPermissionResult: MethodChannel.Result? = null
/// In-flight `ACTION_PICK_IMAGES` Manage flow. Set by
/// [handlePresentLimitedLibraryPicker], cleared by [onActivityResult].
/// Reentrancy is rejected.
private var pendingPickerResult: MethodChannel.Result? = null
/// Dart subscriber on `ux/gallery/changes` — fed by [mediaObserver] /// Dart subscriber on `ux/gallery/changes` — fed by [mediaObserver]
/// when `MediaStore` reports an insert/update/delete so picker UIs /// when `MediaStore` reports an insert/update/delete so picker UIs
/// can reload reactively (parity with iOS's `photoLibraryDidChange`). /// can reload reactively (parity with iOS's `photoLibraryDidChange`).
@@ -64,6 +71,7 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
changesChannel = EventChannel( changesChannel = EventChannel(
binding.binaryMessenger, "ux/gallery/changes", binding.binaryMessenger, "ux/gallery/changes",
).also { it.setStreamHandler(this) } ).also { it.setStreamHandler(this) }
reconcilePersistedPickerUris()
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -106,14 +114,18 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
activity = binding.activity activity = binding.activity
activityBinding = binding activityBinding = binding
binding.addRequestPermissionsResultListener(this) binding.addRequestPermissionsResultListener(this)
binding.addActivityResultListener(this)
} }
override fun onDetachedFromActivity() { override fun onDetachedFromActivity() {
activityBinding?.removeRequestPermissionsResultListener(this) activityBinding?.removeRequestPermissionsResultListener(this)
activityBinding?.removeActivityResultListener(this)
activityBinding = null activityBinding = null
activity = null activity = null
pendingPermissionResult?.success("denied") pendingPermissionResult?.success("denied")
pendingPermissionResult = null pendingPermissionResult = null
pendingPickerResult?.success(null)
pendingPickerResult = null
} }
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@@ -121,7 +133,7 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
"permission" -> handlePermission(result) "permission" -> handlePermission(result)
"requestPermission" -> handleRequestPermission(result) "requestPermission" -> handleRequestPermission(result)
"openSettings" -> handleOpenSettings(result) "openSettings" -> handleOpenSettings(result)
"presentLimitedLibraryPicker" -> result.success(null) // no-op on Android "presentLimitedLibraryPicker" -> handlePresentLimitedLibraryPicker(result)
"albums" -> handleAlbums(call, result) "albums" -> handleAlbums(call, result)
"assets" -> handleAssets(call, result) "assets" -> handleAssets(call, result)
"thumbnail" -> handleThumbnail(call, result) "thumbnail" -> handleThumbnail(call, result)
@@ -222,6 +234,86 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
return true return true
} }
/// Android analogue of iOS's `PHPhotoLibrary.presentLimitedLibraryPicker`.
/// Opens the system Photo Picker (`ACTION_PICK_IMAGES`, API 33+) for
/// multi-select; returned URIs are persisted via
/// `takePersistableUriPermission` in [onActivityResult] and merged
/// with the `MediaStore.Images` / `Video` queries in [handleAssets].
///
/// The legacy `requestPermissions(USER_SELECTED, …)` path falls back to
/// the same Photo Picker UI on Android 14+ when `READ_MEDIA_IMAGES` /
/// `VIDEO` are `USER_FIXED`, but its Done press doesn't write back to
/// the `READ_MEDIA_VISUAL_USER_SELECTED` grant — only this explicit
/// flow reliably grows the accessible subset.
private fun handlePresentLimitedLibraryPicker(result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
result.success(null)
return
}
val act = activity ?: run {
result.success(null)
return
}
if (pendingPickerResult != null) {
result.success(null)
return
}
pendingPickerResult = result
// Don't set `type` — default surfaces both images and video in
// one sheet. Mixing "image/*"+"video/*" in the type string is
// invalid; EXTRA_MIME_TYPES would be the workaround if we ever
// need to filter by kind.
val intent = Intent(MediaStore.ACTION_PICK_IMAGES).apply {
putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, 100)
}
try {
act.startActivityForResult(intent, REQUEST_CODE_PICKER)
} catch (_: Throwable) {
pendingPickerResult = null
result.success(null)
}
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?,
): Boolean {
if (requestCode != REQUEST_CODE_PICKER) return false
val r = pendingPickerResult ?: return true
pendingPickerResult = null
val ctx = context
if (resultCode == Activity.RESULT_OK && data != null && ctx != null) {
val picked = extractUris(data)
for (uri in picked) {
try {
ctx.contentResolver.takePersistableUriPermission(
uri, Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (_: SecurityException) {
// Picker URIs are always persistable, but stay defensive.
}
}
if (picked.isNotEmpty()) {
appendPickerUris(ctx, picked)
libraryEventSink?.success(null)
}
}
r.success(null)
return true
}
private fun extractUris(data: Intent): List<Uri> {
val out = mutableListOf<Uri>()
val clip = data.clipData
if (clip != null) {
for (i in 0 until clip.itemCount) out.add(clip.getItemAt(i).uri)
} else {
data.data?.let { out.add(it) }
}
return out
}
private fun handleOpenSettings(result: MethodChannel.Result) { private fun handleOpenSettings(result: MethodChannel.Result) {
val ctx = context ?: run { val ctx = context ?: run {
result.error("no_context", "no application context", null) result.error("no_context", "no application context", null)
@@ -286,12 +378,16 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
} }
} }
for ((id, name) in seenBuckets) { for ((id, name) in seenBuckets) {
// Skip system / unnamed buckets — their photos remain
// reachable via Recents, but an unlabeled row in the album
// dropdown is dead-end UX.
if (name.isNullOrBlank()) continue
val count = countAssets(ctx, filter, bucketId = id) val count = countAssets(ctx, filter, bucketId = id)
if (count == 0) continue if (count == 0) continue
albums.add( albums.add(
mapOf( mapOf(
"id" to id.toString(), "id" to id.toString(),
"name" to (name ?: ""), "name" to name,
"count" to count, "count" to count,
"cover_kind" to firstAssetKind(ctx, filter, bucketId = id), "cover_kind" to firstAssetKind(ctx, filter, bucketId = id),
), ),
@@ -347,70 +443,107 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
} }
val bucketId = albumId?.takeIf { it != "recents" }?.toLongOrNull() val bucketId = albumId?.takeIf { it != "recents" }?.toLongOrNull()
val (selection, selectionArgs) = filterToSelection(filter, bucketId)
val projection = arrayOf( // Query per-type tables (Images / Video) rather than the unified
MediaStore.Files.FileColumns._ID, // `MediaStore.Files` URI. The Files URI requires the legacy
MediaStore.Files.FileColumns.MEDIA_TYPE, // `READ_EXTERNAL_STORAGE` for full visibility — under
MediaStore.Files.FileColumns.WIDTH, // `READ_MEDIA_VISUAL_USER_SELECTED` (Android 14+ limited mode)
MediaStore.Files.FileColumns.HEIGHT, // it under-reports rows that the per-type URIs return in full.
MediaStore.Files.FileColumns.DURATION, val merged = mutableListOf<Map<String, Any?>>()
MediaStore.Files.FileColumns.DATE_ADDED, if (filter == "image" || filter == "any") {
MediaStore.Files.FileColumns.DATE_TAKEN, merged.addAll(queryMediaTable(
ctx,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
kind = "image",
bucketId = bucketId,
limit = end,
))
}
if (filter == "video" || filter == "any") {
merged.addAll(queryMediaTable(
ctx,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
kind = "video",
bucketId = bucketId,
limit = end,
))
}
// Picker URIs persisted from [handlePresentLimitedLibraryPicker]
// are an additional source: they have no MediaStore bucket so
// they only contribute to Recents, never to per-bucket album views.
if (bucketId == null) {
merged.addAll(queryPersistedPickerUris(ctx, filter, end))
}
merged.sortByDescending { (it["created_ms"] as? Long) ?: 0L }
result.success(merged.drop(start).take(limit))
}
private fun queryMediaTable(
ctx: Context,
tableUri: Uri,
kind: String,
bucketId: Long?,
limit: Int,
): List<Map<String, Any?>> {
val projection = mutableListOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.DATE_ADDED,
MediaStore.MediaColumns.DATE_TAKEN,
) )
if (kind == "video") projection.add(MediaStore.Video.Media.DURATION)
val uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) val sel = mutableListOf<String>()
// API 30+ rejects `LIMIT` / `OFFSET` baked into the sort-order val args = mutableListOf<String>()
// string. Sort here, paginate via cursor positioning below. if (bucketId != null) {
val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC" sel.add("${MediaStore.MediaColumns.BUCKET_ID} = ?")
args.add(bucketId.toString())
}
val selection = sel.joinToString(" AND ").takeIf { it.isNotEmpty() }
val out = mutableListOf<Map<String, Any?>>() val out = mutableListOf<Map<String, Any?>>()
ctx.contentResolver.query(uri, projection, selection, selectionArgs, sortOrder) ctx.contentResolver.query(
?.use { c -> tableUri,
val idIdx = c.getColumnIndex(MediaStore.Files.FileColumns._ID) projection.toTypedArray(),
val typeIdx = c.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE) selection,
val widthIdx = c.getColumnIndex(MediaStore.Files.FileColumns.WIDTH) args.toTypedArray(),
val heightIdx = c.getColumnIndex(MediaStore.Files.FileColumns.HEIGHT) "${MediaStore.MediaColumns.DATE_ADDED} DESC",
val durationIdx = c.getColumnIndex(MediaStore.Files.FileColumns.DURATION) )?.use { c ->
val takenIdx = c.getColumnIndex(MediaStore.Files.FileColumns.DATE_TAKEN) val idIdx = c.getColumnIndex(MediaStore.MediaColumns._ID)
val addedIdx = c.getColumnIndex(MediaStore.Files.FileColumns.DATE_ADDED) val widthIdx = c.getColumnIndex(MediaStore.MediaColumns.WIDTH)
if (start > 0 && !c.moveToPosition(start - 1)) return@use val heightIdx = c.getColumnIndex(MediaStore.MediaColumns.HEIGHT)
var taken = 0 val takenIdx = c.getColumnIndex(MediaStore.MediaColumns.DATE_TAKEN)
while (taken < limit && c.moveToNext()) { val addedIdx = c.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
val id = c.getLong(idIdx) val durationIdx = c.getColumnIndex(MediaStore.Video.Media.DURATION)
val mediaType = c.getInt(typeIdx) var taken = 0
val kind = mediaTypeToKind(mediaType) ?: continue while (taken < limit && c.moveToNext()) {
val width = if (widthIdx >= 0) c.getInt(widthIdx) else 0 val id = c.getLong(idIdx)
val height = if (heightIdx >= 0) c.getInt(heightIdx) else 0 val width = if (widthIdx >= 0) c.getInt(widthIdx) else 0
val durationMs = if (durationIdx >= 0 && !c.isNull(durationIdx)) { val height = if (heightIdx >= 0) c.getInt(heightIdx) else 0
c.getLong(durationIdx) val durationMs = if (kind == "video" && durationIdx >= 0 &&
} else null !c.isNull(durationIdx)) c.getLong(durationIdx) else null
// DATE_TAKEN is in ms since epoch; DATE_ADDED is in // DATE_TAKEN is in ms since epoch; DATE_ADDED is in
// seconds. Prefer taken when set, fall back to added. // seconds. Prefer taken when set, fall back to added.
val createdMs = when { val createdMs = when {
takenIdx >= 0 && !c.isNull(takenIdx) && c.getLong(takenIdx) > 0 -> takenIdx >= 0 && !c.isNull(takenIdx) && c.getLong(takenIdx) > 0 ->
c.getLong(takenIdx) c.getLong(takenIdx)
addedIdx >= 0 && !c.isNull(addedIdx) -> addedIdx >= 0 && !c.isNull(addedIdx) ->
c.getLong(addedIdx) * 1000L c.getLong(addedIdx) * 1000L
else -> 0L else -> 0L
}
out.add(
mapOf<String, Any?>(
"id" to assetUri(id, mediaType).toString(),
"kind" to kind,
"duration_ms" to (
if (kind == "video") durationMs else null
),
"width" to width,
"height" to height,
"created_ms" to createdMs,
),
)
taken++
} }
out.add(
mapOf<String, Any?>(
"id" to ContentUris.withAppendedId(tableUri, id).toString(),
"kind" to kind,
"duration_ms" to durationMs,
"width" to width,
"height" to height,
"created_ms" to createdMs,
),
)
taken++
} }
result.success(out) }
return out
} }
// ─── Thumbnail ────────────────────────────────────────────────────── // ─── Thumbnail ──────────────────────────────────────────────────────
@@ -570,16 +703,6 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
else -> null else -> null
} }
private fun assetUri(id: Long, mediaType: Int): Uri {
val base = when (mediaType) {
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO ->
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
else ->
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
}
return ContentUris.withAppendedId(base, id)
}
private fun parseAssetUri(s: String): Uri? = try { private fun parseAssetUri(s: String): Uri? = try {
val uri = Uri.parse(s) val uri = Uri.parse(s)
if (uri.scheme == "content") uri else null if (uri.scheme == "content") uri else null
@@ -600,7 +723,162 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
else -> mime.substringAfter('/', "bin") else -> mime.substringAfter('/', "bin")
} }
// ─── Photo Picker URI persistence ───────────────────────────────────
/// Reads the persisted picker-URI list in insertion order. Stored as
/// a single newline-separated string under [PICKER_URIS_KEY] —
/// `SharedPreferences.getStringSet` doesn't preserve order, which we
/// need for the "drop oldest on overflow" eviction.
private fun readPickerUris(ctx: Context): LinkedHashSet<String> {
val raw = ctx
.getSharedPreferences(PICKER_PREFS, Context.MODE_PRIVATE)
.getString(PICKER_URIS_KEY, null) ?: return LinkedHashSet()
return raw.split('\n')
.filter { it.isNotEmpty() }
.toCollection(LinkedHashSet())
}
private fun writePickerUris(ctx: Context, set: LinkedHashSet<String>) {
ctx.getSharedPreferences(PICKER_PREFS, Context.MODE_PRIVATE)
.edit()
.putString(PICKER_URIS_KEY, set.joinToString("\n"))
.commit()
}
/// Appends [new] URIs to the persisted set (union, ordered). On
/// overflow past [PICKER_URI_CAP], drops from the head (oldest first)
/// and releases the kernel-level URI grant for each evicted entry.
private fun appendPickerUris(ctx: Context, new: List<Uri>) {
val set = readPickerUris(ctx)
for (uri in new) set.add(uri.toString())
if (set.size > PICKER_URI_CAP) {
val excess = set.size - PICKER_URI_CAP
val iter = set.iterator()
repeat(excess) {
val dropped = iter.next()
iter.remove()
try {
ctx.contentResolver.releasePersistableUriPermission(
Uri.parse(dropped),
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (_: SecurityException) {
// Grant may already be gone — best effort.
}
}
}
writePickerUris(ctx, set)
}
/// Resolves each persisted picker URI to a row in the same shape as
/// [queryMediaTable]. URIs that the system has revoked (Settings,
/// kernel-eviction, deleted source) are pruned from the persisted
/// set inline.
private fun queryPersistedPickerUris(
ctx: Context,
filter: String,
limit: Int,
): List<Map<String, Any?>> {
val set = readPickerUris(ctx)
if (set.isEmpty()) return emptyList()
val projection = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.DATE_ADDED,
MediaStore.MediaColumns.DATE_TAKEN,
MediaStore.MediaColumns.MIME_TYPE,
MediaStore.MediaColumns.DURATION,
)
val out = mutableListOf<Map<String, Any?>>()
val dead = mutableListOf<String>()
for (uriStr in set) {
if (out.size >= limit) break
val uri = try { Uri.parse(uriStr) } catch (_: Throwable) {
dead.add(uriStr); continue
}
val row = try {
ctx.contentResolver.query(uri, projection, null, null, null)
?.use { c -> if (c.moveToFirst()) readRow(c, uri) else null }
} catch (_: SecurityException) {
null
}
if (row == null) {
dead.add(uriStr)
continue
}
val kind = row["kind"] as String
val keep = when (filter) {
"image" -> kind == "image"
"video" -> kind == "video"
else -> true
}
if (keep) out.add(row)
}
if (dead.isNotEmpty()) {
set.removeAll(dead.toSet())
writePickerUris(ctx, set)
for (s in dead) {
try {
ctx.contentResolver.releasePersistableUriPermission(
Uri.parse(s), Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (_: Throwable) { /* best effort */ }
}
}
return out
}
private fun readRow(c: Cursor, uri: Uri): Map<String, Any?> {
val widthIdx = c.getColumnIndex(MediaStore.MediaColumns.WIDTH)
val heightIdx = c.getColumnIndex(MediaStore.MediaColumns.HEIGHT)
val takenIdx = c.getColumnIndex(MediaStore.MediaColumns.DATE_TAKEN)
val addedIdx = c.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
val mimeIdx = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
val durIdx = c.getColumnIndex(MediaStore.MediaColumns.DURATION)
val mime = if (mimeIdx >= 0 && !c.isNull(mimeIdx)) c.getString(mimeIdx) else null
val kind = if (mime?.startsWith("video/") == true) "video" else "image"
val width = if (widthIdx >= 0 && !c.isNull(widthIdx)) c.getInt(widthIdx) else 0
val height = if (heightIdx >= 0 && !c.isNull(heightIdx)) c.getInt(heightIdx) else 0
val durationMs = if (kind == "video" && durIdx >= 0 && !c.isNull(durIdx)) {
c.getLong(durIdx)
} else null
val createdMs = when {
takenIdx >= 0 && !c.isNull(takenIdx) && c.getLong(takenIdx) > 0 ->
c.getLong(takenIdx)
addedIdx >= 0 && !c.isNull(addedIdx) ->
c.getLong(addedIdx) * 1000L
else -> 0L
}
return mapOf(
"id" to uri.toString(),
"kind" to kind,
"duration_ms" to durationMs,
"width" to width,
"height" to height,
"created_ms" to createdMs,
)
}
/// Prune persisted URIs that the kernel no longer reports as granted
/// — covers Settings-side revocation between app runs and the 512-
/// URI kernel cap evicting our oldest grants.
private fun reconcilePersistedPickerUris() {
val ctx = context ?: return
val persisted = readPickerUris(ctx)
if (persisted.isEmpty()) return
val live = ctx.contentResolver.persistedUriPermissions
.map { it.uri.toString() }
.toSet()
val alive = persisted.filterTo(LinkedHashSet()) { it in live }
if (alive.size != persisted.size) writePickerUris(ctx, alive)
}
companion object { companion object {
private const val REQUEST_CODE_PERMISSION = 0xC52 private const val REQUEST_CODE_PERMISSION = 0xC52
private const val REQUEST_CODE_PICKER = 0xC53
private const val PICKER_PREFS = "ux_gallery_picker"
private const val PICKER_URIS_KEY = "picker_uris"
private const val PICKER_URI_CAP = 400
} }
} }