mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Backport the selected client push fixes to `mobile-v0.25`: supply the default iOS sound when APNs omits it, and reset legacy per-community notification preferences to enabled. Later explicit OFF choices persist under the new preference key. Cherry-picked without conflicts from main: [#8443](https://github.com/block/buzz/pull/8443), [#8425](https://github.com/block/buzz/pull/8425), and [#8455](https://github.com/block/buzz/pull/8455). The default-on change is a prerequisite for the reset, not a replacement for it. Only the notification service and community model change in production; remaining changes are tests. This PR targets the protected stable branch and does not publish a release. ### Validation - Existing Swift notification-handoff tests: 2 passed. - Combined community, storage, bootstrap, and notification-toggle tests: 64 passed, including legacy reset and subsequent opt-out persistence. - Full mobile pre-push checks passed. Full local repository CI passed. - The local branch-skew hook assumes a main-targeted PR and rejects this stable-targeted backport; that hook was excluded for publication, and the already-passed unchanged mobile checks were not repeated on the branch-name-only retry. Protected-branch review and CI remain required. - No new device test was performed for this backport. The original sound fix was device-tested before its main merge. Advisory security-review automation currently supports only PRs targeting main; no security review is claimed for this stable-targeted backport. --------- 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();
|