diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 24ac64bdf0..fffecbf769 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -13,6 +13,7 @@ import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/push/push_bridge.dart'; +import '../../shared/push/push_relay_capability_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/utils/string_utils.dart'; import '../pairing/pairing_provider.dart'; diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index bcb140a44f..64f05ede3a 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -10,29 +10,33 @@ class _NotificationsSection extends ConsumerWidget { } final community = ref.watch(activeCommunityProvider).value; if (community == null) return const SizedBox.shrink(); - if (!Env.pushGatewayConfigured) { - return AppListCard( - label: 'Notifications', - verticalPadding: Grid.twelve, - children: [ - AppListRow( - key: const ValueKey('push-notifications-unavailable'), - icon: LucideIcons.bell, - title: 'Push notifications', - subtitle: 'Unavailable in this build', - ), - ], - ); + final capability = ref.watch(currentRelayPushDescriptorProvider); + final hasCapability = + !capability.isLoading && + !capability.hasError && + capability.value != null; + final optOutPending = + community.pushSubscriptionState.pendingTombstoneGeneration != null; + if (!hasCapability && + !community.pushNotificationsEnabled && + !optOutPending) { + return const SizedBox.shrink(); } final authorization = ref.watch(buzzPushAuthorizationStatusProvider); final status = authorization.value; final permissionUnavailable = authorization.hasError; final permissionDenied = status == BuzzPushAuthorizationStatus.denied; final showSettingsRecovery = - community.pushNotificationsEnabled && - (permissionDenied || permissionUnavailable); - final subtitle = !community.pushNotificationsEnabled + optOutPending || + (community.pushNotificationsEnabled && + (permissionDenied || permissionUnavailable)); + final canToggle = hasCapability || community.pushNotificationsEnabled; + final subtitle = optOutPending + ? 'Waiting for relay confirmation; notifications may continue' + : !community.pushNotificationsEnabled ? 'Off for this community' + : !hasCapability + ? 'Push support unavailable; you can still turn notifications off' : switch (status) { BuzzPushAuthorizationStatus.notDetermined => 'Waiting for iOS notification permission', @@ -63,20 +67,24 @@ class _NotificationsSection extends ConsumerWidget { : null, trailing: Switch.adaptive( value: community.pushNotificationsEnabled, - onChanged: (enabled) => unawaited( - ref - .read(communityListProvider.notifier) - .setPushNotificationsEnabled(community.id, enabled), - ), + onChanged: !canToggle + ? null + : (enabled) => unawaited( + ref + .read(communityListProvider.notifier) + .setPushNotificationsEnabled(community.id, enabled), + ), ), - onTap: () => unawaited( - ref - .read(communityListProvider.notifier) - .setPushNotificationsEnabled( - community.id, - !community.pushNotificationsEnabled, + onTap: !canToggle + ? null + : () => unawaited( + ref + .read(communityListProvider.notifier) + .setPushNotificationsEnabled( + community.id, + !community.pushNotificationsEnabled, + ), ), - ), ), if (showSettingsRecovery) AppListRow( diff --git a/mobile/test/features/settings/notification_recovery_test.dart b/mobile/test/features/settings/notification_recovery_test.dart new file mode 100644 index 0000000000..b04cae78d0 --- /dev/null +++ b/mobile/test/features/settings/notification_recovery_test.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +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_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/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.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:shared_preferences/shared_preferences.dart'; + +import '../../shared/community/community_storage_test.dart'; + +void main() { + for (final outcome in ['loading', 'absent', 'error']) { + testWidgets('enabled community can turn push off with $outcome capability', ( + tester, + ) async { + tester.view.physicalSize = const Size(430, 1800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + 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', + ), + ]; + final community = + Community.create( + name: 'Team', + relayUrl: 'https://relay.example', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ).withAccepted(subscriptions: subscriptions, generation: 7), + ); + await storage.save(community); + await storage.saveActiveId(community.id); + final pending = Completer(); + final snapshots = >[]; + final tombstones = []; + var settingsOpened = 0; + var relayAvailable = false; + 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 (!relayAvailable) throw StateError('relay unavailable'); + }), + currentRelayPushDescriptorProvider.overrideWith((ref) { + if (outcome == 'loading') return pending.future; + if (outcome == 'error') throw StateError('discovery failed'); + return Future.value(null); + }), + appLifecycleProvider.overrideWith(_Lifecycle.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => BuzzPushAuthorizationStatus.denied, + ), + buzzPushNotificationSettingsOpenerProvider.overrideWithValue( + () async { + settingsOpened++; + return true; + }, + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => 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(tester.widget(find.byType(Switch)).value, isTrue); + expect( + find.text( + 'Push support unavailable; you can still turn notifications off', + ), + findsOneWidget, + ); + await tester.tap( + find.byKey(const ValueKey('push-notifications-open-settings')), + ); + await tester.pump(); + expect(settingsOpened, 1); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + final stored = (await storage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, 8); + expect(tombstones, [8]); + expect(snapshots.last.single.pushNotificationsEnabled, isFalse); + expect( + find.text('Waiting for relay confirmation; notifications may continue'), + findsOneWidget, + ); + final offSwitch = tester.widget(find.byType(Switch)); + expect(offSwitch.value, isFalse); + expect(offSwitch.onChanged, isNull); + await tester.tap( + find.byKey(const ValueKey('push-notifications-open-settings')), + ); + await tester.pump(); + expect(settingsOpened, 2); + relayAvailable = true; + await container + .read(communityListProvider.notifier) + .retryPendingPushLeaseTombstone(community.id); + await tester.pumpAndSettle(); + expect( + (await storage.loadAll()) + .single + .pushSubscriptionState + .pendingTombstoneGeneration, + isNull, + ); + expect(find.text('Notifications'), findsNothing); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); + } +} + +class _Lifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} + +class _Auth extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); +} diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 1799d7a36d..ad0075a994 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -1,9 +1,13 @@ +import 'dart:async'; + import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/community/community_membership_provider.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_relay_capability_provider.dart'; import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; @@ -76,6 +80,64 @@ void main() { }); } + for (final outcome in ['absent', 'error']) { + testWidgets('hides notifications while capability loads and is $outcome', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final capability = Completer(); + var permissionReads = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith( + (ref) async => Community.create( + name: 'No push', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: false), + ), + currentRelayPushDescriptorProvider.overrideWith( + (ref) => capability.future, + ), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async { + permissionReads += 1; + return BuzzPushAuthorizationStatus.authorized; + }, + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Notifications'), findsNothing); + expect(find.byType(Switch), findsNothing); + if (outcome == 'absent') { + capability.complete(null); + } else { + capability.completeError(StateError('discovery failed')); + } + await tester.pumpAndSettle(); + expect(find.text('Notifications'), findsNothing); + expect(find.text('Push notifications'), findsNothing); + expect(find.byType(Switch), findsNothing); + expect(permissionReads, 0); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); + } + testWidgets('shows the persisted per-community push opt-in on iOS', ( tester, ) async { @@ -88,11 +150,14 @@ void main() { relayUrl: 'wss://relay.example', ).copyWith(pushNotificationsEnabled: true); + final capability = Future.value(_pushDescriptor); + await tester.pumpWidget( ProviderScope( overrides: [ savedPrefsProvider.overrideWithValue(prefs), activeCommunityProvider.overrideWith((ref) async => community), + currentRelayPushDescriptorProvider.overrideWith((ref) => capability), appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), buzzPushAuthorizationStatusReaderProvider.overrideWithValue( () async => BuzzPushAuthorizationStatus.authorized, @@ -136,6 +201,9 @@ void main() { overrides: [ savedPrefsProvider.overrideWithValue(prefs), activeCommunityProvider.overrideWith((ref) async => community), + currentRelayPushDescriptorProvider.overrideWith( + (ref) async => _pushDescriptor, + ), appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), buzzPushAuthorizationStatusReaderProvider.overrideWithValue( () async => BuzzPushAuthorizationStatus.denied, @@ -189,6 +257,9 @@ void main() { overrides: [ savedPrefsProvider.overrideWithValue(prefs), activeCommunityProvider.overrideWith((ref) async => community), + currentRelayPushDescriptorProvider.overrideWith( + (ref) async => _pushDescriptor, + ), appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), buzzPushAuthorizationStatusReaderProvider.overrideWithValue( () async => throw StateError('authorization unavailable'), @@ -541,3 +612,15 @@ class _SettingsLifecycleNotifier extends AppLifecycleNotifier { @override AppLifecycleState build() => AppLifecycleState.resumed; } + +const _pushDescriptor = BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: 'key', + executorPubkey: 'pubkey', + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); diff --git a/mobile/test/shared/push/push_unconfigured_build_test.dart b/mobile/test/shared/push/push_unconfigured_build_test.dart index fc626e50ea..355fc58a6d 100644 --- a/mobile/test/shared/push/push_unconfigured_build_test.dart +++ b/mobile/test/shared/push/push_unconfigured_build_test.dart @@ -73,44 +73,44 @@ void main() { expect(calls, isEmpty); }); - testWidgets( - 'Settings explains push is unavailable without offering opt-in', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - savedPrefsProvider.overrideWithValue(prefs), - activeCommunityProvider.overrideWith( - (ref) async => Community.create( - name: 'Team', - relayUrl: 'wss://relay.example', - ), - ), - buzzPushAuthorizationStatusReaderProvider.overrideWithValue( - () async => - throw StateError('must not query notification permission'), - ), - ], - child: MaterialApp( - theme: AppTheme.light(), - home: SettingsPage( - profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), - identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + testWidgets('Settings hides notifications without push capability', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith( + (ref) async => Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', ), ), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => + throw StateError('must not query notification permission'), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), ), - ); - await tester.pumpAndSettle(); - expect(find.text('Unavailable in this build'), findsOneWidget); - expect(find.byType(Switch), findsNothing); - expect(tester.takeException(), isNull); - debugDefaultTargetPlatformOverride = null; - }, - ); + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Notifications'), findsNothing); + expect(find.text('Push notifications'), findsNothing); + expect(find.byType(Switch), findsNothing); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); }, skip: Env.pushGatewayConfigured); }