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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user