fix(mobile/ios): synchronize push snapshot lifecycle

Co-authored-by: npub15uvq03jgm52chkxa5x7f2ud28xh9v48287f5fj5fsuu0wqpm63ns8hn2er <a71807c648dd158bd8dda1bc9571aa39ae5654ea3f9344ca898738f7003bd467@buzz.block.builderlab.xyz>
Signed-off-by: npub15uvq03jgm52chkxa5x7f2ud28xh9v48287f5fj5fsuu0wqpm63ns8hn2er <a71807c648dd158bd8dda1bc9571aa39ae5654ea3f9344ca898738f7003bd467@buzz.block.builderlab.xyz>
This commit is contained in:
npub15uvq03jgm52chkxa5x7f2ud28xh9v48287f5fj5fsuu0wqpm63ns8hn2er
2026-07-24 10:26:53 -07:00
parent 14a529d4af
commit e067a394dc
4 changed files with 146 additions and 4 deletions

View File

@@ -24,6 +24,7 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
final storage = ref.read(communityStorageProvider);
final communities = await storage.loadAll();
if (communities.isEmpty) {
await ref.read(communitySnapshotWriterProvider)(communities);
return const AuthState(status: AuthStatus.unauthenticated);
}
@@ -36,6 +37,7 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
await storage.saveActiveId(active.id);
if (_hasValidNsec(active.nsec)) {
await ref.read(communitySnapshotWriterProvider)(communities);
return AuthState(status: AuthStatus.authenticated, community: active);
}
@@ -47,6 +49,7 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
}
await storage.clearActiveId();
await ref.read(communitySnapshotWriterProvider)(communities);
return const AuthState(status: AuthStatus.unauthenticated);
}
@@ -57,6 +60,7 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
final storage = ref.read(communityStorageProvider);
await storage.save(community);
await storage.saveActiveId(community.id);
await syncStoredCommunitySnapshot(ref);
// Invalidate community providers so other consumers pick up the new data.
ref.invalidate(communityListProvider);
@@ -76,8 +80,10 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
}
// Check if other communities remain — switch to the next one instead of
// forcing the user back to the pairing screen.
// forcing the user back to the pairing screen. Refreshing the complete
// snapshot also removes revoked keys from the extension Keychain.
final remaining = await storage.loadAll();
await ref.read(communitySnapshotWriterProvider)(remaining);
// Invalidate community providers so other consumers pick up the change.
ref.invalidate(communityListProvider);

View File

@@ -9,11 +9,30 @@ final communityStorageProvider = Provider<CommunityStorage>((ref) {
return CommunityStorage();
});
typedef CommunitySnapshotWriter =
Future<void> Function(List<Community> communities);
/// Writes the complete persisted community set to storage shared with the iOS
/// notification service extension. Tests override this provider to verify that
/// every persistence path refreshes (or clears) the native snapshot.
final communitySnapshotWriterProvider = Provider<CommunitySnapshotWriter>((
ref,
) {
return registerBuzzPushCommunitySnapshot;
});
Future<void> syncStoredCommunitySnapshot(Ref ref) async {
final communities = await ref.read(communityStorageProvider).loadAll();
await ref.read(communitySnapshotWriterProvider)(communities);
}
class CommunityListNotifier extends AsyncNotifier<List<Community>> {
@override
Future<List<Community>> build() async {
final storage = ref.read(communityStorageProvider);
return storage.loadAll();
final communities = await storage.loadAll();
await ref.read(communitySnapshotWriterProvider)(communities);
return communities;
}
/// Add a community. If one with the same relay URL already exists, update

View File

@@ -20,8 +20,16 @@ void main() {
);
await storage.save(invalid);
await storage.saveActiveId(invalid.id);
final snapshots = <List<Community>>[];
final container = ProviderContainer(
overrides: [communityStorageProvider.overrideWithValue(storage)],
overrides: [
communityStorageProvider.overrideWithValue(storage),
communitySnapshotWriterProvider.overrideWithValue((
communities,
) async {
snapshots.add(List.of(communities));
}),
],
);
addTearDown(container.dispose);
@@ -30,6 +38,87 @@ void main() {
expect(auth.status, AuthStatus.unauthenticated);
expect(await storage.loadAll(), isEmpty);
expect(await storage.loadActiveId(), isNull);
expect(snapshots.last, isEmpty);
},
);
test('authenticate exports the complete stored community snapshot', () async {
final storage = CommunityStorage(secure: FakeSecureStorage());
final existing = Community.create(
name: 'Existing',
relayUrl: 'https://existing.example',
nsec: nostr.Keys.generate().nsec,
);
final added = Community.create(
name: 'Added',
relayUrl: 'https://added.example',
nsec: nostr.Keys.generate().nsec,
);
await storage.save(existing);
final snapshots = <List<Community>>[];
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
communitySnapshotWriterProvider.overrideWithValue((communities) async {
snapshots.add(List.of(communities));
}),
],
);
addTearDown(container.dispose);
await container
.read(authProvider.notifier)
.authenticateWithCommunity(added);
expect(snapshots.last.map((community) => community.id), {
existing.id,
added.id,
});
});
test(
'sign out removes the active community from the shared snapshot',
() async {
final storage = CommunityStorage(secure: FakeSecureStorage());
final first = Community.create(
name: 'First',
relayUrl: 'https://first.example',
nsec: nostr.Keys.generate().nsec,
);
final second = Community.create(
name: 'Second',
relayUrl: 'https://second.example',
nsec: nostr.Keys.generate().nsec,
);
await storage.save(first);
await storage.save(second);
await storage.saveActiveId(first.id);
final snapshots = <List<Community>>[];
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
communitySnapshotWriterProvider.overrideWithValue((
communities,
) async {
snapshots.add(List.of(communities));
}),
],
);
addTearDown(container.dispose);
await container.read(authProvider.future);
await container.read(authProvider.notifier).signOut();
expect(
snapshots.any((snapshot) {
return snapshot.length == 1 && snapshot.single.id == second.id;
}),
isTrue,
);
expect(
snapshots.last.map((community) => community.id),
isNot(contains(first.id)),
);
},
);

View File

@@ -1,8 +1,11 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/community/community_storage.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'community_storage_test.dart';
@@ -10,17 +13,24 @@ void main() {
late FakeSecureStorage fakeSecure;
late CommunityStorage communityStorage;
late ProviderContainer container;
late List<List<Community>> snapshots;
setUp(() {
fakeSecure = FakeSecureStorage();
communityStorage = CommunityStorage(secure: fakeSecure);
snapshots = [];
});
tearDown(() => container.dispose());
ProviderContainer createContainer() {
return ProviderContainer(
overrides: [communityStorageProvider.overrideWithValue(communityStorage)],
overrides: [
communityStorageProvider.overrideWithValue(communityStorage),
communitySnapshotWriterProvider.overrideWithValue((communities) async {
snapshots.add(List.of(communities));
}),
],
);
}
@@ -29,6 +39,24 @@ void main() {
container = createContainer();
final communities = await container.read(communityListProvider.future);
expect(communities, isEmpty);
expect(snapshots, [isEmpty]);
});
test('exports migrated communities on startup', () async {
final community = Community.create(
name: 'Migrated',
relayUrl: 'https://migrated.example.com',
nsec: nostr.Keys.generate().nsec,
);
// Seed legacy storage to exercise the same migration path as an app
// upgrade.
fakeSecure['buzz_workspaces'] = jsonEncode([community.toJson()]);
container = createContainer();
await container.read(communityListProvider.future);
expect(snapshots.single.single.id, community.id);
expect(fakeSecure['buzz_workspaces'], isNull);
});
test('addCommunity adds to list', () async {