diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index f0681c0262..026e6a989a 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -42,8 +42,6 @@ import 'features/settings/theme_picker_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; -import 'shared/push/push_subscription_provider.dart'; -import 'shared/push/push_relay_capability_provider.dart'; import 'shared/relay/relay.dart'; import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; @@ -336,11 +334,6 @@ class App extends HookConsumerWidget { ref.listen(observerRelayProvider, (_, _) {}); ref.watch(appLifecycleProvider); ref.listen(userStatusCacheProvider, (_, _) {}); - if (ref.watch(activeCommunityProvider).value?.pushNotificationsEnabled == - true && - ref.watch(currentRelayPushDescriptorProvider).value != null) { - ref.watch(pushSubscriptionSyncProvider); - } hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; hasUnreadSolo = ref.watch( meUnreadChannelIdsProvider.select((ids) => ids.isNotEmpty), diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index bbf6ea4a97..57ebe8ddc2 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -14,6 +14,7 @@ import '../../shared/identity_transfer/nsec_export_sheet.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/success_haptic.dart'; import '../../shared/push/push_relay_capability_provider.dart'; +import '../../shared/push/push_bootstrap.dart'; import '../../shared/relay/relay.dart'; import '../../shared/utils/string_utils.dart'; import '../pairing/pairing_provider.dart'; diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index 23ba05a22f..5173c3cb42 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -10,6 +10,7 @@ class _NotificationsSection extends ConsumerWidget { } final community = ref.watch(activeCommunityProvider).value; if (community == null) return const SizedBox.shrink(); + final setup = ref.watch(buzzPushSetupStatusProvider); final capability = ref.watch(currentRelayPushDescriptorProvider); final hasCapability = !capability.isLoading && @@ -19,7 +20,8 @@ class _NotificationsSection extends ConsumerWidget { community.pushSubscriptionState.pendingTombstoneGeneration != null; if (!hasCapability && !community.pushNotificationsEnabled && - !optOutPending) { + !optOutPending && + !setup.hasError) { return const SizedBox.shrink(); } final canToggle = hasCapability || community.pushNotificationsEnabled; @@ -35,6 +37,13 @@ class _NotificationsSection extends ConsumerWidget { return AppListCard( verticalPadding: Grid.twelve, children: [ + if (setup.hasError) + AppListRow( + key: const ValueKey('push-setup-retry'), + title: 'Notification setup failed', + subtitle: 'You can keep using Buzz. Tap to retry notifications.', + onTap: () => retryBuzzPushSetup(ref), + ), MergeSemantics( key: const ValueKey('push-notifications-setting'), child: Stack( diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 6c1aa734df..f793b6a5d3 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -14,7 +14,6 @@ void main() => runBuzzApp(const App()); Future runBuzzApp(Widget app) async { WidgetsFlutterBinding.ensureInitialized(); installBuzzPushMethodHandler(); - await syncPendingBuzzPushNotificationResponse(); // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index eac9f91e0b..7f073d9242 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -109,14 +109,14 @@ typedef CommunityPushLeaseRevocationEnqueuer = final communityPushLeaseRevocationEnqueuerProvider = Provider((ref) { - return ref.read(buzzPushLeaseRevocationOutboxProvider).enqueueCommunity; + return ref.watch(buzzPushLeaseRevocationOutboxProvider).enqueueCommunity; }); typedef CommunityPushLeaseRevocationTrigger = Future Function(); final communityPushLeaseRevocationTriggerProvider = Provider((ref) { - return ref.read(buzzPushLeaseRevocationOutboxProvider).trigger; + return ref.watch(buzzPushLeaseRevocationOutboxProvider).trigger; }); Future _deactivateCommunityPushLease( diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 49b05859ec..28d690606b 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -14,6 +14,8 @@ import 'push_bridge.dart'; import 'push_lease_revocation_outbox.dart'; import 'push_relay_capability_provider.dart'; import 'push_subscription.dart'; +import 'push_subscription_provider.dart'; +import 'push_setup_recovery.dart'; const _pushBootstrapRetryDelay = Duration(seconds: 5); @@ -151,13 +153,119 @@ Future publishBuzzPushLeaseRecoverably({ return generation; } -/// Starts the push lifecycle only after authenticated relay connectivity and a -/// push-capable NIP-11 descriptor are both present. +typedef _PushInputs = ({ + BuzzPushLeaseRevocationOutbox revocationOutbox, + SessionState session, + List communities, + RelayConfig config, + Community? community, + String? memberPubkey, + BuzzPushLeaseDescriptor? descriptor, +}); + +// Convert synchronous provider failures into data before they reach a widget +// build. Dependencies remain watched so corrected inputs recover immediately. +final _pushInputsProvider = Provider.autoDispose>(( + ref, +) { + try { + return AsyncData(( + revocationOutbox: ref.watch(buzzPushLeaseRevocationOutboxProvider), + session: ref.watch(relaySessionProvider), + communities: ref.watch(communityListProvider).value ?? const [], + config: ref.watch(relayConfigProvider), + community: ref.watch(activeCommunityProvider).value, + memberPubkey: ref.watch(myPubkeyProvider), + descriptor: ref.watch(currentRelayPushDescriptorProvider).value, + )); + } catch (error, stack) { + reportPushSetupError('initialization', error, stack); + return AsyncError(error, stack); + } +}); + +// Pull cold-start notification routing after runApp. A failed platform call +// must not prevent the first frame or disappear as an unobserved future. +final _pendingNotificationResponseProvider = FutureProvider.autoDispose(( + ref, +) async { + try { + await syncPendingBuzzPushNotificationResponse(); + } catch (error, stack) { + reportPushSetupError('pending notification response', error, stack); + rethrow; + } +}, retry: pushSetupRetry); + +/// Observable startup/synchronization status, independent of core app rendering. +final buzzPushSetupStatusProvider = Provider.autoDispose>(( + ref, +) { + final inputs = ref.watch(_pushInputsProvider); + final response = ref.watch(_pendingNotificationResponseProvider); + final subscriptions = ref.watch(pushSubscriptionSyncProvider); + if (response.hasError) return response; + if (inputs.hasError) return AsyncError(inputs.error!, inputs.stackTrace!); + return subscriptions; +}); + +/// Retries push-owned setup dependencies without resetting the app or session. +void retryBuzzPushSetup(WidgetRef ref) { + // Unrelated setup failures must not dispose a healthy cleanup worker. + // Reconstruct only a worker whose provider actually failed to initialize. + try { + ref.read(buzzPushLeaseRevocationOutboxProvider); + } catch (error, stack) { + reportPushSetupError('revocation worker retry', error, stack); + ref.invalidate(buzzPushLeaseRevocationOutboxProvider); + } + ref.invalidate(currentRelayPushDescriptorProvider); + ref.invalidate(pushSubscriptionSyncProvider); + ref.invalidate(_pendingNotificationResponseProvider); + ref.invalidate(_pushInputsProvider); +} + +/// Starts push independently of [child], which stays mounted during failures. class BuzzPushBootstrap extends HookConsumerWidget { const BuzzPushBootstrap({required this.child, super.key}); final Widget child; + @override + Widget build(BuildContext context, WidgetRef ref) { + final inputs = ref.watch(_pushInputsProvider); + ref.watch(buzzPushSetupStatusProvider); + final failures = useRef(0); + useEffect(() { + if (!inputs.hasError) { + failures.value = 0; + return null; + } + final delay = pushSetupRetry(failures.value++, inputs.error!); + if (delay == null) return null; + final timer = Timer(delay, () { + if (context.mounted) retryBuzzPushSetup(ref); + }); + return timer.cancel; + }, [inputs]); + // A stable sibling slot preserves navigation and widget state when push + // fails, retries, or becomes available after startup. + return Stack( + textDirection: TextDirection.ltr, + children: [ + child, + if (inputs.asData case final data?) + _BuzzPushLifecycle(inputs: data.value), + ], + ); + } +} + +class _BuzzPushLifecycle extends HookConsumerWidget { + const _BuzzPushLifecycle({required this.inputs}); + + final _PushInputs inputs; + @override Widget build(BuildContext context, WidgetRef ref) { useListenable(apnsDeviceToken); @@ -165,13 +273,15 @@ class BuzzPushBootstrap extends HookConsumerWidget { final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new); final publicationRetry = useState(0); final tombstoneRetry = useState(0); - final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); - final session = ref.watch(relaySessionProvider); - final communities = ref.watch(communityListProvider).value ?? const []; - final config = ref.watch(relayConfigProvider); - final community = ref.watch(activeCommunityProvider).value; - final memberPubkey = ref.watch(myPubkeyProvider); - final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; + final ( + :revocationOutbox, + :session, + :communities, + :config, + :community, + :memberPubkey, + :descriptor, + ) = inputs; final enablementEpoch = useRef(0); useEffect(() { // Each opt-in needs a fresh active lease even when endpoint, executor, @@ -351,7 +461,7 @@ class BuzzPushBootstrap extends HookConsumerWidget { descriptor: descriptor, ), attemptKey: '${community?.id}|${config.baseUrl}', - child: child, + child: const SizedBox.shrink(), ); } diff --git a/mobile/lib/shared/push/push_setup_recovery.dart b/mobile/lib/shared/push/push_setup_recovery.dart new file mode 100644 index 0000000000..f8657fc229 --- /dev/null +++ b/mobile/lib/shared/push/push_setup_recovery.dart @@ -0,0 +1,13 @@ +import 'package:flutter/foundation.dart'; + +/// Push is optional background work. Retry failures with bounded backoff while +/// keeping the failure observable. After three retries, wait for corrected inputs +/// or an explicit retry instead of looping forever. +Duration? pushSetupRetry(int retryCount, Object error) => + retryCount < 3 ? Duration(seconds: 5 * (1 << retryCount)) : null; + +/// Reports the failed push operation with its original exception and stack. +void reportPushSetupError(String operation, Object error, StackTrace stack) { + debugPrint('Push $operation failed: $error'); + debugPrintStack(stackTrace: stack); +} diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart index 9d24e7cebc..b9625884d1 100644 --- a/mobile/lib/shared/push/push_subscription_provider.dart +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -1,44 +1,53 @@ -import 'dart:async'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../features/channels/channel.dart'; import '../../features/channels/channel_mutes/channel_mutes_provider.dart'; import '../../features/channels/channels_provider.dart'; -import '../community/community.dart'; -import '../community/community_provider.dart'; +import '../auth/auth.dart'; import '../relay/relay_provider.dart'; import 'push_subscription.dart'; +import 'push_relay_capability_provider.dart'; +import 'push_setup_recovery.dart'; /// Keeps the persisted desired lease and the App Group snapshot aligned with /// the active identity, joined channels, and mute state. This is desired client /// policy only. Relay-accepted authority is introduced by lease publication. -final pushSubscriptionSyncProvider = Provider((ref) { - final active = ref.watch(activeCommunityProvider).value; - final channels = ref.watch(channelsProvider).value; - final mutes = ref.watch(channelMutesProvider); - if (active == null || - !active.pushNotificationsEnabled || - channels == null || - !mutes.isReady) { - return; - } +final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( + ref, +) async { + try { + if (ref.watch(authProvider).value?.status != AuthStatus.authenticated) { + return; + } + final active = ref.watch(activeCommunityProvider).value; + if (active == null || + !active.pushNotificationsEnabled || + ref.watch(currentRelayPushDescriptorProvider).value == null) { + return; + } + final channels = ref.watch(channelsProvider).value; + final mutes = ref.watch(channelMutesProvider); + if (channels == null || !mutes.isReady) { + return; + } - final subscriptions = desiredBuzzPushSubscriptions( - community: active, - channels: channels, - mutedChannelIds: [ - for (final entry in mutes.store.channels.entries) - if (entry.value.muted) entry.key, - ], - ); - if (subscriptions == null) return; - unawaited( - ref + final subscriptions = desiredBuzzPushSubscriptions( + community: active, + channels: channels, + mutedChannelIds: [ + for (final entry in mutes.store.channels.entries) + if (entry.value.muted) entry.key, + ], + ); + if (subscriptions == null) return; + await ref .read(communityListProvider.notifier) - .updateDesiredPushSubscriptions(active.id, subscriptions), - ); -}); + .updateDesiredPushSubscriptions(active.id, subscriptions); + } catch (error, stack) { + reportPushSetupError('subscription synchronization', error, stack); + rethrow; + } +}, retry: pushSetupRetry); List? desiredBuzzPushSubscriptions({ required Community community, diff --git a/mobile/test/shared/push/push_root_isolation_test.dart b/mobile/test/shared/push/push_root_isolation_test.dart new file mode 100644 index 0000000000..f945f20660 --- /dev/null +++ b/mobile/test/shared/push/push_root_isolation_test.dart @@ -0,0 +1,493 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:buzz/app.dart'; +import 'package:buzz/main.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/channel_mutes/channel_mutes_provider.dart'; +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/push/push_setup_recovery.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import '../community/community_storage_test.dart' show FakeSecureStorage; +import 'package:buzz/features/age_gate/age_signal_provider.dart'; +import 'package:buzz/features/home/home_page.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bootstrap.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/push/push_lease_revocation_outbox.dart'; +import 'package:buzz/shared/push/push_relay_capability_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final nativeChannels = []; + setUp(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final args = call.arguments as Map; + final channel = MethodChannel('${args['viewType']}/${args['id']}'); + nativeChannels.add(channel); + messenger.setMockMethodCallHandler(channel, (_) async => null); + } + return null; + }); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity_status'), + (_) async => null, + ); + }); + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + for (final channel in nativeChannels) { + messenger.setMockMethodCallHandler(channel, null); + } + nativeChannels.clear(); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity_status'), + null, + ); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('subscription setup failure does not replace the app', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final community = Community.create( + name: 'Team', + relayUrl: 'https://relay.example', + pubkey: 'a' * 64, + ).copyWith(pushNotificationsEnabled: true); + final storage = _FailingStorage(); + await storage.save(community); + storage.fail = true; + final channels = _Channels(); + await tester.pumpWidget( + ProviderScope( + retry: (_, _) => null, + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + authProvider.overrideWith(_Auth.new), + ageSignalProvider.overrideWith(_Age.new), + relaySessionProvider.overrideWith(_Session.new), + activeCommunityProvider.overrideWith((_) async => community), + currentRelayPushDescriptorProvider.overrideWith( + (_) async => _descriptor, + ), + channelsProvider.overrideWith(() => channels), + channelMutesProvider.overrideWith(_Mutes.new), + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + ], + child: const BuzzPushBootstrap(child: App()), + ), + ); + await tester.pump(); + await tester.pump(); + expect(find.byType(HomePage), findsOneWidget); + expect(find.byType(Navigator), findsOneWidget); + expect(tester.takeException(), isNull); + final container = ProviderScope.containerOf( + tester.element(find.byType(App)), + ); + expect( + container.read(buzzPushSetupStatusProvider).error, + isA(), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => const Text('Open conversation'), + ), + ), + ); + await tester.pumpAndSettle(); + + // Correcting the inputs retries the real provider, but asynchronous secure + // storage failure must also remain observable without replacing the route. + channels.useValidChannel(); + await tester.pump(); + await tester.pump(); + expect( + container.read(buzzPushSetupStatusProvider).error, + isA(), + ); + expect( + (await storage.loadAll()).single.pushSubscriptionState.desired, + isEmpty, + ); + expect(find.text('Open conversation'), findsOneWidget); + expect( + tester.state(find.byType(Navigator)), + same(navigator), + ); + + storage.fail = false; + await tester.pump(const Duration(seconds: 5)); + await tester.pump(); + expect(container.read(buzzPushSetupStatusProvider).hasError, isFalse); + expect( + (await storage.loadAll()).single.pushSubscriptionState.desired, + isNotEmpty, + ); + expect(find.text('Open conversation'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets( + 'push dependency failure is visible and retry preserves navigation', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final semantics = tester.ensureSemantics(); + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.runAsync(() async { + await (FontLoader( + 'Inter', + )..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load(); + await (FontLoader( + 'BuzzTabler', + )..addFont(rootBundle.load('assets/fonts/TablerIcons.ttf'))).load(); + }); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final community = Community.create( + name: 'Team', + relayUrl: 'https://relay.example', + ).copyWith(pushNotificationsEnabled: true); + var fail = true; + var attempts = 0; + final journal = BuzzPushLeaseRevocationStorage( + secure: FakeSecureStorage(), + ); + final storage = CommunityStorage(secure: FakeSecureStorage()); + final removals = [for (var i = 0; i < 2; i++) _removableCommunity(i)]; + for (final removal in removals) { + await storage.save(removal); + } + final published = []; + const nativePush = MethodChannel('buzz/push'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + nativePush, + (call) async { + if (call.method != 'endpointGrants') return null; + return [ + { + 'relayOrigin': 'wss://relay.example', + 'relayPubkey': 'a' * 64, + 'installationId': '0' * 32, + 'endpointGrant': 'opaque', + 'endpointHash': 'b' * 64, + 'endpointEpoch': 1, + 'generation': 1, + 'expiresAt': DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, + }, + ]; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + nativePush, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + retry: (_, _) => null, + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + authProvider.overrideWith(_Auth.new), + relaySessionProvider.overrideWith(_Session.new), + activeCommunityProvider.overrideWith((_) async => community), + currentRelayPushDescriptorProvider.overrideWith( + (_) async => _descriptor, + ), + buzzPushLeaseRevocationOutboxProvider.overrideWith((ref) { + attempts++; + if (fail) throw StateError('injected push dependency failure'); + final outbox = BuzzPushLeaseRevocationOutbox( + storage: journal, + publisher: (record) async => + published.add(record.installationId), + ); + ref.onDispose(outbox.dispose); + return outbox; + }), + ], + child: BuzzPushBootstrap( + child: Consumer( + builder: (context, ref, _) => MaterialApp( + theme: AppTheme.light(), + home: TextButton( + onPressed: () => retryBuzzPushSetup(ref), + child: const Text('Core app'), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(find.text('Core app'), findsOneWidget); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Notification setup failed'), findsOneWidget); + expect( + tester + .getSemantics(find.byKey(const ValueKey('push-setup-retry'))) + .getSemanticsData() + .hasAction(ui.SemanticsAction.tap), + isTrue, + ); + await _capture(tester, 'notification-setup-failed'); + final container = ProviderScope.containerOf( + tester.element(find.byType(SettingsPage)), + ); + final communities = container.read(communityListProvider.notifier); + // A removal attempted while initialization is broken caches a failed + // callback provider. Recovery must refresh it as well as the worker. + await expectLater( + communities.removeCommunity(removals.first.id), + throwsA( + predicate( + (error) => + error.toString().contains('injected push dependency failure'), + ), + ), + ); + expect( + (await storage.loadAll()).map((value) => value.id), + contains(removals.first.id), + ); + // Exhaust automatic retries. Persistent failures stay visible, but stop + // waking forever. Manual recovery remains available after exhaustion. + for (final seconds in [5, 10, 20, 60]) { + await tester.pump(Duration(seconds: seconds)); + await tester.pump(); + } + expect(attempts, 4); + fail = false; + await tester.ensureVisible( + find.byKey(const ValueKey('push-setup-retry')), + ); + await tester.tap(find.byKey(const ValueKey('push-setup-retry'))); + await tester.pumpAndSettle(); + expect(find.byType(SettingsPage), findsOneWidget); + expect(find.text('Notification setup failed'), findsNothing); + await _capture(tester, 'notification-setup-recovered'); + expect( + tester.state(find.byType(Navigator)), + same(navigator), + ); + await communities.removeCommunity(removals.first.id); + await tester.pump(); + expect(published, [removals.first.pushLeaseInstallationId]); + expect(await journal.loadAll(), isEmpty); + + // Community removal has now cached this worker's callbacks. Retrying an + // unrelated setup operation must not dispose that healthy worker. + navigator.pop(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Core app')); + await tester.pumpAndSettle(); + await communities.removeCommunity(removals.last.id); + await tester.pump(); + expect(published, [ + for (final removal in removals) removal.pushLeaseInstallationId, + ]); + expect(await journal.loadAll(), isEmpty); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + debugDefaultTargetPlatformOverride = null; + semantics.dispose(); + }, + ); + + testWidgets('cold-start native failure cannot prevent runApp', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + SharedPreferences.setMockInitialValues({}); + const channel = MethodChannel('buzz/push'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + var fail = true; + final pendingResponse = Completer(); + addTearDown(() => pendingPushNotificationLink.value = null); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'takePendingNotificationResponse' && fail) { + return pendingResponse.future; + } + return call.method == 'takePendingNotificationResponse' + ? { + 'eventId': 'event', + 'channelId': 'channel', + 'communityId': 'community', + } + : null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + await runBuzzApp(const MaterialApp(home: Text('Core app'))); + await tester.pump(); + await tester.pump(); + expect(find.text('Core app'), findsOneWidget); + pendingResponse.completeError( + PlatformException(code: 'injected_native_failure'), + ); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.text('Core app')), + ); + expect( + container.read(buzzPushSetupStatusProvider).error, + isA(), + ); + fail = false; + await tester.pump(const Duration(seconds: 5)); + await tester.pump(); + expect(container.read(buzzPushSetupStatusProvider).hasError, isFalse); + expect(pendingPushNotificationLink.value?.messageId, 'event'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + debugDefaultTargetPlatformOverride = null; + }); + + test('setup retries stop after bounded backoff', () { + final error = StateError('persistent failure'); + expect( + [for (var i = 0; i < 5; i++) pushSetupRetry(i, error)], + [ + const Duration(seconds: 5), + const Duration(seconds: 10), + const Duration(seconds: 20), + null, + null, + ], + ); + }); +} + +class _Auth extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.authenticated); +} + +class _Age extends AgeSignalNotifier { + @override + AgeSignalState build() => AgeSignalState.allowed; + @override + Future request() async {} +} + +class _Session extends RelaySessionNotifier { + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); +} + +const _descriptor = BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: 'key', + executorPubkey: 'pubkey', + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); + +class _Channels extends ChannelsNotifier { + @override + Future> build() async => [_channel('invalid-channel')]; + void useValidChannel() => + state = AsyncData([_channel('123e4567-e89b-42d3-a456-426614174000')]); +} + +class _Mutes extends ChannelMutesNotifier { + @override + ChannelMutesState build() => const ChannelMutesState(isReady: true); +} + +Channel _channel(String id) => Channel( + id: id, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: 'a' * 64, + createdAt: DateTime(2026), + memberCount: 2, + isMember: true, +); + +class _FailingStorage extends CommunityStorage { + _FailingStorage() : super(secure: FakeSecureStorage()); + bool fail = false; + @override + Future save(Community community) async { + if (fail) throw StateError('injected secure storage failure'); + await super.save(community); + } +} + +Future _capture(WidgetTester tester, String name) async { + final directory = Platform.environment['PUSH_SETUP_SCREENSHOTS']; + if (directory == null) return; + final boundary = tester.renderObject( + find.byType(RepaintBoundary).first, + ); + await tester.runAsync(() async { + final image = await boundary.toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + await Directory(directory).create(recursive: true); + await File('$directory/$name.png').writeAsBytes(data!.buffer.asUint8List()); + image.dispose(); + }); +} + +Community _removableCommunity(int index) { + final keys = nostr.Keys('${index + 1}' * 64); + return Community.create( + name: 'Removed $index', + relayUrl: 'https://relay.example', + nsec: keys.nsec, + pubkey: keys.public, + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: const BuzzPushLeaseSubscriptionState.desired() + .withReservedGeneration(4), + ); +}