Support PNG and HEIC photos in the native iOS zoom gallery (#9314)

## Summary

The opt-in iOS zoom gallery rejected PNG and HEIC/HEIF photos, so photo
stacks containing those files opened the Flutter fallback. Accept these
formats by MIME type or file extension, including uppercase extensions
and URLs with query strings.

Preserve animated PNG playback by forwarding continuing decoded frames
to the selected native page. Keep the existing five-photo load window,
coalesce frames while the bridge is busy, skip offscreen frame encoding,
and release listeners on eviction or dismissal. Native updates reject
stale sessions, offscreen pages, malformed frames, and closed viewers.

`BUZZ_NATIVE_PHOTO_ZOOM` remains opt-in. iOS below 18 and unsupported
formats retain the existing gallery.

### Related issue

Follow-up to #9259. No matching open issue or PR found; #7666 concerns
desktop HEIC upload tiling, a separate path.

### Testing

- `just mobile-install mobile-check mobile-test` passed.
- Full mobile suite with `BUZZ_NATIVE_PHOTO_ZOOM=true`: 4,623 passed, 68
skipped. Standard configuration: 4,599 passed, 92 skipped; push
configuration and secp256k1 package tests also passed.
- Real PNG and HEIC decoding through the production presentation bridge;
real two-frame APNG decoding with continuing-frame and listener-cleanup
assertions.
- `scripts/test-mobile-photo-zoom.sh` on an iOS 27 simulator exercises
the production native controller, including frame updates, stale-page
rejection, malformed input and teardown.

No physical-device or end-to-end zoom recording captured. This extends
accepted image formats and playback without changing the gallery layout.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-11-10 15:34:40 +00:00
committed by GitHub
parent 13a6aa19f4
commit e2dc54e22f
4 changed files with 348 additions and 94 deletions

View File

@@ -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")
}
}

View File

@@ -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)

View File

@@ -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<bool> 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<void>('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<void> 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<Object> provider;
final ImageConfiguration configuration;
final frame = Completer<ImageInfo?>();
final Future<void> Function(Uint8List)? onFrame;
final bool Function() shouldForwardFrame;
final first = Completer<Uint8List?>();
Future<Uint8List?> get result => first.future;
late final ImageStream stream;
late final ImageStreamListener listener;
late final Timer timer;
late final Future<Uint8List?> result;
ImageInfo? nextFrame;
bool encoding = false;
bool disposed = false;
bool detached = false;
bool receivedFrame = false;
Future<Uint8List?> 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<void> 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();
}
}

View File

@@ -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<dynamic, dynamic>? 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<Uint8List>());
},
skip: !NativePhotoZoomPreview.enabled,
);
);
Map<dynamic, dynamic>? 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<Uint8List>());
},
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<Uint8List> 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<Object?>();
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<Uint8List>();
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<void>();
@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);
}