From e067a394dccd0b9ce91e8375e222ac00f11c4b8f Mon Sep 17 00:00:00 2001 From: npub15uvq03jgm52chkxa5x7f2ud28xh9v48287f5fj5fsuu0wqpm63ns8hn2er Date: Fri, 24 Jul 2026 10:26:53 -0700 Subject: [PATCH] fix(mobile/ios): synchronize push snapshot lifecycle Co-authored-by: npub15uvq03jgm52chkxa5x7f2ud28xh9v48287f5fj5fsuu0wqpm63ns8hn2er Signed-off-by: npub15uvq03jgm52chkxa5x7f2ud28xh9v48287f5fj5fsuu0wqpm63ns8hn2er --- mobile/lib/shared/auth/auth_provider.dart | 8 +- .../shared/community/community_provider.dart | 21 ++++- .../test/shared/auth/auth_provider_test.dart | 91 ++++++++++++++++++- .../community/community_provider_test.dart | 30 +++++- 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index ade2220264..b612eea1f2 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -24,6 +24,7 @@ class AuthNotifier extends AsyncNotifier { 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 { 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 { } await storage.clearActiveId(); + await ref.read(communitySnapshotWriterProvider)(communities); return const AuthState(status: AuthStatus.unauthenticated); } @@ -57,6 +60,7 @@ class AuthNotifier extends AsyncNotifier { 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 { } // 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); diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index a084697a81..98dd435132 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -9,11 +9,30 @@ final communityStorageProvider = Provider((ref) { return CommunityStorage(); }); +typedef CommunitySnapshotWriter = + Future Function(List 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(( + ref, +) { + return registerBuzzPushCommunitySnapshot; +}); + +Future syncStoredCommunitySnapshot(Ref ref) async { + final communities = await ref.read(communityStorageProvider).loadAll(); + await ref.read(communitySnapshotWriterProvider)(communities); +} + class CommunityListNotifier extends AsyncNotifier> { @override Future> 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 diff --git a/mobile/test/shared/auth/auth_provider_test.dart b/mobile/test/shared/auth/auth_provider_test.dart index 7a1e6a6608..9acd88a870 100644 --- a/mobile/test/shared/auth/auth_provider_test.dart +++ b/mobile/test/shared/auth/auth_provider_test.dart @@ -20,8 +20,16 @@ void main() { ); await storage.save(invalid); await storage.saveActiveId(invalid.id); + final snapshots = >[]; 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 = >[]; + 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 = >[]; + 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)), + ); }, ); diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index c58e03c56a..b599093618 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -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> 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 {