mirror of
https://github.com/block/buzz.git
synced 2026-11-10 19:32:00 +00:00
## 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>
364 lines
13 KiB
Dart
364 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:file/file.dart' as pf;
|
|
import 'package:file/local.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../community/community_provider.dart';
|
|
import 'relay_provider.dart' show pubkeyFromNsec;
|
|
|
|
/// Image bytes saved on disk, and whether they are due a background refresh.
|
|
typedef MediaDiskCacheHit = ({Uint8List bytes, bool isStale});
|
|
|
|
/// Persistent store for one community's remote image bytes: custom emoji,
|
|
/// avatars, attachments and anything else [MediaImageProvider] loads.
|
|
///
|
|
/// Built on `flutter_cache_manager`. It keeps the bytes each URL served, so a
|
|
/// relay thumbnail and its original are separate entries. Each community and
|
|
/// account has its own directory under the caches root (see
|
|
/// [mediaCacheRootProvider]), deleted with the community or account. Least recently used files are evicted once
|
|
/// the directory holds more than [maxBytes].
|
|
///
|
|
/// Relay media URLs are content hashes and never go stale. Other URLs can
|
|
/// change in place, so they are served from disk but refetched in the
|
|
/// background once older than [externalRefreshAge].
|
|
class MediaDiskCache {
|
|
/// Opens the store in [directory], creating it on first write, or a store
|
|
/// that saves nothing when [directory] is null. Reads and writes wait for
|
|
/// [directory] to resolve, so a load never skips the disk while it does.
|
|
/// [now] is the clock staleness is judged by.
|
|
///
|
|
/// Tests can shrink the budgets and the delay after a write or first read
|
|
/// before eviction runs (10 s in `flutter_cache_manager`).
|
|
MediaDiskCache.open(
|
|
FutureOr<Directory?> directory, {
|
|
@visibleForTesting DateTime Function() now = DateTime.now,
|
|
@visibleForTesting int byteBudget = maxBytes,
|
|
@visibleForTesting int fileBudget = maxFiles,
|
|
@visibleForTesting Duration? evictionDelay,
|
|
}) : _now = now {
|
|
_manager = Future.value(directory)
|
|
// Images wait for the disk, so never wait on it for long.
|
|
.timeout(_openTimeout, onTimeout: () => null)
|
|
.then((directory) {
|
|
if (directory == null || isClosed) return null;
|
|
return _openManager(
|
|
directory,
|
|
byteBudget: byteBudget,
|
|
fileBudget: fileBudget,
|
|
evictionDelay: evictionDelay,
|
|
);
|
|
})
|
|
.catchError((Object error) {
|
|
debugPrint('[MediaDiskCache] open failed: $error');
|
|
return null;
|
|
});
|
|
}
|
|
|
|
static CacheManager _openManager(
|
|
Directory directory, {
|
|
required int byteBudget,
|
|
required int fileBudget,
|
|
required Duration? evictionDelay,
|
|
}) {
|
|
final manager = CacheManager(
|
|
Config(
|
|
directory.path,
|
|
// Age never evicts: the byte budget below does.
|
|
stalePeriod: _neverStale,
|
|
maxNrOfCacheObjects: fileBudget,
|
|
repo: MediaCacheIndex(
|
|
File(p.join(directory.path, 'index.json')),
|
|
directory,
|
|
byteBudget,
|
|
),
|
|
fileSystem: _DirectoryFileSystem(directory),
|
|
),
|
|
);
|
|
if (evictionDelay != null) {
|
|
manager.store.cleanupRunMinInterval = evictionDelay;
|
|
}
|
|
return manager;
|
|
}
|
|
|
|
static const _openTimeout = Duration(seconds: 3);
|
|
|
|
/// Most bytes one community's cache keeps on disk.
|
|
static const maxBytes = 250 * 1024 * 1024;
|
|
|
|
/// Most files one community's cache keeps, so many tiny files (emoji,
|
|
/// avatars) cannot cost far more disk and index work than their bytes.
|
|
static const maxFiles = 10000;
|
|
|
|
/// Larger images still show, from memory, but are never written to disk,
|
|
/// so one image cannot evict the rest of the cache.
|
|
static const maxFileBytes = 5 * 1024 * 1024;
|
|
|
|
/// Age at which a non-relay image is refetched in the background.
|
|
static const externalRefreshAge = Duration(days: 7);
|
|
|
|
static const _neverStale = Duration(days: 365 * 100);
|
|
|
|
late final Future<CacheManager?> _manager;
|
|
final DateTime Function() _now;
|
|
final _closed = Completer<void>();
|
|
|
|
/// Completes once the cache is disposed, when its community or account is
|
|
/// retired.
|
|
Future<void> get closed => _closed.future;
|
|
|
|
/// Whether the cache was disposed.
|
|
bool get isClosed => _closed.isCompleted;
|
|
|
|
/// The directory holding everything one community persists under [root].
|
|
static Directory communityDirectory(Directory root, String communityId) =>
|
|
Directory(
|
|
p.join(root.path, 'communities', Uri.encodeComponent(communityId)),
|
|
);
|
|
|
|
/// Where [communityId]'s image cache for the account [signer] lives under
|
|
/// [root]. Each account has its own, so one account's images never serve
|
|
/// another's.
|
|
static Directory directoryFor(
|
|
Directory root,
|
|
String communityId,
|
|
String signer,
|
|
) => Directory(
|
|
p.join(communityDirectory(root, communityId).path, 'media', signer),
|
|
);
|
|
|
|
/// Keeps only each retained community's cache for its current signer
|
|
/// (community id to signer pubkey) and deletes every other directory under
|
|
/// [root]: removed communities and replaced accounts, including writes
|
|
/// that landed after their removal. A failed delete is retried on the
|
|
/// next call.
|
|
///
|
|
/// [retained] is read again before each delete, so a community or account
|
|
/// added while the sweep runs is kept.
|
|
static Future<void> removeOrphans(
|
|
Directory root,
|
|
Map<String, String?> Function() retained,
|
|
) async {
|
|
Set<String> keep() => {
|
|
for (final MapEntry(key: id, value: signer) in retained().entries)
|
|
if (signer != null) directoryFor(root, id, signer).path,
|
|
};
|
|
final communities = Directory(p.join(root.path, 'communities'));
|
|
await _deleteExcept(communities, () {
|
|
return {for (final path in keep()) p.dirname(p.dirname(path))};
|
|
});
|
|
if (!await communities.exists()) return;
|
|
await for (final community in communities.list(followLinks: false)) {
|
|
if (community is! Directory) continue;
|
|
await _deleteExcept(Directory(p.join(community.path, 'media')), keep);
|
|
}
|
|
}
|
|
|
|
/// Deletes the entries of [parent] whose paths are not in [keep].
|
|
static Future<void> _deleteExcept(
|
|
Directory parent,
|
|
Set<String> Function() keep,
|
|
) async {
|
|
if (!await parent.exists()) return;
|
|
await for (final entry in parent.list(followLinks: false)) {
|
|
if (keep().contains(entry.path)) continue;
|
|
try {
|
|
await entry.delete(recursive: true);
|
|
} on FileSystemException catch (error) {
|
|
debugPrint('[MediaDiskCache] orphan cleanup failed: $error');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Removes [url]'s saved bytes, for example when they no longer decode.
|
|
/// Failures are logged; a later read finds the same bad bytes.
|
|
Future<void> remove(String url) async {
|
|
try {
|
|
await (await _manager)?.removeFile(url);
|
|
} on Object catch (error) {
|
|
_logFailure(error);
|
|
}
|
|
}
|
|
|
|
/// [url]'s saved bytes, or null when they are not on disk.
|
|
Future<MediaDiskCacheHit?> read(String url) async {
|
|
final FileInfo? info;
|
|
final Uint8List bytes;
|
|
final manager = await _manager;
|
|
if (manager == null) return null;
|
|
try {
|
|
// Past the first lookup, the package serves a URL from memory without
|
|
// marking it used, so a file read often would be evicted first. Going
|
|
// to the index marks it used, and schedules eviction.
|
|
info = await manager.getFileFromCache(url, ignoreMemCache: true);
|
|
if (info == null) return null;
|
|
bytes = await info.file.readAsBytes();
|
|
} on FileSystemException {
|
|
// The OS may purge cache files under the index. Drop the entry and
|
|
// load from the network.
|
|
unawaited(remove(url));
|
|
return null;
|
|
}
|
|
if (bytes.isEmpty) return null;
|
|
return (bytes: bytes, isStale: info.validTill.isBefore(_now()));
|
|
}
|
|
|
|
/// Saves [bytes] for [url]. [immutable] bytes (relay media) are never
|
|
/// refetched; others are refetched after [externalRefreshAge]. Bytes over
|
|
/// [maxFileBytes] are not saved, and drop any older copy so it is not
|
|
/// shown in their place. Failures are logged, never thrown: a missing
|
|
/// entry only costs a later download.
|
|
Future<void> write(
|
|
String url,
|
|
Uint8List bytes, {
|
|
required bool immutable,
|
|
}) async {
|
|
// A download that finishes after its account or community was retired
|
|
// must not repopulate the cache.
|
|
if (isClosed || bytes.isEmpty) return;
|
|
final manager = await _manager;
|
|
if (manager == null || isClosed) return;
|
|
try {
|
|
if (bytes.length > maxFileBytes) {
|
|
await manager.removeFile(url);
|
|
return;
|
|
}
|
|
await manager.putFile(
|
|
url,
|
|
bytes,
|
|
maxAge: immutable ? _neverStale : externalRefreshAge,
|
|
);
|
|
// Replacing a URL the package already holds in memory schedules no
|
|
// eviction, so a refresh that grows the cache would leave it over
|
|
// budget. An index lookup schedules it.
|
|
await manager.getFileFromCache(url, ignoreMemCache: true);
|
|
} on Object catch (error) {
|
|
_logFailure(error);
|
|
}
|
|
}
|
|
|
|
/// Stops writes, then flushes the index.
|
|
Future<void> dispose() {
|
|
if (_closed.isCompleted) return _disposed;
|
|
_closed.complete();
|
|
return _disposed = _manager
|
|
.then((manager) => manager?.dispose())
|
|
.catchError((Object error) {
|
|
debugPrint('[MediaDiskCache] close failed: $error');
|
|
});
|
|
}
|
|
|
|
Future<void> _disposed = Future.value();
|
|
|
|
static void _logFailure(Object error) =>
|
|
debugPrint('[MediaDiskCache] write failed: $error');
|
|
}
|
|
|
|
/// `flutter_cache_manager` caps a cache only by file count. This index keeps
|
|
/// its JSON format but evicts by bytes: once the files total more than
|
|
/// [_maxBytes], the least recently used are over capacity.
|
|
@visibleForTesting
|
|
class MediaCacheIndex extends JsonCacheInfoRepository {
|
|
MediaCacheIndex(super.file, this._directory, this._maxBytes)
|
|
: super.withFile();
|
|
|
|
final Directory _directory;
|
|
final int _maxBytes;
|
|
|
|
/// Least recently used files past [capacity] files or past the byte
|
|
/// budget, whichever keeps fewer.
|
|
@override
|
|
Future<List<CacheObject>> getObjectsOverCapacity(int capacity) async {
|
|
final newestFirst = await getAllObjects()
|
|
..sort((a, b) => b.touched!.compareTo(a.touched!));
|
|
var total = 0;
|
|
var count = 0;
|
|
return [
|
|
for (final object in newestFirst)
|
|
if ((total += object.length ?? 0) > _maxBytes || ++count > capacity)
|
|
object,
|
|
];
|
|
}
|
|
|
|
/// `putFile` records no length, and replacing a file's bytes keeps the old
|
|
/// one, so measure the file on every write.
|
|
@override
|
|
Future<dynamic> updateOrInsert(CacheObject cacheObject) async {
|
|
final file = File(p.join(_directory.path, cacheObject.relativePath));
|
|
final length = await file.exists() ? await file.length() : 0;
|
|
return super.updateOrInsert(cacheObject.copyWith(length: length));
|
|
}
|
|
}
|
|
|
|
/// Keeps cache files in one directory of our choosing rather than the
|
|
/// package's default under the temporary directory.
|
|
class _DirectoryFileSystem implements FileSystem {
|
|
_DirectoryFileSystem(Directory directory)
|
|
: _directory = const LocalFileSystem().directory(directory.path);
|
|
|
|
final pf.Directory _directory;
|
|
|
|
@override
|
|
Future<pf.File> createFile(String name) async {
|
|
await _directory.create(recursive: true);
|
|
return _directory.childFile(name);
|
|
}
|
|
}
|
|
|
|
const _appGroupChannel = MethodChannel('buzz/app_group');
|
|
|
|
/// The directory community caches live under: the shared app group's caches
|
|
/// on iOS, so app extensions can read them, and the app's cache directory
|
|
/// elsewhere. Null when the iOS app group is unavailable.
|
|
Future<Directory?> resolveMediaCacheRoot() async {
|
|
if (!Platform.isIOS) return getApplicationCacheDirectory();
|
|
final path = await _appGroupChannel.invokeMethod<String>('cachesDirectory');
|
|
if (path == null) {
|
|
debugPrint('[MediaDiskCache] no app group container; disk cache is off');
|
|
return null;
|
|
}
|
|
return Directory(path);
|
|
}
|
|
|
|
/// The caches root, overridden at startup with [resolveMediaCacheRoot]. Null
|
|
/// (isolated tests, or no app group) turns the disk cache off. Startup does
|
|
/// not wait for it: images that load first skip the disk.
|
|
final mediaCacheRootProvider = FutureProvider<Directory?>((ref) async => null);
|
|
|
|
/// The active community's image cache, or null when there is no caches root
|
|
/// or no community. Switching community or account disposes the previous one.
|
|
final mediaDiskCacheProvider = Provider<MediaDiskCache?>((ref) {
|
|
final (communityId, signer) = ref.watch(
|
|
activeCommunityProvider.select((value) {
|
|
final community = value.value;
|
|
if (community == null) return (null, null);
|
|
final signer = pubkeyFromNsec(community.nsec?.trim()) ?? community.pubkey;
|
|
return (community.id, signer);
|
|
}),
|
|
);
|
|
if (communityId == null || signer == null) return null;
|
|
// The cache exists at once and opens its directory when the root
|
|
// resolves, so images shown during startup still read the disk.
|
|
final cache = MediaDiskCache.open(
|
|
ref
|
|
.watch(mediaCacheRootProvider.future)
|
|
.then(
|
|
(root) => root == null
|
|
? null
|
|
: MediaDiskCache.directoryFor(
|
|
root,
|
|
communityId,
|
|
signer.toLowerCase(),
|
|
),
|
|
),
|
|
);
|
|
ref.onDispose(() => unawaited(cache.dispose()));
|
|
return cache;
|
|
});
|