Files
buzz/mobile/lib/shared/community/community_provider.dart
Tom Brow 52cb6818fa perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158)
## Summary

The mobile app now saves remote images on disk. After a relaunch,
avatars, custom emoji, message attachments and agent artwork show
immediately. The app does not download them again.

Small images on a relay now load only the relay's thumbnail. The app
never downloads the full-size upload to show a small image.

## Why

Before this change, the app kept remote images only in memory. Each cold
launch downloaded every image again. On a slow connection, attachments
showed a "Loading image" placeholder for a long time.

Also, when a relay had no thumbnail for an image, the app downloaded the
full-size upload. That upload can be up to 20 MiB, for an avatar that is
a few dozen points wide.

## What changes

### Images on disk

- `MediaImageProvider` reads and writes a `MediaDiskCache`. Each account
in each community has its own cache, at `<root>/communities/<community
id>/media/<signer pubkey>`. The cache is built on
`flutter_cache_manager`, with a JSON index.
- On iOS, the root is the app group's `Library/Caches` (from a new
`buzz/app_group` method channel). App extensions can then read the same
cache. On other platforms, the root is the app cache directory.
- Each cache holds a maximum of 250 MB. When it is full, the least
recently used images go first. `MediaCacheIndex` measures each file when
it writes it, because `flutter_cache_manager` limits only the number of
files. Every disk read marks the file used, and every write schedules
eviction, including a refresh that replaces bytes the package already
holds in memory (by default the package does neither for those).
- The cache does not save an image that is larger than 5 MiB. The app
still shows that image from memory. Thus, one large image cannot push
out all the others.
- Relay media URLs contain a content hash, so their bytes never change.
The cache keeps them until it evicts them. For other URLs, the cache
shows the saved image, and then gets a new copy in the background after
one week. A maximum of two of these refreshes run at the same time. The
app decodes a refreshed image before it replaces the saved copy.
- When you remove a community, the app deletes its cache. When a
community gets a new signing key, the app deletes the media of the old
account. At launch, the app also deletes caches of communities and
accounts that are no longer present.
- Startup does not wait for the cache. The cache directory resolves in
the background. Image reads wait for it for a maximum of 3 seconds.

### Thumbnails for small images

- A small relay image loads the relay's WebP thumbnail. If the relay has
no WebP thumbnail, the image loads the JPEG thumbnail. If the relay has
neither, the widget shows its placeholder (initials for an avatar, an
empty tile for artwork).
- Native avatars drawn by UIKit (the navigation bar, the community icon
on the create button, and the New DM and Add Members pickers) use the
same choice, through a new `smallMediaImageProviders`. They try the same
thumbnails, fall back the same way, and show initials when neither
thumbnail exists. Before, they downloaded the full-size upload and only
decoded it small.
- Larger layouts and the full-screen viewer load the full-size upload,
as before.

### Agent artwork

- Preset agent artwork (the tray, flight, video placeholder, avatar hero
and the avatar editor's blurred backdrop) now loads through
`MediaImage`, so it uses the disk cache too.
- The create-agent and empty-bestie pages warm the same
`MediaImageProvider` that their widgets read, through a new
`mediaImageProviderFor(ref, url)`.
- No code in `mobile/lib` uses `Image.network` or `NetworkImage` now.

## Demo

A cold relaunch (the app is stopped, then started) into a synthetic "QA
demo" Solo chat that holds a few generated images. Shown at half speed.

Before: the attachment shows a "Loading image" placeholder while it
downloads.


https://github.com/user-attachments/assets/107f43a3-63d0-40c3-bd80-4ea9d7897a97

After: the attachment shows with the chat, from disk.


https://github.com/user-attachments/assets/d3985bb2-838c-4efa-93cb-44cef51f677d

## Tests

- `test/shared/relay/media_disk_cache_test.dart` follows one journey
through the real provider and a real cache in a temporary directory. The
first launch downloads and saves. An offline relaunch shows the
full-size image, its thumbnail and an external image from disk, and the
oversized image goes back to the network. Eight days later, only the
external image refreshes. Removing the community deletes its directory.
- A second test checks the order of eviction when the cache is full,
that a file read from disk is evicted after files that were not, and
that replacing a file's bytes after eviction last ran evicts again when
the cache goes over budget.
- `community_provider_test` checks that launch deletes orphan media
directories, that a new signing key deletes only the old account's
media, and that removing a community deletes its media.
- `avatar_image_test` checks that a missing WebP thumbnail falls back to
the JPEG thumbnail, and that an avatar with no thumbnail shows its
initials.
- `native_avatar_image_test` checks the same for native avatars: a WebP
404 paints the JPEG thumbnail, the full-size upload is never requested,
a relay with no thumbnail paints initials and is not asked again, and
any other failure stops at the initials.
- `flutter analyze` and the full mobile test suite pass.
- Automated app runs on an iPhone 17 Pro simulator (iOS 27): the channel
list, a DM, Home, the Agents grid and an agent details sheet show their
images. After a relaunch, the images came from disk and the app wrote no
new files. In the "QA demo" chat, a new image from the app showed
immediately.
- Automated run of the native-thumbnail fix on an iPhone 17 Pro
simulator (iOS 27): the native New DM picker shows photo avatars, and
the cache gained only relay `.thumb.webp` entries for them, no full-size
uploads. After a cold relaunch, the picker showed the same avatars
within about 1.5 seconds, and the cache directory gained no files. The
avatar editor backdrop was not exercised in the app. The test community
has no remote agents, and the backdrop tests cover only emoji and
picked-image selections.

## Before merge

- [x] Automated simulator run of this head: avatars and agent artwork
show their thumbnails, and a cold relaunch shows images from disk.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
2026-11-09 07:21:21 -08:00

728 lines
26 KiB
Dart

import '../hosted/hosted_relay_auth.dart';
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:math';
import 'package:buzz/shared/crypto/event_signing.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../auth/auth_provider.dart';
import '../channels/channel_list_snapshot.dart';
import '../hosted/community_entry_requirements.dart';
import '../profile/profile_snapshot.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/media_disk_cache.dart';
import '../relay/signed_event_relay.dart';
import '../relay/relay_provider.dart' show RelayConfig, pubkeyFromNsec;
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);
/// Exports persisted communities to the notification service extension.
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,
relayAuth: ref.read(hostedRelayAuthProvider),
);
});
typedef CommunityPushLeaseRevocationEnqueuer =
Future<bool> Function(Community community);
final communityPushLeaseRevocationEnqueuerProvider =
Provider<CommunityPushLeaseRevocationEnqueuer>((ref) {
return ref.watch(buzzPushLeaseRevocationOutboxProvider).enqueueCommunity;
});
typedef CommunityPushLeaseRevocationTrigger = Future<void> Function();
final communityPushLeaseRevocationTriggerProvider =
Provider<CommunityPushLeaseRevocationTrigger>((ref) {
return ref.watch(buzzPushLeaseRevocationOutboxProvider).trigger;
});
Future<void> _deactivateCommunityPushLease(
Community community, {
int? generation,
required HostedRelayAuth relayAuth,
}) 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 ?? derivePublicKeyHex(decoded.data);
final descriptor = await fetchBuzzPushLeaseDescriptor(
canonicalBuzzPushRelayHttpUrl(community.relayUrl),
);
final matchingGrant = (await readBuzzPushEndpointGrants())
.where((grant) => grant.relayOrigin == descriptor.origin)
.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();
// The community being removed may not be the active session, so ask for its
// own assertion: a hosted relay refuses the socket upgrade without one.
final relayConfig = RelayConfig(
baseUrl: uri.replace(scheme: httpScheme).toString(),
nsec: nsec,
);
// 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}) async =>
submitSignedEventOnce(
wsUrl: wsUrl,
headers: await relayAuth.headers(
relayConfig,
background: true,
fresh: true,
),
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> _mutationTail = Future.value();
Future<void> _serializeMutation(Future<void> Function() operation) {
final result = _mutationTail.then((_) => operation());
_mutationTail = result.then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
);
return result;
}
Future<void> write(List<Community> communities) =>
_serializeMutation(() => _write(communities));
Future<void> _write(List<Community> communities) async {
final effectiveCommunities = communities;
final contentFingerprint = effectiveCommunities
.map(
(community) => [
community.id,
community.name,
community.relayUrl,
community.pubkey,
community.nsec,
community.pushNotificationsEnabled,
buzzPushSubscriptionStateFingerprint(
community.pushSubscriptionState,
),
].join('\u0000'),
)
.join('\u0001');
if (contentFingerprint == _lastSuccessfulSnapshot) return;
await _writer(effectiveCommunities);
_lastSuccessfulSnapshot = contentFingerprint;
}
}
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();
unawaited(_removeOrphanChannelListSnapshots(communities));
unawaited(_removeOrphanMediaCaches(fallback: communities));
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 {
await CommunityEntryRequirements.verify(
community,
ref.read(builderlabAccountProvider),
);
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,
);
final identityChanged =
_channelSnapshotPubkey(existing)?.toLowerCase() !=
_channelSnapshotPubkey(updated)?.toLowerCase();
if (identityChanged) await _removeChannelListSnapshot(existing);
await storage.save(updated);
final updatedList = [...current];
updatedList[existingIndex] = updated;
state = AsyncData(updatedList);
// The new account has its own image cache; drop the previous one's in
// the background.
if (identityChanged) unawaited(_removeOrphanMediaCaches());
await _removeOrphanChannelListSnapshots(updatedList);
await syncCommunitySnapshot(ref, updatedList);
return existing.id;
}
await storage.save(community);
final updatedList = [...current, community];
state = AsyncData(updatedList);
await _removeOrphanChannelListSnapshots(updatedList);
await syncCommunitySnapshot(ref, updatedList);
return community.id;
}
Future<void> removeCommunity(String id) {
return ref.read(communityTransitionProvider).runExclusive(() async {
var revocationJournaled = false;
final storage = ref.read(communityStorageProvider);
final activeId = await storage.loadActiveId();
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) {
await _removeChannelListSnapshot(current[removedIndex]);
// 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();
await _removeOrphanChannelListSnapshots(updatedList);
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]);
ref.invalidate(authProvider);
} else {
await storage.clearActiveId();
// Invalidate auth so it re-evaluates against the now-empty storage
// and transitions to unauthenticated.
ref.invalidate(authProvider);
}
}
// Deleting up to a full cache can take a while; never hold the removal
// on it. The launch sweep retries a failure.
unawaited(_removeOrphanMediaCaches());
if (revocationJournaled) {
unawaited(
ref.read(communityPushLeaseRevocationTriggerProvider)().catchError((
Object error,
StackTrace stackTrace,
) {
reportPushLeaseCleanupError(error, stackTrace);
}),
);
}
});
}
String? _channelSnapshotPubkey(Community community) =>
pubkeyFromNsec(community.nsec?.trim()) ?? community.pubkey;
Future<void> _removeChannelListSnapshot(Community community) async {
final snapshots = ref.read(channelListSnapshotStorageProvider);
final profiles = ref.read(profileSnapshotStorageProvider);
final pubkey = _channelSnapshotPubkey(community);
if (pubkey == null) return;
try {
// Retire writers before the revocation journal or credential changes.
// A disposable cache must never prevent local sign-out.
await Future.wait([
?snapshots?.remove(pubkey, community.relayUrl),
?profiles?.remove(pubkey, community.relayUrl),
]);
} catch (error, stackTrace) {
developer.log(
'Channel snapshot deletion failed; orphan cleanup will retry',
name: 'buzz.community',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> _removeOrphanChannelListSnapshots(
List<Community> communities,
) async {
final snapshots = ref.read(channelListSnapshotStorageProvider);
final profiles = ref.read(profileSnapshotStorageProvider);
final scopes = [
for (final community in communities)
if (_channelSnapshotPubkey(community) case final pubkey?)
(pubkey: pubkey, relay: community.relayUrl),
];
await Future.wait([
?snapshots?.removeOrphans({
for (final scope in scopes)
ChannelListSnapshotStorage.key(scope.pubkey, scope.relay),
}),
?profiles?.removeOrphans({
for (final scope in scopes)
ProfileSnapshotStorage.key(scope.pubkey, scope.relay),
}),
]);
}
/// Deletes the image caches of communities no longer in the list, and of
/// accounts a community no longer signs in as. A failure, or a
/// write that lands after the delete, is swept on the next launch.
///
/// Sweeps run one at a time, each keeping what the community list holds
/// when it starts, so an older sweep can never delete a newer account's
/// cache. [fallback] stands in for the list while it is still loading.
Future<void> _removeOrphanMediaCaches({List<Community>? fallback}) =>
_mediaSweep = _mediaSweep.then((_) => _sweepMediaCaches(fallback));
Future<void> _mediaSweep = Future.value();
Future<void> _sweepMediaCaches(List<Community>? fallback) async {
try {
final root = await ref.read(mediaCacheRootProvider.future);
if (root == null) return;
List<Community>? current() =>
state.isLoading ? fallback ?? state.value : state.value;
var communities = current();
if (communities == null) return;
await MediaDiskCache.removeOrphans(root, () {
// A list briefly unavailable mid-sweep keeps the last one seen.
communities = current() ?? communities;
return {
for (final community in communities!)
community.id: _channelSnapshotPubkey(community)?.toLowerCase(),
};
});
} catch (error, stackTrace) {
developer.log(
'Media cache deletion failed; the next launch will retry',
name: 'buzz.community',
error: error,
stackTrace: 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<bool> 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 false;
final community = current[index];
final acceptedGeneration =
community.pushSubscriptionState.acceptedGeneration ?? 0;
final generationCursor =
community.pushSubscriptionState.generationCursor ?? 0;
if (generation < max(acceptedGeneration, generationCursor)) return false;
if (buzzPushSubscriptionsFingerprint(
community.pushSubscriptionState.desired,
) !=
buzzPushSubscriptionsFingerprint(subscriptions)) {
return false;
}
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);
return true;
});
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);
});
}
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;
// This projection can outlive the list it captured above. Never persist a
// fallback here: a newer authentication may already have selected a community
// absent from that list. Authentication owns durable active-ID repair.
return communities.firstWhere(
(community) => community.id == activeId,
orElse: () => communities.first,
);
});