Files
buzz/mobile/test/shared/push/push_root_isolation_test.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

494 lines
17 KiB
Dart

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