Files
buzz/mobile/test/shared/relay/media_disk_cache_test.dart
Tom Brow 52cb6818fa perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158)
## Summary

The mobile app now saves remote images on disk. After a relaunch,
avatars, custom emoji, message attachments and agent artwork show
immediately. The app does not download them again.

Small images on a relay now load only the relay's thumbnail. The app
never downloads the full-size upload to show a small image.

## Why

Before this change, the app kept remote images only in memory. Each cold
launch downloaded every image again. On a slow connection, attachments
showed a "Loading image" placeholder for a long time.

Also, when a relay had no thumbnail for an image, the app downloaded the
full-size upload. That upload can be up to 20 MiB, for an avatar that is
a few dozen points wide.

## What changes

### Images on disk

- `MediaImageProvider` reads and writes a `MediaDiskCache`. Each account
in each community has its own cache, at `<root>/communities/<community
id>/media/<signer pubkey>`. The cache is built on
`flutter_cache_manager`, with a JSON index.
- On iOS, the root is the app group's `Library/Caches` (from a new
`buzz/app_group` method channel). App extensions can then read the same
cache. On other platforms, the root is the app cache directory.
- Each cache holds a maximum of 250 MB. When it is full, the least
recently used images go first. `MediaCacheIndex` measures each file when
it writes it, because `flutter_cache_manager` limits only the number of
files. Every disk read marks the file used, and every write schedules
eviction, including a refresh that replaces bytes the package already
holds in memory (by default the package does neither for those).
- The cache does not save an image that is larger than 5 MiB. The app
still shows that image from memory. Thus, one large image cannot push
out all the others.
- Relay media URLs contain a content hash, so their bytes never change.
The cache keeps them until it evicts them. For other URLs, the cache
shows the saved image, and then gets a new copy in the background after
one week. A maximum of two of these refreshes run at the same time. The
app decodes a refreshed image before it replaces the saved copy.
- When you remove a community, the app deletes its cache. When a
community gets a new signing key, the app deletes the media of the old
account. At launch, the app also deletes caches of communities and
accounts that are no longer present.
- Startup does not wait for the cache. The cache directory resolves in
the background. Image reads wait for it for a maximum of 3 seconds.

### Thumbnails for small images

- A small relay image loads the relay's WebP thumbnail. If the relay has
no WebP thumbnail, the image loads the JPEG thumbnail. If the relay has
neither, the widget shows its placeholder (initials for an avatar, an
empty tile for artwork).
- Native avatars drawn by UIKit (the navigation bar, the community icon
on the create button, and the New DM and Add Members pickers) use the
same choice, through a new `smallMediaImageProviders`. They try the same
thumbnails, fall back the same way, and show initials when neither
thumbnail exists. Before, they downloaded the full-size upload and only
decoded it small.
- Larger layouts and the full-screen viewer load the full-size upload,
as before.

### Agent artwork

- Preset agent artwork (the tray, flight, video placeholder, avatar hero
and the avatar editor's blurred backdrop) now loads through
`MediaImage`, so it uses the disk cache too.
- The create-agent and empty-bestie pages warm the same
`MediaImageProvider` that their widgets read, through a new
`mediaImageProviderFor(ref, url)`.
- No code in `mobile/lib` uses `Image.network` or `NetworkImage` now.

## Demo

A cold relaunch (the app is stopped, then started) into a synthetic "QA
demo" Solo chat that holds a few generated images. Shown at half speed.

Before: the attachment shows a "Loading image" placeholder while it
downloads.


https://github.com/user-attachments/assets/107f43a3-63d0-40c3-bd80-4ea9d7897a97

After: the attachment shows with the chat, from disk.


https://github.com/user-attachments/assets/d3985bb2-838c-4efa-93cb-44cef51f677d

## Tests

- `test/shared/relay/media_disk_cache_test.dart` follows one journey
through the real provider and a real cache in a temporary directory. The
first launch downloads and saves. An offline relaunch shows the
full-size image, its thumbnail and an external image from disk, and the
oversized image goes back to the network. Eight days later, only the
external image refreshes. Removing the community deletes its directory.
- A second test checks the order of eviction when the cache is full,
that a file read from disk is evicted after files that were not, and
that replacing a file's bytes after eviction last ran evicts again when
the cache goes over budget.
- `community_provider_test` checks that launch deletes orphan media
directories, that a new signing key deletes only the old account's
media, and that removing a community deletes its media.
- `avatar_image_test` checks that a missing WebP thumbnail falls back to
the JPEG thumbnail, and that an avatar with no thumbnail shows its
initials.
- `native_avatar_image_test` checks the same for native avatars: a WebP
404 paints the JPEG thumbnail, the full-size upload is never requested,
a relay with no thumbnail paints initials and is not asked again, and
any other failure stops at the initials.
- `flutter analyze` and the full mobile test suite pass.
- Automated app runs on an iPhone 17 Pro simulator (iOS 27): the channel
list, a DM, Home, the Agents grid and an agent details sheet show their
images. After a relaunch, the images came from disk and the app wrote no
new files. In the "QA demo" chat, a new image from the app showed
immediately.
- Automated run of the native-thumbnail fix on an iPhone 17 Pro
simulator (iOS 27): the native New DM picker shows photo avatars, and
the cache gained only relay `.thumb.webp` entries for them, no full-size
uploads. After a cold relaunch, the picker showed the same avatars
within about 1.5 seconds, and the cache directory gained no files. The
avatar editor backdrop was not exercised in the app. The test community
has no remote agents, and the backdrop tests cover only emoji and
picked-image selections.

## Before merge

- [x] Automated simulator run of this head: avatars and agent artwork
show their thumbnails, and a cold relaunch shows images from disk.

🤖 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>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
2026-11-09 07:21:21 -08:00

392 lines
14 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:buzz/shared/relay/media_auth.dart';
import 'package:buzz/shared/relay/media_disk_cache.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:path/path.dart' as p;
// Minimal valid 1x1 transparent PNG.
final _pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA'
'AAYAAjCB0C8AAAAASUVORK5CYII=',
);
const _relayBase = 'https://relay.example.com';
const _hash =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const _relayUrl = '$_relayBase/media/$_hash.png';
const _thumbnailUrl = '$_relayBase/media/$_hash.thumb.jpg';
const _hugeUrl =
'$_relayBase/media/'
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png';
const _corruptUrl =
'$_relayBase/media/'
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.png';
const _truncatedUrl =
'$_relayBase/media/'
'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd.png';
const _externalUrls = [
'https://images.example.org/a.png',
'https://images.example.org/b.png',
'https://images.example.org/c.png',
];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory root;
final auth = MediaGetAuthService(baseUrl: _relayBase, nsec: null);
setUp(() async {
root = await Directory.systemTemp.createTemp('media_disk_cache_test');
MediaImageProvider.debugResetCooldowns();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
});
tearDown(() => root.delete(recursive: true));
test('images load from disk after a relaunch, and only stale external '
'ones are refetched', () async {
final fetched = <String>[];
var online = true;
// While set, external images answer once it completes, with an image
// too large to save.
Completer<void>? replaceExternal;
// While set, external images answer with a body that never decodes.
var malformedExternal = false;
// Trailing bytes after IEND still decode, so this is a 5 MiB+ image.
final hugeBytes = [..._pngBytes, ...Uint8List(MediaDiskCache.maxFileBytes)];
final client = http_testing.MockClient((request) async {
final url = request.url.toString();
fetched.add(url);
if (!online) throw const SocketException('offline');
if (url == _hugeUrl) return http.Response.bytes(hugeBytes, 200);
if (malformedExternal && _externalUrls.contains(url)) {
return http.Response('not an image', 200);
}
if (replaceExternal != null && _externalUrls.contains(url)) {
await replaceExternal.future;
return http.Response.bytes(hugeBytes, 200);
}
if (request.url.toString() == _corruptUrl) {
return http.Response('not an image', 200);
}
// A valid header, so a codec opens, but the frame fails to decode.
if (request.url.toString() == _truncatedUrl) {
return http.Response.bytes(_pngBytes.sublist(0, 41), 200);
}
return http.Response.bytes(_pngBytes, 200);
});
final directory = MediaDiskCache.directoryFor(
root,
'community-1',
'signer-1',
);
var now = DateTime.now();
Future<void> launch(
Future<void> Function(MediaDiskCache cache) body,
) async {
// The caches root resolves after the first images start loading.
final cache = MediaDiskCache.open(
Future<Directory>.delayed(
const Duration(milliseconds: 20),
() => directory,
),
now: () => now,
);
try {
await body(cache);
} finally {
await cache.dispose();
// A relaunch starts with nothing decoded in memory.
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
}
}
Future<Object?> show(MediaDiskCache cache, String url) => _waitError(
MediaImageProvider(
url: url,
auth: auth,
client: client,
diskCache: cache,
).resolve(ImageConfiguration.empty),
);
const saved = [_relayUrl, _thumbnailUrl, ..._externalUrls];
const urls = [...saved, _hugeUrl];
// First launch downloads everything and saves what fits.
await launch((cache) async {
for (final url in urls) {
expect(await show(cache, url), isNull, reason: url);
}
expect(await show(cache, _corruptUrl), isNotNull);
expect(await show(cache, _truncatedUrl), isNotNull);
await _settle();
});
expect(fetched, [...urls, _corruptUrl, _truncatedUrl]);
// Offline relaunch: the original and its thumbnail are separate entries,
// both shown from disk. The oversized image and the bodies that failed
// to decode were never saved.
fetched.clear();
online = false;
await launch((cache) async {
for (final url in saved) {
expect(await show(cache, url), isNull, reason: url);
}
expect(await show(cache, _hugeUrl), isNotNull);
expect(await show(cache, _corruptUrl), isNotNull);
expect(await show(cache, _truncatedUrl), isNotNull);
});
expect(fetched, [_hugeUrl, _corruptUrl, _truncatedUrl]);
// A week on, external images show from disk at once and refresh in the
// background, two at a time. Relay media never goes stale.
fetched.clear();
online = true;
now = now.add(const Duration(days: 8));
final replacement = replaceExternal = Completer<void>();
await launch((cache) async {
for (final url in saved) {
expect(await show(cache, url), isNull, reason: url);
}
await _settle();
expect(fetched, _externalUrls.take(2));
// The third waits for a slot. The replacements are too large to save,
// so the old copies go rather than showing in their place.
replacement.complete();
await _until(() async {
for (final url in _externalUrls) {
if (await cache.read(url) != null) return false;
}
return true;
});
expect(fetched, _externalUrls);
});
replaceExternal = null;
fetched.clear();
online = false;
await launch((cache) async {
for (final url in _externalUrls) {
expect(await show(cache, url), isNotNull, reason: url);
}
});
expect(fetched, _externalUrls);
// A saved copy that no longer decodes is dropped and fetched again.
fetched.clear();
online = true;
await launch((cache) async {
await cache.write(
_relayUrl,
Uint8List.fromList(utf8.encode('not an image')),
immutable: true,
);
expect(await show(cache, _relayUrl), isNotNull);
await _settle();
expect(await show(cache, _relayUrl), isNull);
await cache.write(_externalUrls.first, _pngBytes, immutable: false);
});
expect(fetched, [_relayUrl]);
// A refresh whose body never decodes backs off rather than refetching
// on every load.
fetched.clear();
malformedExternal = true;
// Forget the failures of the offline launch.
MediaImageProvider.debugResetCooldowns();
now = now.add(const Duration(days: 8));
await launch((cache) async {
for (var i = 0; i < 2; i++) {
expect(await show(cache, _externalUrls.first), isNull);
await _settle();
PaintingBinding.instance.imageCache.clear();
}
});
expect(fetched, [_externalUrls.first]);
// A stale saved copy that fails to decode is dropped without starting a
// refresh, which could otherwise save a new copy just before the drop.
fetched.clear();
malformedExternal = false;
MediaImageProvider.debugResetCooldowns();
await launch((cache) async {
await cache.write(
_externalUrls[1],
Uint8List.fromList(utf8.encode('not an image')),
immutable: false,
);
});
now = now.add(const Duration(days: 8));
await launch((cache) async {
expect(await show(cache, _externalUrls[1]), isNotNull);
await _settle();
expect(fetched, isEmpty);
expect(await show(cache, _externalUrls[1]), isNull);
});
expect(fetched, [_externalUrls[1]]);
// Refreshes queued when the community or account retires never run.
fetched.clear();
malformedExternal = false;
MediaImageProvider.debugResetCooldowns();
await launch((cache) async {
for (final url in _externalUrls) {
await cache.write(url, _pngBytes, immutable: false);
}
});
now = now.add(const Duration(days: 8));
final held = replaceExternal = Completer<void>();
await launch((cache) async {
for (final url in _externalUrls) {
expect(await show(cache, url), isNull, reason: url);
}
await _settle();
});
held.complete();
await _settle();
replaceExternal = null;
expect(fetched, _externalUrls.take(2));
// A replaced account's directory goes, including writes that land after
// its delete; removing the community deletes its directory.
expect(await directory.exists(), isTrue);
await MediaDiskCache.removeOrphans(root, () => {'community-1': 'signer-2'});
expect(await directory.exists(), isFalse);
await directory.create(recursive: true);
await MediaDiskCache.removeOrphans(root, () => {'community-1': 'signer-1'});
expect(await directory.exists(), isTrue);
await MediaDiskCache.removeOrphans(root, () => {'community-2': 'signer-1'});
expect(
await MediaDiskCache.communityDirectory(root, 'community-1').exists(),
isFalse,
);
});
test(
'evicts least recently used files past the byte or file budget',
() async {
// (byte budget, file budget, evicted): three 4-byte files make 12 bytes.
const cases = [
(10, 100, ['old']),
(100, 1, ['old', 'middle']),
];
for (final (bytes, files, evicted) in cases) {
final directory = MediaDiskCache.directoryFor(
root,
'$bytes-$files',
's',
);
final cache = MediaDiskCache.open(
directory,
byteBudget: bytes,
fileBudget: files,
// Like the app's 10 s: long enough for the writes to finish.
evictionDelay: const Duration(milliseconds: 200),
);
for (final name in ['old', 'middle', 'newest']) {
await cache.write(name, Uint8List(4), immutable: true);
// Distinct last-used times.
await Future<void>.delayed(const Duration(milliseconds: 5));
}
// Saving a new URL schedules eviction, as in the app.
await Future<void>.delayed(const Duration(milliseconds: 400));
for (final name in ['old', 'middle', 'newest']) {
expect(
await cache.read(name),
evicted.contains(name) ? isNull : isNotNull,
reason: '$bytes bytes, $files files: $name',
);
}
// Evicted files are gone from disk too; the index (and a save of it
// in progress) remains.
final names = directory
.listSync()
.map((entry) => p.basename(entry.path))
.where((name) => !name.startsWith('index.json'));
expect(names, hasLength(3 - evicted.length), reason: '$bytes $files');
await cache.dispose();
}
Future<MediaDiskCache> open(String name) async => MediaDiskCache.open(
MediaDiskCache.directoryFor(root, name, 's'),
byteBudget: 10,
evictionDelay: const Duration(milliseconds: 200),
);
const evictionRan = Duration(milliseconds: 400);
// Reading a file from disk marks it used, even when the cache already
// holds it in memory.
final reads = await open('reads');
await reads.write('old', Uint8List(4), immutable: true);
await Future<void>.delayed(const Duration(milliseconds: 5));
await reads.write('middle', Uint8List(4), immutable: true);
await Future<void>.delayed(const Duration(milliseconds: 5));
expect(await reads.read('old'), isNotNull);
await reads.write('newest', Uint8List(4), immutable: true);
await Future<void>.delayed(evictionRan);
expect(await reads.read('middle'), isNull);
expect(await reads.read('old'), isNotNull);
await reads.dispose();
// Replacing a file's bytes after eviction last ran, as a background
// refresh does, evicts again once the cache is over budget.
final refresh = await open('refresh');
await refresh.write('other', Uint8List(4), immutable: true);
await Future<void>.delayed(const Duration(milliseconds: 5));
await refresh.write('refreshed', Uint8List(4), immutable: false);
// Eviction ran with the cache in budget. A read would schedule it again.
await Future<void>.delayed(evictionRan);
await refresh.write('refreshed', Uint8List(8), immutable: false);
await Future<void>.delayed(evictionRan);
expect(await refresh.read('other'), isNull);
expect((await refresh.read('refreshed'))?.bytes, hasLength(8));
await refresh.dispose();
},
);
}
/// Waits for background work that a fixed delay cannot bound on a slow
/// machine, such as several refreshes running one after another.
Future<void> _until(Future<bool> Function() done) async {
final deadline = DateTime.now().add(const Duration(seconds: 10));
while (!await done()) {
if (DateTime.now().isAfter(deadline)) fail('timed out');
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
/// Lets fire-and-forget disk writes and refreshes finish.
Future<void> _settle() =>
Future<void>.delayed(const Duration(milliseconds: 200));
Future<Object?> _waitError(ImageStream stream) {
final done = Completer<Object?>();
late final ImageStreamListener listener;
listener = ImageStreamListener(
(image, sync) {
image.dispose();
stream.removeListener(listener);
if (!done.isCompleted) done.complete(null);
},
onError: (error, stack) {
stream.removeListener(listener);
if (!done.isCompleted) done.complete(error);
},
);
stream.addListener(listener);
return done.future;
}