mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(mobile): default community push notifications to on (#8425)
New mobile communities currently require a manual Settings opt-in before push can start. Default that local preference to on for new communities and stored records without a preference. Preserve every saved `true` or `false`, including values written by older clients. Older default-off values are indistinguishable from explicit opt-outs, so this does not migrate them to on. Push still requires a configured gateway, authenticated connectivity, and a valid relay capability. iOS continues to control display authorization through its existing permission prompt. Notification eligibility and subscriptions are unchanged. Settings retains its existing opt-out control for enabled communities even when capability is unavailable. Drafted from the 0.24 baseline; release-branch inclusion is a separate decision. ### Related issue Closest related issue: #3238 (synced defaults). This change is limited to the mobile device-local community preference; no duplicate found. ### Validation - Added or updated coverage for new and missing preferences, saved true/false persistence, opt-out after restart, capability-gated registration, and Settings without a configured gateway. - Full configured mobile suite: 3,499 passed, 4 skipped. Separate no-gateway suite: 4 passed. Flutter analysis passed. - Existing permission-denied and unsupported-relay tests passed as part of the mobile suite. No physical-device or end-to-end APNs validation performed. - Full local repository gate passed (formatting, lint, unit tests, desktop/web builds, Tauri checks/tests, and mobile tests). - Follow-up review edits: restored the original VISION.md wording and shared the default in one private Community constant. All 43 focused community/bootstrap tests passed. <!-- buzz-review-completed --> --------- Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -19,6 +19,8 @@ String _newPushLeaseInstallationId() {
|
||||
enum SensitiveActionPolicy { enabled, disabledByUser }
|
||||
|
||||
class Community {
|
||||
static const _defaultPushNotificationsEnabled = true;
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String relayUrl;
|
||||
@@ -45,7 +47,7 @@ class Community {
|
||||
this.pubkey,
|
||||
this.nsec,
|
||||
this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser,
|
||||
this.pushNotificationsEnabled = false,
|
||||
this.pushNotificationsEnabled = _defaultPushNotificationsEnabled,
|
||||
this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(),
|
||||
this.pushLeaseInstallationId,
|
||||
this.starterSetupIncomplete = false,
|
||||
@@ -130,7 +132,8 @@ class Community {
|
||||
);
|
||||
}
|
||||
final pushNotificationsEnabled =
|
||||
json['pushNotificationsEnabled'] as bool? ?? false;
|
||||
json['pushNotificationsEnabled'] as bool? ??
|
||||
_defaultPushNotificationsEnabled;
|
||||
var pushSubscriptionState = json['pushSubscriptionState'] == null
|
||||
? const BuzzPushLeaseSubscriptionState.desired()
|
||||
: BuzzPushLeaseSubscriptionState.fromJson(
|
||||
|
||||
@@ -122,7 +122,7 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'push notifications default off and opt-in survives restart',
|
||||
'push notifications default on and opt-out survives restart',
|
||||
() async {
|
||||
container = createContainer();
|
||||
await container.read(communityListProvider.future);
|
||||
@@ -137,12 +137,12 @@ void main() {
|
||||
(await container.read(
|
||||
communityListProvider.future,
|
||||
)).single.pushNotificationsEnabled,
|
||||
isFalse,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await container
|
||||
.read(communityListProvider.notifier)
|
||||
.setPushNotificationsEnabled(community.id, true);
|
||||
.setPushNotificationsEnabled(community.id, false);
|
||||
container.dispose();
|
||||
container = createContainer();
|
||||
|
||||
@@ -150,7 +150,7 @@ void main() {
|
||||
(await container.read(
|
||||
communityListProvider.future,
|
||||
)).single.pushNotificationsEnabled,
|
||||
isTrue,
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ void main() {
|
||||
);
|
||||
expect(community.starterSetupIncomplete, isFalse);
|
||||
expect(community.pushLeaseInstallationId, isNull);
|
||||
expect(community.pushNotificationsEnabled, isTrue);
|
||||
expect(community.pushSubscriptionState.pendingTombstoneGeneration, isNull);
|
||||
});
|
||||
|
||||
test('new community gets a unique canonical push lease address id', () {
|
||||
@@ -25,6 +27,8 @@ void main() {
|
||||
relayUrl: 'https://relay.test',
|
||||
);
|
||||
|
||||
expect(first.pushNotificationsEnabled, isTrue);
|
||||
expect(Community.fromJson(first.toJson()).pushNotificationsEnabled, isTrue);
|
||||
expect(first.pushLeaseInstallationId, matches(RegExp(r'^[0-9a-f]{32}$')));
|
||||
expect(
|
||||
second.pushLeaseInstallationId,
|
||||
@@ -49,6 +53,31 @@ void main() {
|
||||
expect(roundTrip.pushLeaseInstallationId, 'a' * 32);
|
||||
});
|
||||
|
||||
for (final enabled in [true, false]) {
|
||||
test('saved push preference $enabled survives loading and copying', () {
|
||||
final community = Community.fromJson({
|
||||
'id': 'one',
|
||||
'name': 'Buzz',
|
||||
'relayUrl': 'https://relay.test',
|
||||
'pushNotificationsEnabled': enabled,
|
||||
'addedAt': '2026-08-05T00:00:00.000Z',
|
||||
});
|
||||
expect(community.pushNotificationsEnabled, enabled);
|
||||
expect(
|
||||
community.copyWith(name: 'Renamed').pushNotificationsEnabled,
|
||||
enabled,
|
||||
);
|
||||
expect(
|
||||
Community.fromJson(community.toJson()).pushNotificationsEnabled,
|
||||
enabled,
|
||||
);
|
||||
expect(
|
||||
community.pushSubscriptionState.pendingTombstoneGeneration,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('malformed stored push lease address id is rejected', () {
|
||||
expect(
|
||||
() => Community.fromJson({
|
||||
|
||||
@@ -26,6 +26,33 @@ void main() {
|
||||
expect(registrations, 1);
|
||||
});
|
||||
|
||||
testWidgets('new community starts registration once capability arrives', (
|
||||
tester,
|
||||
) async {
|
||||
final community = Community.create(
|
||||
name: 'Team',
|
||||
relayUrl: 'wss://relay.example',
|
||||
);
|
||||
var registrations = 0;
|
||||
Widget bootstrap(BuzzPushLeaseDescriptor? descriptor) =>
|
||||
BuzzPushRegistrationBootstrap(
|
||||
shouldRegister: buzzPushLifecycleEnabled(
|
||||
community: community,
|
||||
descriptor: descriptor,
|
||||
),
|
||||
attemptKey: '${community.id}|${community.relayUrl}',
|
||||
startRegistration: () async => registrations += 1,
|
||||
child: const SizedBox(),
|
||||
);
|
||||
await tester.pumpWidget(bootstrap(null));
|
||||
expect(registrations, 0);
|
||||
final descriptor = _descriptor(keyId: 'relay-v1', pubkey: _hex('b'));
|
||||
await tester.pumpWidget(bootstrap(descriptor));
|
||||
expect(registrations, 1);
|
||||
await tester.pumpWidget(bootstrap(descriptor));
|
||||
expect(registrations, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'superseded lease acceptance is a retryable publication failure',
|
||||
() async {
|
||||
@@ -141,12 +168,12 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('relay capability alone does not activate push without opt-in', () {
|
||||
final disabled = Community.create(
|
||||
test('default push requires relay capability and respects opt-out', () {
|
||||
final enabled = Community.create(
|
||||
name: 'Team',
|
||||
relayUrl: 'wss://relay.example',
|
||||
);
|
||||
final enabled = disabled.copyWith(pushNotificationsEnabled: true);
|
||||
final disabled = enabled.copyWith(pushNotificationsEnabled: false);
|
||||
|
||||
expect(
|
||||
buzzPushLifecycleEnabled(
|
||||
|
||||
@@ -73,43 +73,56 @@ void main() {
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
|
||||
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',
|
||||
for (final enabled in [true, false]) {
|
||||
testWidgets(
|
||||
'Settings preserves preference $enabled 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',
|
||||
).copyWith(pushNotificationsEnabled: enabled),
|
||||
),
|
||||
buzzPushAuthorizationStatusReaderProvider.overrideWithValue(
|
||||
() async => throw StateError(
|
||||
'must not query notification permission',
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: SettingsPage(
|
||||
profileHeader: const SizedBox.shrink(),
|
||||
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
buzzPushAuthorizationStatusReaderProvider.overrideWithValue(
|
||||
() async =>
|
||||
throw StateError('must not query notification permission'),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: SettingsPage(
|
||||
profileHeader: const SizedBox.shrink(),
|
||||
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('Notifications'),
|
||||
enabled ? findsOneWidget : findsNothing,
|
||||
);
|
||||
expect(find.text('Push notifications'), findsNothing);
|
||||
if (enabled) {
|
||||
final toggle = tester.widget<Switch>(find.byType(Switch));
|
||||
expect(toggle.value, isTrue);
|
||||
expect(toggle.onChanged, isNotNull);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user