Files
buzz/mobile/lib/shared/relay/media_image.dart
klopez4212 d61fe2071d Use native UIKit scrolling for iOS photo carousels (#8361)
Builds on the mobile polish merged in #8359; this PR now targets `main`.
On iOS, inline photo galleries now use a UIKit collection view for
dragging, deceleration, and snapping. Photo taps retain the existing
full-screen viewer and reply/more actions; gallery scrolling continues
to fade the message avatar. The final photo stops at the full message
gutter rather than the smaller gap between photos. Android keeps the
current Flutter carousel.

Visible cells subscribe to continuing frames from Flutter's existing
authenticated image providers, preserving animated GIF/APNG/WebP media.
Request IDs fence late frames and cancel subscriptions on
reuse/disappearance/disposal. A four-slot bound includes retired
unresolved loads until their transport terminates. Shared media fetches
now use abortable HTTP requests with a 30-second deadline through
response-body completion, so stalled requests release their slots
without leaving live transports behind; native eager precaching is
disabled so it cannot bypass that bound. Frame conversion coalesces to
the newest pending frame. Native cells expose photo labels and positions
to VoiceOver, support RTL, and preserve the selected photo during
resizing.

Validation:
- Mobile formatting and analysis passed.
- Native simulator regression checks passed for geometry, RTL, snapping,
taps, frame replacement, cancellation, and resizing. The native CI lane
now runs the carousel executable.
- Full mobile suite and pre-push gates passed, including two-frame GIF
delivery, delayed-provider bounds/cancellation, real HTTP
stalled-header/body recovery with four requests maximum, disposal, and
unconfigured-push tests.
- Signed iPhone Release build of the frame/subscription implementation
passed. Physical iPhone testing covered the earlier carousel/gutter
revision; the new animation/cancellation changes have local Flutter and
native simulator coverage, not a new physical-device journey.
Preview-only signing overrides preserve its bundle/Keychain group;
production entitlements are unchanged. Local provisioning excludes
production push/App Attest capabilities.
- Full `just ci` was started but stopped after unrelated Rust/desktop
compilation consumed about 5 GB; broad CI is incomplete.

Device build required local Xcode 27 workarounds: iOS 16 deployment
target and a generated swift-secp256k1 cache compatibility edit. Neither
changes repository source.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-15 13:22:12 -04:00

267 lines
8.6 KiB
Dart

import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'media_auth.dart';
/// Shared, keep-alive HTTP client for media fetches. One client per container
/// so TLS connections are reused across images instead of re-handshaking per
/// fetch. Override in tests to stub the network.
final mediaHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// An [ImageProvider] for relay-hosted (and arbitrary remote) media that fixes
/// the fetch-stampede failure modes of `Image.network` + per-build auth
/// headers:
///
/// 1. **Stable cache identity.** Flutter's [NetworkImage] includes the full
/// headers map in its `==`/`hashCode`, so a freshly signed Authorization
/// header on every widget build made the *same URL* a new cache key and
/// bypassed the in-memory [ImageCache] entirely. This provider keys on
/// (url, scale, auth scope) and injects auth headers at *fetch* time, so
/// header refreshes never invalidate cached images.
/// 2. **Failure cooldown.** A URL that fails to load is not retried until a
/// cooldown elapses (honoring `Retry-After` on 429), so error retries on
/// rebuild cannot amplify into a request storm against the rate limiter.
///
/// The auth scope (relay base URL + signing identity) is part of key equality:
/// switching relay or account can never reuse another identity's cached bytes.
class MediaImageProvider extends ImageProvider<MediaImageProvider> {
final String url;
final double scale;
final MediaGetAuthService auth;
/// Optional observer for bytes already fetched by the foreground image path.
/// Excluded from equality because it is a side effect, not cache identity.
final ValueChanged<Uint8List>? onBytesLoaded;
/// Excluded from equality: transport, not identity.
final http.Client client;
/// Deadline for the complete response, including a stalled response body.
/// Aborts the request without closing the shared client.
final Duration requestTimeout;
const MediaImageProvider({
required this.url,
required this.auth,
required this.client,
this.onBytesLoaded,
this.scale = 1.0,
this.requestTimeout = const Duration(seconds: 30),
});
static const _defaultCooldown = Duration(seconds: 30);
static const _maxRetryAfter = Duration(minutes: 5);
static final Map<String, DateTime> _cooldownUntil = {};
/// Injectable clock for cooldown tests.
@visibleForTesting
static DateTime Function() debugNow = DateTime.now;
@visibleForTesting
static void debugResetCooldowns() => _cooldownUntil.clear();
@override
Future<MediaImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<MediaImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(
MediaImageProvider key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
debugLabel: url,
);
}
Future<ui.Codec> _loadAsync(
MediaImageProvider key,
ImageDecoderCallback decode,
) async {
try {
final until = _cooldownUntil[url];
if (until != null) {
if (debugNow().isBefore(until)) {
throw MediaImageCooldownException(url: url, until: until);
}
_cooldownUntil.remove(url);
}
final uri = Uri.parse(url);
final http.Response response;
try {
final abort = Completer<void>();
final deadline = Timer(requestTimeout, abort.complete);
try {
final request = http.AbortableRequest(
'GET',
uri,
abortTrigger: abort.future,
)..headers.addAll(auth.headersFor(url));
response = await http.Response.fromStream(await client.send(request));
} finally {
deadline.cancel();
}
} catch (_) {
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
rethrow;
}
if (response.statusCode != 200) {
_cooldownUntil[url] = debugNow().add(_cooldownFor(response));
throw NetworkImageLoadException(
statusCode: response.statusCode,
uri: uri,
);
}
final bytes = response.bodyBytes;
if (bytes.isEmpty) {
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
throw NetworkImageLoadException(statusCode: 200, uri: uri);
}
onBytesLoaded?.call(bytes);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return decode(buffer);
} catch (_) {
// Match NetworkImage: make sure an errored key is not retained in the
// cache. The cooldown map (not the cache) throttles retries.
scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key);
});
rethrow;
}
}
Duration _cooldownFor(http.Response response) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
if (retryAfter != null && retryAfter > 0) {
final requested = Duration(seconds: retryAfter);
return requested > _maxRetryAfter ? _maxRetryAfter : requested;
}
return _defaultCooldown;
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is MediaImageProvider &&
other.url == url &&
other.scale == scale &&
other.auth == auth;
}
@override
int get hashCode => Object.hash(url, scale, auth);
@override
String toString() =>
'MediaImageProvider("$url", scale: ${scale.toStringAsFixed(1)})';
}
/// Thrown when a fetch is suppressed because the URL recently failed.
class MediaImageCooldownException implements Exception {
final String url;
final DateTime until;
const MediaImageCooldownException({required this.url, required this.until});
@override
String toString() =>
'MediaImageCooldownException: $url is cooling down until $until';
}
/// Drop-in replacement for the media `Image.network` call sites.
///
/// Renders [url] through [MediaImageProvider] (stable cache key, fetch-time
/// auth, failure cooldown) and bounds the decode size so a handful of large
/// originals cannot thrash the global [ImageCache]:
///
/// - [decodeWidth] set: decode at that logical width (x device pixel ratio).
/// - otherwise, when [boundDecodeToLayout] is true (default): decode at the
/// layout width reported by [LayoutBuilder], when finite.
/// - [boundDecodeToLayout] false and no [decodeWidth]: full-resolution decode
/// (the zoomable full-screen viewer).
class MediaImage extends ConsumerWidget {
final String url;
final BoxFit? fit;
final double? width;
final double? height;
final String? semanticLabel;
final ImageErrorWidgetBuilder? errorBuilder;
final ImageFrameBuilder? frameBuilder;
final FilterQuality filterQuality;
final double? decodeWidth;
final bool boundDecodeToLayout;
final ValueChanged<Uint8List>? onBytesLoaded;
const MediaImage({
super.key,
required this.url,
this.fit,
this.width,
this.height,
this.semanticLabel,
this.errorBuilder,
this.frameBuilder,
this.filterQuality = FilterQuality.medium,
this.decodeWidth,
this.boundDecodeToLayout = true,
this.onBytesLoaded,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = MediaImageProvider(
url: url,
auth: ref.watch(mediaGetAuthServiceProvider),
client: ref.watch(mediaHttpClientProvider),
onBytesLoaded: onBytesLoaded,
);
if (decodeWidth != null) {
return _image(provider, _physicalWidth(context, decodeWidth!));
}
if (!boundDecodeToLayout) {
return _image(provider, null);
}
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cacheWidth = maxWidth.isFinite && maxWidth > 0
? _physicalWidth(context, maxWidth)
: null;
return _image(provider, cacheWidth);
},
);
}
int _physicalWidth(BuildContext context, double logicalWidth) {
return (logicalWidth * MediaQuery.devicePixelRatioOf(context)).ceil();
}
Widget _image(MediaImageProvider provider, int? cacheWidth) {
return Image(
image: ResizeImage.resizeIfNeeded(cacheWidth, null, provider),
fit: fit,
width: width,
height: height,
semanticLabel: semanticLabel,
errorBuilder: errorBuilder,
frameBuilder: frameBuilder,
filterQuality: filterQuality,
gaplessPlayback: true,
);
}
}