From 4e98d17817cde51295433cdcf038180d092e530c Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Mon, 27 Jul 2026 19:50:21 -0700 Subject: [PATCH] feat(mobile): carry desired lease subscriptions in push snapshots Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> --- mobile/lib/app.dart | 2 + mobile/lib/shared/community/community.dart | 13 + .../shared/community/community_provider.dart | 31 ++ mobile/lib/shared/push/push_bridge.dart | 1 + mobile/lib/shared/push/push_models.dart | 7 + mobile/lib/shared/push/push_subscription.dart | 391 ++++++++++++++++++ .../push/push_subscription_provider.dart | 53 +++ .../community/community_storage_test.dart | 31 ++ mobile/test/shared/push/push_models_test.dart | 24 ++ .../push/push_subscription_provider_test.dart | 64 +++ .../shared/push/push_subscription_test.dart | 124 ++++++ 11 files changed, 741 insertions(+) create mode 100644 mobile/lib/shared/push/push_subscription.dart create mode 100644 mobile/lib/shared/push/push_subscription_provider.dart create mode 100644 mobile/test/shared/push/push_subscription_provider_test.dart create mode 100644 mobile/test/shared/push/push_subscription_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 38a7e60aee..21d4aad137 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -13,6 +13,7 @@ import 'features/profile/user_status_cache_provider.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/relay/relay.dart'; +import 'shared/push/push_subscription_provider.dart'; import 'shared/theme/theme.dart'; class App extends HookConsumerWidget { @@ -39,6 +40,7 @@ class App extends HookConsumerWidget { ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); ref.watch(userStatusCacheProvider); + ref.watch(pushSubscriptionSyncProvider); } // Start listening for buzz:// links immediately (even pre-auth) so a diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 1858609e05..35239949d9 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -1,5 +1,7 @@ import 'package:uuid/uuid.dart'; +import '../push/push_subscription.dart'; + const _uuid = Uuid(); const _sentinel = Object(); @@ -9,6 +11,7 @@ class Community { final String relayUrl; final String? pubkey; final String? nsec; + final BuzzPushLeaseSubscriptionState pushSubscriptionState; final DateTime addedAt; const Community({ @@ -17,6 +20,7 @@ class Community { required this.relayUrl, this.pubkey, this.nsec, + this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(), required this.addedAt, }); @@ -41,6 +45,7 @@ class Community { String? relayUrl, Object? pubkey = _sentinel, Object? nsec = _sentinel, + BuzzPushLeaseSubscriptionState? pushSubscriptionState, }) { return Community( id: id, @@ -48,6 +53,8 @@ class Community { relayUrl: relayUrl ?? this.relayUrl, pubkey: pubkey == _sentinel ? this.pubkey : pubkey as String?, nsec: nsec == _sentinel ? this.nsec : nsec as String?, + pushSubscriptionState: + pushSubscriptionState ?? this.pushSubscriptionState, addedAt: addedAt, ); } @@ -58,6 +65,7 @@ class Community { 'relayUrl': relayUrl, if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, + 'pushSubscriptionState': pushSubscriptionState.toJson(), 'addedAt': addedAt.toIso8601String(), }; @@ -67,6 +75,11 @@ class Community { relayUrl: json['relayUrl'] as String, pubkey: json['pubkey'] as String?, nsec: json['nsec'] as String?, + pushSubscriptionState: json['pushSubscriptionState'] == null + ? const BuzzPushLeaseSubscriptionState.desired() + : BuzzPushLeaseSubscriptionState.fromJson( + Map.from(json['pushSubscriptionState'] as Map), + ), addedAt: DateTime.parse(json['addedAt'] as String), ); diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 842877c9cd..bf2f4d7583 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth_provider.dart'; import '../push/push_bridge.dart'; +import '../push/push_subscription.dart'; import 'community.dart'; import 'community_storage.dart'; @@ -40,6 +41,9 @@ class _CommunitySnapshotSync { community.relayUrl, community.pubkey, community.nsec, + buzzPushSubscriptionStateFingerprint( + community.pushSubscriptionState, + ), ].join('\u0000'), ) .join('\u0001'); @@ -141,6 +145,33 @@ class CommunityListNotifier extends AsyncNotifier> { ref.invalidate(authProvider); } + Future updateDesiredPushSubscriptions( + String id, + List desired, + ) 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 renameCommunity(String id, String name) async { final storage = ref.read(communityStorageProvider); final current = state.value ?? []; diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 24021af194..2777db2632 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -37,6 +37,7 @@ Future registerBuzzPushCommunitySnapshot( name: community.name, relayUrl: community.relayUrl, pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), + pushSubscriptionState: community.pushSubscriptionState, ), ]; final signingKeys = {}; diff --git a/mobile/lib/shared/push/push_models.dart b/mobile/lib/shared/push/push_models.dart index f741e5b9ec..2649486af7 100644 --- a/mobile/lib/shared/push/push_models.dart +++ b/mobile/lib/shared/push/push_models.dart @@ -1,4 +1,5 @@ import '../relay/nostr_models.dart'; +import 'push_subscription.dart'; const buzzPushFallbackBody = 'Reconnect to your relay now'; @@ -7,12 +8,14 @@ class BuzzPushCommunitySnapshot { final String name; final String relayUrl; final String? pubkey; + final BuzzPushLeaseSubscriptionState pushSubscriptionState; const BuzzPushCommunitySnapshot({ required this.id, required this.name, required this.relayUrl, this.pubkey, + required this.pushSubscriptionState, }); Map toJson() => { @@ -20,6 +23,7 @@ class BuzzPushCommunitySnapshot { 'name': name, 'relayUrl': relayUrl, if (pubkey != null) 'pubkey': pubkey, + 'pushSubscriptionState': pushSubscriptionState.toJson(), }; factory BuzzPushCommunitySnapshot.fromJson(Map json) { @@ -28,6 +32,9 @@ class BuzzPushCommunitySnapshot { name: json['name'] as String, relayUrl: json['relayUrl'] as String, pubkey: json['pubkey'] as String?, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.fromJson( + Map.from(json['pushSubscriptionState'] as Map), + ), ); } } diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart new file mode 100644 index 0000000000..3d71e57aad --- /dev/null +++ b/mobile/lib/shared/push/push_subscription.dart @@ -0,0 +1,391 @@ +import 'dart:convert'; + +const buzzPushEligibleKinds = [7, 9, 1059, 40007, 46010]; +const buzzPushRenderableKinds = [9]; +const buzzPushChannelKinds = [9]; +const buzzPushChannelChunkSize = 50; +const buzzPushMaxSubscriptions = 16; +const buzzPushMaxIgnoreFilters = 8; +const buzzPushHellthreadParticipantLimit = 20; + +const _supportedNotificationClasses = {'silent', 'default', 'time_sensitive'}; +const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; +final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); +final _channelIdPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', +); + +enum BuzzPushLeaseSubscriptionAuthority { desired, accepted } + +class BuzzPushFilter { + final List kinds; + final List? authors; + final List? pTags; + final List? hTags; + final List? eTags; + + BuzzPushFilter({ + required Iterable kinds, + Iterable? authors, + Iterable? pTags, + Iterable? hTags, + Iterable? eTags, + }) : kinds = List.unmodifiable(kinds), + authors = _optionalList(authors), + pTags = _optionalList(pTags), + hTags = _optionalList(hTags), + eTags = _optionalList(eTags) { + _validate(); + } + + Map toJson() => { + 'kinds': kinds, + if (authors != null) 'authors': authors, + if (pTags != null) '#p': pTags, + if (hTags != null) '#h': hTags, + if (eTags != null) '#e': eTags, + }; + + factory BuzzPushFilter.fromJson(Map json) { + _rejectUnknownKeys(json, _filterKeys, 'push filter'); + return BuzzPushFilter( + kinds: _intList(json, 'kinds'), + authors: _optionalStringList(json, 'authors'), + pTags: _optionalStringList(json, '#p'), + hTags: _optionalStringList(json, '#h'), + eTags: _optionalStringList(json, '#e'), + ); + } + + void _validate() { + if (kinds.isEmpty || + kinds.any((kind) => !buzzPushEligibleKinds.contains(kind))) { + throw const FormatException('Push filter contains invalid kinds.'); + } + for (final value in [...?authors, ...?pTags, ...?eTags]) { + if (!_exactHexPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains a non-exact hex value.', + ); + } + } + for (final value in hTags ?? const []) { + if (!_channelIdPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains an invalid channel ID.', + ); + } + } + } +} + +class BuzzPushSuppression { + final int pTagsMax; + + const BuzzPushSuppression({required this.pTagsMax}) : assert(pTagsMax > 0); + + Map toJson() => {'p_tags_max': pTagsMax}; + + factory BuzzPushSuppression.fromJson(Map json) { + _rejectUnknownKeys(json, const {'p_tags_max'}, 'push suppression'); + final value = json['p_tags_max']; + if (value is! int || value <= 0) { + throw const FormatException('p_tags_max must be a positive integer.'); + } + return BuzzPushSuppression(pTagsMax: value); + } +} + +class BuzzPushSubscription { + final BuzzPushFilter filter; + final String notificationClass; + final List ignore; + final BuzzPushSuppression? suppress; + + BuzzPushSubscription({ + required this.filter, + required this.notificationClass, + Iterable ignore = const [], + this.suppress, + }) : ignore = List.unmodifiable(ignore) { + if (!_supportedNotificationClasses.contains(notificationClass)) { + throw const FormatException('Unsupported push notification class.'); + } + if (filter.authors == null && + filter.pTags == null && + filter.hTags == null) { + throw const FormatException('Push subscription filter is not narrowed.'); + } + if (this.ignore.length > buzzPushMaxIgnoreFilters) { + throw const FormatException( + 'Push subscription has too many ignore filters.', + ); + } + } + + Map toJson() => { + 'filter': filter.toJson(), + 'class': notificationClass, + if (ignore.isNotEmpty) + 'ignore': [for (final filter in ignore) filter.toJson()], + if (suppress != null) 'suppress': suppress!.toJson(), + }; + + factory BuzzPushSubscription.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'filter', + 'class', + 'ignore', + 'suppress', + }, 'push subscription'); + final filter = json['filter']; + final notificationClass = json['class']; + final ignore = json['ignore']; + final suppress = json['suppress']; + if (filter is! Map || notificationClass is! String) { + throw const FormatException('Malformed push subscription.'); + } + if (ignore != null && ignore is! List) { + throw const FormatException('Push subscription ignore must be a list.'); + } + if (suppress != null && suppress is! Map) { + throw const FormatException( + 'Push subscription suppress must be an object.', + ); + } + return BuzzPushSubscription( + filter: BuzzPushFilter.fromJson(Map.from(filter)), + notificationClass: notificationClass, + ignore: [ + for (final raw in ignore as List? ?? const []) + if (raw is Map) + BuzzPushFilter.fromJson(Map.from(raw)) + else + throw const FormatException('Malformed push ignore filter.'), + ], + suppress: suppress == null + ? null + : BuzzPushSuppression.fromJson(Map.from(suppress)), + ); + } +} + +/// Desired and relay-accepted lease subscriptions are intentionally separate. +/// +/// Workstream A has no lease publisher, so snapshots use [desired] authority. +/// A later publisher must persist an observed [accepted] lease before changing +/// authority. Keeping both sets makes relay rejection, clamping, or expiry +/// detectable instead of assuming the desired lease was accepted unchanged. +class BuzzPushLeaseSubscriptionState { + final BuzzPushLeaseSubscriptionAuthority authority; + final List desired; + final List? accepted; + + const BuzzPushLeaseSubscriptionState.desired({ + this.desired = const [], + this.accepted, + }) : authority = BuzzPushLeaseSubscriptionAuthority.desired; + + BuzzPushLeaseSubscriptionState.accepted({ + required Iterable desired, + required Iterable acceptedSubscriptions, + }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, + desired = List.unmodifiable(desired), + accepted = List.unmodifiable(acceptedSubscriptions); + + List get authoritative => switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => desired, + BuzzPushLeaseSubscriptionAuthority.accepted => accepted!, + }; + + BuzzPushLeaseSubscriptionState withDesired( + Iterable subscriptions, + ) { + final updated = List.unmodifiable(subscriptions); + return switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => + BuzzPushLeaseSubscriptionState.desired( + desired: updated, + accepted: accepted, + ), + BuzzPushLeaseSubscriptionAuthority.accepted => + BuzzPushLeaseSubscriptionState.accepted( + desired: updated, + acceptedSubscriptions: accepted!, + ), + }; + } + + Map toJson() => { + 'authority': authority.name, + 'desired': [for (final subscription in desired) subscription.toJson()], + if (accepted != null) + 'accepted': [for (final subscription in accepted!) subscription.toJson()], + }; + + factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'authority', + 'desired', + 'accepted', + }, 'push subscription state'); + final authority = json['authority']; + final desired = _subscriptionList( + json['desired'], + 'desired', + allowEmpty: authority == 'desired', + ); + final acceptedRaw = json['accepted']; + final accepted = acceptedRaw == null + ? null + : _subscriptionList(acceptedRaw, 'accepted'); + return switch (authority) { + 'desired' => BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + ), + 'accepted' when accepted != null => + BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: accepted, + ), + 'accepted' => throw const FormatException( + 'Accepted push authority requires accepted subscriptions.', + ), + _ => throw const FormatException('Unknown push subscription authority.'), + }; + } +} + +List buildDesiredBuzzPushSubscriptions({ + required String myPubkey, + Iterable channelIds = const [], + Iterable mutedChannelIds = const [], +}) { + final normalizedPubkey = myPubkey.toLowerCase(); + if (!_exactHexPattern.hasMatch(normalizedPubkey)) { + throw const FormatException('Push subscription pubkey must be exact hex.'); + } + + final normalizedChannelIds = channelIds.map(_normalizeChannelID).toSet(); + final normalizedMuted = mutedChannelIds.map(_normalizeChannelID).toSet(); + final activeMuted = + normalizedMuted.intersection(normalizedChannelIds).toList()..sort(); + final mutedIgnoreFilters = []; + for (final chunk in _chunks(activeMuted, buzzPushChannelChunkSize)) { + mutedIgnoreFilters.add( + BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + ); + } + if (mutedIgnoreFilters.length + 1 > buzzPushMaxIgnoreFilters) { + throw const FormatException('Too many muted channels for a push lease.'); + } + + final selfAuthored = BuzzPushFilter( + kinds: buzzPushEligibleKinds, + authors: [normalizedPubkey], + ); + final ignores = [selfAuthored, ...mutedIgnoreFilters]; + const suppression = BuzzPushSuppression( + pTagsMax: buzzPushHellthreadParticipantLimit, + ); + final subscriptions = [ + BuzzPushSubscription( + filter: BuzzPushFilter( + kinds: buzzPushEligibleKinds, + pTags: [normalizedPubkey], + ), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ]; + + final channels = normalizedChannelIds.difference(normalizedMuted).toList() + ..sort(); + for (final chunk in _chunks(channels, buzzPushChannelChunkSize)) { + subscriptions.add( + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ); + } + if (subscriptions.length > buzzPushMaxSubscriptions) { + throw const FormatException('Too many channels for a push lease.'); + } + return List.unmodifiable(subscriptions); +} + +String buzzPushSubscriptionsFingerprint( + List subscriptions, +) => jsonEncode([ + for (final subscription in subscriptions) subscription.toJson(), +]); + +String buzzPushSubscriptionStateFingerprint( + BuzzPushLeaseSubscriptionState state, +) => jsonEncode(state.toJson()); + +List> _chunks(List values, int size) => [ + for (var offset = 0; offset < values.length; offset += size) + values.sublist(offset, (offset + size).clamp(0, values.length)), +]; + +String _normalizeChannelID(String value) { + final normalized = value.toLowerCase(); + if (!_channelIdPattern.hasMatch(normalized)) { + throw const FormatException('Push subscription channel ID is invalid.'); + } + return normalized; +} + +List? _optionalList(Iterable? values) => + values == null ? null : List.unmodifiable(values); + +List _intList(Map json, String key) { + final raw = json[key]; + if (raw is! List || raw.any((value) => value is! int)) { + throw FormatException('$key must be an integer list.'); + } + return raw.cast(); +} + +List? _optionalStringList(Map json, String key) { + if (!json.containsKey(key)) return null; + final raw = json[key]; + if (raw is! List || raw.isEmpty || raw.any((value) => value is! String)) { + throw FormatException('$key must be a non-empty string list.'); + } + return raw.cast(); +} + +List _subscriptionList( + Object? raw, + String label, { + bool allowEmpty = false, +}) { + if (raw is! List || (!allowEmpty && raw.isEmpty)) { + throw FormatException('$label subscriptions must be a non-empty list.'); + } + return [ + for (final item in raw) + if (item is Map) + BuzzPushSubscription.fromJson(Map.from(item)) + else + throw FormatException('Malformed $label subscription.'), + ]; +} + +void _rejectUnknownKeys( + Map json, + Set allowed, + String label, +) { + final unknown = json.keys.where((key) => !allowed.contains(key)); + if (unknown.isNotEmpty) { + throw FormatException('$label contains unknown field ${unknown.first}.'); + } +} diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart new file mode 100644 index 0000000000..5a3e835f02 --- /dev/null +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../features/channels/channel.dart'; +import '../../features/channels/channel_mutes/channel_mutes_provider.dart'; +import '../../features/channels/channels_provider.dart'; +import '../community/community.dart'; +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import 'push_subscription.dart'; + +/// Keeps the persisted desired lease and the App Group snapshot aligned with +/// the active identity, joined channels, and mute state. This is desired client +/// policy only. Relay-accepted authority is introduced by lease publication. +final pushSubscriptionSyncProvider = Provider((ref) { + final active = ref.watch(activeCommunityProvider).value; + final channels = ref.watch(channelsProvider).value; + final mutes = ref.watch(channelMutesProvider); + if (active == null || channels == null || !mutes.isReady) return; + + final subscriptions = desiredBuzzPushSubscriptions( + community: active, + channels: channels, + mutedChannelIds: [ + for (final entry in mutes.store.channels.entries) + if (entry.value.muted) entry.key, + ], + ); + if (subscriptions == null) return; + unawaited( + ref + .read(communityListProvider.notifier) + .updateDesiredPushSubscriptions(active.id, subscriptions), + ); +}); + +List? desiredBuzzPushSubscriptions({ + required Community community, + required Iterable channels, + required Iterable mutedChannelIds, +}) { + final pubkey = community.pubkey ?? pubkeyFromNsec(community.nsec); + if (pubkey == null || pubkey.isEmpty) return null; + return buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: [ + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel.id, + ], + mutedChannelIds: mutedChannelIds, + ); +} diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index dc0835e622..40ba6903d7 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; /// In-memory fake that extends Fake to satisfy all FlutterSecureStorage /// interface methods, but implements the core read/write/delete with real @@ -117,6 +118,36 @@ void main() { expect(loaded.first.name, 'Test'); expect(loaded.first.relayUrl, 'https://relay.example.com'); expect(loaded.first.pubkey, 'abc123'); + expect( + loaded.first.pushSubscriptionState.authority, + BuzzPushLeaseSubscriptionAuthority.desired, + ); + }); + + test('round-trips desired push subscription state', () async { + final pubkey = 'a' * 64; + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: const ['123e4567-e89b-42d3-a456-426614174000'], + ); + final community = + Community.create( + name: 'Push', + relayUrl: 'https://relay.example.com', + pubkey: pubkey, + ).copyWith( + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ), + ); + + await storage.save(community); + final loaded = (await storage.loadAll()).single; + + expect( + loaded.pushSubscriptionState.toJson(), + community.pushSubscriptionState.toJson(), + ); }); test('save updates existing community with same id', () async { diff --git a/mobile/test/shared/push/push_models_test.dart b/mobile/test/shared/push/push_models_test.dart index ed0ee46d66..3643c66973 100644 --- a/mobile/test/shared/push/push_models_test.dart +++ b/mobile/test/shared/push/push_models_test.dart @@ -1,8 +1,32 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/push/push_models.dart'; import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; void main() { + test('push community snapshot carries explicit subscription authority', () { + final subscription = buildDesiredBuzzPushSubscriptions( + myPubkey: 'a' * 64, + ).single; + final snapshot = BuzzPushCommunitySnapshot( + id: 'community', + name: 'Team', + relayUrl: 'https://relay.example.com', + pubkey: 'a' * 64, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ), + ); + + final decoded = BuzzPushCommunitySnapshot.fromJson(snapshot.toJson()); + + expect(decoded.toJson(), snapshot.toJson()); + expect( + decoded.pushSubscriptionState.authority, + BuzzPushLeaseSubscriptionAuthority.desired, + ); + }); + test('resolves newest user-visible event into notification content', () { final mine = 'a' * 64; final alice = 'b' * 64; diff --git a/mobile/test/shared/push/push_subscription_provider_test.dart b/mobile/test/shared/push/push_subscription_provider_test.dart new file mode 100644 index 0000000000..f8865e5b4a --- /dev/null +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -0,0 +1,64 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/push/push_subscription_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + const activeID = '123e4567-e89b-42d3-a456-426614174000'; + const archivedID = '123e4567-e89b-42d3-a456-426614174001'; + const nonMemberID = '123e4567-e89b-42d3-a456-426614174002'; + + test( + 'derives desired subscriptions from nsec, membership, and mute state', + () { + final nsec = nostr.Keys.generate().nsec; + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + nsec: nsec, + ), + channels: [ + channel(activeID), + channel(archivedID, archived: true), + channel(nonMemberID, isMember: false), + ], + mutedChannelIds: const [activeID, archivedID], + ); + + expect(subscriptions, isNotNull); + expect(subscriptions, hasLength(1)); + expect(subscriptions!.single.filter.pTags, hasLength(1)); + expect(subscriptions.single.ignore, hasLength(2)); + expect(subscriptions.single.ignore.last.hTags, [activeID]); + }, + ); + + test('returns no desired subscriptions without a signing identity', () { + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + ), + channels: [channel(activeID)], + mutedChannelIds: const [], + ); + + expect(subscriptions, isNull); + }); +} + +Channel channel(String id, {bool isMember = true, bool archived = false}) => + Channel( + id: id, + name: id, + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'author', + createdAt: DateTime(2026), + memberCount: 1, + isMember: isMember, + archivedAt: archived ? DateTime(2026) : null, + ); diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart new file mode 100644 index 0000000000..ca8b5f4522 --- /dev/null +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final me = 'a' * 64; + const channelA = '123e4567-e89b-42d3-a456-426614174000'; + const channelB = '123e4567-e89b-42d3-a456-426614174001'; + + test( + 'desired subscription state round-trips with an accepted-state seam', + () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [channelB, channelA], + ); + final state = BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ); + + final decoded = BuzzPushLeaseSubscriptionState.fromJson( + jsonDecode(jsonEncode(state.toJson())) as Map, + ); + + expect(decoded.authority, BuzzPushLeaseSubscriptionAuthority.desired); + expect(decoded.accepted, isNull); + expect(decoded.toJson(), state.toJson()); + expect(decoded.authoritative, hasLength(2)); + expect(decoded.authoritative.last.filter.hTags, [channelA, channelB]); + }, + ); + + test('accepted authority requires observed accepted subscriptions', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + + expect( + () => BuzzPushLeaseSubscriptionState.fromJson({ + 'authority': 'accepted', + 'desired': [subscription.toJson()], + }), + throwsFormatException, + ); + }); + + test('builds aligned self and unmuted channel subscriptions', () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me.toUpperCase(), + channelIds: [channelB, channelA], + mutedChannelIds: [channelB, '123e4567-e89b-42d3-a456-426614174099'], + ); + + expect(subscriptions, hasLength(2)); + expect(subscriptions.first.filter.kinds, buzzPushEligibleKinds); + expect(subscriptions.first.filter.pTags, [me]); + expect(subscriptions.last.filter.kinds, buzzPushChannelKinds); + expect(subscriptions.last.filter.hTags, [channelA]); + expect(subscriptions.first.ignore, hasLength(2)); + expect(subscriptions.first.ignore.last.hTags, [channelB]); + expect( + subscriptions.first.suppress?.pTagsMax, + buzzPushHellthreadParticipantLimit, + ); + }); + + test('chunks channel subscriptions to relay limits deterministically', () { + final channels = [ + for (var i = 0; i < 51; i++) + '00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}', + ]..shuffle(); + + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: channels, + ); + + expect(subscriptions, hasLength(3)); + expect(subscriptions[1].filter.hTags, hasLength(50)); + expect(subscriptions[2].filter.hTags, hasLength(1)); + expect( + subscriptions[1].filter.hTags, + orderedEquals([...subscriptions[1].filter.hTags!]..sort()), + ); + }); + + test('rejects malformed and unsupported subscription fields', () { + final valid = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + ).single.toJson(); + + expect( + () => buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: const ['not-a-channel'], + ), + throwsFormatException, + ); + + expect( + () => BuzzPushSubscription.fromJson({...valid, 'unexpected': true}), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [40002], + '#p': [me], + }, + 'class': 'default', + }), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [9], + '#p': ['not-a-pubkey'], + }, + 'class': 'default', + }), + throwsFormatException, + ); + }); +}