mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
## Summary Returning to a previously visited community shows its saved channel list immediately, including offline, while membership and unread state refresh in the background. Initial revalidation failures keep the list visible and retry every 60 seconds without overlapping background attempts. After successful history revalidation, ordinary reconnects use metadata-only refreshes. A reconnect arriving during a background request queues one follow-up attempt; timer ticks never queue overlapping work. History deadlines retain the full-revalidation retry until history succeeds. Snapshots are disposable, versioned and scoped by canonical relay URL and signing identity, capped at 2 MB / 2,000 member channels. They include hidden-DM IDs and prefer fresher in-memory state. Community removal and identity replacement retire outstanding writes; re-adding unchanged credentials preserves the current cache and writer. Startup sweeps orphan snapshots without waiting for disk acknowledgement; community mutations also sweep orphan snapshots, including data restored without secure community records. Cache-delete failures do not block sign-out or strand push revocation; deletion is attempted before journaling and retried by orphan cleanup. Successfully persisted unchanged snapshots skip disk writes; failed writes remain retryable. The cold path reuses fetched membership rosters, overlaps profile/latest-message requests, shares verified DM profiles with avatars, and fetches the own profile once per authenticated connection. Community connection work overlaps the switching animation; avatar hydration no longer gates landing. Full channel refreshes also revalidate cached DM participant profiles so relay-side name changes reach labels and avatars; metadata-only reconnects and background refreshes reuse admitted profiles. Me-chat and invite starter recovery use an explicit fresh membership fetch that propagates errors while leaving the cached list visible. Cached layout/read preferences permit immediate landing; newer cross-device preferences may update ordering or badges after reveal. Integrated with main’s #8490 idle/resume changes: hidden chats detach live subscriptions, background membership polling stays quiet, and each resume reconciles once. Chat history follows main’s live-admission-first ordering to avoid missing messages committed during relay admission; cached community landing remains immediate. ## Validation - `just mobile-install mobile-check mobile-test`: clean analysis; 3,778 tests passed, 19 skipped; four checks for builds without push configuration passed. File-size and diff checks passed. - Provider/storage journeys cover offline startup, failed initial revalidation recovering live delivery, bounded retries, lightweight reconnects, scope isolation, default-port URLs, padded/mismatched stored credentials, credential replacement, backup-restored orphan data, removal races, failed platform deletes and failed writes. - Follow-up regressions cover interrupted native Me-chat lookup, invite failure propagation, retry after an initial list failure, history deadlines, queued reconnects, escaped-path keys, unchanged credential re-adds and non-blocking startup cleanup. Reverting each of the eight behavior/caller/cleanup-order fixes fails its corresponding regression. - DM-name regression changes only the relay profile, verifies a full refresh updates the channel label and shared profile cache, and confirms ordinary reconnects avoid another profile request. The original cache-only implementation fails this regression. - Original signed-in iPhone 18 Pro debug simulator measurements: Buzz's 413 channels went from 6.26–6.30 s to approximately 1.5 s on returning; all three warmed communities switched in 1.47–1.54 s. Real socket pause restored the cached list offline and retained it on reconnect. These are debug simulator observations, not release-device benchmarks. - Integration with current main also passes the real-session resume/background/AUTH and live-admission/detach journeys. - Follow-up failure paths were exercised with deterministic provider/storage tests and widget journeys; the running simulator was not modified during this follow-up. Human acceptance remains pending; this PR stays draft. Try switching among previously visited communities, opening a channel, restarting and repeating. First visits and oversized snapshots require network loading. Cached metadata can be stale until refresh; it lives in ordinary preferences without additional application-level encryption. The snapshot contains no message bodies or credentials. Persistent disk-deletion failures can leave metadata until a later cleanup succeeds. --------- Signed-off-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz> Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz> Co-authored-by: Rocket <0c41329ade659e01801116549bc617351689892a382bc864a1186dc9191293a7@buzz.block.builderlab.xyz> Co-authored-by: Groot <f4c08302591916fb4b80926bf7e18d22eef8aa0c877e4e841acc46405a45700b@buzz.block.builderlab.xyz> Co-authored-by: Mantis <530af7635ef7d0e1a5189d234338db88cb4ef1ede7119b4ebb4fb05c3eafe3b1@buzz.block.builderlab.xyz>
523 lines
17 KiB
Dart
523 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
|
import 'package:buzz/shared/profile/profile_event_parser.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:pointycastle/digests/sha256.dart';
|
|
|
|
void main() {
|
|
test('preload reports a profile batch failure', () async {
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(_FailingProfileSession.new),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final succeeded = await container.read(userCacheProvider.notifier).preload(
|
|
const ['agent'],
|
|
);
|
|
|
|
expect(succeeded, isFalse);
|
|
});
|
|
|
|
test('refresh queries profiles that are already cached', () async {
|
|
final session = _RecordingProfileSession();
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'cached-profile', createdAt: 1, name: 'Cached Human'),
|
|
);
|
|
|
|
final succeeded = await cache.refresh(const ['AGENT']);
|
|
|
|
expect(succeeded, isTrue);
|
|
expect(session.requestedFilter?.kinds, const [0]);
|
|
expect(session.requestedFilter?.authors, const ['agent']);
|
|
expect(session.requestedFilter?.limit, 1);
|
|
});
|
|
|
|
test('older refresh cannot overwrite a newer live profile', () async {
|
|
final refreshCompleter = Completer<List<NostrEvent>>();
|
|
final session = _RecordingProfileSession(result: refreshCompleter.future);
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final owner = nostr.Keys.generate();
|
|
final agent = nostr.Keys.generate();
|
|
final refresh = cache.refresh([agent.public]);
|
|
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(
|
|
id: 'newer-agent',
|
|
pubkey: agent.public,
|
|
createdAt: 2,
|
|
name: 'Agent',
|
|
tags: [_authTag(owner, agent.public)],
|
|
),
|
|
);
|
|
refreshCompleter.complete([
|
|
_profileEvent(
|
|
id: 'older-human',
|
|
pubkey: agent.public,
|
|
createdAt: 1,
|
|
name: 'Human',
|
|
),
|
|
]);
|
|
|
|
expect(await refresh, isTrue);
|
|
expect(cache.state[agent.public]?.displayName, 'Agent');
|
|
expect(cache.state[agent.public]?.ownerPubkey, owner.public);
|
|
});
|
|
|
|
test('newer refresh can remove obsolete owner attribution', () async {
|
|
final owner = nostr.Keys.generate();
|
|
final agent = nostr.Keys.generate();
|
|
final session = _RecordingProfileSession(
|
|
result: Future.value([
|
|
_profileEvent(
|
|
id: 'newer-human',
|
|
pubkey: agent.public,
|
|
createdAt: 2,
|
|
name: 'Human',
|
|
),
|
|
]),
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(
|
|
id: 'older-agent',
|
|
pubkey: agent.public,
|
|
createdAt: 1,
|
|
name: 'Agent',
|
|
tags: [_authTag(owner, agent.public)],
|
|
),
|
|
);
|
|
|
|
expect(await cache.refresh([agent.public]), isTrue);
|
|
expect(cache.state[agent.public]?.displayName, 'Human');
|
|
expect(cache.state[agent.public]?.ownerPubkey, isNull);
|
|
});
|
|
|
|
test('non-profile history cannot poison profile order', () async {
|
|
final session = _RecordingProfileSession(
|
|
results: [
|
|
Future.value([
|
|
_profileEvent(
|
|
id: 'non-profile-newer',
|
|
createdAt: 3,
|
|
name: 'Ignored',
|
|
kind: 1,
|
|
),
|
|
]),
|
|
Future.value([
|
|
_profileEvent(id: 'valid-older', createdAt: 2, name: 'Valid'),
|
|
]),
|
|
],
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
|
|
expect(await cache.refresh(const ['agent']), isTrue);
|
|
expect(cache.state['agent'], isNull);
|
|
expect(await cache.refresh(const ['agent']), isTrue);
|
|
expect(cache.state['agent']?.displayName, 'Valid');
|
|
});
|
|
|
|
for (final operation in ['refresh', 'preload', 'resolve']) {
|
|
test('$operation merges after parsing against newer live state', () async {
|
|
final started = Completer<void>();
|
|
final release = Completer<void>();
|
|
final session = _RecordingProfileSession(
|
|
result: Future.value([
|
|
_profileEvent(id: 'old', createdAt: 1, name: 'Old'),
|
|
]),
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(() => session),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
final parsed = await parseProfileEventBatch(events);
|
|
started.complete();
|
|
await release.future;
|
|
return parsed;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final result = switch (operation) {
|
|
'preload' => cache.preload(['agent']),
|
|
'resolve' => cache.resolve(['agent']).then((_) => true),
|
|
_ => cache.refresh(['agent']),
|
|
};
|
|
await started.future;
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'new', createdAt: 2, name: 'Live'),
|
|
);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(
|
|
id: 'other',
|
|
pubkey: 'other',
|
|
createdAt: 1,
|
|
name: 'Other',
|
|
),
|
|
);
|
|
release.complete();
|
|
expect(await result, isTrue);
|
|
expect(cache.state['agent']?.displayName, 'Live');
|
|
expect(cache.state['other']?.displayName, 'Other');
|
|
});
|
|
|
|
test('$operation rejects a retired parsing completion', () async {
|
|
final started = Completer<void>();
|
|
final release = Completer<void>();
|
|
final session = _RecordingProfileSession(
|
|
result: Future.value([
|
|
_profileEvent(id: 'old', createdAt: 1, name: 'Previous community'),
|
|
]),
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(() => session),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
final parsed = await parseProfileEventBatch(events);
|
|
started.complete();
|
|
await release.future;
|
|
return parsed;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final result = switch (operation) {
|
|
'preload' => cache.preload(['agent']),
|
|
'resolve' => cache.resolve(['agent']).then((_) => true),
|
|
_ => cache.refresh(['agent']),
|
|
};
|
|
await started.future;
|
|
// This is the same dependency invalidation caused by community config.
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://next-community.invalid');
|
|
container.read(userCacheProvider);
|
|
release.complete();
|
|
if (operation == 'resolve') {
|
|
await expectLater(result, throwsStateError);
|
|
} else {
|
|
expect(await result, isFalse);
|
|
}
|
|
expect(container.read(userCacheProvider), isEmpty);
|
|
});
|
|
}
|
|
|
|
test(
|
|
'community change rejects an old network response before parsing',
|
|
() async {
|
|
final response = Completer<List<NostrEvent>>();
|
|
var parsed = false;
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(
|
|
() => _RecordingProfileSession(result: response.future),
|
|
),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
parsed = true;
|
|
return parseProfileEventBatch(events);
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final result = container.read(userCacheProvider.notifier).refresh([
|
|
'agent',
|
|
]);
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://next-community.invalid');
|
|
container.read(userCacheProvider);
|
|
response.complete([_profileEvent(id: 'old', createdAt: 1, name: 'Old')]);
|
|
expect(await result, isFalse);
|
|
expect(parsed, isFalse);
|
|
expect(container.read(userCacheProvider), isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'parser failure reports refresh failure without modifying cache',
|
|
() async {
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(
|
|
() => _RecordingProfileSession(
|
|
result: Future.value([
|
|
_profileEvent(id: 'next', createdAt: 2, name: 'Next'),
|
|
]),
|
|
),
|
|
),
|
|
profileEventBatchParserProvider.overrideWithValue(
|
|
(_) => Future.error(StateError('parse failed')),
|
|
),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'cached', createdAt: 1, name: 'Cached'),
|
|
);
|
|
expect(await cache.refresh(['agent']), isFalse);
|
|
expect(cache.state['agent']?.displayName, 'Cached');
|
|
},
|
|
);
|
|
|
|
test('later refresh completion cannot roll back a newer refresh', () async {
|
|
final oldResponse = Completer<List<NostrEvent>>();
|
|
final session = _RecordingProfileSession(
|
|
results: [
|
|
oldResponse.future,
|
|
Future.value([_profileEvent(id: 'new', createdAt: 2, name: 'New')]),
|
|
],
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final old = cache.refresh(['agent']);
|
|
expect(await cache.refresh(['agent']), isTrue);
|
|
oldResponse.complete([_profileEvent(id: 'old', createdAt: 1, name: 'Old')]);
|
|
expect(await old, isTrue);
|
|
expect(cache.state['agent']?.displayName, 'New');
|
|
});
|
|
|
|
test('stale malformed profile is skipped before parsing', () async {
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(
|
|
() => _RecordingProfileSession(
|
|
result: Future.value([
|
|
NostrEvent(
|
|
id: 'old',
|
|
pubkey: 'agent',
|
|
createdAt: 1,
|
|
kind: 0,
|
|
tags: [],
|
|
content: '{"name":42}',
|
|
sig: 'sig',
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'new', createdAt: 2, name: 'New'),
|
|
);
|
|
expect(await cache.refresh(['agent']), isTrue);
|
|
expect(cache.state['agent']?.displayName, 'New');
|
|
});
|
|
|
|
test(
|
|
'refresh and preload share one verification worker and coalesce pending lookups',
|
|
() async {
|
|
final started = Completer<void>();
|
|
final release = Completer<void>();
|
|
var running = 0;
|
|
var maxRunning = 0;
|
|
var calls = 0;
|
|
final session = _RecordingProfileSession(
|
|
results: [
|
|
Future.value([
|
|
_profileEvent(id: 'a', pubkey: 'a', createdAt: 1, name: 'A'),
|
|
]),
|
|
Future.value([
|
|
_profileEvent(id: 'b', pubkey: 'b', createdAt: 1, name: 'B'),
|
|
]),
|
|
Future.value([
|
|
_profileEvent(id: 'c', pubkey: 'c', createdAt: 1, name: 'C'),
|
|
]),
|
|
],
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(() => session),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
running++;
|
|
if (running > maxRunning) maxRunning = running;
|
|
if (calls++ == 0) {
|
|
started.complete();
|
|
await release.future;
|
|
}
|
|
final parsed = await parseProfileEventBatch(events);
|
|
running--;
|
|
return parsed;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final first = cache.preload(['a']);
|
|
await started.future;
|
|
final refresh = cache.refresh(['b']);
|
|
final next = cache.preload(['c']);
|
|
final duplicate = cache.preload(['c']);
|
|
// Let the refresh reach the worker queue before releasing its predecessor.
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(calls, 1);
|
|
release.complete();
|
|
expect(
|
|
await Future.wait([first, refresh, next, duplicate]),
|
|
everyElement(isTrue),
|
|
);
|
|
expect(maxRunning, 1);
|
|
expect(calls, 3);
|
|
expect(session.requestCount, 3);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'refresh verifies real OA batch while allowing main-isolate timers',
|
|
() async {
|
|
final owner = nostr.Keys.generate();
|
|
final agents = List.generate(16, (_) => nostr.Keys.generate());
|
|
final events = [
|
|
for (final agent in agents)
|
|
_profileEvent(
|
|
id: agent.public,
|
|
pubkey: agent.public,
|
|
createdAt: 1,
|
|
name: 'Synthetic',
|
|
tags: [_authTag(owner, agent.public)],
|
|
),
|
|
];
|
|
var timerRanBeforeParsingCompleted = false;
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(
|
|
() => _RecordingProfileSession(result: Future.value(events)),
|
|
),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
var timerRan = false;
|
|
final timer = Timer(Duration.zero, () => timerRan = true);
|
|
final parsed = await parseProfileEventBatch(events);
|
|
timerRanBeforeParsingCompleted = timerRan;
|
|
timer.cancel();
|
|
return parsed;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
expect(await cache.refresh(agents.map((a) => a.public).toList()), isTrue);
|
|
expect(timerRanBeforeParsingCompleted, isTrue);
|
|
for (final agent in agents) {
|
|
expect(cache.state[agent.public]?.ownerPubkey, owner.public);
|
|
}
|
|
},
|
|
);
|
|
|
|
test('same-second profile tie keeps the lowest event id', () {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'b', createdAt: 1, name: 'Larger ID'),
|
|
);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'a', createdAt: 1, name: 'Lower ID'),
|
|
);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'c', createdAt: 1, name: 'Later Larger ID'),
|
|
);
|
|
|
|
expect(cache.state['agent']?.displayName, 'Lower ID');
|
|
});
|
|
}
|
|
|
|
NostrEvent _profileEvent({
|
|
required String id,
|
|
required int createdAt,
|
|
required String name,
|
|
String pubkey = 'agent',
|
|
List<List<String>> tags = const [],
|
|
int kind = 0,
|
|
}) => NostrEvent(
|
|
id: id,
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: kind,
|
|
tags: tags,
|
|
content: jsonEncode({'name': name}),
|
|
sig: 'sig',
|
|
);
|
|
|
|
List<String> _authTag(nostr.Keys owner, String agentPubkey) {
|
|
final digest = SHA256Digest().process(
|
|
Uint8List.fromList(
|
|
utf8.encode('nostr:agent-auth:${agentPubkey.toLowerCase()}:'),
|
|
),
|
|
);
|
|
final message = digest
|
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
|
.join();
|
|
final signature = nostr.Schnorr.sign(
|
|
secretKey: owner.secret,
|
|
message: message,
|
|
);
|
|
return ['auth', owner.public, '', signature];
|
|
}
|
|
|
|
class _RecordingProfileSession extends RelaySessionNotifier {
|
|
_RecordingProfileSession({
|
|
Future<List<NostrEvent>>? result,
|
|
List<Future<List<NostrEvent>>>? results,
|
|
}) : _results = [...?results, ?result];
|
|
|
|
final List<Future<List<NostrEvent>>> _results;
|
|
NostrFilter? requestedFilter;
|
|
int requestCount = 0;
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
requestCount++;
|
|
requestedFilter = filter;
|
|
return _results.isEmpty ? const [] : _results.removeAt(0);
|
|
}
|
|
}
|
|
|
|
class _FailingProfileSession extends RelaySessionNotifier {
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) => Future.error('profile unavailable');
|
|
}
|