Files
buzz/mobile/lib/shared/push/push_bootstrap.dart
Tom Brow 5996e5dd8b 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>
2026-10-26 19:32:38 -07:00

533 lines
16 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../community/community.dart';
import '../community/community_provider.dart';
import '../relay/relay_provider.dart';
import '../relay/relay_session.dart';
import '../relay/signed_event_relay.dart';
import 'dev_push_lease.dart';
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);
@visibleForTesting
class BuzzPushAttemptGate {
BuzzPushAttemptGate({this.retryDelay = _pushBootstrapRetryDelay});
final Duration retryDelay;
String? _attempt;
Timer? _retryTimer;
bool tryBegin(String attempt) {
if (_attempt == attempt) return false;
_retryTimer?.cancel();
_retryTimer = null;
_attempt = attempt;
return true;
}
void failed(String attempt, {required VoidCallback retry}) {
if (_attempt != attempt) return;
_retryTimer?.cancel();
_retryTimer = Timer(retryDelay, () {
_retryTimer = null;
if (_attempt != attempt) return;
_attempt = null;
retry();
});
}
void retryAfter(
String attempt, {
required Duration delay,
required VoidCallback retry,
}) {
if (_attempt != attempt) return;
_retryTimer?.cancel();
_retryTimer = Timer(delay, () {
_retryTimer = null;
if (_attempt != attempt) return;
_attempt = null;
retry();
});
}
void complete(String attempt) {
if (_attempt != attempt) return;
_retryTimer?.cancel();
_retryTimer = null;
_attempt = null;
}
void dispose() => _retryTimer?.cancel();
}
@visibleForTesting
String buzzPushPublicationAttemptKey({
required String communityId,
required String relayBaseUrl,
required String token,
required BuzzPushLeaseDescriptor descriptor,
required List<BuzzPushSubscription> subscriptions,
}) => [
communityId,
relayBaseUrl,
token,
descriptor.executorKeyId,
descriptor.executorPubkey,
buzzPushSubscriptionsFingerprint(subscriptions),
].join('|');
@visibleForTesting
bool buzzPushLifecycleEnabled({
required Community? community,
required BuzzPushLeaseDescriptor? descriptor,
}) =>
Env.pushGatewayConfigured &&
community?.pushNotificationsEnabled == true &&
descriptor != null;
/// Starts APNs registration when the active community opts in.
@visibleForTesting
class BuzzPushRegistrationBootstrap extends HookWidget {
const BuzzPushRegistrationBootstrap({
required this.shouldRegister,
required this.attemptKey,
required this.child,
this.startRegistration = startBuzzPushRegistration,
super.key,
});
final bool shouldRegister;
final String attemptKey;
final Widget child;
final Future<void> Function() startRegistration;
@override
Widget build(BuildContext context) {
final attemptGate = useMemoized(BuzzPushAttemptGate.new);
final retry = useState(0);
useEffect(() => attemptGate.dispose, const []);
useEffect(() {
if (!shouldRegister || !attemptGate.tryBegin(attemptKey)) return null;
unawaited(() async {
try {
await startRegistration();
} catch (error, stack) {
attemptGate.failed(
attemptKey,
retry: () {
if (context.mounted) retry.value += 1;
},
);
debugPrint('Push registration bootstrap failed: $error');
debugPrintStack(stackTrace: stack);
}
}());
return null;
}, [shouldRegister, attemptKey, retry.value]);
return child;
}
}
@visibleForTesting
Future<int> publishBuzzPushLeaseRecoverably({
required Future<int> Function() reserveGeneration,
required Future<void> Function(int generation) publish,
required Future<bool> Function(int generation) markAccepted,
}) async {
final generation = await reserveGeneration();
await publish(generation);
if (!await markAccepted(generation)) {
throw StateError('A newer push lease superseded the published generation.');
}
return generation;
}
typedef _PushInputs = ({
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 {
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);
final publicationAttempt = useMemoized(BuzzPushAttemptGate.new);
final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new);
final publicationRetry = useState(0);
final tombstoneRetry = useState(0);
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,
// and subscriptions match the lease replaced by the opt-out tombstone.
// A distinct key also fences renewal callbacks from the prior opt-in.
if (community?.pushNotificationsEnabled == true) {
enablementEpoch.value += 1;
}
return null;
}, [community?.id, community?.pushNotificationsEnabled]);
useEffect(() {
final listener = AppLifecycleListener(
onResume: () => _runRevocationOutbox(revocationOutbox.trigger),
);
_runRevocationOutbox(revocationOutbox.start);
return listener.dispose;
}, [revocationOutbox]);
useEffect(() {
if (session.status == SessionStatus.connected) {
_runRevocationOutbox(revocationOutbox.trigger);
}
return null;
}, [revocationOutbox, session.status]);
useEffect(
() => () {
publicationAttempt.dispose();
tombstoneAttempt.dispose();
},
const [],
);
useEffect(
() {
final pendingCommunities = communities
.where(
(candidate) =>
!candidate.pushNotificationsEnabled &&
candidate.pushSubscriptionState.pendingTombstoneGeneration !=
null,
)
.toList();
if (session.status != SessionStatus.connected ||
pendingCommunities.isEmpty) {
return null;
}
const attempt = 'pending-tombstones';
if (!tombstoneAttempt.tryBegin(attempt)) return null;
unawaited(() async {
try {
Object? firstError;
StackTrace? firstStack;
for (final pendingCommunity in pendingCommunities) {
try {
await ref
.read(communityListProvider.notifier)
.retryPendingPushLeaseTombstone(
pendingCommunity.id,
advanceGeneration: true,
);
} catch (error, stack) {
firstError ??= error;
firstStack ??= stack;
}
}
if (firstError != null) {
Error.throwWithStackTrace(firstError, firstStack!);
}
tombstoneAttempt.complete(attempt);
} catch (error, stack) {
tombstoneAttempt.failed(
attempt,
retry: () {
if (context.mounted) tombstoneRetry.value += 1;
},
);
debugPrint('Push lease tombstone retry failed: $error');
debugPrintStack(stackTrace: stack);
}
}());
return null;
},
[
session.status,
for (final candidate in communities)
'${candidate.id}|${candidate.pushNotificationsEnabled}|'
'${candidate.pushSubscriptionState.pendingTombstoneGeneration}',
tombstoneRetry.value,
],
);
final token = apnsDeviceToken.value;
useEffect(
() {
if (!_ready(session, config, community, memberPubkey) ||
!buzzPushLifecycleEnabled(
community: community,
descriptor: descriptor,
) ||
token == null) {
return null;
}
final activeCommunity = community!;
final activeDescriptor = descriptor!;
final state = activeCommunity.pushSubscriptionState;
if (state.desired.isEmpty) return null;
final publicationKey = buzzPushPublicationAttemptKey(
communityId: activeCommunity.id,
relayBaseUrl: config.baseUrl,
token: token,
descriptor: activeDescriptor,
subscriptions: state.desired,
);
final attempt = '${enablementEpoch.value}|$publicationKey';
if (!publicationAttempt.tryBegin(attempt)) return null;
final relay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: config.nsec!,
);
unawaited(() async {
try {
final grant = await _publish(
ref,
config,
activeCommunity,
memberPubkey!,
relay,
);
final renewInMilliseconds =
grant.expiresAt * 1000 -
DateTime.now().millisecondsSinceEpoch -
const Duration(minutes: 5).inMilliseconds;
publicationAttempt.retryAfter(
attempt,
delay: Duration(
milliseconds: renewInMilliseconds > 1000
? renewInMilliseconds
: 1000,
),
retry: () {
if (context.mounted) publicationRetry.value += 1;
},
);
} catch (error, stack) {
publicationAttempt.failed(
attempt,
retry: () {
if (context.mounted) publicationRetry.value += 1;
},
);
debugPrint('Push lease bootstrap failed: $error');
debugPrintStack(stackTrace: stack);
}
}());
return null;
},
[
session.status,
config.baseUrl,
community?.id,
community?.pushNotificationsEnabled,
community?.pushSubscriptionState,
memberPubkey,
descriptor,
token,
publicationRetry.value,
],
);
return BuzzPushRegistrationBootstrap(
shouldRegister:
_ready(session, config, community, memberPubkey) &&
buzzPushLifecycleEnabled(
community: community,
descriptor: descriptor,
),
attemptKey: '${community?.id}|${config.baseUrl}',
child: const SizedBox.shrink(),
);
}
static bool _ready(
SessionState session,
RelayConfig config,
Community? community,
String? memberPubkey,
) =>
session.status == SessionStatus.connected &&
community != null &&
config.nsec != null &&
config.nsec!.isNotEmpty &&
memberPubkey != null &&
memberPubkey.isNotEmpty;
static Future<BuzzPushEndpointGrant> _publish(
WidgetRef ref,
RelayConfig config,
Community community,
String memberPubkey,
SignedEventRelay relay,
) async {
final state = community.pushSubscriptionState;
final desired = state.desired;
final descriptor = await ref.read(buzzPushDescriptorFetcherProvider)(
config.baseUrl,
);
final grant = await enrollBuzzPush(
config.wsUrl,
Env.pushGatewayUrl,
communitiesForSnapshotRefresh:
ref.read(communityListProvider).value ?? [community],
);
// Relay lease replacement and gateway delegation are independent state
// machines. Subscription changes advance only the kind-30350 generation;
// the opaque grant remains reusable until its own authority changes.
final notifier = ref.read(communityListProvider.notifier);
await publishBuzzPushLeaseRecoverably(
reserveGeneration: () =>
notifier.reservePushLeaseGeneration(community.id),
publish: (leaseGeneration) => publishBuzzDevPushLeaseThroughRelay(
grant: grant,
leaseInstallationId: community.pushLeaseInstallationId,
leaseGeneration: leaseGeneration,
descriptor: descriptor,
nsec: config.nsec!,
memberPubkey: memberPubkey,
subscriptions: desired,
relay: relay,
),
markAccepted: (leaseGeneration) => notifier.markPushLeaseAccepted(
community.id,
subscriptions: desired,
generation: leaseGeneration,
),
);
return grant;
}
}
void _runRevocationOutbox(Future<void> Function() operation) {
unawaited(
operation().catchError((Object error, StackTrace stackTrace) {
reportPushLeaseCleanupError(error, stackTrace);
}),
);
}