From 5efb40b53dbff42167030a1946d5917e73496704 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 26 Oct 2026 22:42:03 -0700 Subject: [PATCH] fix(mobile): size push leases without allocating and show Solo overflow The lease fit check built a placeholder as long as the relay's advertised endpoint limit and checked only plaintext. It now adds the limit arithmetically and checks NIP-44's exact payload length against the content limit and NIP-44's own plaintext ceiling, so a selected lease always passes publication's guards. When the lease has no room for some Solo chats, Notifications settings now says how many notify only on mention instead of logging it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Brow --- .../lib/features/settings/settings_page.dart | 1 + .../settings_page/notifications_section.dart | 11 + mobile/lib/shared/crypto/nip44.dart | 9 + mobile/lib/shared/push/dev_push_lease.dart | 27 +- .../push/push_subscription_provider.dart | 35 +-- .../features/settings/settings_page_test.dart | 11 + .../shared/crypto/nip44_interop_test.dart | 18 ++ .../push/push_subscription_provider_test.dart | 253 ++++++++++-------- 8 files changed, 230 insertions(+), 135 deletions(-) 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, );