fix(mobile): isolate push setup failures from app rendering (#8610)

Push subscription setup ran synchronously inside the root widget build,
so an invalid subscription could replace the entire app with Flutter's
error surface. Push initialization and the cold-start native
notification read could also prevent the app from rendering.

Keep push work independent of the app subtree, observe asynchronous
persistence failures, and retain the original errors and stack traces.
Setup retries use bounded backoff; Settings shows a failure and offers
manual retry without resetting navigation. Retry preserves a healthy
revocation worker, and community-removal callbacks follow a recovered
worker so lease cleanup continues after setup recovery.

Related: [#8608](https://github.com/block/buzz/pull/8608) fixes the
specific unrelated-mute input that exposed this startup coupling.

### Validation

Added Flutter journeys cover invalid subscriptions, asynchronous storage
failure, dependency initialization failure, retry exhaustion and manual
recovery, navigation preservation, accessible retry actions, and
pending/failed native startup reads with notification-target recovery.
Both original rendering regressions failed before the fix.
Community-removal coverage also verifies cleanup after a worker
initialization failure and after retrying healthy setup; that regression
failed before the worker fix.

The full mobile suite passed (3,782 tests; 19 existing skips), as did
the four unconfigured-build tests and static analysis. The corrected
head also passed the full pre-push mobile checks and tests, plus 42
focused push and community tests. Rendered widget evidence uses mocked
native platform channels; no device APNs delivery test was performed.

Rendered Flutter widget evidence with synthetic data and mocked native
platform views.

### Setup failure
The app remains usable and Settings offers a retry after push
initialization fails.


![notification-setup-failed](https://raw.githubusercontent.com/block/buzz/af12b52923a75c6ad9fda774b9f4b3af37961422/pr-8610--notification-setup-failed.png)

### Recovery
Retry clears the error without replacing the navigator or leaving
Settings.


![notification-setup-recovered](https://raw.githubusercontent.com/block/buzz/af12b52923a75c6ad9fda774b9f4b3af37961422/pr-8610--notification-setup-recovered.png)

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-26 19:32:38 -07:00
committed by GitHub
parent 8216db1c71
commit 5996e5dd8b
9 changed files with 676 additions and 49 deletions

View File

@@ -42,8 +42,6 @@ import 'features/settings/theme_picker_page.dart';
import 'shared/auth/auth.dart'; import 'shared/auth/auth.dart';
import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/deeplink/pending_deep_link_provider.dart';
import 'shared/emoji/emoji_burst.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/relay/relay.dart';
import 'shared/read_state/read_state_provider.dart'; import 'shared/read_state/read_state_provider.dart';
import 'shared/theme/theme.dart'; import 'shared/theme/theme.dart';
@@ -336,11 +334,6 @@ class App extends HookConsumerWidget {
ref.listen(observerRelayProvider, (_, _) {}); ref.listen(observerRelayProvider, (_, _) {});
ref.watch(appLifecycleProvider); ref.watch(appLifecycleProvider);
ref.listen(userStatusCacheProvider, (_, _) {}); ref.listen(userStatusCacheProvider, (_, _) {});
if (ref.watch(activeCommunityProvider).value?.pushNotificationsEnabled ==
true &&
ref.watch(currentRelayPushDescriptorProvider).value != null) {
ref.watch(pushSubscriptionSyncProvider);
}
hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0;
hasUnreadSolo = ref.watch( hasUnreadSolo = ref.watch(
meUnreadChannelIdsProvider.select((ids) => ids.isNotEmpty), meUnreadChannelIdsProvider.select((ids) => ids.isNotEmpty),

View File

@@ -14,6 +14,7 @@ import '../../shared/identity_transfer/nsec_export_sheet.dart';
import '../../shared/clipboard_utils.dart'; import '../../shared/clipboard_utils.dart';
import '../../shared/success_haptic.dart'; import '../../shared/success_haptic.dart';
import '../../shared/push/push_relay_capability_provider.dart'; import '../../shared/push/push_relay_capability_provider.dart';
import '../../shared/push/push_bootstrap.dart';
import '../../shared/relay/relay.dart'; import '../../shared/relay/relay.dart';
import '../../shared/utils/string_utils.dart'; import '../../shared/utils/string_utils.dart';
import '../pairing/pairing_provider.dart'; import '../pairing/pairing_provider.dart';

View File

@@ -10,6 +10,7 @@ class _NotificationsSection extends ConsumerWidget {
} }
final community = ref.watch(activeCommunityProvider).value; final community = ref.watch(activeCommunityProvider).value;
if (community == null) return const SizedBox.shrink(); if (community == null) return const SizedBox.shrink();
final setup = ref.watch(buzzPushSetupStatusProvider);
final capability = ref.watch(currentRelayPushDescriptorProvider); final capability = ref.watch(currentRelayPushDescriptorProvider);
final hasCapability = final hasCapability =
!capability.isLoading && !capability.isLoading &&
@@ -19,7 +20,8 @@ class _NotificationsSection extends ConsumerWidget {
community.pushSubscriptionState.pendingTombstoneGeneration != null; community.pushSubscriptionState.pendingTombstoneGeneration != null;
if (!hasCapability && if (!hasCapability &&
!community.pushNotificationsEnabled && !community.pushNotificationsEnabled &&
!optOutPending) { !optOutPending &&
!setup.hasError) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final canToggle = hasCapability || community.pushNotificationsEnabled; final canToggle = hasCapability || community.pushNotificationsEnabled;
@@ -35,6 +37,13 @@ class _NotificationsSection extends ConsumerWidget {
return AppListCard( return AppListCard(
verticalPadding: Grid.twelve, verticalPadding: Grid.twelve,
children: [ 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( MergeSemantics(
key: const ValueKey('push-notifications-setting'), key: const ValueKey('push-notifications-setting'),
child: Stack( child: Stack(

View File

@@ -14,7 +14,6 @@ void main() => runBuzzApp(const App());
Future<void> runBuzzApp(Widget app) async { Future<void> runBuzzApp(Widget app) async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
installBuzzPushMethodHandler(); installBuzzPushMethodHandler();
await syncPendingBuzzPushNotificationResponse();
// Pre-load preferences so the first frame uses the saved theme/accent. // Pre-load preferences so the first frame uses the saved theme/accent.
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();

View File

@@ -109,14 +109,14 @@ typedef CommunityPushLeaseRevocationEnqueuer =
final communityPushLeaseRevocationEnqueuerProvider = final communityPushLeaseRevocationEnqueuerProvider =
Provider<CommunityPushLeaseRevocationEnqueuer>((ref) { Provider<CommunityPushLeaseRevocationEnqueuer>((ref) {
return ref.read(buzzPushLeaseRevocationOutboxProvider).enqueueCommunity; return ref.watch(buzzPushLeaseRevocationOutboxProvider).enqueueCommunity;
}); });
typedef CommunityPushLeaseRevocationTrigger = Future<void> Function(); typedef CommunityPushLeaseRevocationTrigger = Future<void> Function();
final communityPushLeaseRevocationTriggerProvider = final communityPushLeaseRevocationTriggerProvider =
Provider<CommunityPushLeaseRevocationTrigger>((ref) { Provider<CommunityPushLeaseRevocationTrigger>((ref) {
return ref.read(buzzPushLeaseRevocationOutboxProvider).trigger; return ref.watch(buzzPushLeaseRevocationOutboxProvider).trigger;
}); });
Future<void> _deactivateCommunityPushLease( Future<void> _deactivateCommunityPushLease(

View File

@@ -14,6 +14,8 @@ import 'push_bridge.dart';
import 'push_lease_revocation_outbox.dart'; import 'push_lease_revocation_outbox.dart';
import 'push_relay_capability_provider.dart'; import 'push_relay_capability_provider.dart';
import 'push_subscription.dart'; import 'push_subscription.dart';
import 'push_subscription_provider.dart';
import 'push_setup_recovery.dart';
const _pushBootstrapRetryDelay = Duration(seconds: 5); const _pushBootstrapRetryDelay = Duration(seconds: 5);
@@ -151,13 +153,119 @@ Future<int> publishBuzzPushLeaseRecoverably({
return generation; return generation;
} }
/// Starts the push lifecycle only after authenticated relay connectivity and a typedef _PushInputs = ({
/// push-capable NIP-11 descriptor are both present. BuzzPushLeaseRevocationOutbox revocationOutbox,
SessionState session,
List<Community> 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<AsyncValue<_PushInputs>>((
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<void>((
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<AsyncValue<void>>((
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 { class BuzzPushBootstrap extends HookConsumerWidget {
const BuzzPushBootstrap({required this.child, super.key}); const BuzzPushBootstrap({required this.child, super.key});
final Widget child; 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
useListenable(apnsDeviceToken); useListenable(apnsDeviceToken);
@@ -165,13 +273,15 @@ class BuzzPushBootstrap extends HookConsumerWidget {
final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new); final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new);
final publicationRetry = useState(0); final publicationRetry = useState(0);
final tombstoneRetry = useState(0); final tombstoneRetry = useState(0);
final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); final (
final session = ref.watch(relaySessionProvider); :revocationOutbox,
final communities = ref.watch(communityListProvider).value ?? const []; :session,
final config = ref.watch(relayConfigProvider); :communities,
final community = ref.watch(activeCommunityProvider).value; :config,
final memberPubkey = ref.watch(myPubkeyProvider); :community,
final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; :memberPubkey,
:descriptor,
) = inputs;
final enablementEpoch = useRef(0); final enablementEpoch = useRef(0);
useEffect(() { useEffect(() {
// Each opt-in needs a fresh active lease even when endpoint, executor, // Each opt-in needs a fresh active lease even when endpoint, executor,
@@ -351,7 +461,7 @@ class BuzzPushBootstrap extends HookConsumerWidget {
descriptor: descriptor, descriptor: descriptor,
), ),
attemptKey: '${community?.id}|${config.baseUrl}', attemptKey: '${community?.id}|${config.baseUrl}',
child: child, child: const SizedBox.shrink(),
); );
} }

View File

@@ -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);
}

View File

@@ -1,44 +1,53 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../features/channels/channel.dart'; import '../../features/channels/channel.dart';
import '../../features/channels/channel_mutes/channel_mutes_provider.dart'; import '../../features/channels/channel_mutes/channel_mutes_provider.dart';
import '../../features/channels/channels_provider.dart'; import '../../features/channels/channels_provider.dart';
import '../community/community.dart'; import '../auth/auth.dart';
import '../community/community_provider.dart';
import '../relay/relay_provider.dart'; import '../relay/relay_provider.dart';
import 'push_subscription.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 /// Keeps the persisted desired lease and the App Group snapshot aligned with
/// the active identity, joined channels, and mute state. This is desired client /// the active identity, joined channels, and mute state. This is desired client
/// policy only. Relay-accepted authority is introduced by lease publication. /// policy only. Relay-accepted authority is introduced by lease publication.
final pushSubscriptionSyncProvider = Provider<void>((ref) { final pushSubscriptionSyncProvider = FutureProvider.autoDispose<void>((
final active = ref.watch(activeCommunityProvider).value; ref,
final channels = ref.watch(channelsProvider).value; ) async {
final mutes = ref.watch(channelMutesProvider); try {
if (active == null || if (ref.watch(authProvider).value?.status != AuthStatus.authenticated) {
!active.pushNotificationsEnabled || return;
channels == null || }
!mutes.isReady) { final active = ref.watch(activeCommunityProvider).value;
return; 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( final subscriptions = desiredBuzzPushSubscriptions(
community: active, community: active,
channels: channels, channels: channels,
mutedChannelIds: [ mutedChannelIds: [
for (final entry in mutes.store.channels.entries) for (final entry in mutes.store.channels.entries)
if (entry.value.muted) entry.key, if (entry.value.muted) entry.key,
], ],
); );
if (subscriptions == null) return; if (subscriptions == null) return;
unawaited( await ref
ref
.read(communityListProvider.notifier) .read(communityListProvider.notifier)
.updateDesiredPushSubscriptions(active.id, subscriptions), .updateDesiredPushSubscriptions(active.id, subscriptions);
); } catch (error, stack) {
}); reportPushSetupError('subscription synchronization', error, stack);
rethrow;
}
}, retry: pushSetupRetry);
List<BuzzPushSubscription>? desiredBuzzPushSubscriptions({ List<BuzzPushSubscription>? desiredBuzzPushSubscriptions({
required Community community, required Community community,

View File

@@ -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 = <MethodChannel>[];
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<FormatException>(),
);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
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<StateError>(),
);
expect(
(await storage.loadAll()).single.pushSubscriptionState.desired,
isEmpty,
);
expect(find.text('Open conversation'), findsOneWidget);
expect(
tester.state<NavigatorState>(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 = <String>[];
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<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
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<NavigatorState>(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<Object?>();
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<PlatformException>(),
);
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<AuthState> build() async =>
const AuthState(status: AuthStatus.authenticated);
}
class _Age extends AgeSignalNotifier {
@override
AgeSignalState build() => AgeSignalState.allowed;
@override
Future<void> 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<List<Channel>> 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<void> save(Community community) async {
if (fail) throw StateError('injected secure storage failure');
await super.save(community);
}
}
Future<void> _capture(WidgetTester tester, String name) async {
final directory = Platform.environment['PUSH_SETUP_SCREENSHOTS'];
if (directory == null) return;
final boundary = tester.renderObject<RenderRepaintBoundary>(
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),
);
}