Files
buzz/mobile/test/shared/relay/media_image_test.dart
John Matthew Tennant b894fb02ff fix(mobile): show transparent avatars without a black background (#9105)
## Summary

Transparent PNG and GIF avatars render on a black background in mobile
avatars and iOS notification avatars. Both load the relay's small
thumbnail, which was always a JPEG with no alpha channel.

[block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays
write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside
the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP
first.

- `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then
`.thumb.jpg`, then the original. Only a 404 moves on to the next source;
any other failure still shows the error widget. Missing thumbnails are
remembered as before, so later builds skip straight to the source that
exists.
- `BuzzNotificationAvatarLoader` follows the same order for notification
avatars.

Relays without 9003, and images uploaded before it, keep rendering from
the JPEG as they do today.

### Screenshots

A transparent PNG avatar rendered by `MediaImage` on the same relay,
before and after (widget harness on an iOS simulator).

| Before | After |
| --- | --- |
| ![Before: black square behind the
avatar](https://github.com/user-attachments/assets/5da0a704-df15-4917-9d5c-9745bc83224d)
| ![After: transparent
corners](https://github.com/user-attachments/assets/3d31f4d4-cf65-4923-b367-4fd8cf3af679)
|

### Related issue

AIDA-2308 (internal). The desktop counterpart is
[block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477).

### Testing

- `missing thumbnails fall back to the JPEG, then the original`
(`avatar_image_test.dart`): when the relay 404s every thumbnail, the
avatar requests the WebP, then the JPEG, then the original. When the
WebP fails with a 500, the avatar shows its fallback and requests
neither the JPEG nor the original.
- The relay thumbnail journey tests run against a relay that 404s
`.thumb.webp`, so they cover the older-relay path end to end.
- `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG
thumbnail order for relay media, and that non-relay media gets no
thumbnails.

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

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:32:58 -08:00

702 lines
24 KiB
Dart

import 'dart:convert';
import 'dart:async';
import 'dart:typed_data';
import 'package:buzz/shared/relay/media_auth.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:buzz/shared/utils/bounded_fetch.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:nostr/nostr.dart' as nostr;
// Minimal valid 1x1 transparent PNG.
final _pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA'
'AAYAAjCB0C8AAAAASUVORK5CYII=',
);
const _relayBase = 'https://relay.example.com';
const _mediaUrl = '$_relayBase/media/abc123.png';
MediaGetAuthService _auth({String? nsec, DateTime Function()? now}) =>
MediaGetAuthService(baseUrl: _relayBase, nsec: nsec, now: now);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
MediaImageProvider.debugResetCooldowns();
MediaImageProvider.debugNow = DateTime.now;
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
});
group('MediaGetAuthService mint-per-request', () {
test('consecutive calls return distinct headers (mint-per-request)', () {
final nsec = nostr.Keys.generate().nsec;
final auth = _auth(nsec: nsec);
final first = auth.headersFor(_mediaUrl);
final second = auth.headersFor(_mediaUrl);
expect(first, isNotEmpty);
// With lifetime == margin == 60s, refreshAt == signedAt, so the cache
// never hits: each call produces a fresh proof.
expect(identical(first, second), isFalse);
expect(second['Authorization'], isNot(first['Authorization']));
});
test('each call produces a fresh token regardless of elapsed time', () {
final nsec = nostr.Keys.generate().nsec;
var current = DateTime.utc(2026, 7, 21, 12);
final auth = _auth(nsec: nsec, now: () => current);
final first = auth.headersFor(_mediaUrl);
// No elapsed time: still mints fresh.
final atZero = auth.headersFor(_mediaUrl);
expect(identical(atZero, first), isFalse);
expect(atZero['Authorization'], isNot(first['Authorization']));
// Advance clock: still mints fresh.
current = current.add(const Duration(seconds: 30));
final at30 = auth.headersFor(_mediaUrl);
expect(identical(at30, first), isFalse);
expect(at30['Authorization'], isNot(first['Authorization']));
});
test('non-relay URLs get no headers even with a key', () {
final nsec = nostr.Keys.generate().nsec;
final auth = _auth(nsec: nsec);
expect(auth.headersFor('https://elsewhere.com/media/abc.png'), isEmpty);
expect(auth.headersFor('$_relayBase/not-media/abc.png'), isEmpty);
});
});
group('relayThumbnailUrls', () {
final hash = 'ab' * 32;
final auth = _auth();
test(
'maps relay blobs of any format to the WebP, then JPEG, thumbnail',
() {
for (final ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif', '']) {
expect(relayThumbnailUrls('$_relayBase/media/$hash$ext', auth), [
'$_relayBase/media/$hash.thumb.webp',
'$_relayBase/media/$hash.thumb.jpg',
]);
}
},
);
test('leaves everything else alone', () {
for (final url in [
'https://elsewhere.example/media/$hash.jpg',
'$_relayBase/media/$hash.thumb.jpg',
'$_relayBase/media/$hash.thumb.webp',
'$_relayBase/media/$hash.jpg?v=2',
'$_relayBase/media/not-a-hash.jpg',
'$_relayBase/avatars/$hash.jpg',
'$_relayBase/media/$hash.JPG',
]) {
expect(relayThumbnailUrls(url, auth), isEmpty, reason: url);
}
});
});
group('MediaImageProvider cache identity', () {
test('equal url + same auth service => equal keys', () {
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final client = http.Client();
addTearDown(client.close);
final a = MediaImageProvider(url: _mediaUrl, auth: auth, client: client);
final b = MediaImageProvider(url: _mediaUrl, auth: auth, client: client);
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('different auth service (relay/account switch) => unequal keys', () {
final client = http.Client();
addTearDown(client.close);
final a = MediaImageProvider(
url: _mediaUrl,
auth: _auth(nsec: nostr.Keys.generate().nsec),
client: client,
);
final b = MediaImageProvider(
url: _mediaUrl,
auth: _auth(nsec: nostr.Keys.generate().nsec),
client: client,
);
expect(a, isNot(equals(b)));
});
test('transport client is not part of identity', () {
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final c1 = http.Client();
final c2 = http.Client();
addTearDown(c1.close);
addTearDown(c2.close);
expect(
MediaImageProvider(url: _mediaUrl, auth: auth, client: c1),
equals(MediaImageProvider(url: _mediaUrl, auth: auth, client: c2)),
);
});
});
group('MediaImageProvider fetching', () {
test(
'oversized image streams abort before decode and preserve the client',
() async {
for (final declared in [true, false]) {
var cancelled = false;
var aborted = false;
var delivered = false;
final controller = StreamController<List<int>>(
onCancel: () => cancelled = true,
);
final client = _StreamingClient((request) async {
if (request.url.path.endsWith('valid.png')) {
return http.StreamedResponse(Stream.value(_pngBytes), 200);
}
(request as http.AbortableRequest).abortTrigger!.then((_) {
aborted = true;
});
// Never finish: the loader must stop as soon as the cap is crossed.
controller.add(Uint8List(MediaImageProvider.maxResponseBytes));
controller.add([0]);
return http.StreamedResponse(
controller.stream,
200,
contentLength: declared
? MediaImageProvider.maxResponseBytes + 1
: null,
);
});
final provider = MediaImageProvider(
url: 'https://avatar.example/$declared.png',
auth: _auth(),
client: client,
onBytesLoaded: (_) => delivered = true,
);
final error = await _waitError(
provider.resolve(ImageConfiguration.empty),
).timeout(const Duration(seconds: 2));
expect(error, isA<ResponseTooLargeException>());
expect(cancelled, isTrue);
expect(aborted, isTrue);
expect(delivered, isFalse);
await controller.close();
await _wait(
MediaImageProvider(
url: 'https://avatar.example/valid.png',
auth: _auth(),
client: client,
).resolve(ImageConfiguration.empty),
);
PaintingBinding.instance.imageCache.clear();
}
},
);
test('resolves one fetch for repeated resolves of the same key', () async {
var fetches = 0;
final client = http_testing.MockClient((request) async {
fetches += 1;
return http.Response.bytes(_pngBytes, 200);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
for (var i = 0; i < 3; i++) {
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
final completer = provider.resolve(ImageConfiguration.empty);
await _wait(completer);
}
expect(fetches, 1);
});
test(
'abandoning a load another consumer still waits for keeps it cached',
() async {
var fetches = 0;
var aborted = false;
final response = Completer<void>();
final client = _StreamingClient((request) async {
fetches += 1;
(request as http.AbortableRequest).abortTrigger!.then((_) {
if (!response.isCompleted) aborted = true;
});
await response.future;
return http.StreamedResponse(Stream.value(_pngBytes), 200);
});
final provider = MediaImageProvider(
url: _mediaUrl,
auth: _auth(),
client: client,
);
const config = ImageConfiguration.empty;
final cell = provider.resolve(config);
final cellListener = ImageStreamListener((_, _) {});
cell.addListener(cellListener);
final prefetch = provider.resolve(config);
final loaded = Completer<void>();
final prefetchListener = ImageStreamListener((image, _) {
image.dispose();
if (!loaded.isCompleted) loaded.complete();
});
prefetch.addListener(prefetchListener);
// The cell scrolls away while the prefetch still wants the image.
cell.removeListener(cellListener);
abandonPendingImage(cell, provider, config);
response.complete();
await loaded.future.timeout(const Duration(seconds: 2));
prefetch.removeListener(prefetchListener);
expect(aborted, isFalse);
expect(
PaintingBinding.instance.imageCache.containsKey(provider),
isTrue,
);
await _wait(provider.resolve(config));
expect(fetches, 1);
},
);
test('abandoning a load nobody else waits for aborts it', () async {
final aborted = Completer<void>();
final client = _StreamingClient((request) async {
(request as http.AbortableRequest).abortTrigger!.then((_) {
if (!aborted.isCompleted) aborted.complete();
});
await aborted.future;
throw http.RequestAbortedException(request.url);
});
final provider = MediaImageProvider(
url: _mediaUrl,
auth: _auth(),
client: client,
);
final stream = provider.resolve(ImageConfiguration.empty);
final listener = ImageStreamListener((_, _) {});
stream.addListener(listener);
stream.removeListener(listener);
abandonPendingImage(stream, provider, ImageConfiguration.empty);
await aborted.future.timeout(const Duration(seconds: 2));
expect(
PaintingBinding.instance.imageCache.containsKey(provider),
isFalse,
);
});
test('one URL shown at two decode sizes downloads once', () async {
var fetches = 0;
final response = Completer<void>();
final client = http_testing.MockClient((request) async {
fetches += 1;
await response.future;
return http.Response.bytes(_pngBytes, 200);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
ImageStream resolveAt(int width) => ResizeImage(
MediaImageProvider(url: _mediaUrl, auth: auth, client: client),
width: width,
).resolve(ImageConfiguration.empty);
final small = _wait(resolveAt(40));
final large = _wait(resolveAt(120));
await pumpEventQueue();
response.complete();
await Future.wait([small, large]);
expect(fetches, 1);
});
test(
'a download shared by two sizes outlives one abandoned size',
() async {
var fetches = 0;
var aborts = 0;
final response = Completer<void>();
final client = _StreamingClient((request) async {
fetches += 1;
final trigger = (request as http.AbortableRequest).abortTrigger!;
unawaited(
trigger.then((_) {
if (!response.isCompleted) aborts += 1;
}),
);
await Future.any([response.future, trigger]);
if (!response.isCompleted) {
throw http.RequestAbortedException(request.url);
}
return http.StreamedResponse(Stream.value(_pngBytes), 200);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final base = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
const config = ImageConfiguration.empty;
final smallProvider = ResizeImage(base, width: 40);
final largeProvider = ResizeImage(base, width: 120);
final small = smallProvider.resolve(config);
final smallListener = ImageStreamListener((_, _) {});
small.addListener(smallListener);
final largeDone = _wait(largeProvider.resolve(config));
await pumpEventQueue();
small.removeListener(smallListener);
abandonPendingImage(small, smallProvider, config);
await pumpEventQueue();
expect(aborts, 0);
response.complete();
await largeDone.timeout(const Duration(seconds: 2));
expect(fetches, 1);
expect(aborts, 0);
expect(MediaImageProvider.debugCooldownUrls, isEmpty);
},
);
test('sends auth headers with the fetch', () async {
Map<String, String>? seen;
final client = http_testing.MockClient((request) async {
seen = request.headers;
return http.Response.bytes(_pngBytes, 200);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
await _wait(provider.resolve(ImageConfiguration.empty));
expect(seen?['Authorization'], startsWith('Nostr '));
});
test(
'a slow body that keeps arriving outlives the stall timeout',
() async {
const gap = Duration(milliseconds: 60);
final client = _StreamingClient((request) async {
// Honor the abort trigger the way the real IO client does.
var aborted = false;
(request as http.AbortableRequest).abortTrigger!.then((_) {
aborted = true;
});
Stream<List<int>> trickle() async* {
for (var i = 0; i < _pngBytes.length; i += 12) {
await Future<void>.delayed(gap);
if (aborted) throw http.RequestAbortedException(request.url);
yield _pngBytes.sublist(i, (i + 12).clamp(0, _pngBytes.length));
}
}
return http.StreamedResponse(trickle(), 200);
});
final stopwatch = Stopwatch()..start();
await _wait(
MediaImageProvider(
url: _mediaUrl,
auth: _auth(),
client: client,
stallTimeout: gap * 2.5,
).resolve(ImageConfiguration.empty),
).timeout(const Duration(seconds: 5));
// The whole body took several stall windows; only gaps are bounded.
expect(stopwatch.elapsed, greaterThan(gap * 5));
},
);
test('a body that never ends is aborted at the fetch cap', () async {
final client = _StreamingClient((request) async {
var aborted = false;
(request as http.AbortableRequest).abortTrigger!.then((_) {
aborted = true;
});
Stream<List<int>> endless() async* {
while (true) {
await Future<void>.delayed(const Duration(milliseconds: 20));
if (aborted) throw http.RequestAbortedException(request.url);
yield const [0];
}
}
return http.StreamedResponse(endless(), 200);
});
final error = await _waitError(
MediaImageProvider(
url: _mediaUrl,
auth: _auth(),
client: client,
stallTimeout: const Duration(seconds: 5),
maxFetchDuration: const Duration(milliseconds: 200),
).resolve(ImageConfiguration.empty),
).timeout(const Duration(seconds: 3));
expect(error, isA<http.RequestAbortedException>());
});
test('failed URL is not refetched until cooldown elapses', () async {
var fetches = 0;
final client = http_testing.MockClient((request) async {
fetches += 1;
return http.Response('rate limited', 429);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
var current = DateTime.utc(2026, 7, 21, 12);
MediaImageProvider.debugNow = () => current;
Future<Object?> attempt() async {
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
return _waitError(provider.resolve(ImageConfiguration.empty));
}
expect(await attempt(), isA<NetworkImageLoadException>());
expect(fetches, 1);
// Within cooldown: suppressed, no network call.
expect(await attempt(), isA<MediaImageCooldownException>());
expect(fetches, 1);
// After cooldown: retried.
current = current.add(const Duration(seconds: 31));
expect(await attempt(), isA<NetworkImageLoadException>());
expect(fetches, 2);
});
test(
'honors Retry-After on 429 (capped) without reading the body',
() async {
var fetches = 0;
// A body that never ends: the error must not wait for it.
Stream<List<int>> endless() async* {
while (true) {
await Future<void>.delayed(const Duration(milliseconds: 20));
yield const [0];
}
}
final client = _StreamingClient((request) async {
fetches += 1;
return http.StreamedResponse(
endless(),
429,
headers: {'retry-after': '120'},
);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
var current = DateTime.utc(2026, 7, 21, 12);
MediaImageProvider.debugNow = () => current;
Future<Object?> attempt() async {
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
return _waitError(provider.resolve(ImageConfiguration.empty));
}
final error = await attempt().timeout(const Duration(seconds: 3));
expect(error, isA<NetworkImageLoadException>());
expect(fetches, 1);
current = current.add(const Duration(seconds: 60));
expect(await attempt(), isA<MediaImageCooldownException>());
expect(fetches, 1);
current = current.add(const Duration(seconds: 61));
await attempt();
expect(fetches, 2);
},
);
});
group('stalls', () {
// Each row stalls at a different point; both must abort at the stall
// timeout, well before the fetch cap, and put the URL on cooldown.
final rows =
<
String,
Future<http.StreamedResponse> Function(
http.BaseRequest request,
Future<void> aborted,
)
>{
'headers never arrive': (request, aborted) async {
await aborted;
throw http.RequestAbortedException(request.url);
},
'the body stops partway': (request, aborted) async {
Stream<List<int>> body() async* {
yield _pngBytes.sublist(0, 8);
await aborted;
throw http.RequestAbortedException(request.url);
}
return http.StreamedResponse(body(), 200);
},
};
for (final MapEntry(key: name, value: respond) in rows.entries) {
test('a silent connection is aborted when $name', () async {
final client = _StreamingClient(
(request) => respond(
request,
(request as http.AbortableRequest).abortTrigger!,
),
);
final stopwatch = Stopwatch()..start();
final error = await _waitError(
MediaImageProvider(
url: _mediaUrl,
auth: _auth(),
client: client,
stallTimeout: const Duration(milliseconds: 150),
maxFetchDuration: const Duration(seconds: 30),
).resolve(ImageConfiguration.empty),
).timeout(const Duration(seconds: 5));
expect(error, isA<http.RequestAbortedException>());
expect(stopwatch.elapsed, lessThan(const Duration(seconds: 5)));
expect(MediaImageProvider.debugCooldownUrls, [_mediaUrl]);
});
}
});
testWidgets('an image that leaves before it loads stops downloading, '
'unless another widget still shows it', (tester) async {
var aborted = false;
var requests = 0;
final client = _StreamingClient((request) async {
requests += 1;
// A slow link: the body has started but does not finish in this test.
final body = StreamController<List<int>>()..add(const [0]);
(request as http.AbortableRequest).abortTrigger!.then((_) {
aborted = true;
body
..addError(http.RequestAbortedException(request.url))
..close();
});
return http.StreamedResponse(body.stream, 200);
});
final shown = ValueNotifier(2);
addTearDown(shown.dispose);
// A fast fling: the scrollable asks images to defer loading.
final flinging = ValueNotifier(true);
addTearDown(flinging.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
mediaGetAuthServiceProvider.overrideWithValue(_auth()),
mediaHttpClientProvider.overrideWithValue(client),
],
child: MaterialApp(
home: ListenableBuilder(
listenable: Listenable.merge([shown, flinging]),
builder: (_, _) => SingleChildScrollView(
physics: flinging.value ? const _DeferringPhysics() : null,
child: Column(
children: [
for (var i = 0; i < shown.value; i++)
const SizedBox.square(
dimension: 40,
child: MediaImage(url: _mediaUrl),
),
],
),
),
),
),
),
);
Future<void> settle() async {
for (var i = 0; i < 3; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
}
}
await settle();
// Nothing downloads while the fling defers loading.
expect(requests, 0);
flinging.value = false;
await settle();
// Both copies share one download.
expect(requests, 1);
// One copy scrolls away; the other still shows the image.
shown.value = 1;
await settle();
expect(aborted, isFalse);
// The last one leaves: the download stops, and the URL itself did not
// fail, so it is not put on cooldown.
shown.value = 0;
await settle();
expect(aborted, isTrue);
expect(MediaImageProvider.debugCooldownUrls, isEmpty);
});
}
Future<void> _wait(ImageStream stream) {
final done = Completer<void>();
late final ImageStreamListener listener;
listener = ImageStreamListener(
(image, sync) {
image.dispose();
stream.removeListener(listener);
if (!done.isCompleted) done.complete();
},
onError: (error, stack) {
stream.removeListener(listener);
if (!done.isCompleted) done.completeError(error, stack);
},
);
stream.addListener(listener);
return done.future;
}
Future<Object?> _waitError(ImageStream stream) async {
try {
await _wait(stream);
return null;
} catch (e) {
return e;
}
}
class _StreamingClient extends http.BaseClient {
_StreamingClient(this.respond);
final Future<http.StreamedResponse> Function(http.BaseRequest) respond;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) =>
respond(request);
}
/// Physics that always recommend deferring image loads, as during a fling.
class _DeferringPhysics extends ScrollPhysics {
const _DeferringPhysics({super.parent});
@override
_DeferringPhysics applyTo(ScrollPhysics? ancestor) =>
_DeferringPhysics(parent: buildParent(ancestor));
@override
bool recommendDeferredLoading(
double velocity,
ScrollMetrics metrics,
BuildContext context,
) => true;
}