Files
buzz/mobile/lib/shared/widgets/ios_media_carousel.dart
Tom Brow 2e0abbcc0c fix(mobile): let slow image downloads finish (#8881)
On a slow link, a large image could never appear. Every image fetch was
aborted 30s after it started, even while bytes were still arriving, and
a failed fetch then sat on a 30s cooldown before it could be retried. At
240 kbps, anything over about 900 KB could not finish. On a shaped
iPhone SE, a 1.45 MB avatar was fetched, aborted at 30s and never shown.

- **Abort stalls, not slow downloads.** A fetch is now aborted when no
headers or body bytes arrive for 20s, so a dead connection still frees
up quickly. A download that keeps making progress can run for up to 5
minutes, so a server that trickles bytes cannot hold a connection
forever. The 20 MiB response cap still bounds its size.
- **Cancel previews scrolled past in the iOS carousel.** The carousel
loads at most four previews at a time, and a preview scrolled past kept
its slot until its download ended. With the longer limit, four slow
previews could block the visible one for minutes. Now the carousel drops
a cancelled preview's pending image-cache entry, and with no one left
listening, the image loader aborts its HTTP request. Scrolling back
starts a fresh download with no failure cooldown. A download that
another consumer, such as the full-screen viewer or a gallery prefetch,
is still listening to keeps going, and its finished image is still
cached. Closing the carousel aborts its pending previews the same way.
- **Cancel gallery prefetches that leave the window.** The message
gallery prefetches a few pages around the current one with two workers.
Each prefetch used to hold its download until it ended, which also kept
the carousel's cancel from aborting it. Now a prefetch that leaves the
window, or whose gallery goes away, lets go and aborts the same way.

### Validation

Added tests (each fails without its fix):

- A body that trickles in for longer than the stall timeout still loads,
and one that never ends is aborted at the overall cap.
- In the iOS carousel, previews scrolled past abort their stalled
downloads (before headers and mid-body), the next preview loads, and
scrolling back starts a new download. The test fails if the carousel
doesn't evict, if the loader doesn't abort, or if the abort leaves a
cooldown.
- Closing the carousel aborts its stalled previews.
- In a message gallery, jumping to the end and back aborts both
in-flight prefetches and moves the workers to the new window.
- Abandoning a load another consumer still waits for keeps the finished
image cached with no second fetch; abandoning one nobody else waits for
aborts it.
- Rapid scrolling releases each cancelled carousel load at once, and
cancelled loads never reach UIKit.

The full mobile suite passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:57:11 -08:00

289 lines
8.4 KiB
Dart

import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../relay/media_image.dart';
/// UIKit's horizontally scrolling photo strip, using the app's image providers.
/// Authentication and image-cache isolation remain owned by Flutter.
class IosMediaCarousel extends HookWidget {
const IosMediaCarousel({
super.key,
required this.providers,
required this.labels,
required this.width,
required this.gutter,
required this.gap,
required this.trailingPadding,
required this.radius,
required this.onOpen,
required this.onScroll,
required this.onPageChanged,
});
static const viewType = 'buzz/ios_media_carousel';
final List<ImageProvider<Object>> providers;
final List<String> labels;
final double width;
final double gutter;
final double gap;
final double trailingPadding;
final double radius;
final ValueChanged<int> onOpen;
final ValueChanged<double> onScroll;
final ValueChanged<int> onPageChanged;
@override
Widget build(BuildContext context) {
final channel = useState<MethodChannel?>(null);
final latest = useRef(this)..value = this;
final brightness = Theme.of(context).brightness.name;
final labelSignature = Object.hashAll(labels);
final rtl = Directionality.of(context) == TextDirection.rtl;
final configuration = <String, Object>{
'labels': labels,
'width': width,
'gutter': gutter,
'gap': gap,
'trailingPadding': trailingPadding,
'radius': radius,
'brightness': brightness,
'rtl': rtl,
};
useEffect(() {
final bridge = channel.value;
if (bridge == null) return null;
final images = _CarouselImages(bridge);
channel.value!.setMethodCallHandler((call) async {
final widget = latest.value;
final argument = call.arguments;
if (call.method == 'cancelImage' && argument is String) {
images.cancel(argument);
} else if (call.method == 'subscribeImage' && argument is Map) {
final id = argument['id'];
final index = argument['index'];
if (id is String &&
index is int &&
index >= 0 &&
index < widget.providers.length) {
images.subscribe(
id,
widget.providers[index],
createLocalImageConfiguration(context),
);
}
} else if (call.method == 'scroll' && argument is num) {
widget.onScroll(argument.toDouble());
} else if (argument is int &&
argument >= 0 &&
argument < widget.providers.length) {
if (call.method == 'open') widget.onOpen(argument);
if (call.method == 'page') widget.onPageChanged(argument);
}
return null;
});
// UIKit requests visible thumbnails only after the Dart handler exists.
unawaited(bridge.invokeMethod<void>('configure', configuration));
return () {
images.dispose();
bridge.setMethodCallHandler(null);
};
}, [channel.value]);
useEffect(
() {
final bridge = channel.value;
if (bridge != null) {
unawaited(bridge.invokeMethod<void>('configure', configuration));
}
return null;
},
[
width,
gutter,
gap,
trailingPadding,
radius,
brightness,
rtl,
labelSignature,
],
);
return UiKitView(
viewType: viewType,
creationParams: configuration,
creationParamsCodec: const StandardMessageCodec(),
gestureRecognizers: {
Factory<HorizontalDragGestureRecognizer>(
HorizontalDragGestureRecognizer.new,
),
},
onPlatformViewCreated: (id) {
channel.value = MethodChannel('$viewType/$id');
},
);
}
}
// Keep native subscriptions and unresolved provider loads bounded together.
// A load cancelled or disposed before its first frame evicts its pending cache
// entry and stops listening; with no listener left, MediaImageProvider aborts its
// download, so the slot is released at once.
class _CarouselImages {
_CarouselImages(this.channel);
final MethodChannel channel;
final _queued = <String, _CarouselImage>{};
final _running = <String, _CarouselImage>{};
bool _disposed = false;
bool _draining = false;
void subscribe(
String id,
ImageProvider<Object> provider,
ImageConfiguration config,
) {
if (_disposed || _running.containsKey(id) || _queued.containsKey(id)) {
return;
}
_queued[id] = _CarouselImage(id, provider, config);
_drain();
}
void cancel(String id) {
_queued.remove(id);
final image = _running[id];
if (image == null) return;
image.cancelled = true;
image.nextFrame?.dispose();
image.nextFrame = null;
image.detach();
_releasePending(image);
if (!image.encoding) _finish(image);
}
void _releasePending(_CarouselImage image) {
final stream = image.stream;
if (stream == null || image.receivedFrame) return;
abandonPendingImage(stream, image.provider, image.config);
}
void _drain() {
if (_draining || _disposed) return;
_draining = true;
try {
while (_running.length < 4 && _queued.isNotEmpty) {
final image = _queued.remove(_queued.keys.first)!;
_running[image.id] = image;
image.stream = image.provider.resolve(image.config);
image.listener = ImageStreamListener(
(frame, _) {
image.receivedFrame = true;
if (image.cancelled || _disposed) {
frame.dispose();
_finish(image);
return;
}
image.nextFrame?.dispose();
image.nextFrame = frame;
if (!image.encoding) unawaited(_sendFrames(image));
},
onError: (Object _, StackTrace? _) {
image.receivedFrame = true;
if (!image.cancelled && !_disposed) {
unawaited(_send(image.id, null));
}
image.cancelled = true;
image.detach();
if (!image.encoding) _finish(image);
},
);
image.stream!.addListener(image.listener!);
}
} finally {
_draining = false;
}
}
Future<void> _sendFrames(_CarouselImage image) async {
image.encoding = true;
try {
while (!_disposed && !image.cancelled && image.nextFrame != null) {
final frame = image.nextFrame!;
image.nextFrame = null;
try {
final bytes = await frame.image.toByteData(
format: ui.ImageByteFormat.png,
);
if (!_disposed && !image.cancelled) {
await _send(image.id, bytes?.buffer.asUint8List());
}
} finally {
frame.dispose();
}
}
} catch (_) {
if (!_disposed && !image.cancelled) await _send(image.id, null);
image.cancelled = true;
} finally {
image.encoding = false;
if (_disposed || image.cancelled) _finish(image);
}
}
Future<void> _send(String id, Uint8List? bytes) async {
try {
await channel.invokeMethod<void>('imageFrame', {
'id': id,
'bytes': bytes,
});
} on PlatformException {
// The native view may disappear while a frame crosses the bridge.
} on MissingPluginException {
// Disposal can remove the channel before an in-flight frame arrives.
}
}
void _finish(_CarouselImage image) {
image.detach();
image.nextFrame?.dispose();
image.nextFrame = null;
_running.remove(image.id);
_drain();
}
void dispose() {
_disposed = true;
_queued.clear();
for (final image in _running.values.toList()) {
image.cancelled = true;
image.detach();
_releasePending(image);
_finish(image);
}
}
}
class _CarouselImage {
_CarouselImage(this.id, this.provider, this.config);
final String id;
final ImageProvider<Object> provider;
final ImageConfiguration config;
ImageStream? stream;
ImageStreamListener? listener;
ImageInfo? nextFrame;
bool cancelled = false;
bool receivedFrame = false;
bool encoding = false;
void detach() {
final listener = this.listener;
if (listener != null) stream?.removeListener(listener);
this.listener = null;
}
}