diff --git a/android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt index bbcb53a..72e7b92 100644 --- a/android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt @@ -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 diff --git a/ios/Classes/GalleryPlugin.swift b/ios/Classes/GalleryPlugin.swift index d4ae513..d0b5c4f 100644 --- a/ios/Classes/GalleryPlugin.swift +++ b/ios/Classes/GalleryPlugin.swift @@ -6,9 +6,17 @@ import UIKit /// `Photos.framework` bridge for `UxGallery` — paginated asset queries, /// cell-sized thumbnails via `PHCachingImageManager`, and on-demand /// file resolution into the app cache. -public class GalleryPlugin: NSObject, NativePlugin { +public class GalleryPlugin: NSObject, NativePlugin, PHPhotoLibraryChangeObserver, FlutterStreamHandler { private let imageManager = PHCachingImageManager() private var fetchCache: [String: PHFetchResult] = [:] + private var libraryObserverRegistered = false + + /// Active Dart subscriber for the `ux/gallery/changes` event channel. + /// Push a `nil` event on every `photoLibraryDidChange` so callers + /// reload reactively against the committed library state — the + /// observer fires after iOS commits, sidestepping the + /// `presentLimitedLibraryPicker` completion-vs-commit race. + private var libraryEventSink: FlutterEventSink? public func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( @@ -18,15 +26,69 @@ public class GalleryPlugin: NSObject, NativePlugin { channel.setMethodCallHandler { [weak self] call, result in self?.handle(call: call, result: result) } + let events = FlutterEventChannel( + name: "ux/gallery/changes", + binaryMessenger: registrar.messenger(), + ) + events.setStreamHandler(self) + } + + public func onListen( + withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink, + ) -> FlutterError? { + libraryEventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + libraryEventSink = nil + return nil + } + + /// `PHFetchResult` snapshots go stale after the limited-library + /// subset changes or any external Photos.app edit; observing + /// change events lets us drop the cache so the next `assets` + /// call re-fetches against the live library. Deferred to first + /// granted/limited authorization — registering at plugin init + /// triggers iOS's permission evaluation on app launch even before + /// the user touches the picker. + private func ensureLibraryObserver() { + guard !libraryObserverRegistered else { return } + PHPhotoLibrary.shared().register(self) + libraryObserverRegistered = true + } + + deinit { + if libraryObserverRegistered { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + } + } + + public func photoLibraryDidChange(_ changeInstance: PHChange) { + // Callback runs on an arbitrary background thread; hop to main + // before touching `fetchCache` or pushing the Flutter event so + // we don't race the method-channel handler. + DispatchQueue.main.async { [weak self] in + self?.fetchCache.removeAll() + self?.libraryEventSink?(nil) + } } private func handle(call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "permission": - result(Self.permissionString(Self.currentAuthorization())) + let status = Self.currentAuthorization() + if Self.isAuthorizedForLibrary(status) { + ensureLibraryObserver() + } + result(Self.permissionString(status)) case "requestPermission": - Self.requestAuthorization { status in + Self.requestAuthorization { [weak self] status in DispatchQueue.main.async { + if Self.isAuthorizedForLibrary(status) { + self?.ensureLibraryObserver() + } result(Self.permissionString(status)) } } @@ -36,10 +98,19 @@ public class GalleryPlugin: NSObject, NativePlugin { } result(nil) case "presentLimitedLibraryPicker": - if #available(iOS 14, *), let vc = UxWindow.topViewController { + // Just dismissal signal — the actual reload trigger is the + // `ux/gallery/changes` event channel driven by the library + // observer (which fires after iOS commits the new subset). + if #available(iOS 15, *), let vc = UxWindow.topViewController { + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: vc) { _ in + result(nil) + } + } else if #available(iOS 14, *), let vc = UxWindow.topViewController { PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: vc) + result(nil) + } else { + result(nil) } - result(nil) case "albums": handleAlbums(call: call, result: result) case "assets": @@ -70,6 +141,12 @@ public class GalleryPlugin: NSObject, NativePlugin { } } + private static func isAuthorizedForLibrary(_ status: PHAuthorizationStatus) -> Bool { + if status == .authorized { return true } + if #available(iOS 14, *), status == .limited { return true } + return false + } + private static func permissionString(_ status: PHAuthorizationStatus) -> String { switch status { case .notDetermined: return "notDetermined" diff --git a/lib/src/gallery.dart b/lib/src/gallery.dart index 6a38b2f..e33e4c0 100644 --- a/lib/src/gallery.dart +++ b/lib/src/gallery.dart @@ -91,6 +91,13 @@ abstract class UxGalleryBackend { }); Future thumbnail(String assetId, {required int sizePx}); Future resolveFile(String assetId); + + /// Emits whenever the underlying photo library reports a change — + /// limited-subset edits via `presentLimitedLibraryPicker`, external + /// edits from Photos.app, or permission flips from Settings. + /// Subscribers should re-fetch any cached album / asset listings on + /// each event. + Stream get libraryChanges; } /// Static facade for the system photo library. All state lives in the @@ -150,6 +157,11 @@ class UxGallery { /// app cache (since `dart:io` can't read content URIs directly). static Future resolveFile(String assetId) => backend.resolveFile(assetId); + + /// Emits whenever the system photo library changes — limited-subset + /// edits, external edits, permission changes. Picker UIs subscribe + /// to reload their cached asset list reactively. + static Stream get libraryChanges => backend.libraryChanges; } /// Default [UxGalleryBackend] — dispatches to native code via the @@ -157,6 +169,14 @@ class UxGallery { /// after swapping to a fake. class MethodChannelGalleryBackend implements UxGalleryBackend { static const _channel = MethodChannel('ux/gallery'); + static const _changesChannel = EventChannel('ux/gallery/changes'); + + /// Lazy broadcast view of `ux/gallery/changes`. The native side only + /// holds a single sink; a broadcast Stream lets multiple Dart + /// listeners (e.g. picker + selected-strip) subscribe independently. + @override + late final Stream libraryChanges = + _changesChannel.receiveBroadcastStream().map((_) {}); static String _kindArg(UxAssetKind? k) => switch (k) { null => 'any', diff --git a/lib/src/testing/fake_gallery.dart b/lib/src/testing/fake_gallery.dart index c4b3ff5..9eb0f41 100644 --- a/lib/src/testing/fake_gallery.dart +++ b/lib/src/testing/fake_gallery.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; import 'dart:typed_data'; @@ -45,6 +46,17 @@ class FakeUxGalleryBackend implements UxGalleryBackend { final UxAssetThumbnail Function(String assetId, int sizePx) _thumbnailFor; final io.File Function(String assetId) _fileFor; + final StreamController _libraryChanges = + StreamController.broadcast(); + + /// Tests call this to simulate the platform `photoLibraryDidChange` + /// event — drives any reactive reload wiring without going through + /// the real method channel. + void emitLibraryChange() => _libraryChanges.add(null); + + @override + Stream get libraryChanges => _libraryChanges.stream; + /// Optional hook fired on `requestPermission`. Default updates /// `permissionState` to [UxGalleryPermission.granted]. final UxGalleryPermission Function()? onRequestPermission; diff --git a/macos/Classes/GalleryPlugin.swift b/macos/Classes/GalleryPlugin.swift index 70beb3e..d1a9e82 100644 --- a/macos/Classes/GalleryPlugin.swift +++ b/macos/Classes/GalleryPlugin.swift @@ -5,9 +5,14 @@ import Photos /// macOS counterpart of the iOS gallery bridge — same `Photos.framework` /// data layer, with `NSImage` swapped in for `UIImage` and the /// limited-library picker dropped (macOS has no equivalent). -public class GalleryPlugin: NSObject, NativePlugin { +public class GalleryPlugin: NSObject, NativePlugin, PHPhotoLibraryChangeObserver, FlutterStreamHandler { private let imageManager = PHCachingImageManager() private var fetchCache: [String: PHFetchResult] = [:] + private var libraryObserverRegistered = false + + /// Sink for the `ux/gallery/changes` event channel — emits void on + /// every `photoLibraryDidChange` so picker UIs can reload reactively. + private var libraryEventSink: FlutterEventSink? public func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( @@ -17,15 +22,68 @@ public class GalleryPlugin: NSObject, NativePlugin { channel.setMethodCallHandler { [weak self] call, result in self?.handle(call: call, result: result) } + let events = FlutterEventChannel( + name: "ux/gallery/changes", + binaryMessenger: registrar.messenger, + ) + events.setStreamHandler(self) + } + + public func onListen( + withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink, + ) -> FlutterError? { + libraryEventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + libraryEventSink = nil + return nil + } + + /// Photos delivers change events only after the user grants access; + /// defer registration until then so plugin init doesn't poke the + /// permission machinery at app launch. + private func ensureLibraryObserver() { + guard !libraryObserverRegistered else { return } + PHPhotoLibrary.shared().register(self) + libraryObserverRegistered = true + } + + deinit { + if libraryObserverRegistered { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + } + } + + public func photoLibraryDidChange(_ changeInstance: PHChange) { + DispatchQueue.main.async { [weak self] in + self?.fetchCache.removeAll() + self?.libraryEventSink?(nil) + } + } + + private static func isAuthorizedForLibrary(_ status: PHAuthorizationStatus) -> Bool { + if status == .authorized { return true } + if #available(macOS 11.0, *), status == .limited { return true } + return false } private func handle(call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "permission": - result(Self.permissionString(Self.currentAuthorization())) + let status = Self.currentAuthorization() + if Self.isAuthorizedForLibrary(status) { + ensureLibraryObserver() + } + result(Self.permissionString(status)) case "requestPermission": - Self.requestAuthorization { status in + Self.requestAuthorization { [weak self] status in DispatchQueue.main.async { + if Self.isAuthorizedForLibrary(status) { + self?.ensureLibraryObserver() + } result(Self.permissionString(status)) } }