mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user