Files
buzz/mobile/lib/app.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

471 lines
16 KiB
Dart

import 'dart:async';
import 'hosted_app_gate.dart';
import 'onboarding_app_entry.dart';
import 'shared/widgets/ios_navigation_metrics.dart';
import 'shared/community/paired_community_landing.dart';
import 'shared/community/community_loading_surface.dart';
import 'package:app_badge_plus/app_badge_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:uuid/uuid.dart';
import 'features/age_gate/age_restriction_page.dart';
import 'features/age_gate/age_signal_provider.dart';
import 'features/activity/activity_provider.dart';
import 'features/activity/inbox_local_state_provider.dart';
import 'features/activity/inbox_read_state.dart';
import 'features/channels/channel.dart';
import 'features/channels/channel_management_provider.dart';
import 'features/channels/channels_provider.dart';
import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
import 'features/channels/me/me_page.dart';
import 'features/channels/me/me_unread_provider.dart';
import 'features/invites/invite_create_page.dart';
import 'features/invites/invite_join_provider.dart';
import 'features/pairing/pairing_page.dart';
import 'features/pairing/pairing_provider.dart';
import 'features/channels/agent_activity/observer_subscription.dart';
import 'features/channels/channel_detail_page.dart';
import 'shared/widgets/app_route_observer.dart';
import 'features/profile/user_status_cache_provider.dart';
import 'features/profile/settings_profile_header.dart';
import 'features/profile/set_status_sheet.dart';
import 'features/profile/user_status_provider.dart';
import 'features/profile/profile_edit_page.dart';
import 'features/profile/profile_text_editor.dart';
import 'features/settings/settings_page.dart';
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/relay/relay.dart';
import 'shared/read_state/read_state_provider.dart';
import 'shared/theme/theme.dart';
import 'shared/widgets/buzz_loading_indicator.dart';
const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0';
const _starterChannels = [
(slug: 'general', description: 'General conversation and community updates.'),
(
slug: 'welcome-everyone',
description: 'Say hi, ask a question, or share what brought you here.',
),
];
final _inviteRelayConnectedProvider = FutureProvider.family<void, String>((
ref,
expectedRelayUrl,
) async {
final currentConfig = ref.read(relayConfigProvider);
if (currentConfig.baseUrl != expectedRelayUrl) {
throw StateError('Active community changed before invite recovery');
}
if (ref.read(relaySessionProvider).status == SessionStatus.connected) return;
final connected = Completer<void>();
ref.listen(relaySessionProvider, (_, next) {
if (connected.isCompleted) return;
if (ref.read(relayConfigProvider).baseUrl != expectedRelayUrl) {
connected.completeError(
StateError('Active community changed during invite recovery'),
);
} else if (next.status == SessionStatus.connected) {
connected.complete();
}
});
await connected.future;
});
/// App-level bridge from invite joining to the channels feature.
class MobileInviteJoinRecovery implements InviteJoinRecovery {
final Future<List<Channel>> Function() _loadChannels;
final Future<Channel> Function({
required String channelId,
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
})
_createChannel;
final Future<void> Function(String channelId) _joinChannel;
final String _relayHttpOrigin;
final bool Function() _isScopeCurrent;
/// Creates recovery from channel-loading, creation, and join operations.
MobileInviteJoinRecovery({
required Future<List<Channel>> Function() loadChannels,
required Future<Channel> Function({
required String channelId,
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
})
createChannel,
required Future<void> Function(String channelId) joinChannel,
required String relayHttpOrigin,
bool Function()? isScopeCurrent,
}) : _loadChannels = loadChannels,
_createChannel = createChannel,
_joinChannel = joinChannel,
_relayHttpOrigin = relayHttpOrigin,
_isScopeCurrent = isScopeCurrent ?? _alwaysCurrent;
/// Ensures memberships in the same public starter channels as desktop.
///
/// All starter work is fenced to the community and identity that started it.
/// A partial setup remains recoverable, so a failed operation deliberately
/// propagates to the invite flow instead of being reported as success.
@override
Future<String?> ensureStarterChannels() async {
_ensureScopeCurrent();
var channels = await _loadChannels();
_ensureScopeCurrent();
String? welcomeEveryoneId;
for (final starter in _starterChannels) {
_ensureScopeCurrent();
var channel = _findStarterChannel(channels, starter.slug);
if (channel == null) {
final channelId = desktopStarterChannelId(
relayHttpOrigin: _relayHttpOrigin,
slug: starter.slug,
);
try {
channel = await _createChannel(
channelId: channelId,
name: starter.slug,
channelType: 'stream',
visibility: 'open',
description: starter.description,
);
_ensureScopeCurrent();
} catch (error) {
if (!_isDuplicateChannelError(error)) rethrow;
_ensureScopeCurrent();
channels = await _loadChannels();
_ensureScopeCurrent();
channel =
_findStarterChannel(channels, starter.slug) ??
channels
.where((candidate) => candidate.id == channelId)
.firstOrNull;
if (channel == null) rethrow;
}
}
_ensureScopeCurrent();
if (!channel.isMember) {
await _joinChannel(channel.id);
_ensureScopeCurrent();
}
if (starter.slug == 'welcome-everyone') {
welcomeEveryoneId = channel.id;
}
}
return welcomeEveryoneId;
}
static bool _alwaysCurrent() => true;
void _ensureScopeCurrent() {
if (!_isScopeCurrent()) {
throw StateError('Active community changed during invite recovery');
}
}
}
Channel? _findStarterChannel(List<Channel> channels, String name) {
final normalizedName = name.trim().toLowerCase();
return channels
.where(
(channel) =>
channel.name.trim().toLowerCase() == normalizedName &&
channel.isStream &&
channel.visibility == 'open' &&
!channel.isArchived,
)
.firstOrNull;
}
bool _isDuplicateChannelError(Object error) =>
error.toString().contains('duplicate: channel already exists');
/// Returns desktop's deterministic per-relay UUID for a public starter.
@visibleForTesting
String desktopStarterChannelId({
required String relayHttpOrigin,
required String slug,
}) {
final scope = relayHttpOrigin.trim().replaceFirst(RegExp(r'/+$'), '');
return const Uuid().v5(
_starterChannelNamespace,
'starter-channel:v1:$scope:$slug',
);
}
/// Builds one fresh, identity-scoped invite recovery against the app container.
InviteJoinRecovery buildMobileInviteJoinRecovery(
Ref ref,
InviteJoinRecoveryScope scope,
) {
final expectedConfig = ref.read(relayConfigProvider);
if (expectedConfig.baseUrl != scope.relayHttpOrigin ||
expectedConfig.nsec != scope.nsec) {
throw StateError('Active community changed before invite recovery');
}
final channelActions = ref.read(channelActionsProvider);
bool isScopeCurrent() {
final currentConfig = ref.read(relayConfigProvider);
return currentConfig.baseUrl == scope.relayHttpOrigin &&
currentConfig.nsec == scope.nsec;
}
void ensureScopeCurrent() {
if (!isScopeCurrent()) {
throw StateError('Active community changed during invite recovery');
}
}
return MobileInviteJoinRecovery(
loadChannels: () async {
ensureScopeCurrent();
await ref.read(activeCommunityProvider.future);
ensureScopeCurrent();
await ref
.read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future)
.timeout(const Duration(seconds: 15));
ensureScopeCurrent();
final channels = await ref
.read(channelsProvider.notifier)
.fetchFresh(fetchDirectory: true);
ensureScopeCurrent();
return channels;
},
createChannel:
({
required channelId,
required name,
required channelType,
required visibility,
description,
ttlSeconds,
}) => channelActions.createChannel(
channelId: channelId,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
ttlSeconds: ttlSeconds,
),
joinChannel: channelActions.joinChannel,
relayHttpOrigin: scope.relayHttpOrigin,
isScopeCurrent: isScopeCurrent,
);
}
/// App-shell projection that joins Activity state for the Home navigation.
///
/// This belongs at the composition root because it deliberately aggregates
/// Activity feature providers for a sibling navigation surface.
final _unreadInboxItemCountProvider = Provider<int>((ref) {
final readState = ref.watch(readStateProvider);
if (!readState.isReady) return 0;
final localState = ref.watch(inboxLocalStateProvider);
final items = ref.watch(inboxItemsProvider);
return items
.where(
(item) => !isInboxItemDone(
item,
markerOf: readState.effectiveTimestamp,
localUnreadOverrides: localState.unreadIds,
localDoneSet: localState.doneIds,
),
)
.length;
});
final _mobileRootNavigatorKey = GlobalKey<NavigatorState>();
class App extends HookConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ageSignalState = ref.watch(ageSignalProvider);
ref.listen(ageSignalProvider, (_, next) {
if (next == AgeSignalState.restricted) {
ref.read(pairingProvider.notifier).reset();
}
});
final communityTheme = ageSignalState != AgeSignalState.restricted
? ref.watch(communityThemeProvider)
: defaultCommunityTheme;
final themeMode = communityTheme.mode;
final authState = ref.watch(authProvider);
final pairedCommunity = ref.watch(pairedCommunityLandingProvider);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(ref.read(ageSignalProvider.notifier).request());
});
return null;
}, const []);
// Eagerly initialize websocket session and lifecycle observer when
// authenticated. These providers connect and manage the websocket.
var hasUnreadInbox = false;
var hasUnreadSolo = false;
if (ageSignalState != AgeSignalState.restricted &&
authState.value?.status == AuthStatus.authenticated) {
ref.watch(relaySessionProvider);
// Listen, not watch: these publish often, and an App rebuild rebuilds
// every route, including an open chat.
ref.listen(observerRelayProvider, (_, _) {});
ref.watch(appLifecycleProvider);
ref.listen(userStatusCacheProvider, (_, _) {});
hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0;
hasUnreadSolo = ref.watch(
meUnreadChannelIdsProvider.select((ids) => ids.isNotEmpty),
);
}
// Start listening for buzz:// links immediately (even pre-auth) so a
// cold-start link survives until the authenticated UI can dispatch it.
ref.watch(pendingDeepLinkProvider);
void applyBadge(UnreadBadgeState state) {
if (state.highPriorityCount > 0) {
AppBadgePlus.updateBadge(state.highPriorityCount);
} else if (state.generalUnreadCount > 0) {
AppBadgePlus.updateBadge(1);
} else {
AppBadgePlus.updateBadge(0);
}
}
useEffect(() {
if (ageSignalState != AgeSignalState.restricted) {
applyBadge(ref.read(unreadBadgeProvider));
} else {
AppBadgePlus.updateBadge(0);
}
return null;
}, [ageSignalState]);
if (ageSignalState != AgeSignalState.restricted) {
ref.listen<UnreadBadgeState>(unreadBadgeProvider, (_, next) {
applyBadge(next);
});
}
return MaterialApp(
navigatorKey: _mobileRootNavigatorKey,
navigatorObservers: [appRouteObserver],
title: 'Buzz',
theme: mobileAppearanceTheme(Brightness.light, communityTheme.accent),
darkTheme: mobileAppearanceTheme(Brightness.dark, communityTheme.accent),
themeMode: themeMode,
// Above the navigator, so an age restriction cannot be bypassed by a
// route that was pushed while the store signal request was in flight.
builder: (context, child) => switch (ageSignalState) {
AgeSignalState.restricted => const AgeRestrictionPage(),
_ => IosNavigationMetricsHost(
child: AppMarkdownTheme(
child: MobileHuddleShell(
navigatorKey: _mobileRootNavigatorKey,
child: EmojiBurstOverlay(
child: Stack(
fit: StackFit.expand,
children: [
ExcludeSemantics(
excluding: pairedCommunity != null,
child: AbsorbPointer(
absorbing: pairedCommunity != null,
child: child ?? const SizedBox.shrink(),
),
),
// Cover the navigator while onboarding is removed and the
// destination installs the matching avatar-flight route.
if (pairedCommunity != null)
CommunityLoadingSurface(
name: pairedCommunity.name,
relayUrl: pairedCommunity.relayUrl,
),
],
),
),
),
),
),
},
home: authState.when(
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
data: (state) => switch (state.status) {
AuthStatus.authenticated => HostedAppGate(
authenticated: true,
child: HomePage(
mePageBuilder: (_, action) => MePage(newChatAction: action),
settingsPageBuilder: _buildSettingsPage,
communityInvitePageBuilder: (_) => const CommunityInvitePage(),
communityAppearancePageBuilder: (_) => const ThemePickerPage(),
hasUnreadInbox: hasUnreadInbox,
hasUnreadSolo: hasUnreadSolo,
),
),
_ => const HostedAppGate(
authenticated: false,
child: OnboardingAppEntry(),
),
},
),
);
}
}
Widget _buildSettingsPage(BuildContext context) => const _SettingsPageContent();
class _SettingsPageContent extends ConsumerWidget {
const _SettingsPageContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
return SettingsPage(
profileHeader: const SettingsProfileHeader(),
profileEditPageBuilder: (_) =>
const ProfileEditPage(startInPhotoEditor: true),
onSetStatus: (context) => showSetStatusSheet(
context,
currentStatus: ref.read(userStatusProvider).asData?.value,
),
onEditDisplayName: showProfileDisplayNameEditor,
onEditProfileDescription: showProfileDescriptionEditor,
identityRecoveryPageBuilder: (_) =>
const PairingPage(addingCommunity: true, identityRecoveryOnly: true),
);
}
}
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: BuzzLoadingIndicator(size: 56, semanticLabel: 'Starting Buzz'),
),
);
}
}