mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
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:
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
13
mobile/lib/shared/push/push_setup_recovery.dart
Normal file
13
mobile/lib/shared/push/push_setup_recovery.dart
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
493
mobile/test/shared/push/push_root_isolation_test.dart
Normal file
493
mobile/test/shared/push/push_root_isolation_test.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user