Merge remote-tracking branch 'origin/main' into mobile-solo-push

* origin/main:
  fix(mobile): isolate push setup failures from app rendering (#8610)

Signed-off-by: Tom Brow <tomb@block.xyz>

# Conflicts:
#	mobile/lib/shared/push/push_subscription_provider.dart
This commit is contained in:
Tom Brow
2026-10-26 19:48:07 -07:00
10 changed files with 720 additions and 59 deletions

View File

@@ -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),

View File

@@ -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';

View File

@@ -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(

View File

@@ -14,7 +14,6 @@ void main() => runBuzzApp(const App());
Future<void> 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();

View File

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

View File

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

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,51 +1,62 @@
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 '../me/me_sync.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<void>((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<void>((
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;
}
// Legacy Solo chats are listed only in the Solo record. Wait for it (cached
// or fetched) so an unresolved record never drops them from the lease.
final soloRecord = ref.watch(meGroupsProvider).value;
if (soloRecord == null) return;
// Legacy Solo chats are listed only in the Solo record. Wait while it
// loads so an unresolved record never drops them. If it fails without a
// cached copy, keep DMs and mentions current; the Solo tab cannot list
// those chats either, and recovery republishes them.
final soloRecord = ref.watch(meGroupsProvider);
if (soloRecord.isLoading && !soloRecord.hasValue) return;
final subscriptions = desiredBuzzPushSubscriptions(
community: active,
channels: channels,
legacySoloChannelIds: soloRecord.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,
legacySoloChannelIds: soloRecord.value?.channels ?? const [],
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<BuzzPushSubscription>? desiredBuzzPushSubscriptions({
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),
);
}

View File

@@ -3,11 +3,12 @@ import 'dart:async';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_mutes/channel_mutes_provider.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/push/push_subscription.dart';
import 'package:buzz/shared/push/dev_push_lease.dart';
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
import 'package:buzz/shared/push/push_subscription_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -88,7 +89,7 @@ void main() {
expect(subscriptions.last.filter.hTags, [dmID, soloID, legacySoloID]);
});
test('waits for the Solo record before updating the desired lease', () async {
test('waits for the Solo record but not past its failure', () async {
const legacySoloID = '123e4567-e89b-42d3-a456-426614174009';
final community = Community.create(
name: 'Team',
@@ -99,7 +100,12 @@ void main() {
final soloRecord = StreamController<MeGroups>();
addTearDown(soloRecord.close);
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
authProvider.overrideWith(_Authenticated.new),
currentRelayPushDescriptorProvider.overrideWith(
(ref) async => _descriptor,
),
activeCommunityProvider.overrideWith((ref) async => community),
communityListProvider.overrideWith(() => communities),
channelsProvider.overrideWith(
@@ -114,17 +120,25 @@ void main() {
);
addTearDown(container.dispose);
container.listen(pushSubscriptionSyncProvider, (_, _) {});
await container.read(authProvider.future);
await container.read(currentRelayPushDescriptorProvider.future);
await container.read(activeCommunityProvider.future);
await container.read(channelsProvider.future);
await pumpEventQueue();
expect(communities.desired, isEmpty);
soloRecord.add(const MeGroups(channels: [legacySoloID]));
soloRecord.addError(StateError('Solo sync failed'));
await pumpEventQueue();
expect(communities.desired, hasLength(1));
expect(communities.desired.single.last.filter.hTags, [
expect(communities.desired.last.last.filter.hTags, [activeID]);
soloRecord.add(const MeGroups(channels: [legacySoloID]));
await pumpEventQueue();
expect(communities.desired, hasLength(2));
expect(communities.desired.last.last.filter.hTags, [
activeID,
legacySoloID,
]);
@@ -192,3 +206,21 @@ class _ReadyMutes extends ChannelMutesNotifier {
@override
ChannelMutesState build() => const ChannelMutesState(isReady: true);
}
class _Authenticated extends AuthNotifier {
@override
Future<AuthState> build() async =>
const AuthState(status: AuthStatus.authenticated);
}
const _descriptor = BuzzPushLeaseDescriptor(
origin: 'wss://relay.example',
executorKeyId: 'key',
executorPubkey: 'pubkey',
transport: 'apns',
maxLeaseTtlSeconds: 3600,
maxContentLength: 4096,
maxPlaintextLength: 4096,
maxEndpointLength: 2048,
maxStringLength: 512,
);