diff --git a/mobile/ios/NativeControlTests/NativePhotoZoomTests.swift b/mobile/ios/NativeControlTests/NativePhotoZoomTests.swift index f5790063f5..34b98c71f5 100644 --- a/mobile/ios/NativeControlTests/NativePhotoZoomTests.swift +++ b/mobile/ios/NativeControlTests/NativePhotoZoomTests.swift @@ -33,8 +33,20 @@ struct NativePhotoZoomTests { precondition(photo.image == nil, "Late old-window responses cannot paint the selected photo") let current = requests requests.removeAll() + let blueData = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in + UIColor.blue.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) + } + let previous = photo.image + viewer.updateFrame(blueData, at: 0) + precondition(photo.image === previous, "Offscreen animation frames cannot paint the selected photo") + viewer.updateFrame(blueData, at: 50) + precondition(photo.image !== previous, "Continuing animation frames must update the selected photo") + let updated = photo.image + precondition(current.contains { $0.0 == "image" && $0.1 == 50 }, "Hold the selected initial-image reply until after a continuing frame") for request in current { request.2(FlutterStandardTypedData(bytes: data)) } - precondition(photo.image != nil, "Current-window responses must paint the actual page") + precondition(photo.image === updated, "Delayed initial image and preview replies must not rewind a continuing frame") + viewer.updateFrame(Data([0, 1]), at: 50) + precondition(photo.image === updated, "Malformed frames must preserve the last displayed image") indicator.currentPage = 0; sendValueChanged(indicator) precondition(requests.contains { $0.1 == 0 }, "Leaving the window evicts old decoded images") let closing = requests @@ -44,6 +56,12 @@ struct NativePhotoZoomTests { viewer.viewDidDisappear(false) for request in closing { request.2(FlutterStandardTypedData(bytes: data)) } precondition(closed && requests.isEmpty, "Teardown must ignore late loads and stop requesting") + let closedPage = pager.viewControllers!.first! + closedPage.loadViewIfNeeded() + let closedPhoto = descendants(closedPage.view).compactMap { $0 as? UIImageView }.first! + let closedImage = closedPhoto.image + viewer.updateFrame(data, at: 0) + precondition(closedPhoto.image === closedImage, "Closed viewers must ignore continuing frames") print("PASS: native photo window, stale responses, eviction and teardown") } } diff --git a/mobile/ios/Runner/NativePhotoZoomCoordinator.swift b/mobile/ios/Runner/NativePhotoZoomCoordinator.swift index 52d6ae5fc8..e1b25ae039 100644 --- a/mobile/ios/Runner/NativePhotoZoomCoordinator.swift +++ b/mobile/ios/Runner/NativePhotoZoomCoordinator.swift @@ -6,6 +6,7 @@ final class NativePhotoZoomCoordinator { private let channel: FlutterMethodChannel private weak var parent: UIViewController? private var active: UIViewController? + private var activeSession: Int? init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) { channel = FlutterMethodChannel(name: "buzz/native_photo_zoom", binaryMessenger: messenger) @@ -16,6 +17,19 @@ final class NativePhotoZoomCoordinator { if #available(iOS 18.0, *) { result(true) } else { result(false) } return } + if call.method == "imageFrame" { + if #available(iOS 18.0, *), + let args = call.arguments as? [String: Any], + let session = args["session"] as? Int, session == self.activeSession, + let index = args["index"] as? Int, + let bytes = args["bytes"] as? FlutterStandardTypedData, + let controller = self.active as? NativePhotoZoomViewController + { + controller.updateFrame(bytes.data, at: index) + } + result(nil) + return + } guard call.method == "present" else { result(FlutterMethodNotImplemented); return } guard #available(iOS 18.0, *), self.active == nil, let args = call.arguments as? [String: Any], @@ -94,9 +108,11 @@ final class NativePhotoZoomCoordinator { presenter?.view.accessibilityElementsHidden = previousAccessibilityHidden anchor?.removeFromSuperview() self?.active = nil + self?.activeSession = nil result(action) } self.active = controller + self.activeSession = session presenter.present(controller, animated: animate) } } @@ -320,7 +336,9 @@ private final class NativePhotoZoomViewController: UIViewController, UIPageViewC guard let self, !self.finishing, self.isNearby(value), self.pending[key] == token else { return } self.pending.removeValue(forKey: key) let image = (result as? FlutterStandardTypedData).flatMap { UIImage(data: $0.data) } - if let image { + // A continuing frame can arrive before this initial reply. Keep the + // newer full image authoritative over both delayed image and preview data. + if let image, self.images[value] == nil { if method == "preview" { self.previews[value] = image } else { self.images[value] = image } } @@ -331,6 +349,15 @@ private final class NativePhotoZoomViewController: UIViewController, UIPageViewC if self.index == value, method == "image" { self.syncSelection() } } } + // Flutter decodes authenticated originals and forwards continuing frames. + // Ignore old pages and closed presentations before allocating a UIImage. + func updateFrame(_ data: Data, at value: Int) { + guard !finishing, value == index, let image = UIImage(data: data) else { return } + images[value] = image + for page in pager.children.compactMap({ $0 as? NativePhotoPage }) where page.index == value { + page.setImage(image) + } + } private func page(_ value: Int) -> NativePhotoPage { let page = NativePhotoPage(index: value, label: labels[value], image: images[value] ?? previews[value]) load("image", value) diff --git a/mobile/lib/features/channels/native_photo_zoom_preview.dart b/mobile/lib/features/channels/native_photo_zoom_preview.dart index f613fcdb26..0be132c3c7 100644 --- a/mobile/lib/features/channels/native_photo_zoom_preview.dart +++ b/mobile/lib/features/channels/native_photo_zoom_preview.dart @@ -23,8 +23,8 @@ class NativePhotoZoomPreview { /// Returns true only when the existing Flutter gallery is needed as fallback. /// - /// Only JPEG galleries use the static PNG bridge. Animation-capable formats - /// (including ordinary PNGs) and unknown types keep the Flutter renderer. + /// JPEG, PNG and HEIC/HEIF photos use the native gallery. Continuing frames + /// are forwarded for animated PNGs; other formats keep the Flutter renderer. /// [mimeTypes], when provided, contains one declared type per [urls] entry. static Future open({ required BuildContext context, @@ -41,15 +41,26 @@ class NativePhotoZoomPreview { NativePhotoActions? actions, }) async { if (!enabled || defaultTargetPlatform != TargetPlatform.iOS) return true; - // PNG/APNG, WebP, GIF and unknown formats may animate. The PNG bridge - // cannot preserve their frames, so keep the whole gallery in Flutter. // Metadata wins over the extension (e.g. a GIF served at a .jpg URL). for (var i = 0; i < urls.length; i++) { final mime = mimeTypes?[i]?.split(';').first.trim().toLowerCase(); final path = Uri.tryParse(urls[i])?.path.toLowerCase() ?? ''; if (mime != null - ? mime != 'image/jpeg' - : !(path.endsWith('.jpg') || path.endsWith('.jpeg'))) { + ? !const { + 'image/jpeg', + 'image/png', + 'image/apng', + 'image/heic', + 'image/heif', + }.contains(mime) + : !const [ + '.jpg', + '.jpeg', + '.png', + '.apng', + '.heic', + '.heif', + ].any(path.endsWith)) { return true; } } @@ -62,7 +73,17 @@ class NativePhotoZoomPreview { if (!context.mounted) return false; final configuration = createLocalImageConfiguration(context); final reduceMotion = MediaQuery.disableAnimationsOf(context); - final loads = _PhotoWindow(configuration, initialIndex); + final loads = _PhotoWindow(configuration, initialIndex, ( + index, + bytes, + ) async { + if (!context.mounted) return; + await channel.invokeMethod('imageFrame', { + 'session': session, + 'index': index, + 'bytes': bytes, + }); + }); window = loads; final bytes = await loads.load( initialIndex, @@ -156,7 +177,8 @@ class NativePhotoZoomPreview { // At most five nearby photos, at preview and full resolution. Moving the window // abandons stale downloads; closing also releases all listeners and PNG bytes. class _PhotoWindow { - _PhotoWindow(this.configuration, this.index); + _PhotoWindow(this.configuration, this.index, this.onFrame); + final Future Function(int, Uint8List) onFrame; final ImageConfiguration configuration; int index; bool disposed = false; @@ -171,6 +193,12 @@ class _PhotoWindow { return (loads[(value, preview)] ??= _PhotoLoad( provider, configuration, + shouldForwardFrame: () => !disposed && value == index, + onFrame: preview + ? null + : (bytes) async { + if (!disposed && value == index) await onFrame(value, bytes); + }, )).result; } @@ -191,50 +219,80 @@ class _PhotoWindow { } class _PhotoLoad { - _PhotoLoad(this.provider, this.configuration) { + _PhotoLoad( + this.provider, + this.configuration, { + this.onFrame, + required this.shouldForwardFrame, + }) { stream = provider.resolve(configuration); - listener = ImageStreamListener( - (info, _) { - receivedFrame = true; - if (!frame.isCompleted) { - frame.complete(info); - } else { - info.dispose(); - } - }, - onError: (Object _, StackTrace? _) { - if (!frame.isCompleted) frame.complete(null); - }, - ); - stream.addListener(listener); + listener = ImageStreamListener((info, _) { + receivedFrame = true; + if (disposed || (first.isCompleted && !shouldForwardFrame())) { + info.dispose(); + return; + } + nextFrame?.dispose(); + nextFrame = info; + if (!encoding) unawaited(encode()); + }, onError: (Object _, StackTrace? _) => dispose()); timer = Timer(const Duration(seconds: 10), dispose); - result = encode(); + stream.addListener(listener); } final ImageProvider provider; final ImageConfiguration configuration; - final frame = Completer(); + final Future Function(Uint8List)? onFrame; + final bool Function() shouldForwardFrame; + final first = Completer(); + Future get result => first.future; late final ImageStream stream; late final ImageStreamListener listener; late final Timer timer; - late final Future result; + ImageInfo? nextFrame; + bool encoding = false; bool disposed = false; bool detached = false; bool receivedFrame = false; - Future encode() async { - final info = await frame.future; - detach(); + // One encoding and one latest frame per load, including while the platform + // channel is busy. Never queue an animation's entire decoded frame history. + Future encode() async { + encoding = true; try { - if (info == null || disposed) return null; - final data = await info.image.toByteData(format: ui.ImageByteFormat.png); - if (disposed || data == null) return null; - return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + while (!disposed && nextFrame != null) { + final info = nextFrame!; + nextFrame = null; + try { + final data = await info.image.toByteData( + format: ui.ImageByteFormat.png, + ); + if (disposed || data == null) { + dispose(); + return; + } + final bytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + if (!first.isCompleted) { + first.complete(bytes); + timer.cancel(); + if (onFrame == null) { + dispose(); + return; + } + } else { + await onFrame?.call(bytes); + } + } finally { + info.dispose(); + } + } } catch (_) { - return null; + dispose(); } finally { - info?.dispose(); - timer.cancel(); + encoding = false; } } @@ -246,9 +304,12 @@ class _PhotoLoad { } void dispose() { + if (disposed) return; disposed = true; timer.cancel(); - if (!frame.isCompleted) frame.complete(null); + if (!first.isCompleted) first.complete(null); + nextFrame?.dispose(); + nextFrame = null; detach(); } } diff --git a/mobile/test/features/channels/native_photo_zoom_preview_test.dart b/mobile/test/features/channels/native_photo_zoom_preview_test.dart index 665cac0127..b036f286da 100644 --- a/mobile/test/features/channels/native_photo_zoom_preview_test.dart +++ b/mobile/test/features/channels/native_photo_zoom_preview_test.dart @@ -17,63 +17,90 @@ void main() { 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEElEQVR4nGNgaPj/H4xhDABS0gn5PEa22gAAAABJRU5ErkJggg==', ), ); + // Locally generated 2x2 HEIC, encoded with macOS sips. Exercise the real codec. + final heicProvider = MemoryImage( + base64Decode( + 'AAAAJGZ0eXBoZWljAAAAAG1pZjFNaVBybWlhZk1pSEJoZWljAAABh21ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QAAAAAAAAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAAADnBpdG0AAAAAAAEAAAAjaWluZgAAAAAAAQAAABVpbmZlAgAAAAABAABodmMxAAAAAOdpcHJwAAAAxmlwY28AAAATY29scm5jbHgAAgACAAaAAAAADGNsbGkAywBAAAAAFGlzcGUAAAAAAAAAAgAAAAIAAAAJaXJvdAAAAAAQcGl4aQAAAAADCAgIAAAAcmh2Y0MBA3AAAACwAAAAAAAe8AD8/fj4AAALA6AAAQAXQAEMAf//A3AAAAMAsAAAAwAAAwAecCShAAEAJEIBAQNwAAADALAAAAMAAAMAHqAUIEHAoQQYh7kWVTcCAgYAgKIAAQAJRAHAYXLIRFNkAAAAGWlwbWEAAAAAAAAAAQABBoECAwWGhAAAAB5pbG9jAAAAAEQAAAEAAQAAAAEAAAG7AAAAPwAAAAFtZGF0AAAAAAAAAE8AAAA7KAGvovpGgXz/+yvev+23//+zsP1X/1Uf//NsCf9xTPdMsmf6IPnCObL7f8z6O/GSQi9hX4K3CEmyK4A=', + ), + ); tearDown(() { debugDefaultTargetPlatformOverride = null; messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, null); }); - for (final outcome in ['closed', 'reply', 'unavailable']) { - testWidgets( - 'native photo preview handles $outcome and selected source bounds', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - final key = GlobalKey(); - late BuildContext context; - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (value) { - context = value; - return Center( - child: SizedBox(key: key, width: 200, height: 200), - ); - }, + for (final source in [ + ('photo.jpg', null), + ('photo.png', null), + ('photo.HEIC?download=1', null), + ('photo.heif', null), + ('photo', 'image/png'), + ('photo', 'image/heic'), + ('photo', 'image/heif; charset=binary'), + ]) { + for (final outcome in ['closed', 'reply', 'unavailable']) { + testWidgets( + 'native photo preview handles ${source.$1} ${source.$2} $outcome and selected source bounds', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final key = GlobalKey(); + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (value) { + context = value; + return Center( + child: SizedBox(key: key, width: 200, height: 200), + ); + }, + ), ), - ), - ); - Map? presented; - messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, ( - call, - ) async { - if (call.method == 'isSupported') return true; - presented = call.arguments as Map; - return outcome; - }); - final result = await tester.runAsync( - () => NativePhotoZoomPreview.open( - context: context, - sourceKeys: [key], - providers: [provider], - previewProviders: [provider], - urls: ['https://example.org/photo.jpg'], - initialIndex: 0, - onSelect: (_) async {}, - labels: ['Selected photo'], - ), - ); - debugDefaultTargetPlatformOverride = null; - expect(result, outcome == 'unavailable'); - expect(presented?['labels'], ['Selected photo']); - expect(presented?.containsKey('previews'), isFalse); - expect(presented?['width'], 200); - expect(presented?['height'], 200); - expect(presented?['x'], 400); - expect(presented?['y'], 300); - expect(presented?['angle'], 0); - expect(presented?['bytes'], isA()); - }, - skip: !NativePhotoZoomPreview.enabled, - ); + ); + Map? presented; + messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, ( + call, + ) async { + if (call.method == 'isSupported') return true; + presented = call.arguments as Map; + return outcome; + }); + final result = await tester.runAsync( + () => NativePhotoZoomPreview.open( + context: context, + sourceKeys: [key], + providers: [ + source.$1.toLowerCase().contains('hei') || + (source.$2?.contains('hei') ?? false) + ? heicProvider + : provider, + ], + previewProviders: [ + source.$1.toLowerCase().contains('hei') || + (source.$2?.contains('hei') ?? false) + ? heicProvider + : provider, + ], + urls: ['https://example.org/${source.$1}'], + mimeTypes: [source.$2], + initialIndex: 0, + onSelect: (_) async {}, + labels: ['Selected photo'], + ), + ); + debugDefaultTargetPlatformOverride = null; + expect(result, outcome == 'unavailable'); + expect(presented?['labels'], ['Selected photo']); + expect(presented?.containsKey('previews'), isFalse); + expect(presented?['width'], 200); + expect(presented?['height'], 200); + expect(presented?['x'], 400); + expect(presented?['y'], 300); + expect(presented?['angle'], 0); + expect(presented?['bytes'], isA()); + }, + skip: !NativePhotoZoomPreview.enabled, + ); + } } testWidgets( @@ -272,7 +299,7 @@ void main() { skip: !NativePhotoZoomPreview.enabled, ); - testWidgets('animation-capable galleries retain all frames in Flutter', ( + testWidgets('unsupported formats retain all frames in Flutter', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; @@ -300,8 +327,6 @@ void main() { return false; }); for (final source in [ - ('https://example.org/photo.png', null), - ('https://example.org/photo.apng', null), ('https://example.org/photo.webp', null), ('https://example.org/photo.jpg', 'image/gif'), ('https://example.org/photo', 'image/webp'), @@ -324,7 +349,7 @@ void main() { expect( bridgeCalls, 0, - reason: 'Potential animations must bypass the PNG bridge', + reason: 'Other formats must retain the existing fallback', ); await tester.runAsync(() async { final codec = await ui.instantiateImageCodec(animated); @@ -344,6 +369,106 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('animated PNG forwards changing frames and stops on close', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final key = GlobalKey(); + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (value) { + context = value; + return SizedBox(key: key, width: 100, height: 100); + }, + ), + ), + ); + // Locally generated two-frame APNG, red then blue. + final frames = await tester.runAsync(() async { + final codec = await ui.instantiateImageCodec( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAABAAAAAQAAAAAAAAAAAAEACgAAWn8w0AAAAA1JREFUeJxj+M/A8B8ABQAB/4mZPR0AAAAaZmNUTAAAAAEAAAABAAAAAQAAAAAAAAAAAAEACgAAwQzaBAAAABFmZEFUAAAAAnicY2Bg+P8fAAMCAf/1e6XXAAAAAElFTkSuQmCC', + ), + ); + try { + expect(codec.frameCount, 2); + return [ + (await codec.getNextFrame()).image, + (await codec.getNextFrame()).image, + ]; + } finally { + codec.dispose(); + } + }); + final animated = _StreamingImage(); + late Completer update; + Uint8List? initial; + var updates = 0; + const codec = StandardMethodCodec(); + messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, ( + call, + ) async { + if (call.method == 'isSupported') return true; + final args = call.arguments as Map; + if (call.method == 'imageFrame') { + updates++; + expect(args['index'], 0); + update.complete(args['bytes'] as Uint8List); + return null; + } + final first = Completer(); + unawaited( + messenger.handlePlatformMessage( + NativePhotoZoomPreview.channel.name, + codec.encodeMethodCall( + MethodCall('image', {'session': args['session'], 'index': 0}), + ), + (data) => first.complete(codec.decodeEnvelope(data!)), + ), + ); + await animated.started.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw StateError('Image was not requested'), + ); + animated.completer.emit(ImageInfo(image: frames![0])); + initial = + await first.future.timeout( + const Duration(seconds: 5), + onTimeout: () => + throw StateError('First frame did not complete'), + ) + as Uint8List; + animated.completer.emit(ImageInfo(image: frames[1])); + await update.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw StateError('Continuing frame did not arrive'), + ); + return 'closed'; + }); + await tester.runAsync(() async { + update = Completer(); + expect( + await NativePhotoZoomPreview.open( + context: context, + sourceKeys: [key], + providers: [animated], + previewProvider: provider, + urls: ['https://example.org/animated.png'], + labels: ['Animated PNG'], + initialIndex: 0, + onSelect: (_) async {}, + ), + isFalse, + ); + expect(await update.future, isNot(orderedEquals(initial!))); + }); + expect(animated.completer.listening, isFalse); + expect(updates, 1); + debugDefaultTargetPlatformOverride = null; + }, skip: !NativePhotoZoomPreview.enabled); + testWidgets( 'unsupported platform preserves existing gallery without decoding', (tester) async { @@ -401,3 +526,26 @@ class _PendingPreview extends ImageProvider<_PendingPreview> { }); } } + +class _StreamingImage extends ImageProvider<_StreamingImage> { + final completer = _StreamingCompleter(); + late final started = Completer(); + @override + Future<_StreamingImage> obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + @override + void resolveStreamForKey( + ImageConfiguration configuration, + ImageStream stream, + _StreamingImage key, + ImageErrorListener handleError, + ) { + stream.setCompleter(completer); + started.complete(); + } +} + +class _StreamingCompleter extends ImageStreamCompleter { + bool get listening => hasListeners; + void emit(ImageInfo info) => setImage(info); +}