diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 1f1a5b620e..035ba67484 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,4 +1,5 @@ import '../hosted/hosted_relay_auth.dart'; + import 'dart:async'; import 'dart:developer' as developer; import 'dart:math'; @@ -10,6 +11,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../auth/auth_provider.dart'; import '../channels/channel_list_snapshot.dart'; import '../hosted/community_entry_requirements.dart'; +import '../profile/profile_snapshot.dart'; import '../push/dev_push_lease.dart'; import '../push/push_bridge.dart'; import '../push/push_lease_revocation_outbox.dart'; @@ -375,12 +377,16 @@ class CommunityListNotifier extends AsyncNotifier> { Future _removeChannelListSnapshot(Community community) async { final snapshots = ref.read(channelListSnapshotStorageProvider); + final profiles = ref.read(profileSnapshotStorageProvider); final pubkey = _channelSnapshotPubkey(community); - if (snapshots == null || pubkey == null) return; + if (pubkey == null) return; try { // Retire writers before the revocation journal or credential changes. // A disposable cache must never prevent local sign-out. - await snapshots.remove(pubkey, community.relayUrl); + await Future.wait([ + ?snapshots?.remove(pubkey, community.relayUrl), + ?profiles?.remove(pubkey, community.relayUrl), + ]); } catch (error, stackTrace) { developer.log( 'Channel snapshot deletion failed; orphan cleanup will retry', @@ -395,12 +401,22 @@ class CommunityListNotifier extends AsyncNotifier> { List communities, ) async { final snapshots = ref.read(channelListSnapshotStorageProvider); - if (snapshots == null) return; - await snapshots.removeOrphans({ + final profiles = ref.read(profileSnapshotStorageProvider); + final scopes = [ for (final community in communities) if (_channelSnapshotPubkey(community) case final pubkey?) - ChannelListSnapshotStorage.key(pubkey, community.relayUrl), - }); + (pubkey: pubkey, relay: community.relayUrl), + ]; + await Future.wait([ + ?snapshots?.removeOrphans({ + for (final scope in scopes) + ChannelListSnapshotStorage.key(scope.pubkey, scope.relay), + }), + ?profiles?.removeOrphans({ + for (final scope in scopes) + ProfileSnapshotStorage.key(scope.pubkey, scope.relay), + }), + ]); } Future switchCommunity(String id) { diff --git a/mobile/lib/shared/profile/profile_snapshot.dart b/mobile/lib/shared/profile/profile_snapshot.dart new file mode 100644 index 0000000000..250483e467 --- /dev/null +++ b/mobile/lib/shared/profile/profile_snapshot.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../relay/relay_provider.dart' show RelayConfig; +import '../theme/community_theme_preference.dart'; +import '../theme/theme_provider.dart'; +import 'profile_event_parser.dart'; +import 'user_profile.dart'; + +const _keyPrefix = 'buzz.profiles.v1'; +const _version = 1; + +/// Profiles beyond these bounds are not stored; they load from the relay. +const maxProfileSnapshotProfiles = 2000; +const maxProfileSnapshotBytes = 2 * 1024 * 1024; + +/// A single stored profile may not exceed this. Inline `data:` avatars can be +/// large; such profiles are skipped rather than crowding out everyone else. +const maxProfileSnapshotEntryBytes = 16 * 1024; + +/// Permission to write one scope's snapshot, captured when the cache starts. +/// +/// [ProfileSnapshotStorage.remove] retires every token captured before it, so +/// a pending write during sign-out cannot recreate the snapshot. +class ProfileSnapshotToken { + const ProfileSnapshotToken._(this._key, this._epoch); + + final String _key; + final int _epoch; +} + +/// Stores the last verified profiles seen per relay and identity, so names and +/// avatars render on launch before the relay answers. +/// +/// The data is a cache derived from relay kind:0 events: unreadable, +/// unsupported or oversized data is discarded rather than migrated. Restored +/// profiles are revalidated against the relay on first use each session. +class ProfileSnapshotStorage { + ProfileSnapshotStorage(this._prefs); + + final SharedPreferences _prefs; + + /// Retirement epochs shared by every storage over the same preferences. + static final _epochsByPrefs = Expando>(); + + Map get _epochs => _epochsByPrefs[_prefs] ??= {}; + + /// The scope key. `wss://`/`ws://` origins fold to `https://`/`http://` + /// like `RelayConfig.baseUrl`, so both onboarding paths share a snapshot. + static String key(String pubkey, String relayUrl) { + final relay = normalizeCommunityRelayUrl( + RelayConfig(baseUrl: normalizeCommunityRelayUrl(relayUrl)).baseUrl, + ); + return '$_keyPrefix:${pubkey.toLowerCase()}:${Uri.encodeComponent(relay)}'; + } + + /// Returns the scope's profiles, or an empty list when absent or unusable. + List read(String pubkey, String relayUrl) { + final key = ProfileSnapshotStorage.key(pubkey, relayUrl); + final raw = _prefs.getString(key); + if (raw == null) return const []; + try { + if (raw.length > maxProfileSnapshotBytes || + utf8.encode(raw).length > maxProfileSnapshotBytes) { + throw const FormatException('size'); + } + final json = jsonDecode(raw) as Map; + if (json['v'] != _version) throw const FormatException('version'); + final profiles = json['profiles'] as List; + if (profiles.length > maxProfileSnapshotProfiles) { + throw const FormatException('count'); + } + return [ + for (final entry in profiles) _decode(entry as Map), + ]; + } catch (_) { + // Unusable cache: drop it without retiring the live writer. + _prefs.remove(key).ignore(); + return const []; + } + } + + /// Captures write permission for the scope. + ProfileSnapshotToken capture(String pubkey, String relayUrl) { + final key = ProfileSnapshotStorage.key(pubkey, relayUrl); + return ProfileSnapshotToken._(key, _epochs[key] ?? 0); + } + + /// Replaces the scope's snapshot with the newest [profiles] that fit the + /// bounds. Returns false when [token] was retired or storage rejected it. + Future write( + ProfileSnapshotToken token, + Iterable profiles, + ) async { + if ((_epochs[token._key] ?? 0) != token._epoch) return false; + final newestFirst = profiles.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final entries = []; + // Two bytes per entry cover the list's brackets and separators. + var bytes = 32; + for (final profile in newestFirst) { + if (entries.length == maxProfileSnapshotProfiles) break; + final entry = jsonEncode(_encode(profile)); + final entryBytes = utf8.encode(entry).length; + if (entryBytes > maxProfileSnapshotEntryBytes) continue; + if (bytes + entryBytes + 1 > maxProfileSnapshotBytes) break; + bytes += entryBytes + 1; + entries.add(entry); + } + final raw = '{"v":$_version,"profiles":[${entries.join(',')}]}'; + if (_prefs.getString(token._key) == raw) return true; + // The token check and SharedPreferences' synchronous in-memory update run + // without an intervening await, so a later remove always wins. + return _prefs.setString(token._key, raw); + } + + /// Deletes the scope's snapshot and retires its outstanding tokens. + Future remove(String pubkey, String relayUrl) => + _removeKey(ProfileSnapshotStorage.key(pubkey, relayUrl)); + + /// Retires and deletes snapshots without a stored community. + Future removeOrphans(Set retainedKeys) async { + final keys = {..._prefs.getKeys(), ..._epochs.keys}; + await Future.wait([ + for (final key in keys) + if (key.startsWith('$_keyPrefix:') && !retainedKeys.contains(key)) + _removeKey(key).catchError((Object error) { + debugPrint('[ProfileSnapshotStorage] cleanup failed: $error'); + }), + ]); + } + + Future _removeKey(String key) async { + _epochs[key] = (_epochs[key] ?? 0) + 1; + if (!await _prefs.remove(key)) { + throw StateError('Profile snapshot could not be removed.'); + } + } + + static Map _encode(ParsedProfileEvent parsed) { + final profile = parsed.profile; + return { + 'pubkey': profile.pubkey, + 'event_id': parsed.eventId, + 'created_at': parsed.createdAt, + 'display_name': ?profile.displayName, + 'avatar_url': ?profile.avatarUrl, + 'about': ?profile.about, + 'nip05': ?profile.nip05Handle, + 'owner': ?profile.ownerPubkey, + }; + } + + static ParsedProfileEvent _decode(Map json) => ( + profile: UserProfile( + pubkey: (json['pubkey'] as String).toLowerCase(), + displayName: json['display_name'] as String?, + avatarUrl: json['avatar_url'] as String?, + about: json['about'] as String?, + nip05Handle: json['nip05'] as String?, + ownerPubkey: json['owner'] as String?, + ), + createdAt: json['created_at'] as int, + eventId: json['event_id'] as String, + ); +} + +/// The app's profile snapshot storage, or null where preferences were never +/// provided (isolated tests). Null means profiles live only in memory. +final profileSnapshotStorageProvider = Provider((ref) { + final SharedPreferences prefs; + try { + prefs = ref.watch(savedPrefsProvider); + } catch (_) { + // The unprovided override throws, wrapped by Riverpod. + return null; + } + return ProfileSnapshotStorage(prefs); +}); diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index ccdd2919b5..921739c6d9 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -1,13 +1,19 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../relay/relay.dart'; import '../verified_profiles/verified_profile_directory.dart'; import 'user_profile.dart'; import 'profile_event_parser.dart'; +import 'profile_snapshot.dart'; -/// In-memory cache of user profiles, fetched in batches from the relay. +/// Cache of user profiles, fetched in batches from the relay. +/// +/// Verified profiles persist per identity and relay ([ProfileSnapshotStorage]) +/// so names and avatars render on launch, even on a slow link. Restored +/// profiles are served immediately and revalidated on their first lookup. /// /// Lookups requested via [get] or [preload] are coalesced into a single /// kind:0 batch query (NIP-01 `authors` filter) every 50ms. Larger requests @@ -15,6 +21,7 @@ import 'profile_event_parser.dart'; class UserCacheNotifier extends Notifier> { // The relay clamps each history response to DEFAULT_MAX_PAGE_LIMIT (1000). static const _maximumProfilesPerQuery = 1000; + static const _snapshotWriteDelay = Duration(seconds: 2); final Set _pending = {}; final Map _profileEventOrders = {}; @@ -24,11 +31,22 @@ class UserCacheNotifier extends Notifier> { Timer? _batchTimer; Completer? _batchCompleter; + /// Restored from the snapshot and not yet checked with the relay this scope. + final Set _unrevalidated = {}; + + /// Lookups whose batch query is on the wire. + final Set _inFlight = {}; + void Function()? _snapshotWriter; + Timer? _snapshotTimer; + Map _latest = const {}; + @override Map build() { - ref.watch(relayConfigProvider); + final config = ref.watch(relayConfigProvider); _generation++; _profileEventOrders.clear(); + _unrevalidated.clear(); + _inFlight.clear(); ref.onDispose(() { _generation++; _pending.clear(); @@ -36,18 +54,77 @@ class UserCacheNotifier extends Notifier> { _batchTimer = null; _batchCompleter?.complete(false); _batchCompleter = null; + // Flush rather than drop: switching community must not lose the + // profiles learned since the last write. + if (_snapshotTimer?.isActive ?? false) _snapshotWriter?.call(); + _snapshotTimer?.cancel(); + _snapshotTimer = null; + _snapshotWriter = null; }); - return {}; + return _restoreSnapshot(config); } + Map _restoreSnapshot(RelayConfig config) { + final storage = ref.read(profileSnapshotStorageProvider); + final pubkey = pubkeyFromNsec(config.nsec); + if (storage == null || pubkey == null) return {}; + final relay = config.baseUrl; + final token = storage.capture(pubkey, relay); + _snapshotWriter = () { + final profiles = [ + for (final MapEntry(:key, value: order) in _profileEventOrders.entries) + if (_latest[key] case final profile?) + ( + profile: profile, + createdAt: order.createdAt, + eventId: order.eventId, + ), + ]; + storage + .write(token, profiles) + .then( + (saved) { + if (!saved) debugPrint('[UserCache] snapshot was not saved'); + }, + onError: (Object error, StackTrace stack) { + debugPrint('[UserCache] snapshot write failed: $error'); + }, + ); + }; + final restored = {}; + for (final parsed in storage.read(pubkey, relay)) { + final key = parsed.profile.pubkey; + restored[key] = parsed.profile; + _profileEventOrders[key] = ( + createdAt: parsed.createdAt, + eventId: parsed.eventId, + ); + _unrevalidated.add(key); + } + return _latest = restored; + } + + @override + set state(Map value) { + super.state = _latest = value; + if (_snapshotWriter == null) return; + _snapshotTimer ??= Timer(_snapshotWriteDelay, () { + _snapshotTimer = null; + _snapshotWriter?.call(); + }); + } + + /// Whether [pubkey] has a profile that is current for this scope. + bool _isCached(String pubkey) => + state.containsKey(pubkey) && !_unrevalidated.contains(pubkey); + /// Request a profile for [pubkey]. Returns immediately from cache if /// available, otherwise schedules a batch fetch. UserProfile? get(String pubkey) { _trackVerified([pubkey]); - final cached = state[pubkey.toLowerCase()]; - if (cached != null) return cached; - _scheduleFetch(pubkey.toLowerCase()); - return null; + final key = pubkey.toLowerCase(); + _scheduleFetch(key); + return state[key]; } /// Stores a profile that was fetched or updated outside the batch loader. @@ -67,7 +144,7 @@ class UserCacheNotifier extends Notifier> { final requested = pubkeys.map((pubkey) => pubkey.toLowerCase()).toSet(); _trackVerified(requested); final toFetch = requested - .where((pubkey) => refreshCached || !state.containsKey(pubkey)) + .where((pubkey) => refreshCached || !_isCached(pubkey)) .toList(); final generation = _generation; final session = ref.read(relaySessionProvider.notifier); @@ -85,6 +162,7 @@ class UserCacheNotifier extends Notifier> { if (!_isCurrent(generation)) { throw StateError('Profile request belongs to a retired cache'); } + _unrevalidated.removeAll(toFetch); return {for (final pubkey in requested) pubkey: ?state[pubkey]}; } @@ -93,11 +171,16 @@ class UserCacheNotifier extends Notifier> { Future preload(List pubkeys) { _trackVerified(pubkeys); final normalized = pubkeys.map((pk) => pk.toLowerCase()).toSet(); - final alreadyPending = normalized.any(_pending.contains); + // Restored profiles being revalidated already render; don't wait on them. + final alreadyPending = normalized.any( + (pk) => _pending.contains(pk) && !state.containsKey(pk), + ); final uncached = normalized - .map((pk) => pk.toLowerCase()) .where((pk) => !state.containsKey(pk) && !_pending.contains(pk)) .toList(); + // Restored profiles already render; revalidate them without making the + // caller wait on the network. + normalized.where(_unrevalidated.contains).forEach(_scheduleFetch); if (uncached.isEmpty && !alreadyPending) return Future.value(true); _pending.addAll(uncached); final completer = _batchCompleter ??= Completer(); @@ -128,6 +211,7 @@ class UserCacheNotifier extends Notifier> { if (!_isCurrent(generation)) return false; if (!await _verifyAndMerge(events, generation)) return false; } + _unrevalidated.removeAll(normalized); return true; } catch (_) { return false; @@ -149,7 +233,11 @@ class UserCacheNotifier extends Notifier> { ref.read(verifiedProfilesProvider.notifier).track(pubkeys); void _scheduleFetch(String pubkey) { - if (state.containsKey(pubkey) || _pending.contains(pubkey)) return; + if (_isCached(pubkey) || + _pending.contains(pubkey) || + _inFlight.contains(pubkey)) { + return; + } _pending.add(pubkey); _batchCompleter ??= Completer(); _scheduleBatch(); @@ -165,6 +253,7 @@ class UserCacheNotifier extends Notifier> { _batchCompleter = null; _flushInFlight = true; + _inFlight.addAll(pubkeys); final generation = _generation; var succeeded = false; try { @@ -177,6 +266,8 @@ class UserCacheNotifier extends Notifier> { ); if (!_isCurrent(generation)) return; if (!await _verifyAndMerge(events, generation)) return; + // Confirmed by the relay, whether or not it still has a profile. + _unrevalidated.removeAll(batch); remaining -= batch.length; if (remaining == 0) { // All requested pages are ready. @@ -185,8 +276,10 @@ class UserCacheNotifier extends Notifier> { } } } catch (_) { - // Silently fail — non-gating callers will just show pubkeys. + // Silently fail — non-gating callers will just show pubkeys, or the + // restored profile, which the next lookup revalidates again. } finally { + if (_isCurrent(generation)) _inFlight.removeAll(pubkeys); if (completer != null && !completer.isCompleted) { completer.complete(succeeded); } diff --git a/mobile/test/shared/profile/user_cache_snapshot_test.dart b/mobile/test/shared/profile/user_cache_snapshot_test.dart new file mode 100644 index 0000000000..91ea22238f --- /dev/null +++ b/mobile/test/shared/profile/user_cache_snapshot_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/profile/profile_snapshot.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +const _relay = 'https://relay.test'; +final _nsec = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '1' * 64, +); +const _alice = 'a11ce'; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + ProviderContainer cacheScope(_ProfileSession session) { + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(_Config.new), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + return container; + } + + test('profiles persist across launches and revalidate once', () async { + // First launch: nothing on disk, so the name loads from the relay. + final first = _ProfileSession([ + Future.value([_profile('v1', 1, 'Alice')]), + ]); + final launch1 = cacheScope(first); + final cache1 = launch1.read(userCacheProvider.notifier); + expect(cache1.get(_alice), isNull); + await cache1.preload(const [_alice]); + expect(cache1.state[_alice]?.displayName, 'Alice'); + // Leaving the scope flushes the pending snapshot write. + launch1.dispose(); + await pumpEventQueue(); + + // Second launch, relay unreachable: the stored name renders immediately + // and the failed revalidation leaves it in place for another attempt. + final offline = _ProfileSession([], fail: true); + final launch2 = cacheScope(offline); + final cache2 = launch2.read(userCacheProvider.notifier); + expect(cache2.get(_alice)?.displayName, 'Alice'); + expect(await cache2.preload(const [_alice]), isTrue); + await Future.delayed(const Duration(milliseconds: 80)); + expect(offline.requests, 1); + expect(cache2.state[_alice]?.displayName, 'Alice'); + cache2.get(_alice); + await Future.delayed(const Duration(milliseconds: 80)); + expect(offline.requests, 2, reason: 'a failed revalidation retries'); + launch2.dispose(); + + // Third launch, relay reachable: one revalidation picks up the rename, + // and later lookups are served from memory. + final held = Completer>(); + final online = _ProfileSession([ + held.future, + Future.value([_profile('v2', 2, 'Alice Renamed')]), + ]); + final launch3 = cacheScope(online); + final cache3 = launch3.read(userCacheProvider.notifier); + expect(cache3.get(_alice)?.displayName, 'Alice'); + await Future.delayed(const Duration(milliseconds: 80)); + expect(online.requests, 1); + // A gate resolving while that revalidation is on the wire must not trust + // the restored profile; it confirms with the relay itself. + final resolved = await cache3.resolve(const [_alice]); + expect(resolved[_alice]?.displayName, 'Alice Renamed'); + expect(online.requests, 2); + held.complete(const []); + await Future.delayed(const Duration(milliseconds: 80)); + expect(cache3.state[_alice]?.displayName, 'Alice Renamed'); + cache3.get(_alice); + await cache3.preload(const [_alice]); + await Future.delayed(const Duration(milliseconds: 80)); + expect(online.requests, 2); + launch3.dispose(); + await pumpEventQueue(); + + // Signing out of the community deletes the snapshot. + final storage = ProfileSnapshotStorage(prefs); + final pubkey = pubkeyFromNsec(_nsec)!; + expect( + storage.read(pubkey, _relay).single.profile.displayName, + 'Alice Renamed', + ); + await storage.remove(pubkey, _relay); + final launch4 = cacheScope(_ProfileSession([])); + expect(launch4.read(userCacheProvider), isEmpty); + }); + + test('snapshot scopes are per identity and relay, and bounded', () async { + final storage = ProfileSnapshotStorage(prefs); + final huge = _parsed('huge', 3, 'x' * maxProfileSnapshotEntryBytes); + final token = storage.capture('ME', 'wss://relay.test'); + expect( + await storage.write(token, [_parsed(_alice, 1, 'Alice'), huge]), + isTrue, + ); + // The wss:// and https:// onboarding paths share a scope. + expect(storage.read('me', _relay).map((p) => p.profile.pubkey), [_alice]); + expect(storage.read('someone-else', _relay), isEmpty); + + // A removal retires writers captured before it. + await storage.remove('me', _relay); + expect(await storage.write(token, [_parsed(_alice, 1, 'Alice')]), isFalse); + expect(storage.read('me', _relay), isEmpty); + + // Corrupt data is discarded. + await prefs.setString(ProfileSnapshotStorage.key('me', _relay), '{'); + expect(storage.read('me', _relay), isEmpty); + expect(prefs.getString(ProfileSnapshotStorage.key('me', _relay)), isNull); + }); +} + +NostrEvent _profile(String id, int createdAt, String name) => NostrEvent( + id: id, + pubkey: _alice, + createdAt: createdAt, + kind: 0, + tags: const [], + content: jsonEncode({'name': name}), + sig: 'sig', +); + +({UserProfile profile, int createdAt, String eventId}) _parsed( + String pubkey, + int createdAt, + String name, +) => ( + profile: UserProfile(pubkey: pubkey, displayName: name), + createdAt: createdAt, + eventId: 'e-$pubkey', +); + +class _Config extends RelayConfigNotifier { + @override + RelayConfig build() => RelayConfig(baseUrl: _relay, nsec: _nsec); +} + +class _ProfileSession extends RelaySessionNotifier { + _ProfileSession(this._results, {this.fail = false}); + + final List>> _results; + final bool fail; + int requests = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + requests++; + if (fail) throw StateError('offline'); + return _results.isEmpty ? const [] : await _results.removeAt(0); + } +}