gallery: library-change Stream + native observer parity

Add UxGallery.libraryChanges — a Stream<void> that emits whenever the
underlying photo library reports a change, so picker UIs can drop
their cached asset lists and reload reactively.

- iOS: GalleryPlugin conforms to PHPhotoLibraryChangeObserver +
  FlutterStreamHandler; on photoLibraryDidChange the fetchCache is
  cleared and a void event is pushed over ux/gallery/changes. The
  observer is registered lazily on the first granted/limited
  authorization so plugin init doesn't trigger iOS's permission
  evaluation at app launch.
- macOS: near-verbatim port of the iOS shape (same Photos.framework,
  same fetchCache staleness, same fix).
- Android: registers a MediaStore ContentObserver on
  Files.getContentUri(VOLUME_EXTERNAL) with notifyForDescendants =
  true; observer lifecycle tracks the Dart subscription (registered
  on onListen, unregistered on onCancel / plugin detach). No native
  cache to invalidate today, but the pipeline is wired for the
  upcoming READ_MEDIA_VISUAL_USER_SELECTED limited-access work.
- iOS presentLimitedLibraryPicker switched to the iOS 15+ completion-
  handler variant so the Dart await resolves on dismissal, not on
  presentation. The actual reload is now driven by the change
  observer (which fires after iOS commits the new subset),
  side-stepping the completion-vs-commit race that produced an
  off-by-one in the picker on consecutive MANAGE taps.
- FakeUxGalleryBackend exposes emitLibraryChange() so tests can
  drive the reactive-reload wiring without going through the real
  method channel.
This commit is contained in:
agra
2026-05-11 09:52:17 +03:00
parent 3eba30358c
commit 77cda5a17e
5 changed files with 226 additions and 9 deletions

View File

@@ -6,15 +6,19 @@ import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.provider.Settings
import android.util.Size
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
@@ -28,8 +32,9 @@ import java.io.FileOutputStream
/// resolution into the app cache so `dart:io` can read what the
/// system holds behind a `content://` URI.
class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
PluginRegistry.RequestPermissionsResultListener {
PluginRegistry.RequestPermissionsResultListener, EventChannel.StreamHandler {
private var methodChannel: MethodChannel? = null
private var changesChannel: EventChannel? = null
private var context: Context? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
@@ -39,19 +44,64 @@ class GalleryPlugin : NativePlugin, MethodChannel.MethodCallHandler,
/// is rejected.
private var pendingPermissionResult: MethodChannel.Result? = null
/// Dart subscriber on `ux/gallery/changes` — fed by [mediaObserver]
/// when `MediaStore` reports an insert/update/delete so picker UIs
/// can reload reactively (parity with iOS's `photoLibraryDidChange`).
private var libraryEventSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val mediaObserver = object : ContentObserver(mainHandler) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
libraryEventSink?.success(null)
}
}
private var mediaObserverRegistered = false
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, "ux/gallery").also {
it.setMethodCallHandler(this)
}
changesChannel = EventChannel(
binding.binaryMessenger, "ux/gallery/changes",
).also { it.setStreamHandler(this) }
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
changesChannel?.setStreamHandler(null)
changesChannel = null
unregisterMediaObserver()
context = null
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
libraryEventSink = events
registerMediaObserver()
}
override fun onCancel(arguments: Any?) {
libraryEventSink = null
unregisterMediaObserver()
}
private fun registerMediaObserver() {
if (mediaObserverRegistered) return
val ctx = context ?: return
ctx.contentResolver.registerContentObserver(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
/* notifyForDescendants */ true,
mediaObserver,
)
mediaObserverRegistered = true
}
private fun unregisterMediaObserver() {
if (!mediaObserverRegistered) return
context?.contentResolver?.unregisterContentObserver(mediaObserver)
mediaObserverRegistered = false
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding