Files
buzz/mobile/test/features/profile/profile_provider_test.dart
Tom Brow 41c5ace937 perf(mobile): move profile sig checks off main thread (#7648)
The mobile app loads user profiles to show names, avatars, and agent
ownership. Some profiles include a signature that proves who owns the
agent. Checking these signatures for a batch of profiles can block
interaction because the checks run on the same thread that draws the
screen and handles input.

This change moves profile batch parsing and signature checks to a
background worker. It processes one batch at a time and combines queued
profile requests. It keeps the same signature checks, preserves newer
profiles that arrive while a batch runs, and discards results after a
community change. Individual live profile updates still use synchronous
verification.

Tests cover newer live updates, community changes, overlapping requests,
stale malformed events, and UI timer responsiveness through the
production parser. The responsiveness test fails if parsing is moved
back to the UI isolate. A regression also exercises a confirmed profile
save while an older batch is paused, including preservation of verified
ownership. All 38 focused tests, all 2,132 mobile tests, and the
analyzer pass.

With 500 valid owner signatures, an AOT test reduced the main-isolate
timer delay from about 3.4 seconds to less than a millisecond. Total
verification time stayed similar. This measures verification work, not
overall screen speed.

GitHub CI passes. The full local test run failed on two timing-sensitive
ACP tests in unchanged Rust code; both passed isolated retries. All
other local checks passed, including the downstream tests and builds run
after that failure.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-15 20:00:37 +00:00

802 lines
25 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/shared/profile/profile_event_parser.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.dart';
import 'package:buzz/shared/utils/string_utils.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
void main() {
test('profile updates preserve existing kind:0 metadata', () async {
final keys = nostr.Keys.generate();
final owner = nostr.Keys.generate();
final profileTags = [
_authTag(owner, keys.public),
const ['custom', 'preserve-tag'],
];
final relaySession = _ProfileRelaySession(
NostrEvent(
id: 'profile-1',
pubkey: keys.public,
createdAt: 1,
kind: EventKind.profile,
tags: profileTags,
content: jsonEncode({
'name': 'alice',
'display_name': 'Alice',
'about': 'Building Buzz',
'picture': 'https://relay.example/alice.png',
'nip05': 'alice@example.com',
'custom': 'preserve-me',
}),
sig: 'sig',
),
);
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(
() => _FixedRelayConfigNotifier(keys.nsec),
),
relaySessionProvider.overrideWith(() => relaySession),
],
);
addTearDown(container.dispose);
await container.read(profileProvider.future);
await container.read(profileProvider.notifier).updateDisplayName('Alice L');
expect(relaySession.published, hasLength(1));
final content =
jsonDecode(relaySession.published.single.content)
as Map<String, dynamic>;
expect(content['display_name'], 'Alice L');
expect(content['about'], 'Building Buzz');
expect(content['picture'], 'https://relay.example/alice.png');
expect(content['nip05'], 'alice@example.com');
expect(content['custom'], 'preserve-me');
expect(relaySession.published.single.tags, profileTags);
expect(
container.read(profileProvider).requireValue?.displayName,
'Alice L',
);
expect(
container.read(profileProvider).requireValue?.ownerPubkey,
owner.public.toLowerCase(),
);
expect(
container.read(userCacheProvider)[keys.public]?.ownerPubkey,
owner.public.toLowerCase(),
);
});
test('confirmed profile save survives an older in-flight batch', () async {
final keys = nostr.Keys.generate();
final owner = nostr.Keys.generate();
NostrEvent event(String id, int createdAt, {bool owned = false}) =>
NostrEvent(
id: id,
pubkey: keys.public,
createdAt: createdAt,
kind: EventKind.profile,
tags: owned ? [_authTag(owner, keys.public)] : const [],
content: jsonEncode({
'display_name': owned ? 'Current' : 'Old relay profile',
'picture': owned ? 'https://example.com/new.png' : 'old.png',
'about': owned ? 'Current about' : 'Old about',
'nip05': owned ? 'agent@example.com' : 'old@example.com',
}),
sig: 'sig',
);
var history = [event('old', 1)];
final relaySession = _ControlledProfileRelaySession(
fetch: () async => history,
);
final parserStarted = Completer<void>();
final releaseParser = Completer<void>();
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(
() => _FixedRelayConfigNotifier(keys.nsec),
),
relaySessionProvider.overrideWith(() => relaySession),
profileEventBatchParserProvider.overrideWithValue((events) async {
final parsed = await parseProfileEventBatch(events);
parserStarted.complete();
await releaseParser.future;
return parsed;
}),
],
);
addTearDown(container.dispose);
await container.read(profileProvider.future);
final cache = container.read(userCacheProvider.notifier);
final refresh = cache.refresh([keys.public]);
await parserStarted.future;
history = [event('current', 2, owned: true)];
await container
.read(profileProvider.notifier)
.updateDisplayName('Saved locally');
final saved = container.read(userCacheProvider)[keys.public]!;
expect(saved.displayName, 'Saved locally');
expect(saved.avatarUrl, 'https://example.com/new.png');
expect(saved.about, 'Current about');
expect(saved.nip05Handle, 'agent@example.com');
expect(saved.ownerPubkey, owner.public.toLowerCase());
expect(relaySession.published, hasLength(1));
releaseParser.complete();
expect(await refresh, isTrue);
final afterBatch = container.read(userCacheProvider)[keys.public]!;
expect(afterBatch.displayName, 'Saved locally');
expect(afterBatch.avatarUrl, saved.avatarUrl);
expect(afterBatch.about, saved.about);
expect(afterBatch.nip05Handle, saved.nip05Handle);
expect(afterBatch.ownerPubkey, owner.public.toLowerCase());
expect(
container.read(profileProvider).requireValue?.ownerPubkey,
owner.public.toLowerCase(),
);
});
test('clearing a display name restores the pubkey label fallback', () async {
final keys = nostr.Keys.generate();
final relaySession = _ProfileRelaySession(
NostrEvent(
id: 'profile-1',
pubkey: keys.public,
createdAt: 1,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({
'name': 'legacy-alice',
'display_name': 'Alice',
'about': 'Building Buzz',
}),
sig: 'sig',
),
);
final container = _profileContainer(keys.nsec, relaySession);
addTearDown(container.dispose);
await container.read(profileProvider.future);
await container.read(profileProvider.notifier).updateDisplayName(' ');
final content =
jsonDecode(relaySession.published.single.content)
as Map<String, dynamic>;
expect(content, {'about': 'Building Buzz'});
final profile = container.read(profileProvider).requireValue!;
expect(profile.displayName, isNull);
// The exact compact label is pinned; its npub shape is covered by the
// shared string_utils vectors.
expect(profile.label, shortPubkey(keys.public));
});
test('malformed profile metadata can be repaired by an edit', () async {
final keys = nostr.Keys.generate();
final relaySession = _ProfileRelaySession(
NostrEvent(
id: 'profile-malformed',
pubkey: keys.public,
createdAt: 1,
kind: EventKind.profile,
tags: const [],
content: 'not-json',
sig: 'sig',
),
);
final container = _profileContainer(keys.nsec, relaySession);
addTearDown(container.dispose);
expect(await container.read(profileProvider.future), isNotNull);
await container.read(profileProvider.notifier).updateAbout('Repaired');
expect(jsonDecode(relaySession.published.single.content), {
'about': 'Repaired',
});
});
test('profile updates fail closed while hydration is pending', () async {
final keys = nostr.Keys.generate();
final history = Completer<List<NostrEvent>>();
final relaySession = _ControlledProfileRelaySession(
fetch: () => history.future,
);
final container = _profileContainer(keys.nsec, relaySession);
addTearDown(container.dispose);
container.read(profileProvider);
await expectLater(
container.read(profileProvider.notifier).updateDisplayName('Unsafe'),
throwsStateError,
);
expect(relaySession.published, isEmpty);
history.complete(const []);
});
test('profile updates fail closed after hydration errors', () async {
final keys = nostr.Keys.generate();
final relaySession = _ControlledProfileRelaySession(
fetch: () async => throw Exception('relay unavailable'),
);
final container = _profileContainer(keys.nsec, relaySession);
addTearDown(container.dispose);
container.read(profileProvider);
await Future<void>.delayed(Duration.zero);
expect(container.read(profileProvider).hasError, isTrue);
await expectLater(
container.read(profileProvider.notifier).updateAbout('Unsafe'),
throwsStateError,
);
expect(relaySession.published, isEmpty);
});
test('a confirmed empty history can publish a new profile', () async {
final keys = nostr.Keys.generate();
final relaySession = _ControlledProfileRelaySession(fetch: () async => []);
final container = _profileContainer(keys.nsec, relaySession);
addTearDown(container.dispose);
expect(await container.read(profileProvider.future), isNull);
await container.read(profileProvider.notifier).updateDisplayName('Alice');
expect(relaySession.published, hasLength(1));
expect(jsonDecode(relaySession.published.single.content), {
'display_name': 'Alice',
});
});
test('same-second profile replacements use monotonic timestamps', () async {
final keys = nostr.Keys.generate();
final futureTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 60;
final relaySession = _ControlledProfileRelaySession(
fetch: () async => [
NostrEvent(
id: 'profile-future',
pubkey: keys.public,
createdAt: futureTimestamp,
kind: EventKind.profile,
tags: const [],
content: '{}',
sig: 'sig',
),
],
);
final container = _profileContainer(keys.nsec, relaySession);
addTearDown(container.dispose);
await container.read(profileProvider.future);
await container.read(profileProvider.notifier).updateDisplayName('Alice');
await container.read(profileProvider.notifier).updateAbout('Hello');
expect(relaySession.published.map((event) => event.createdAt), [
futureTimestamp + 1,
futureTimestamp + 2,
]);
});
test(
'profile updates merge the current relay head before publishing',
() async {
final keys = nostr.Keys.generate();
var history = [
NostrEvent(
id: 'profile-initial',
pubkey: keys.public,
createdAt: 10,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({
'display_name': 'Initial',
'about': 'Initial about',
'custom': 'initial',
}),
sig: 'sig',
),
];
final relaySession = _ControlledProfileRelaySession(
fetch: () async => history,
);
final container = _profileContainer(keys.nsec, relaySession);
addTearDown(container.dispose);
await container.read(profileProvider.future);
history = [
NostrEvent(
id: 'profile-remote',
pubkey: keys.public,
createdAt: 20,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({
'display_name': 'Remote',
'about': 'Remote about',
'custom': 'remote',
}),
sig: 'sig',
),
];
await container
.read(profileProvider.notifier)
.updateDisplayName('Mobile');
final content =
jsonDecode(relaySession.published.single.content)
as Map<String, dynamic>;
expect(content, {
'display_name': 'Mobile',
'about': 'Remote about',
'custom': 'remote',
});
expect(relaySession.published.single.createdAt, greaterThan(20));
},
);
test(
'a competing profile head does not become optimistic local state',
() async {
final keys = nostr.Keys.generate();
final relaySession = _LosingProfileRelaySession(
NostrEvent(
id: 'profile-initial',
pubkey: keys.public,
createdAt: 10,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({'display_name': 'Initial'}),
sig: 'sig',
),
);
final container = _profileContainer(keys.nsec, relaySession);
addTearDown(container.dispose);
await container.read(profileProvider.future);
await expectLater(
container.read(profileProvider.notifier).updateDisplayName('Mobile'),
throwsStateError,
);
expect(relaySession.published, hasLength(1));
expect(
container.read(profileProvider).requireValue?.displayName,
'Initial',
);
},
);
test(
'overlapping profile updates serialize their full merge cycles',
() async {
final keys = nostr.Keys.generate();
final relaySession = _ControlledProfileRelaySession(
fetch: () async => [
NostrEvent(
id: 'profile-initial',
pubkey: keys.public,
createdAt: 10,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({
'display_name': 'Initial',
'about': 'Initial about',
}),
sig: 'sig',
),
],
);
final container = _profileContainer(keys.nsec, relaySession);
addTearDown(container.dispose);
await container.read(profileProvider.future);
await Future.wait([
container.read(profileProvider.notifier).updateDisplayName('Mobile'),
container.read(profileProvider.notifier).updateAbout('Mobile about'),
]);
expect(relaySession.published, hasLength(2));
expect(jsonDecode(relaySession.published.last.content), {
'display_name': 'Mobile',
'about': 'Mobile about',
});
expect(
relaySession.published.last.createdAt,
greaterThan(relaySession.published.first.createdAt),
);
},
);
test('profile updates abort when the active community changes', () async {
final keys = nostr.Keys.generate();
final otherKeys = nostr.Keys.generate();
final initial = NostrEvent(
id: 'profile-initial',
pubkey: keys.public,
createdAt: 10,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({'display_name': 'Initial'}),
sig: 'sig',
);
final patchFetchStarted = Completer<void>();
final patchHistory = Completer<List<NostrEvent>>();
var fetchCount = 0;
final relaySession = _ControlledProfileRelaySession(
fetch: () async {
fetchCount += 1;
if (fetchCount == 1) return [initial];
if (!patchFetchStarted.isCompleted) patchFetchStarted.complete();
return patchHistory.future;
},
);
final config = _MutableRelayConfigNotifier(keys.nsec);
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(() => config),
relaySessionProvider.overrideWith(() => relaySession),
],
);
addTearDown(container.dispose);
await container.read(profileProvider.future);
final update = container
.read(profileProvider.notifier)
.updateDisplayName('Mobile');
await patchFetchStarted.future;
config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec);
patchHistory.complete([initial]);
await expectLater(update, throwsStateError);
expect(relaySession.published, isEmpty);
});
test(
'queued profile updates report a community change before rehydration',
() async {
final keys = nostr.Keys.generate();
final otherKeys = nostr.Keys.generate();
final initial = NostrEvent(
id: 'profile-initial',
pubkey: keys.public,
createdAt: 10,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({'display_name': 'Initial'}),
sig: 'sig',
);
final patchFetchStarted = Completer<void>();
final patchHistory = Completer<List<NostrEvent>>();
final rehydration = Completer<List<NostrEvent>>();
var fetchCount = 0;
final relaySession = _ControlledProfileRelaySession(
fetch: () async {
fetchCount += 1;
if (fetchCount == 1) return [initial];
if (fetchCount == 2) {
patchFetchStarted.complete();
return patchHistory.future;
}
return rehydration.future;
},
);
final config = _MutableRelayConfigNotifier(keys.nsec);
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(() => config),
relaySessionProvider.overrideWith(() => relaySession),
],
);
addTearDown(container.dispose);
await container.read(profileProvider.future);
final firstUpdate = container
.read(profileProvider.notifier)
.updateDisplayName('First');
await patchFetchStarted.future;
final queuedUpdate = container
.read(profileProvider.notifier)
.updateAbout('Queued');
config.update(
baseUrl: 'https://other-relay.example',
nsec: otherKeys.nsec,
);
await Future<void>.delayed(Duration.zero);
patchHistory.complete([initial]);
await expectLater(
firstUpdate,
throwsA(isA<ProfileCommunityChangedException>()),
);
await expectLater(
queuedUpdate,
throwsA(isA<ProfileCommunityChangedException>()),
);
expect(relaySession.published, isEmpty);
rehydration.complete(const []);
},
);
test('stale hydration cannot overwrite the active community head', () async {
final keys = nostr.Keys.generate();
final otherKeys = nostr.Keys.generate();
final oldFetchStarted = Completer<void>();
final oldHistory = Completer<List<NostrEvent>>();
final activeProfile = NostrEvent(
id: 'profile-active',
pubkey: otherKeys.public,
createdAt: 20,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({'display_name': 'Active'}),
sig: 'sig',
);
var fetchCount = 0;
final relaySession = _ControlledProfileRelaySession(
fetch: () async {
fetchCount += 1;
if (fetchCount == 1) {
oldFetchStarted.complete();
return oldHistory.future;
}
return [activeProfile];
},
);
final config = _MutableRelayConfigNotifier(keys.nsec);
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(() => config),
relaySessionProvider.overrideWith(() => relaySession),
],
);
addTearDown(container.dispose);
container.read(profileProvider);
await oldFetchStarted.future;
config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec);
expect(
(await container.read(profileProvider.future))?.displayName,
'Active',
);
oldHistory.complete([
NostrEvent(
id: 'profile-stale',
pubkey: keys.public,
createdAt: 100,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({'display_name': 'Stale'}),
sig: 'sig',
),
]);
await Future<void>.delayed(Duration.zero);
await container.read(profileProvider.notifier).updateAbout('Active about');
expect(relaySession.published, hasLength(1));
expect(jsonDecode(relaySession.published.single.content), {
'display_name': 'Active',
'about': 'Active about',
});
});
test(
'manual presence persists until Online restores automatic mode',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
var container = _buildContainer(prefs);
expect(
await container
.read(presenceProvider.future)
.timeout(
const Duration(seconds: 2),
onTimeout: () =>
throw StateError('initial presence did not resolve'),
),
'online',
);
await container
.read(presenceProvider.notifier)
.setPresence('away')
.timeout(
const Duration(seconds: 2),
onTimeout: () => throw StateError('setting Away did not resolve'),
);
expect(container.read(presenceProvider).value, 'away');
expect(prefs.getString('buzz_presence_preference_aabb'), 'away');
container.dispose();
container = _buildContainer(prefs);
addTearDown(container.dispose);
expect(
await container
.read(presenceProvider.future)
.timeout(
const Duration(seconds: 2),
onTimeout: () =>
throw StateError('stored presence did not resolve'),
),
'away',
);
await container
.read(presenceProvider.notifier)
.setPresence('online')
.timeout(
const Duration(seconds: 2),
onTimeout: () => throw StateError('setting Online did not resolve'),
);
expect(container.read(presenceProvider).value, 'online');
expect(prefs.getString('buzz_presence_preference_aabb'), 'auto');
},
);
}
List<String> _authTag(nostr.Keys owner, String agentPubkey) {
final digest = SHA256Digest().process(
Uint8List.fromList(utf8.encode('nostr:agent-auth:$agentPubkey:')),
);
final message = digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return [
'auth',
owner.public,
'',
nostr.Schnorr.sign(secretKey: owner.secret, message: message),
];
}
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
_FixedRelayConfigNotifier(this.nsec);
final String nsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://relay.example', nsec: nsec);
}
class _MutableRelayConfigNotifier extends RelayConfigNotifier {
_MutableRelayConfigNotifier(this.initialNsec);
final String initialNsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://relay.example', nsec: initialNsec);
}
class _ProfileRelaySession extends RelaySessionNotifier {
_ProfileRelaySession(this.profile);
final NostrEvent profile;
final List<NostrEvent> published = [];
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [profile, ...published];
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
published.add(event);
return event;
}
}
ProviderContainer _profileContainer(
String nsec,
RelaySessionNotifier relaySession,
) => ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(() => _FixedRelayConfigNotifier(nsec)),
relaySessionProvider.overrideWith(() => relaySession),
],
);
class _ControlledProfileRelaySession extends RelaySessionNotifier {
_ControlledProfileRelaySession({required this.fetch});
final Future<List<NostrEvent>> Function() fetch;
final List<NostrEvent> published = [];
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [...await fetch(), ...published];
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
published.add(event);
return event;
}
}
class _LosingProfileRelaySession extends RelaySessionNotifier {
_LosingProfileRelaySession(this.initial);
final NostrEvent initial;
final List<NostrEvent> published = [];
NostrEvent? competing;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [competing ?? initial];
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
published.add(event);
competing = NostrEvent(
id: 'profile-competing',
pubkey: initial.pubkey,
createdAt: event.createdAt + 1,
kind: EventKind.profile,
tags: const [],
content: jsonEncode({'display_name': 'Remote'}),
sig: 'sig',
);
return event;
}
}
ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
myPubkeyProvider.overrideWithValue('aabb'),
profileProvider.overrideWith(_FakeProfileNotifier.new),
relaySessionProvider.overrideWith(_DisconnectedRelaySession.new),
appLifecycleProvider.overrideWith(_ResumedLifecycle.new),
],
);
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _DisconnectedRelaySession extends RelaySessionNotifier {
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
}
class _ResumedLifecycle extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}