diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index 34b46a4cd0..23ba05a22f 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -24,32 +24,44 @@ class _NotificationsSection extends ConsumerWidget { } final canToggle = hasCapability || community.pushNotificationsEnabled; + void setEnabled(bool enabled) { + unawaited( + ref + .read(communityListProvider.notifier) + .setPushNotificationsEnabled(community.id, enabled), + ); + } + return AppListCard( verticalPadding: Grid.twelve, children: [ - AppListRow( - key: const ValueKey('push-notifications-enabled'), - title: 'Notifications', - trailing: Switch.adaptive( - value: community.pushNotificationsEnabled, - onChanged: !canToggle - ? null - : (enabled) => unawaited( - ref - .read(communityListProvider.notifier) - .setPushNotificationsEnabled(community.id, enabled), + MergeSemantics( + key: const ValueKey('push-notifications-setting'), + child: Stack( + alignment: Alignment.centerRight, + children: [ + AppListRow( + key: const ValueKey('push-notifications-enabled'), + title: 'Notifications', + // Reserve horizontal room without letting the switch's 48dp + // layout height expand this single-line row. + trailing: const SizedBox(width: 60), + onTap: canToggle + ? () => setEnabled(!community.pushNotificationsEnabled) + : null, + ), + Positioned.fill( + right: Grid.xs, + child: Align( + alignment: Alignment.centerRight, + child: Switch.adaptive( + value: community.pushNotificationsEnabled, + onChanged: canToggle ? setEnabled : null, ), - ), - onTap: !canToggle - ? null - : () => unawaited( - ref - .read(communityListProvider.notifier) - .setPushNotificationsEnabled( - community.id, - !community.pushNotificationsEnabled, - ), ), + ), + ], + ), ), ], ); diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 847668fefe..49b05859ec 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -35,11 +35,12 @@ class BuzzPushAttemptGate { void failed(String attempt, {required VoidCallback retry}) { if (_attempt != attempt) return; - _attempt = null; _retryTimer?.cancel(); _retryTimer = Timer(retryDelay, () { _retryTimer = null; - if (_attempt == null) retry(); + if (_attempt != attempt) return; + _attempt = null; + retry(); }); } @@ -171,6 +172,16 @@ class BuzzPushBootstrap extends HookConsumerWidget { final community = ref.watch(activeCommunityProvider).value; final memberPubkey = ref.watch(myPubkeyProvider); final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; + final enablementEpoch = useRef(0); + useEffect(() { + // Each opt-in needs a fresh active lease even when endpoint, executor, + // and subscriptions match the lease replaced by the opt-out tombstone. + // A distinct key also fences renewal callbacks from the prior opt-in. + if (community?.pushNotificationsEnabled == true) { + enablementEpoch.value += 1; + } + return null; + }, [community?.id, community?.pushNotificationsEnabled]); useEffect(() { final listener = AppLifecycleListener( @@ -269,13 +280,14 @@ class BuzzPushBootstrap extends HookConsumerWidget { final activeDescriptor = descriptor!; final state = activeCommunity.pushSubscriptionState; if (state.desired.isEmpty) return null; - final attempt = buzzPushPublicationAttemptKey( + final publicationKey = buzzPushPublicationAttemptKey( communityId: activeCommunity.id, relayBaseUrl: config.baseUrl, token: token, descriptor: activeDescriptor, subscriptions: state.desired, ); + final attempt = '${enablementEpoch.value}|$publicationKey'; if (!publicationAttempt.tryBegin(attempt)) return null; final relay = SignedEventRelay( session: ref.read(relaySessionProvider.notifier), @@ -365,7 +377,9 @@ class BuzzPushBootstrap extends HookConsumerWidget { ) async { final state = community.pushSubscriptionState; final desired = state.desired; - final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); + final descriptor = await ref.read(buzzPushDescriptorFetcherProvider)( + config.baseUrl, + ); final grant = await enrollBuzzPush( config.wsUrl, Env.pushGatewayUrl, diff --git a/mobile/lib/shared/push/push_relay_capability_provider.dart b/mobile/lib/shared/push/push_relay_capability_provider.dart index bcfaf06fa4..6688797993 100644 --- a/mobile/lib/shared/push/push_relay_capability_provider.dart +++ b/mobile/lib/shared/push/push_relay_capability_provider.dart @@ -22,12 +22,19 @@ final buzzPushDescriptorFetcherProvider = Provider( final currentRelayPushDescriptorProvider = FutureProvider.autoDispose((ref) async { if (!Env.pushGatewayConfigured) return null; - final session = ref.watch(relaySessionProvider); + final sessionStatus = ref.watch( + relaySessionProvider.select((session) => session.status), + ); final config = ref.watch(relayConfigProvider); - final community = ref.watch(activeCommunityProvider).value; + // Preference and lease bookkeeping do not change relay capability. + // Watching the whole community rediscovered NIP-11 after every toggle, + // disabling the off switch when that redundant request could not finish. + final communityId = ref.watch( + activeCommunityProvider.select((value) => value.value?.id), + ); final memberPubkey = ref.watch(myPubkeyProvider); - if (session.status != SessionStatus.connected || - community == null || + if (sessionStatus != SessionStatus.connected || + communityId == null || config.nsec == null || config.nsec!.isEmpty || memberPubkey == null || diff --git a/mobile/test/features/settings/notification_toggle_test.dart b/mobile/test/features/settings/notification_toggle_test.dart new file mode 100644 index 0000000000..39e46ad9e8 --- /dev/null +++ b/mobile/test/features/settings/notification_toggle_test.dart @@ -0,0 +1,343 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bootstrap.dart'; +import 'package:buzz/shared/push/push_lease_revocation_outbox.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:buzz/shared/relay/signed_event_relay.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/push_relay_capability_provider.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../shared/community/community_storage_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + final fonts = FontLoader('Inter') + ..addFont(rootBundle.load('assets/fonts/InterVariable.ttf')); + await fonts.load(); + }); + for (final outcome in ['accepted', 'failed', 'pending', 'scaled']) { + testWidgets('off then on remains usable with $outcome cleanup', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + // iPhone 12 mini width; use a wider screen for the 2x single-line case. + tester.view.physicalSize = Size(outcome == 'scaled' ? 430 : 375, 1800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + if (outcome == 'scaled') { + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + } + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + PackageInfo.setMockInitialValues( + appName: 'Buzz', + packageName: 'buzz', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = CommunityStorage(secure: FakeSecureStorage()); + final subscriptions = [ + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ), + ]; + var discoveries = 0; + final session = _Connected(); + apnsDeviceToken.value = 'test-apns-token'; + addTearDown(() => apnsDeviceToken.value = null); + final grant = { + 'relayOrigin': 'wss://relay.example', + 'relayPubkey': _descriptor.executorPubkey, + 'installationId': 'b' * 32, + 'endpointGrant': 'test-endpoint-grant', + 'endpointHash': 'c' * 64, + 'appProfile': buzzDevPushAppProfile, + 'endpointEpoch': 1, + 'generation': 1, + 'expiresAt': DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, + }; + const nativePush = MethodChannel('buzz/push'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + nativePush, + (call) async => switch (call.method) { + 'enrollPush' => grant, + 'endpointGrants' => [grant], + 'startRegistration' || 'syncPushSnapshot' => null, + _ => throw StateError('Unexpected native call ${call.method}'), + }, + ); + addTearDown(() => messenger.setMockMethodCallHandler(nativePush, null)); + final community = + Community.create( + name: 'Team', + relayUrl: 'https://relay.example', + ).copyWith( + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '1' * 64, + ), + pushNotificationsEnabled: true, + pushLeaseInstallationId: 'd' * 32, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ).withAccepted(subscriptions: subscriptions, generation: 7), + ); + await storage.save(community); + await storage.saveActiveId(community.id); + + final cleanup = Completer(); + final showingSettings = ValueNotifier(true); + addTearDown(showingSettings.dispose); + final snapshots = >[]; + final tombstones = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + authProvider.overrideWith(_Auth.new), + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + communityPushLeaseDeactivatorProvider.overrideWithValue(( + community, { + generation, + }) async { + tombstones.add(generation); + if (outcome == 'failed') throw StateError('relay unavailable'); + if (outcome == 'pending') await cleanup.future; + await publishBuzzPushLeaseTombstone( + descriptor: _descriptor, + installationId: community.pushLeaseInstallationId!, + generation: generation!, + nsec: community.nsec!, + memberPubkey: pubkeyFromNsec(community.nsec)!, + submit: SignedEventRelay( + session: session, + nsec: community.nsec, + ).submit, + ); + }), + buzzPushLeaseRevocationStorageProvider.overrideWithValue( + BuzzPushLeaseRevocationStorage(secure: FakeSecureStorage()), + ), + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(nostr.Keys('1' * 64).public), + buzzPushDescriptorFetcherProvider.overrideWithValue((_) async { + discoveries++; + return _descriptor; + }), + appLifecycleProvider.overrideWith(_Lifecycle.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => BuzzPushAuthorizationStatus.denied, + ), + ], + child: BuzzPushBootstrap( + child: MaterialApp( + theme: AppTheme.light(), + home: ValueListenableBuilder( + valueListenable: showingSettings, + builder: (_, showing, _) => showing + ? SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => + const SizedBox.shrink(), + ) + : const SizedBox.shrink(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(SettingsPage)), + ); + expect( + container.read(activeCommunityProvider).value?.pushNotificationsEnabled, + isTrue, + reason: + '${container.read(activeCommunityProvider)} / ${container.read(communityListProvider)}', + ); + expect(session.leases.single['active'], isTrue); + // Capability discovery plus the active publication. + expect(discoveries, 2); + expect(tester.widget(find.byType(Switch)).value, isTrue); + expect( + tester.getSemantics( + find.byKey(const ValueKey('push-notifications-setting')), + ), + isSemantics( + label: 'Notifications', + hasToggledState: true, + isToggled: true, + ), + ); + expect( + tester.getSize(find.byType(Switch)).height, + greaterThanOrEqualTo(48), + ); + // Compare rendered row bounds, not just their padding parameters. + final notificationHeight = tester + .getSize(find.byKey(const ValueKey('push-notifications-enabled'))) + .height; + final statusHeight = tester + .getSize(find.byKey(const ValueKey('settings-set-status'))) + .height; + expect( + notificationHeight, + statusHeight, + reason: 'single-line settings rows share a height', + ); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + final stored = (await storage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect( + stored.pushSubscriptionState.pendingTombstoneGeneration, + outcome == 'failed' || outcome == 'pending' + ? greaterThanOrEqualTo(9) + : isNull, + ); + expect(tombstones.first, 9); + expect( + tombstones.length, + lessThanOrEqualTo(2), + reason: 'failed cleanup must not defeat the retry backoff', + ); + expect(discoveries, 2); + expect(snapshots.last.single.pushNotificationsEnabled, isFalse); + final offSwitch = tester.widget(find.byType(Switch)); + expect(offSwitch.value, isFalse); + expect(offSwitch.onChanged, isNotNull); + // Closing and reopening Settings must not strand the off control either. + showingSettings.value = false; + await tester.pumpAndSettle(); + showingSettings.value = true; + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Switch)).onChanged, isNotNull); + await tester.tap(find.text('Notifications')); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Switch)).value, isTrue); + expect((await storage.loadAll()).single.pushNotificationsEnabled, isTrue); + expect( + session.leases.where((lease) => lease['active'] == true).length, + 2, + reason: + 're-enabling must publish an active lease before the renewal timer', + ); + expect(session.leases.last['generation'], greaterThan(tombstones.last!)); + if (outcome == 'pending') { + cleanup.complete(); + await tester.pumpAndSettle(); + expect( + (await storage.loadAll()).single.pushNotificationsEnabled, + isTrue, + ); + } + final latestLease = session.leases.reduce( + (a, b) => (a['generation'] as int) > (b['generation'] as int) ? a : b, + ); + expect( + latestLease['active'], + isTrue, + reason: 'a delayed old tombstone cannot supersede the new active lease', + ); + expect(discoveries, 3); + // A real reconnect must still discover capability again. + (container.read(relaySessionProvider.notifier) as _Connected).setStatus( + SessionStatus.disconnected, + ); + await tester.pumpAndSettle(); + expect(container.read(currentRelayPushDescriptorProvider).value, isNull); + (container.read(relaySessionProvider.notifier) as _Connected).setStatus( + SessionStatus.connected, + ); + await tester.pumpAndSettle(); + expect(discoveries, 4); + expect(tester.widget(find.byType(Switch)).onChanged, isNotNull); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + semantics.dispose(); + debugDefaultTargetPlatformOverride = null; + }); + } +} + +class _Lifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} + +class _Auth extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); +} + +class _Connected extends RelaySessionNotifier { + final leases = >[]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(event.kind, buzzPushLeaseKind); + final plaintext = nip44Decrypt( + getConversationKey('2' * 64, event.pubkey), + event.content, + ); + leases.add(jsonDecode(plaintext) as Map); + return event; + } + + void setStatus(SessionStatus status) => state = SessionState(status: status); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); +} + +final _descriptor = BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: 'relay-v1', + executorPubkey: nostr.Keys('2' * 64).public, + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 2a3752e9f8..6770ad546e 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -53,6 +53,23 @@ void main() { expect(gate.tryBegin('attempt'), isTrue); }); + testWidgets('a failed attempt stays blocked until the retry delay', ( + tester, + ) async { + final gate = BuzzPushAttemptGate(); + addTearDown(gate.dispose); + var retries = 0; + expect(gate.tryBegin('attempt'), isTrue); + gate.failed('attempt', retry: () => retries++); + expect(gate.tryBegin('attempt'), isFalse); + await tester.pump(const Duration(seconds: 4)); + expect(gate.tryBegin('attempt'), isFalse); + expect(retries, 0); + await tester.pump(const Duration(seconds: 1)); + expect(retries, 1); + expect(gate.tryBegin('attempt'), isTrue); + }); + test('a new attempt cancels an obsolete scheduled retry', () async { final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); addTearDown(gate.dispose);