mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(mobile): hide notification settings without relay push capability (#8025)
Hide the iOS Notifications settings section for communities that have not enabled push unless their relay advertises valid push support. A build with a configured gateway previously showed the switch even when the relay could not support push. Communities where push is already enabled retain an off switch and iOS Settings recovery during discovery or when push support is unavailable. The section explains that support is unavailable instead of claiming notifications will arrive. Capability discovery keeps its existing refresh behavior. Returning after at least five seconds in the background reconnects the session and fetches capability again. Tom explicitly chose this behavior without new retries, polling, or refresh triggers. Companion client change for squareup/buzz-releases#104. Related: #7826 (broader notification availability diagnostics). ### Validation Settings widget tests cover hidden first-time opt-in during loading, absent capability, and discovery errors. Recovery tests exercise the real off switch for already-enabled communities in all three states, verifying persisted opt-out, the native snapshot writer, a durable pending relay tombstone after failure, and the iOS Settings link. They also verify that pending recovery stays visible after a relay failure and disappears only after opt-out confirmation. Tests without a gateway definition verify hidden first-time settings and disabled native registration. Mobile analysis and the mobile test suite passed. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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,13 +67,17 @@ class _NotificationsSection extends ConsumerWidget {
|
||||
: null,
|
||||
trailing: Switch.adaptive(
|
||||
value: community.pushNotificationsEnabled,
|
||||
onChanged: (enabled) => unawaited(
|
||||
onChanged: !canToggle
|
||||
? null
|
||||
: (enabled) => unawaited(
|
||||
ref
|
||||
.read(communityListProvider.notifier)
|
||||
.setPushNotificationsEnabled(community.id, enabled),
|
||||
),
|
||||
),
|
||||
onTap: () => unawaited(
|
||||
onTap: !canToggle
|
||||
? null
|
||||
: () => unawaited(
|
||||
ref
|
||||
.read(communityListProvider.notifier)
|
||||
.setPushNotificationsEnabled(
|
||||
|
||||
179
mobile/test/features/settings/notification_recovery_test.dart
Normal file
179
mobile/test/features/settings/notification_recovery_test.dart
Normal file
@@ -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<BuzzPushLeaseDescriptor?>();
|
||||
final snapshots = <List<Community>>[];
|
||||
final tombstones = <int?>[];
|
||||
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<Switch>(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<Switch>(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<AuthState> build() async =>
|
||||
const AuthState(status: AuthStatus.unauthenticated);
|
||||
}
|
||||
@@ -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<BuzzPushLeaseDescriptor?>();
|
||||
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<BuzzPushLeaseDescriptor?>.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,
|
||||
);
|
||||
|
||||
@@ -73,9 +73,9 @@ void main() {
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Settings explains push is unavailable without offering opt-in',
|
||||
(tester) async {
|
||||
testWidgets('Settings hides notifications without push capability', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
@@ -106,11 +106,11 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Unavailable in this build'), findsOneWidget);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user