mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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>
285 lines
8.5 KiB
Dart
285 lines
8.5 KiB
Dart
import 'package:buzz/shared/push/dev_push_lease.dart';
|
|
import 'package:buzz/shared/community/community.dart';
|
|
import 'package:buzz/shared/push/push_bootstrap.dart';
|
|
import 'package:buzz/shared/push/push_subscription.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
void main() {
|
|
testWidgets('first opt-in starts registration without switching community', (
|
|
tester,
|
|
) async {
|
|
var registrations = 0;
|
|
Widget bootstrap(bool enabled) => BuzzPushRegistrationBootstrap(
|
|
shouldRegister: enabled,
|
|
attemptKey: 'same-community|same-relay',
|
|
startRegistration: () async {
|
|
registrations += 1;
|
|
},
|
|
child: const SizedBox(),
|
|
);
|
|
await tester.pumpWidget(bootstrap(false));
|
|
expect(registrations, 0);
|
|
await tester.pumpWidget(bootstrap(true));
|
|
expect(registrations, 1);
|
|
await tester.pumpWidget(bootstrap(true));
|
|
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 {
|
|
await expectLater(
|
|
publishBuzzPushLeaseRecoverably(
|
|
reserveGeneration: () async => 1,
|
|
publish: (_) async {},
|
|
markAccepted: (_) async => false,
|
|
),
|
|
throwsStateError,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('failed bootstrap attempt becomes retryable after the delay', () async {
|
|
final gate = BuzzPushAttemptGate(retryDelay: Duration.zero);
|
|
addTearDown(gate.dispose);
|
|
var retries = 0;
|
|
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
gate.failed('attempt', retry: () => retries += 1);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(retries, 1);
|
|
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);
|
|
var retries = 0;
|
|
|
|
expect(gate.tryBegin('old'), isTrue);
|
|
gate.failed('old', retry: () => retries += 1);
|
|
expect(gate.tryBegin('new'), isTrue);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(retries, 0);
|
|
expect(gate.tryBegin('new'), isFalse);
|
|
});
|
|
|
|
test('successful bootstrap becomes retryable at renewal time', () async {
|
|
final gate = BuzzPushAttemptGate(retryDelay: Duration.zero);
|
|
addTearDown(gate.dispose);
|
|
var retries = 0;
|
|
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
gate.retryAfter('attempt', delay: Duration.zero, retry: () => retries += 1);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(retries, 1);
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
});
|
|
|
|
test('completed bootstrap attempt can run again for later work', () {
|
|
final gate = BuzzPushAttemptGate();
|
|
addTearDown(gate.dispose);
|
|
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
gate.complete('attempt');
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
});
|
|
|
|
test('publication attempt changes when the relay executor rotates', () {
|
|
final subscription = BuzzPushSubscription(
|
|
filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]),
|
|
notificationClass: 'default',
|
|
);
|
|
final original = buzzPushPublicationAttemptKey(
|
|
communityId: 'community',
|
|
relayBaseUrl: 'https://relay.example',
|
|
token: 'token',
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')),
|
|
subscriptions: [subscription],
|
|
);
|
|
|
|
expect(
|
|
buzzPushPublicationAttemptKey(
|
|
communityId: 'community',
|
|
relayBaseUrl: 'https://relay.example',
|
|
token: 'token',
|
|
descriptor: _descriptor(keyId: 'relay-v2', pubkey: _hex('b')),
|
|
subscriptions: [subscription],
|
|
),
|
|
isNot(original),
|
|
);
|
|
expect(
|
|
buzzPushPublicationAttemptKey(
|
|
communityId: 'community',
|
|
relayBaseUrl: 'https://relay.example',
|
|
token: 'token',
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('c')),
|
|
subscriptions: [subscription],
|
|
),
|
|
isNot(original),
|
|
);
|
|
});
|
|
|
|
test('default push requires relay capability and respects opt-out', () {
|
|
final enabled = Community.create(
|
|
name: 'Team',
|
|
relayUrl: 'wss://relay.example',
|
|
);
|
|
final disabled = enabled.copyWith(pushNotificationsEnabled: false);
|
|
|
|
expect(
|
|
buzzPushLifecycleEnabled(
|
|
community: disabled,
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')),
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
buzzPushLifecycleEnabled(
|
|
community: enabled,
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')),
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
buzzPushLifecycleEnabled(community: enabled, descriptor: null),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('pending opt-out tombstone keeps active push lifecycle disabled', () {
|
|
final subscription = BuzzPushSubscription(
|
|
filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]),
|
|
notificationClass: 'default',
|
|
);
|
|
final community =
|
|
Community.create(
|
|
name: 'Team',
|
|
relayUrl: 'wss://relay.example',
|
|
).copyWith(
|
|
pushNotificationsEnabled: false,
|
|
pushSubscriptionState:
|
|
BuzzPushLeaseSubscriptionState.desired(desired: [subscription])
|
|
.withAccepted(subscriptions: [subscription], generation: 3)
|
|
.withPendingTombstone(4),
|
|
);
|
|
|
|
expect(
|
|
buzzPushLifecycleEnabled(
|
|
community: community,
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')),
|
|
),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'relay commit followed by local failure retries at a newer generation',
|
|
() async {
|
|
var durableCursor = 0;
|
|
var relayGeneration = 0;
|
|
var acceptedGeneration = 0;
|
|
var failLocalSave = true;
|
|
|
|
Future<int> reserve() async => ++durableCursor;
|
|
Future<void> publish(int generation) async {
|
|
expect(generation, greaterThan(relayGeneration));
|
|
relayGeneration = generation;
|
|
}
|
|
|
|
Future<bool> markAccepted(int generation) async {
|
|
if (failLocalSave) {
|
|
failLocalSave = false;
|
|
throw StateError('injected local persistence failure');
|
|
}
|
|
acceptedGeneration = generation;
|
|
return true;
|
|
}
|
|
|
|
await expectLater(
|
|
publishBuzzPushLeaseRecoverably(
|
|
reserveGeneration: reserve,
|
|
publish: publish,
|
|
markAccepted: markAccepted,
|
|
),
|
|
throwsStateError,
|
|
);
|
|
expect(relayGeneration, 1);
|
|
expect(acceptedGeneration, 0);
|
|
|
|
await publishBuzzPushLeaseRecoverably(
|
|
reserveGeneration: reserve,
|
|
publish: publish,
|
|
markAccepted: markAccepted,
|
|
);
|
|
expect(relayGeneration, 2);
|
|
expect(acceptedGeneration, 2);
|
|
},
|
|
);
|
|
}
|
|
|
|
BuzzPushLeaseDescriptor _descriptor({
|
|
required String keyId,
|
|
required String pubkey,
|
|
}) => BuzzPushLeaseDescriptor(
|
|
origin: 'wss://relay.example',
|
|
executorKeyId: keyId,
|
|
executorPubkey: pubkey,
|
|
transport: 'apns',
|
|
maxLeaseTtlSeconds: 3600,
|
|
maxContentLength: 4096,
|
|
maxPlaintextLength: 4096,
|
|
maxEndpointLength: 2048,
|
|
maxStringLength: 512,
|
|
);
|
|
|
|
String _hex(String character) => List.filled(64, character).join();
|