mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
This PR implements MVP, iOS-only,
[NIP-PL](8d2d0ff5ad/docs/nips/NIP-PL.md)-compliant
push notifications.
A relay with `BUZZ_PUSH_ENABLED` will send a push notification for any
message that appears in the in-app Notifications tab.
## Enrollment flow
The first time the client first connects to a relay with
`BUZZ_PUSH_ENABLED`:
```mermaid
sequenceDiagram
autonumber
participant App as Buzz iOS app
participant iOS
participant Relay as Buzz relay
participant Attest as Apple App Attest
participant Gateway as Push gateway
App->>Relay: Fetch NIP-11 push capability
Relay-->>App: Push profile, current relay public key, and limits
par
App->>iOS: Request notification permission
iOS-->>App: Permission result
and
App->>iOS: Register for remote notifications
iOS-->>App: Device token
end
App->>Gateway: Request installation challenge
Gateway-->>App: Single-use challenge
App->>Attest: Attest installation transcript
Attest-->>App: Attestation proof
App->>Gateway: Enroll device token and proof
Gateway-->>App: Installation handle
App->>Gateway: Request delegation challenge
Gateway-->>App: Single-use challenge
App->>Attest: Assert relay-key delegation
Attest-->>App: Assertion
App->>Gateway: Create delegation
Gateway-->>App: Opaque endpoint grant
App->>Relay: Publish encrypted push lease and filters
Relay-->>App: Lease acknowledged
```
## Push-time flow
When a notification-eligible event is received by the relay:
```mermaid
%%{init: {
"sequence": {
"actorMargin": 20,
"width": 110,
"messageMargin": 18,
"diagramMarginX": 8,
"wrap": true
}
}}%%
sequenceDiagram
autonumber
participant Relay as Buzz relay
participant Gateway as Push gateway
participant APNs as Apple Push<br/>Notification service
participant iOS
participant NSE as Notification service<br/>extension
Relay->>Gateway: POST /v1/deliveries/apns<br/>opaque endpoint grant, request ID, expiry, NIP-98 authorization
Gateway->>APNs: POST /3/device/{device-token}<br/>topic, request ID, expiry, constant mutable-content payload
APNs-->>Gateway: 200 OK: request accepted
Gateway-->>Relay: 200 OK: accepted status
APNs-->>iOS: Notification: constant reconnect alert<br/>mutable-content = 1
iOS->>NSE: Invoke extension<br/>original notification content
NSE->>Relay: POST /query: subscription filters, limit 10<br/>NIP-98 authorization
Relay-->>NSE: 200 OK: signed Nostr events<br/>kinds 9, 40002, 45001, or 45003
NSE->>iOS: Complete notification: title, body, subtitle<br/>thread ID, exact-message target
```
relay → push gateway → APNs -> NSE -> Notification Center
## Known limitations
The APNs wake payload is intentionally constant and opaque: it contains
no originating community or message identifier, in keeping with the
implemented NIP-PL privacy design.
The Notification Service Extension must therefore reconnect to the relay
and resolve eligible messages after each wake. Around overlapping wakes,
timing boundaries, or resolution windows, notification presentation may
occasionally omit an expected message or display a message more than
once.
This best-effort behavior is deliberately accepted for the current
implementation and will be measured during the internal rollout to
determine whether the user experience is acceptable before any broader
deployment; the implementation does not claim exactly-once presentation.
## Validation
Live end-to-end hardware validation used an internal remotely hosted
development relay and push gateway, the APNs sandbox, and a physical
iPhone 12 mini:
- A second real Buzz client published a uniquely marked message through
the hosted relay.
- The relay matched the message and sent the constant opaque wake
through the hosted gateway. The gateway made an actual APNs request; no
`simctl push` or simulated notification was used.
- The iPhone received the notification on its lock screen. The
Notification Service Extension reconnected to the relay, fetched the
event, verified its ID and signature, and replaced the placeholder
content with the real notification title and body.
- After the app populated its shared presentation cache, a final marked
notification visibly showed the sender display name, sender avatar, and
hashtag-prefixed channel name.
- Tapping a lock-screen notification opened Buzz and exercised the
notification-response path and navigated to the corresponding message.
Final validation with a dogfood-signed artifact and production App
Attest/APNs configuration remains a release step.
## Independent pre-reviews
- **First pass:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=74ab9a93bb227f3e762568f1cf9fee66d7495b0edc3918735ff787238b9cc585)
found missing transient retries, executor-key rotation suppression,
duplicate installation renewal, and an unauthenticated challenge write
amplifier. These were resolved by [retry-safe
bootstrap](https://github.com/block/buzz/pull/6269/changes/12c66ea62)
and [authenticated renewal plus a cross-replica
quota](https://github.com/block/buzz/pull/6269/changes/8e5ece0bd).
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=d26d53daa4684669e2ed354638241f13f36c3a97027fe8b4dd738aff09038962)
found delegation generation burning and an edited applied migration,
resolved by [exact-generation
revocation](https://github.com/block/buzz/pull/6269/changes/c26d2159d)
and a [forward-only
migration](https://github.com/block/buzz/pull/6269/changes/956c1d099).
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=d43139138a0b15f806cbdbeeedd8f69d992cadf2e805876db6fdde6a34c7eda1)
found no blockers.
- **Exact-head re-review:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=a897721673459301b0cf26e8b85a1478d7ebbb56a4621f93d774c98d395b8f68),
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=fb2159f709ec68f74f7b21459acd76da0e8a7c5c0f3d469f99826b0cc2380849),
and
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=2ce2842910435f562e9d9cc718595848f281b122c94605e523a4b964254b8bfb)
independently returned **NO BLOCKERS** at `7eb3a650b`; k3-max also
revalidated every remediation and the endpoint-specific App Attest
enrollment bound.
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jordan Mecom <jm@squareup.com>
621 lines
22 KiB
Dart
621 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'dart:developer' as developer;
|
|
import 'dart:math';
|
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
import '../auth/auth_provider.dart';
|
|
import '../push/dev_push_lease.dart';
|
|
import '../push/push_bridge.dart';
|
|
import '../push/push_lease_revocation_outbox.dart';
|
|
import '../push/push_subscription.dart';
|
|
import '../relay/signed_event_relay.dart';
|
|
import 'community.dart';
|
|
import 'community_storage.dart';
|
|
|
|
final class CommunityTransitionCoordinator {
|
|
final Map<Object, Future<void> Function()> _callbacks = {};
|
|
Future<void>? _inFlight;
|
|
Future<void> _operationTail = Future.value();
|
|
|
|
void Function() register(Future<void> Function() callback) {
|
|
final owner = Object();
|
|
_callbacks[owner] = callback;
|
|
return () => _callbacks.remove(owner);
|
|
}
|
|
|
|
/// Runs a complete community mutation after every earlier mutation finishes.
|
|
///
|
|
/// Cleanup alone is intentionally memoized by [run], but callers that also
|
|
/// mutate credentials or the active community must queue the whole operation
|
|
/// here so they cannot race after sharing the same cleanup future.
|
|
Future<void> runExclusive(Future<void> Function() operation) {
|
|
final result = _operationTail.then((_) => operation());
|
|
_operationTail = result.then<void>(
|
|
(_) {},
|
|
onError: (Object _, StackTrace _) {},
|
|
);
|
|
return result;
|
|
}
|
|
|
|
Future<void> run() {
|
|
final inFlight = _inFlight;
|
|
if (inFlight != null) return inFlight;
|
|
|
|
final run = _runCallbacks();
|
|
_inFlight = run;
|
|
run.whenComplete(() {
|
|
if (identical(_inFlight, run)) _inFlight = null;
|
|
});
|
|
return run;
|
|
}
|
|
|
|
Future<void> _runCallbacks() async {
|
|
await Future.wait(
|
|
_callbacks.values.map((callback) async {
|
|
try {
|
|
await callback();
|
|
} catch (error, stackTrace) {
|
|
developer.log(
|
|
'Community transition cleanup failed',
|
|
name: 'buzz.community',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
final communityTransitionProvider = Provider<CommunityTransitionCoordinator>(
|
|
(ref) => CommunityTransitionCoordinator(),
|
|
);
|
|
|
|
final communityStorageProvider = Provider<CommunityStorage>((ref) {
|
|
return CommunityStorage();
|
|
});
|
|
|
|
typedef CommunitySnapshotWriter =
|
|
Future<void> Function(List<Community> communities);
|
|
|
|
/// Writes the complete persisted community set to storage shared with the iOS
|
|
/// notification service extension. Tests override this provider to verify that
|
|
/// every persistence path refreshes (or clears) the native snapshot.
|
|
final communitySnapshotWriterProvider = Provider<CommunitySnapshotWriter>((
|
|
ref,
|
|
) {
|
|
return registerBuzzPushCommunitySnapshot;
|
|
});
|
|
|
|
final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) {
|
|
return _CommunitySnapshotSync(ref.read(communitySnapshotWriterProvider));
|
|
});
|
|
|
|
typedef CommunityPushLeaseDeactivator =
|
|
Future<void> Function(Community community, {int? generation});
|
|
|
|
final communityPushLeaseDeactivatorProvider =
|
|
Provider<CommunityPushLeaseDeactivator>((ref) {
|
|
return (community, {generation}) =>
|
|
_deactivateCommunityPushLease(community, generation: generation);
|
|
});
|
|
|
|
typedef CommunityPushLeaseRevocationEnqueuer =
|
|
Future<bool> Function(Community community);
|
|
|
|
final communityPushLeaseRevocationEnqueuerProvider =
|
|
Provider<CommunityPushLeaseRevocationEnqueuer>((ref) {
|
|
return ref.read(buzzPushLeaseRevocationOutboxProvider).enqueueCommunity;
|
|
});
|
|
|
|
typedef CommunityPushLeaseRevocationTrigger = Future<void> Function();
|
|
|
|
final communityPushLeaseRevocationTriggerProvider =
|
|
Provider<CommunityPushLeaseRevocationTrigger>((ref) {
|
|
return ref.read(buzzPushLeaseRevocationOutboxProvider).trigger;
|
|
});
|
|
|
|
Future<void> _deactivateCommunityPushLease(
|
|
Community community, {
|
|
int? generation,
|
|
}) async {
|
|
final state = community.pushSubscriptionState;
|
|
final acceptedGeneration = state.acceptedGeneration;
|
|
final nsec = community.nsec;
|
|
if (acceptedGeneration == null && generation == null) {
|
|
return;
|
|
}
|
|
if (nsec == null || nsec.isEmpty) {
|
|
throw StateError('Push lease tombstone requires community signing key.');
|
|
}
|
|
final decoded = nostr.Nip19.decode(payload: nsec);
|
|
final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public;
|
|
final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl);
|
|
final matchingGrant = (await readBuzzPushEndpointGrants())
|
|
.where(
|
|
(grant) =>
|
|
grant.relayOrigin == descriptor.origin &&
|
|
grant.appProfile == buzzDevPushAppProfile,
|
|
)
|
|
.firstOrNull;
|
|
if (matchingGrant == null) {
|
|
throw StateError('No endpoint grant exists for push lease tombstone.');
|
|
}
|
|
final installationId =
|
|
community.pushLeaseInstallationId ?? matchingGrant.installationId;
|
|
final uri = Uri.parse(community.relayUrl);
|
|
final httpScheme = switch (uri.scheme) {
|
|
'wss' => 'https',
|
|
'ws' => 'http',
|
|
_ => uri.scheme,
|
|
};
|
|
final wsScheme = httpScheme == 'https' ? 'wss' : 'ws';
|
|
final wsUrl = uri.replace(scheme: wsScheme).toString();
|
|
// Skip over the one renewal generation that could already be in flight
|
|
// when removal begins. Strict relay monotonicity then makes any stale
|
|
// active publication lose to this tombstone.
|
|
final tombstoneGeneration =
|
|
generation ?? (state.generationCursor ?? acceptedGeneration!) + 2;
|
|
await publishBuzzPushLeaseTombstone(
|
|
descriptor: descriptor,
|
|
installationId: installationId,
|
|
generation: tombstoneGeneration,
|
|
nsec: nsec,
|
|
memberPubkey: memberPubkey,
|
|
submit: ({required kind, required content, required tags, createdAt}) =>
|
|
submitSignedEventOnce(
|
|
wsUrl: wsUrl,
|
|
nsec: nsec,
|
|
kind: kind,
|
|
content: content,
|
|
tags: tags,
|
|
createdAt: createdAt,
|
|
),
|
|
);
|
|
pushLeaseCleanupError.value = null;
|
|
}
|
|
|
|
class _CommunitySnapshotSync {
|
|
_CommunitySnapshotSync(this._writer);
|
|
|
|
final CommunitySnapshotWriter _writer;
|
|
String? _lastSuccessfulSnapshot;
|
|
|
|
Future<void> write(List<Community> communities) async {
|
|
final fingerprint = communities
|
|
.map(
|
|
(community) => [
|
|
community.id,
|
|
community.name,
|
|
community.relayUrl,
|
|
community.pubkey,
|
|
community.nsec,
|
|
community.pushNotificationsEnabled,
|
|
buzzPushSubscriptionStateFingerprint(
|
|
community.pushSubscriptionState,
|
|
),
|
|
].join('\u0000'),
|
|
)
|
|
.join('\u0001');
|
|
if (fingerprint == _lastSuccessfulSnapshot) return;
|
|
|
|
await _writer(communities);
|
|
_lastSuccessfulSnapshot = fingerprint;
|
|
}
|
|
}
|
|
|
|
Future<void> syncCommunitySnapshot(Ref ref, List<Community> communities) async {
|
|
try {
|
|
await ref.read(_communitySnapshotSyncProvider).write(communities);
|
|
pushCommunitySnapshotError.value = null;
|
|
} catch (error, stackTrace) {
|
|
reportPushCommunitySnapshotError(error, stackTrace);
|
|
}
|
|
}
|
|
|
|
Future<void> syncStoredCommunitySnapshot(Ref ref) async {
|
|
final communities = await ref.read(communityStorageProvider).loadAll();
|
|
await syncCommunitySnapshot(ref, communities);
|
|
}
|
|
|
|
class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
|
Future<void> _pushMutationTail = Future.value();
|
|
final Map<String, Future<void>> _tombstoneAttempts = {};
|
|
|
|
Future<T> _serializePushMutation<T>(Future<T> Function() operation) {
|
|
final result = _pushMutationTail.then((_) => operation());
|
|
_pushMutationTail = result.then<void>(
|
|
(_) {},
|
|
onError: (Object _, StackTrace _) {},
|
|
);
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
Future<List<Community>> build() async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final communities = await storage.loadAll();
|
|
await syncCommunitySnapshot(ref, communities);
|
|
return communities;
|
|
}
|
|
|
|
/// Add a community. If one with the same relay URL already exists, update
|
|
/// its credentials instead. Returns the effective community ID.
|
|
Future<String> addCommunity(Community community) async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final current = state.value ?? [];
|
|
|
|
// If a community with the same relay URL exists, update its credentials
|
|
// instead of creating a duplicate entry.
|
|
final existingIndex = current.indexWhere(
|
|
(w) => w.relayUrl == community.relayUrl,
|
|
);
|
|
if (existingIndex >= 0) {
|
|
final existing = current[existingIndex];
|
|
final updated = existing.copyWith(
|
|
pubkey: community.pubkey,
|
|
nsec: community.nsec,
|
|
);
|
|
await storage.save(updated);
|
|
final updatedList = [...current];
|
|
updatedList[existingIndex] = updated;
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
return existing.id;
|
|
}
|
|
|
|
await storage.save(community);
|
|
final updatedList = [...current, community];
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
return community.id;
|
|
}
|
|
|
|
Future<void> removeCommunity(String id) =>
|
|
_removeCommunity(id, invalidateAuthentication: true);
|
|
|
|
/// Removes the active community through the same local-first path as the
|
|
/// community list while allowing [AuthNotifier] to publish its final state.
|
|
Future<void> removeActiveCommunityForSignOut() =>
|
|
_removeCommunity(null, invalidateAuthentication: false);
|
|
|
|
Future<void> _removeCommunity(
|
|
String? requestedId, {
|
|
required bool invalidateAuthentication,
|
|
}) {
|
|
return ref.read(communityTransitionProvider).runExclusive(() async {
|
|
var revocationJournaled = false;
|
|
final storage = ref.read(communityStorageProvider);
|
|
final activeId = await storage.loadActiveId();
|
|
final id = requestedId ?? activeId;
|
|
if (id == null) return;
|
|
if (activeId == id) {
|
|
await ref.read(communityTransitionProvider).run();
|
|
}
|
|
final current = state.value ?? await storage.loadAll();
|
|
final removedIndex = current.indexWhere(
|
|
(community) => community.id == id,
|
|
);
|
|
if (removedIndex >= 0) {
|
|
// Persist every remote-cleanup dependency before erasing credentials.
|
|
// This local transaction is the only removal prerequisite. Relay I/O
|
|
// starts after the community and NSE snapshot have been removed.
|
|
revocationJournaled = await ref.read(
|
|
communityPushLeaseRevocationEnqueuerProvider,
|
|
)(current[removedIndex]);
|
|
}
|
|
await storage.remove(id);
|
|
|
|
final updatedList = current.where((w) => w.id != id).toList();
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
|
|
// If we removed the active community, switch to another or sign out.
|
|
if (activeId == id) {
|
|
final remaining = state.value ?? [];
|
|
if (remaining.isNotEmpty) {
|
|
await storage.saveActiveId(remaining.first.id);
|
|
// Reassign list state so activeCommunityProvider picks up the new ID.
|
|
state = AsyncData([...remaining]);
|
|
if (invalidateAuthentication) ref.invalidate(authProvider);
|
|
} else {
|
|
await storage.clearActiveId();
|
|
// Invalidate auth so it re-evaluates against the now-empty storage
|
|
// and transitions to unauthenticated.
|
|
if (invalidateAuthentication) ref.invalidate(authProvider);
|
|
}
|
|
}
|
|
if (revocationJournaled) {
|
|
unawaited(
|
|
ref.read(communityPushLeaseRevocationTriggerProvider)().catchError((
|
|
Object error,
|
|
StackTrace stackTrace,
|
|
) {
|
|
reportPushLeaseCleanupError(error, stackTrace);
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> switchCommunity(String id) {
|
|
return ref.read(communityTransitionProvider).runExclusive(() async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final activeId = await storage.loadActiveId();
|
|
if (activeId == id) return;
|
|
await ref.read(communityTransitionProvider).run();
|
|
await storage.saveActiveId(id);
|
|
// Reassign list state to trigger activeCommunityProvider (which watches
|
|
// communityListProvider.future) to rebuild and pick up the new active ID.
|
|
// We can't use ref.invalidate(activeCommunityProvider) here because that
|
|
// creates a circular dependency — activeCommunityProvider watches us.
|
|
state = AsyncData([...state.value ?? []]);
|
|
// Invalidate auth so AuthState.community reflects the new active community.
|
|
ref.invalidate(authProvider);
|
|
});
|
|
}
|
|
|
|
Future<void> updateDesiredPushSubscriptions(
|
|
String id,
|
|
List<BuzzPushSubscription> desired,
|
|
) => _serializePushMutation(() async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final current = state.value ?? await storage.loadAll();
|
|
final index = current.indexWhere((community) => community.id == id);
|
|
if (index < 0) return;
|
|
|
|
final community = current[index];
|
|
if (buzzPushSubscriptionsFingerprint(
|
|
community.pushSubscriptionState.desired,
|
|
) ==
|
|
buzzPushSubscriptionsFingerprint(desired)) {
|
|
return;
|
|
}
|
|
final updated = community.copyWith(
|
|
pushSubscriptionState: community.pushSubscriptionState.withDesired(
|
|
desired,
|
|
),
|
|
);
|
|
await storage.save(updated);
|
|
final updatedList = [...current]..[index] = updated;
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
});
|
|
|
|
Future<int> reservePushLeaseGeneration(String id) {
|
|
return _serializePushMutation(() async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final current = state.value ?? await storage.loadAll();
|
|
final index = current.indexWhere((community) => community.id == id);
|
|
if (index < 0) throw StateError('Push community is unavailable.');
|
|
|
|
final community = current[index];
|
|
if (!community.pushNotificationsEnabled) {
|
|
throw StateError('Push notifications are disabled.');
|
|
}
|
|
final cursor =
|
|
community.pushSubscriptionState.generationCursor ??
|
|
community.pushSubscriptionState.acceptedGeneration ??
|
|
0;
|
|
final generation = cursor + 1;
|
|
final updated = community.copyWith(
|
|
pushSubscriptionState: community.pushSubscriptionState
|
|
.withReservedGeneration(generation),
|
|
);
|
|
await storage.save(updated);
|
|
final updatedList = [...current]..[index] = updated;
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
return generation;
|
|
});
|
|
}
|
|
|
|
Future<void> markPushLeaseAccepted(
|
|
String id, {
|
|
required List<BuzzPushSubscription> subscriptions,
|
|
required int generation,
|
|
}) => _serializePushMutation(() async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final current = state.value ?? await storage.loadAll();
|
|
final index = current.indexWhere((community) => community.id == id);
|
|
if (index < 0) return;
|
|
|
|
final community = current[index];
|
|
final acceptedGeneration =
|
|
community.pushSubscriptionState.acceptedGeneration ?? 0;
|
|
final generationCursor =
|
|
community.pushSubscriptionState.generationCursor ?? 0;
|
|
if (generation < max(acceptedGeneration, generationCursor)) return;
|
|
final updated = community.copyWith(
|
|
pushSubscriptionState: community.pushSubscriptionState.withAccepted(
|
|
subscriptions: subscriptions,
|
|
generation: generation,
|
|
),
|
|
);
|
|
await storage.save(updated);
|
|
final updatedList = [...current]..[index] = updated;
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
});
|
|
|
|
Future<void> setPushNotificationsEnabled(String id, bool enabled) async {
|
|
var shouldDeactivate = false;
|
|
await _serializePushMutation(() async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final current = state.value ?? await storage.loadAll();
|
|
final index = current.indexWhere((community) => community.id == id);
|
|
if (index < 0) return;
|
|
|
|
final community = current[index];
|
|
if (community.pushNotificationsEnabled == enabled) return;
|
|
var pushState = community.pushSubscriptionState;
|
|
if (!enabled &&
|
|
(pushState.acceptedGeneration != null ||
|
|
pushState.generationCursor != null)) {
|
|
final cursor =
|
|
pushState.generationCursor ?? pushState.acceptedGeneration ?? 0;
|
|
pushState = pushState.withPendingTombstone(cursor + 1);
|
|
}
|
|
final updated = community.copyWith(
|
|
pushNotificationsEnabled: enabled,
|
|
pushSubscriptionState: pushState,
|
|
);
|
|
await storage.save(updated);
|
|
final updatedList = [...current]..[index] = updated;
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
shouldDeactivate =
|
|
!enabled && pushState.pendingTombstoneGeneration != null;
|
|
});
|
|
if (shouldDeactivate) {
|
|
try {
|
|
await retryPendingPushLeaseTombstone(id);
|
|
} catch (_) {
|
|
// The durable journal remains pending. BuzzPushBootstrap retries it
|
|
// after reconnect while all registration/enrollment paths stay off.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Publishes a durably journaled opt-out tombstone.
|
|
///
|
|
/// A retry advances the generation before network I/O. That makes an
|
|
/// ambiguous relay-commit/local-save failure idempotent in effect: the next
|
|
/// inactive replacement wins even if the prior tombstone already committed.
|
|
Future<void> retryPendingPushLeaseTombstone(
|
|
String id, {
|
|
bool advanceGeneration = false,
|
|
}) {
|
|
final existing = _tombstoneAttempts[id];
|
|
if (existing != null) return existing;
|
|
final attempt = _retryPendingPushLeaseTombstone(
|
|
id,
|
|
advanceGeneration: advanceGeneration,
|
|
);
|
|
_tombstoneAttempts[id] = attempt;
|
|
void clearAttempt() {
|
|
if (identical(_tombstoneAttempts[id], attempt)) {
|
|
_tombstoneAttempts.remove(id);
|
|
}
|
|
}
|
|
|
|
attempt.then<void>(
|
|
(_) => clearAttempt(),
|
|
onError: (_, _) => clearAttempt(),
|
|
);
|
|
return attempt;
|
|
}
|
|
|
|
Future<void> _retryPendingPushLeaseTombstone(
|
|
String id, {
|
|
required bool advanceGeneration,
|
|
}) async {
|
|
Community? pendingCommunity;
|
|
int? pendingGeneration;
|
|
await _serializePushMutation(() async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final current = state.value ?? await storage.loadAll();
|
|
final index = current.indexWhere((community) => community.id == id);
|
|
if (index < 0) return;
|
|
final community = current[index];
|
|
var pushState = community.pushSubscriptionState;
|
|
final pending = pushState.pendingTombstoneGeneration;
|
|
if (community.pushNotificationsEnabled || pending == null) return;
|
|
if (advanceGeneration) {
|
|
final cursor = pushState.generationCursor ?? pending;
|
|
pushState = pushState.withPendingTombstone(cursor + 1);
|
|
}
|
|
final updated = community.copyWith(pushSubscriptionState: pushState);
|
|
if (!identical(pushState, community.pushSubscriptionState)) {
|
|
await storage.save(updated);
|
|
final updatedList = [...current]..[index] = updated;
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
}
|
|
pendingCommunity = updated;
|
|
pendingGeneration = pushState.pendingTombstoneGeneration;
|
|
});
|
|
final community = pendingCommunity;
|
|
final generation = pendingGeneration;
|
|
if (community == null || generation == null) return;
|
|
try {
|
|
await ref.read(communityPushLeaseDeactivatorProvider)(
|
|
community,
|
|
generation: generation,
|
|
);
|
|
await _markPushLeaseTombstoneAccepted(id, generation);
|
|
pushLeaseCleanupError.value = null;
|
|
} catch (error, stackTrace) {
|
|
reportPushLeaseCleanupError(error, stackTrace);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> _markPushLeaseTombstoneAccepted(String id, int generation) =>
|
|
_serializePushMutation(() async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final current = state.value ?? await storage.loadAll();
|
|
final index = current.indexWhere((community) => community.id == id);
|
|
if (index < 0) return;
|
|
final community = current[index];
|
|
final updatedState = community.pushSubscriptionState
|
|
.withAcceptedTombstone(generation);
|
|
if (identical(updatedState, community.pushSubscriptionState)) return;
|
|
final updated = community.copyWith(pushSubscriptionState: updatedState);
|
|
await storage.save(updated);
|
|
final updatedList = [...current]..[index] = updated;
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
});
|
|
|
|
Future<void> renameCommunity(String id, String name) async {
|
|
final storage = ref.read(communityStorageProvider);
|
|
final current = state.value ?? [];
|
|
final index = current.indexWhere((w) => w.id == id);
|
|
if (index < 0) return;
|
|
|
|
final updated = current[index].copyWith(name: name);
|
|
await storage.save(updated);
|
|
|
|
final updatedList = [...current];
|
|
updatedList[index] = updated;
|
|
state = AsyncData(updatedList);
|
|
await syncCommunitySnapshot(ref, updatedList);
|
|
}
|
|
}
|
|
|
|
final communityListProvider =
|
|
AsyncNotifierProvider<CommunityListNotifier, List<Community>>(
|
|
CommunityListNotifier.new,
|
|
);
|
|
|
|
/// The currently active community, derived from the stored active ID and
|
|
/// the community list.
|
|
final activeCommunityProvider = FutureProvider<Community?>((ref) async {
|
|
final communities = await ref.watch(communityListProvider.future);
|
|
final storage = ref.read(communityStorageProvider);
|
|
final activeId = await storage.loadActiveId();
|
|
|
|
if (communities.isEmpty) return null;
|
|
|
|
if (activeId == null) {
|
|
// No active ID stored but communities exist — fall back to first.
|
|
await storage.saveActiveId(communities.first.id);
|
|
return communities.first;
|
|
}
|
|
|
|
try {
|
|
return communities.firstWhere((w) => w.id == activeId);
|
|
} on StateError {
|
|
// Active ID points to a community that no longer exists.
|
|
// Fall back to first community.
|
|
if (communities.isNotEmpty) {
|
|
await storage.saveActiveId(communities.first.id);
|
|
return communities.first;
|
|
}
|
|
return null;
|
|
}
|
|
});
|