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:
Tom Brow
2026-10-18 16:10:01 -07:00
committed by GitHub
parent 305fb7b5cd
commit 8f2c7710e9
5 changed files with 116 additions and 44 deletions

View File

@@ -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(

View File

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

View File

@@ -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({

View File

@@ -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(

View File

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