diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 57ebe8ddc2..59ff41ee87 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -15,6 +15,7 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/success_haptic.dart'; import '../../shared/push/push_relay_capability_provider.dart'; import '../../shared/push/push_bootstrap.dart'; +import '../../shared/push/push_subscription_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/utils/string_utils.dart'; import '../pairing/pairing_provider.dart'; diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index 5173c3cb42..ab807ea92b 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -25,6 +25,9 @@ class _NotificationsSection extends ConsumerWidget { return const SizedBox.shrink(); } final canToggle = hasCapability || community.pushNotificationsEnabled; + final omittedSoloChats = community.pushNotificationsEnabled + ? ref.watch(pushSubscriptionSyncProvider).value ?? 0 + : 0; void setEnabled(bool enabled) { unawaited( @@ -72,6 +75,14 @@ class _NotificationsSection extends ConsumerWidget { ], ), ), + if (omittedSoloChats > 0) + AppListRow( + key: const ValueKey('push-solo-coverage'), + title: 'Older Solo chats notify only on mention', + subtitle: omittedSoloChats == 1 + ? '1 chat is past this relay’s notification limit.' + : '$omittedSoloChats chats are past this relay’s notification limit.', + ), ], ); } diff --git a/mobile/lib/shared/crypto/nip44.dart b/mobile/lib/shared/crypto/nip44.dart index 32e025be18..dc72998ae7 100644 --- a/mobile/lib/shared/crypto/nip44.dart +++ b/mobile/lib/shared/crypto/nip44.dart @@ -88,6 +88,15 @@ String nip44Decrypt(Uint8List conversationKey, String payloadBase64) { return _unpad(padded); } +/// Length of the base64 payload [nip44Encrypt] returns for a plaintext of +/// [plaintextBytes] UTF-8 bytes, or null when NIP-44 cannot encrypt it. +int? nip44PayloadLength(int plaintextBytes) { + if (plaintextBytes < 1 || plaintextBytes > 65535) return null; + // version(1) || nonce(32) || length(2) || padded || mac(32) + final payload = 1 + 32 + 2 + _calcPaddedLen(plaintextBytes) + 32; + return 4 * ((payload + 2) ~/ 3); +} + // ── Padding ───────────────────────────────────────────────────────────────── int _calcPaddedLen(int unpaddedLen) { diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index 6834d210b9..9ab4c93bd3 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -18,19 +18,28 @@ const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$'; const _installationIdPattern = r'^[0-9a-f]{32}$'; /// Whether an active lease carrying [subscriptions] fits [descriptor]'s -/// plaintext limit with any endpoint grant and generation it accepts. -/// Publication still checks the ciphertext limit. +/// plaintext and content limits with any endpoint grant and generation it +/// accepts. Sizes are computed, not built, since the limits come from the relay. bool buzzPushSubscriptionsFitLease( BuzzPushLeaseDescriptor descriptor, List subscriptions, ) { - final plaintext = _activeLeasePlaintext( - descriptor: descriptor, - endpoint: 'x' * descriptor.maxEndpointLength, - generation: _maxSafeJsonInteger, - subscriptions: subscriptions, - ); - return utf8.encode(plaintext).length <= descriptor.maxPlaintextLength; + final withoutEndpoint = utf8 + .encode( + _activeLeasePlaintext( + descriptor: descriptor, + endpoint: '', + generation: _maxSafeJsonInteger, + subscriptions: subscriptions, + ), + ) + .length; + // A grant at the endpoint limit that needs no JSON escaping adds exactly it. + final plaintext = withoutEndpoint + descriptor.maxEndpointLength; + final content = nip44PayloadLength(plaintext); + return plaintext <= descriptor.maxPlaintextLength && + content != null && + content <= descriptor.maxContentLength; } String _activeLeasePlaintext({ diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart index c2293b1241..80c31c1885 100644 --- a/mobile/lib/shared/push/push_subscription_provider.dart +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../features/channels/channel.dart'; @@ -15,24 +14,26 @@ import 'push_setup_recovery.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 = FutureProvider.autoDispose(( +/// +/// Yields how many Solo chats the lease had no room for. +final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( ref, ) async { try { if (ref.watch(authProvider).value?.status != AuthStatus.authenticated) { - return; + return 0; } final active = ref.watch(activeCommunityProvider).value; final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; if (active == null || !active.pushNotificationsEnabled || descriptor == null) { - return; + return 0; } final channels = ref.watch(channelsProvider).value; final mutes = ref.watch(channelMutesProvider); if (channels == null || !mutes.isReady) { - return; + return 0; } // Legacy Solo chats are listed only in the Solo record. Wait while it @@ -40,9 +41,9 @@ final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( // cached copy, keep DMs and mentions current; the Solo tab cannot list // those chats either, and recovery republishes them. final soloRecord = ref.watch(meGroupsProvider); - if (soloRecord.isLoading && !soloRecord.hasValue) return; + if (soloRecord.isLoading && !soloRecord.hasValue) return 0; - final subscriptions = desiredBuzzPushSubscriptions( + final desired = desiredBuzzPushSubscriptions( community: active, descriptor: descriptor, channels: channels, @@ -52,17 +53,20 @@ final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( if (entry.value.muted) entry.key, ], ); - if (subscriptions == null) return; + if (desired == null) return 0; await ref .read(communityListProvider.notifier) - .updateDesiredPushSubscriptions(active.id, subscriptions); + .updateDesiredPushSubscriptions(active.id, desired.subscriptions); + return desired.omittedSoloChats; } catch (error, stack) { reportPushSetupError('subscription synchronization', error, stack); rethrow; } }, retry: pushSetupRetry); -List? desiredBuzzPushSubscriptions({ +/// The desired lease, and how many Solo chats it had no room for. +({List subscriptions, int omittedSoloChats})? +desiredBuzzPushSubscriptions({ required Community community, required BuzzPushLeaseDescriptor descriptor, required Iterable channels, @@ -100,13 +104,10 @@ List? desiredBuzzPushSubscriptions({ } fitting = low; } - final omitted = candidates.omitted + candidates.solos.length - fitting; - if (omitted > 0) { - debugPrint( - 'Push lease is full; $omitted older Solo chats push only on mention.', - ); - } - return lease(fitting); + return ( + subscriptions: lease(fitting), + omittedSoloChats: candidates.omitted + candidates.solos.length - fitting, + ); } /// Channel-wide traffic pushes only for joined DMs and the viewer's own Solo diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 5cc2f78b38..1d29e35d93 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -12,6 +12,7 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/shared/push/dev_push_lease.dart'; import 'package:buzz/shared/push/push_relay_capability_provider.dart'; +import 'package:buzz/shared/push/push_subscription_provider.dart'; import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; @@ -170,6 +171,8 @@ void main() { savedPrefsProvider.overrideWithValue(prefs), activeCommunityProvider.overrideWith((ref) async => community), currentRelayPushDescriptorProvider.overrideWith((ref) => capability), + // The lease had no room for 12 older Solo chats. + pushSubscriptionSyncProvider.overrideWith((ref) async => 12), appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), buzzPushAuthorizationStatusReaderProvider.overrideWithValue( () async => BuzzPushAuthorizationStatus.authorized, @@ -191,6 +194,14 @@ void main() { findsOneWidget, ); expect(tester.widget(find.byType(Switch)).value, isTrue); + expect( + find.text('Older Solo chats notify only on mention'), + findsOneWidget, + ); + expect( + find.text('12 chats are past this relay’s notification limit.'), + findsOneWidget, + ); debugDefaultTargetPlatformOverride = null; }); diff --git a/mobile/test/shared/crypto/nip44_interop_test.dart b/mobile/test/shared/crypto/nip44_interop_test.dart index be4e7d87bc..ada5aca6c0 100644 --- a/mobile/test/shared/crypto/nip44_interop_test.dart +++ b/mobile/test/shared/crypto/nip44_interop_test.dart @@ -18,4 +18,22 @@ void main() { '{"version":1,"theme":"catppuccin-latte","accent":"#f97316","followSystem":false}', ); }); + + test('predicts the encrypted payload length at every padding boundary', () { + final key = getConversationKey( + '0000000000000000000000000000000000000000000000000000000000000001', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + ); + for (final length in [ + 1, 32, 33, 256, 257, 4096, 4097, 32768, 32769, 65535, // + ]) { + expect( + nip44PayloadLength(length), + nip44Encrypt(key, 'x' * length).length, + reason: '$length bytes', + ); + } + expect(nip44PayloadLength(0), isNull); + expect(nip44PayloadLength(65536), isNull); + }); } diff --git a/mobile/test/shared/push/push_subscription_provider_test.dart b/mobile/test/shared/push/push_subscription_provider_test.dart index 5968344351..17602afa6e 100644 --- a/mobile/test/shared/push/push_subscription_provider_test.dart +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -6,7 +6,9 @@ import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; import 'package:buzz/shared/push/dev_push_lease.dart'; import 'package:buzz/shared/push/push_relay_capability_provider.dart'; import 'package:buzz/shared/push/push_subscription_provider.dart'; @@ -24,7 +26,7 @@ void main() { () { final nsec = nostr.Keys.generate().nsec; final subscriptions = desiredBuzzPushSubscriptions( - descriptor: _relayDescriptor, + descriptor: _descriptor(), community: Community.create( name: 'Team', relayUrl: 'https://relay.example.com', @@ -36,7 +38,7 @@ void main() { channel(nonMemberID, isMember: false), ], mutedChannelIds: const [activeID, archivedID], - ); + )?.subscriptions; expect(subscriptions, isNotNull); expect(subscriptions, hasLength(1)); @@ -66,7 +68,7 @@ void main() { archived: archived, ); final subscriptions = desiredBuzzPushSubscriptions( - descriptor: _relayDescriptor, + descriptor: _descriptor(), community: Community.create( name: 'Team', relayUrl: 'https://relay.example.com', @@ -83,7 +85,7 @@ void main() { ], legacySoloChannelIds: const [legacySoloID], mutedChannelIds: const [mutedSoloID], - ); + )?.subscriptions; expect(subscriptions, hasLength(2)); expect(subscriptions!.first.ignore.last.hTags, [mutedSoloID]); @@ -91,109 +93,130 @@ void main() { expect(subscriptions.last.filter.hTags, [dmID, soloID, legacySoloID]); }); - test('fills lease capacity with the most recent Solo chats', () { + test('fills the lease channel count with the most recent Solo chats', () { final keys = nostr.Keys.generate(); - String id(int n) => - '123e4567-e89b-42d3-a456-${n.toRadixString(16).padLeft(12, '0')}'; - Channel solo(int n) => channel( - id(n), - channelType: 'stream', - visibility: 'private', - labels: const ['buzz.solo'], - createdBy: keys.public, - lastMessageAt: DateTime(2026).add(Duration(minutes: n)), - ); const dmCount = 10; - const unmutedSolos = buzzPushMaxChannelWideChannels; - const mutedSolos = buzzPushMaxMutedChannels + 5; + const soloCount = buzzPushMaxChannelWideChannels; final channels = [ - for (var n = 0; n < dmCount; n++) channel(id(n)), - for (var n = dmCount; n < dmCount + unmutedSolos + mutedSolos; n++) - solo(n), - ]; - final muted = [ - for (var n = dmCount + unmutedSolos; n < channels.length; n++) id(n), + for (var n = 0; n < dmCount; n++) channel(_id(n)), + for (var n = dmCount; n < dmCount + soloCount; n++) _solo(n, keys.public), ]; - final subscriptions = desiredBuzzPushSubscriptions( - descriptor: _roomyDescriptor, - community: Community.create( - name: 'Team', - relayUrl: 'https://relay.example.com', - nsec: keys.nsec, - ), + final desired = desiredBuzzPushSubscriptions( + descriptor: _descriptor(), + community: _community(keys), channels: channels, - mutedChannelIds: muted, + mutedChannelIds: const [], )!; final followed = { - for (final subscription in subscriptions.skip(1)) + for (final subscription in desired.subscriptions.skip(1)) ...?subscription.filter.hTags, }; - final ignoredMutes = { - for (final ignore in subscriptions.first.ignore.skip(1)) ...?ignore.hTags, - }; - expect(subscriptions, hasLength(buzzPushMaxSubscriptions)); + expect(desired.subscriptions, hasLength(buzzPushMaxSubscriptions)); expect(followed, hasLength(buzzPushMaxChannelWideChannels)); - expect(followed, containsAll([for (var n = 0; n < dmCount; n++) id(n)])); - expect(followed, contains(id(dmCount + unmutedSolos - 1))); - expect(followed, isNot(contains(id(dmCount + dmCount - 1)))); - expect(followed, contains(id(dmCount + dmCount))); - expect(ignoredMutes, hasLength(buzzPushMaxMutedChannels)); - expect(ignoredMutes, contains(id(channels.length - 1))); - expect(ignoredMutes, isNot(contains(id(dmCount + unmutedSolos)))); - }); + expect(followed, containsAll([for (var n = 0; n < dmCount; n++) _id(n)])); + expect(followed, contains(_id(channels.length - 1))); + expect(followed, isNot(contains(_id(dmCount + dmCount - 1)))); + expect(desired.omittedSoloChats, dmCount); - test('keeps the most recent Solo chats that fit the lease plaintext', () { - final keys = nostr.Keys.generate(); - String id(int n) => - '123e4567-e89b-42d3-a456-${n.toRadixString(16).padLeft(12, '0')}'; - const dmCount = 10; - const soloCount = 400; - final channels = [ - for (var n = 0; n < dmCount; n++) channel(id(n)), - for (var n = dmCount; n < dmCount + soloCount; n++) - channel( - id(n), - channelType: 'stream', - visibility: 'private', - labels: const ['buzz.solo'], - createdBy: keys.public, - lastMessageAt: DateTime(2026).add(Duration(minutes: n)), - ), - ]; - // The newest 100 Solo chats are muted, so every lease filter ignores them. - final muted = [ - for (var n = dmCount + soloCount - 100; n < dmCount + soloCount; n++) - id(n), - ]; - - final subscriptions = desiredBuzzPushSubscriptions( - community: Community.create( - name: 'Team', - relayUrl: 'https://relay.example.com', - nsec: keys.nsec, - ), - descriptor: _relayDescriptor, + // A relay may advertise any endpoint limit; sizing must not allocate it. + final unbounded = desiredBuzzPushSubscriptions( + descriptor: _descriptor(endpointLength: 1 << 50), + community: _community(keys), channels: channels, - mutedChannelIds: muted, + mutedChannelIds: const [], )!; - - final kept = { - for (final subscription in subscriptions.skip(1)) - ...?subscription.filter.hTags, - for (final ignore in subscriptions.first.ignore.skip(1)) ...?ignore.hTags, - }; - expect( - buzzPushSubscriptionsFitLease(_relayDescriptor, subscriptions), - isTrue, - ); - expect(kept, containsAll([for (var n = 0; n < dmCount; n++) id(n)])); - expect(kept, containsAll(muted)); - expect(kept.length, lessThan(dmCount + soloCount)); - expect(kept, isNot(contains(id(dmCount)))); + expect(unbounded.omittedSoloChats, soloCount); }); + for (final (limit, descriptor, soloCount, mutedSolos) in [ + ( + 'plaintext', + _descriptor(contentLength: 65536, plaintextLength: 32768), + 400, + 100, + ), + ( + 'content', + _descriptor(contentLength: 24000, plaintextLength: 32768), + 400, + 100, + ), + ('muted channel count', _descriptor(), 400, 400), + ('NIP-44 plaintext', _descriptor(), 600, 200), + ]) { + test('keeps the newest Solo chats within the $limit limit', () async { + final keys = nostr.Keys.generate(); + const dmCount = 10; + final channels = [ + for (var n = 0; n < dmCount; n++) channel(_id(n)), + for (var n = dmCount; n < dmCount + soloCount; n++) + _solo(n, keys.public), + ]; + // Muted chats are the newest, so every lease filter ignores them. + final muted = [ + for (var n = channels.length - mutedSolos; n < channels.length; n++) + _id(n), + ]; + + final desired = desiredBuzzPushSubscriptions( + community: _community(keys), + descriptor: descriptor, + channels: channels, + mutedChannelIds: muted, + )!; + + final kept = { + for (final subscription in desired.subscriptions.skip(1)) + ...?subscription.filter.hTags, + for (final ignore in desired.subscriptions.first.ignore.skip(1)) + ...?ignore.hTags, + }; + final keptSolos = soloCount - desired.omittedSoloChats; + expect(keptSolos, inInclusiveRange(1, soloCount - 1)); + expect(kept, { + for (var n = 0; n < dmCount; n++) _id(n), + for (var n = channels.length - keptSolos; n < channels.length; n++) + _id(n), + }); + // The relay accepts any grant up to its endpoint limit. + final publication = await publishBuzzDevPushLease( + grant: BuzzPushEndpointGrant( + relayOrigin: descriptor.origin, + relayPubkey: descriptor.executorPubkey, + installationId: 'c' * 32, + endpointGrant: 'x' * descriptor.maxEndpointLength, + endpointHash: 'd' * 64, + endpointEpoch: 1, + generation: 9007199254740991, + expiresAt: 1756212000, + ), + descriptor: descriptor, + nsec: keys.nsec, + memberPubkey: keys.public, + subscriptions: desired.subscriptions, + now: () => DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000), + submit: + ({ + required kind, + required content, + required tags, + createdAt, + }) async => const NostrEvent( + id: 'accepted-id', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: '', + sig: '', + ), + ); + expect(publication.eventId, 'accepted-id'); + }); + } + test('waits for the Solo record but not past its failure', () async { const legacySoloID = '123e4567-e89b-42d3-a456-426614174009'; final community = Community.create( @@ -209,7 +232,7 @@ void main() { overrides: [ authProvider.overrideWith(_Authenticated.new), currentRelayPushDescriptorProvider.overrideWith( - (ref) async => _relayDescriptor, + (ref) async => _descriptor(), ), activeCommunityProvider.overrideWith((ref) async => community), communityListProvider.overrideWith(() => communities), @@ -251,7 +274,7 @@ void main() { test('returns no desired subscriptions without a signing identity', () { final subscriptions = desiredBuzzPushSubscriptions( - descriptor: _relayDescriptor, + descriptor: _descriptor(), community: Community.create( name: 'Team', relayUrl: 'https://relay.example.com', @@ -321,28 +344,40 @@ class _Authenticated extends AuthNotifier { const AuthState(status: AuthStatus.authenticated); } -/// Limits the relay advertises in NIP-11 (`crates/buzz-relay/src/nip11.rs`). -const _relayDescriptor = BuzzPushLeaseDescriptor( - origin: 'wss://relay.example', - executorKeyId: 'key', - executorPubkey: 'pubkey', - transport: 'apns', - maxLeaseTtlSeconds: 3600, - maxContentLength: 65536, - maxPlaintextLength: 32768, - maxEndpointLength: 4096, - maxStringLength: 512, +final _relayKeys = nostr.Keys.generate(); + +String _id(int n) => + '123e4567-e89b-42d3-a456-${n.toRadixString(16).padLeft(12, '0')}'; + +Channel _solo(int n, String owner) => channel( + _id(n), + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + createdBy: owner, + lastMessageAt: DateTime(2026).add(Duration(minutes: n)), ); -/// Room for any lease the channel counts allow. -const _roomyDescriptor = BuzzPushLeaseDescriptor( +Community _community(nostr.Keys keys) => Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + nsec: keys.nsec, +); + +/// Defaults to no limit beyond NIP-44's own. The relay advertises 65536 and +/// 32768 in NIP-11 (`crates/buzz-relay/src/nip11.rs`). +BuzzPushLeaseDescriptor _descriptor({ + int contentLength = 1 << 22, + int plaintextLength = 1 << 21, + int endpointLength = 4096, +}) => BuzzPushLeaseDescriptor( origin: 'wss://relay.example', executorKeyId: 'key', - executorPubkey: 'pubkey', + executorPubkey: _relayKeys.public, transport: 'apns', maxLeaseTtlSeconds: 3600, - maxContentLength: 1 << 22, - maxPlaintextLength: 1 << 21, - maxEndpointLength: 4096, + maxContentLength: contentLength, + maxPlaintextLength: plaintextLength, + maxEndpointLength: endpointLength, maxStringLength: 512, );