mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
## Summary Mobile push only covered Solo chats through explicit mentions. Solo chats are private streams rather than DMs, and the push lease subscribed channel-wide only to DMs. Agent replies in your own Solo chats therefore never woke the phone. The lease now subscribes channel-wide to the viewer's own joined, unarchived Solo chats, using the same predicate as the Solo tab, including older unlabelled Solo chats listed in the encrypted Solo record. Self-authored messages stay excluded through the existing lease ignores. Muted Solo chats stay out of the lease, like muted channels, so they push only on mention. The lease waits while the Solo record loads so an unresolved record never drops legacy Solo chats. If that record fails with no cached copy, DM and mention updates proceed without the legacy chats, which the Solo tab cannot list either, and they return when it recovers. Solo chats created by someone else still push only on mention, matching how they appear in the channel list. A lease is bounded by its channel count (750), by the relay's advertised plaintext and content sizes, and by NIP-44's 65535-byte plaintext ceiling. DMs always stay, as before. Solo chats fill the remaining room most recent first, sized with the publisher's own lease encoding, the exact NIP-44 payload length and the largest endpoint the relay accepts. Sizes are computed rather than built, so a relay's advertised limits never drive an allocation. Solo chats beyond the room push only on mention instead of failing the lease update or publication, and Notifications settings says how many. A relay too small even for DMs and mentions fails push setup visibly instead of saving a lease it would reject. Publication accepts only endpoint grants in the gateway's JSON-safe alphabet, so the sizing assumption holds. Thread replies outside DMs still do not push unless they mention you. That is a separate gap. ### Related issue None found. ### Validation - Added test: the desired lease includes joined DMs and the viewer's own Solo chats, and includes legacy Solo chats from the Solo record, and excludes archived and muted Solo chats, other people's Solo chats, and unlabelled private streams. It fails without the fix. - Added test: the sync provider holds the desired lease while the Solo record loads, proceeds without legacy chats if the record fails, and adds them once it arrives. It fails if loading is treated as empty or if a failure blocks updates. - Added tests: over the channel count, and over each size limit (relay plaintext, relay content, NIP-44 plaintext), the lease keeps every DM and exactly the newest Solo chats, reports the rest as omitted, and is accepted by `publishBuzzDevPushLease` with a grant at the relay's endpoint limit, while a max-length grant that JSON would escape is rejected as not a gateway grant. A relay advertising a 2^50-byte endpoint limit is sized without allocating. Each row fails if its guard is removed. - Added test: a relay whose content or plaintext limit cannot fit even the lease without Solo chats rejects that lease at publication, and the provider fails setup instead of saving it. It fails if the guard is removed. - Added test: the NIP-44 payload length matches real encryption at every padding boundary up to 65535 bytes. - Added test: Notifications settings shows "Older Solo chats notify only on mention" with the omitted count. It fails without the row. - Mobile lint and the full mobile test suite pass. - Not exercised end to end on a device. A signed dev build was installed on a test iPhone against a personal dev relay, but the phone's dev identity owns no assistant there, so the device could not create a Solo chat without exporting its key. This will be validated in dogfood. The relay matcher already handles channel-wide `#h` leases for DMs, and Solo messages use the same kind and scoping. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
495 lines
17 KiB
Dart
495 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:buzz/app.dart';
|
|
import 'package:buzz/main.dart';
|
|
import 'package:buzz/features/channels/channel.dart';
|
|
import 'package:buzz/features/channels/channels_provider.dart';
|
|
import 'package:buzz/features/channels/channel_mutes/channel_mutes_provider.dart';
|
|
import 'package:buzz/features/settings/settings_page.dart';
|
|
import 'package:buzz/shared/push/push_setup_recovery.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../community/community_storage_test.dart' show FakeSecureStorage;
|
|
import 'package:buzz/features/age_gate/age_signal_provider.dart';
|
|
import 'package:buzz/features/home/home_page.dart';
|
|
import 'package:buzz/shared/auth/auth.dart';
|
|
import 'package:buzz/shared/push/dev_push_lease.dart';
|
|
import 'package:buzz/shared/push/push_bootstrap.dart';
|
|
import 'package:buzz/shared/push/push_bridge.dart';
|
|
import 'package:buzz/shared/push/push_subscription.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:buzz/shared/push/push_lease_revocation_outbox.dart';
|
|
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:buzz/shared/theme/theme.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
final nativeChannels = <MethodChannel>[];
|
|
setUp(() {
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
messenger.setMockMethodCallHandler(SystemChannels.platform_views, (
|
|
call,
|
|
) async {
|
|
if (call.method == 'create') {
|
|
final args = call.arguments as Map;
|
|
final channel = MethodChannel('${args['viewType']}/${args['id']}');
|
|
nativeChannels.add(channel);
|
|
messenger.setMockMethodCallHandler(channel, (_) async => null);
|
|
}
|
|
return null;
|
|
});
|
|
messenger.setMockMethodCallHandler(
|
|
const MethodChannel('dev.fluttercommunity.plus/connectivity_status'),
|
|
(_) async => null,
|
|
);
|
|
});
|
|
tearDown(() {
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
messenger.setMockMethodCallHandler(SystemChannels.platform_views, null);
|
|
for (final channel in nativeChannels) {
|
|
messenger.setMockMethodCallHandler(channel, null);
|
|
}
|
|
nativeChannels.clear();
|
|
messenger.setMockMethodCallHandler(
|
|
const MethodChannel('dev.fluttercommunity.plus/connectivity_status'),
|
|
null,
|
|
);
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
testWidgets('subscription setup failure does not replace the app', (
|
|
tester,
|
|
) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final community = Community.create(
|
|
name: 'Team',
|
|
relayUrl: 'https://relay.example',
|
|
pubkey: 'a' * 64,
|
|
).copyWith(pushNotificationsEnabled: true);
|
|
final storage = _FailingStorage();
|
|
await storage.save(community);
|
|
storage.fail = true;
|
|
final channels = _Channels();
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
retry: (_, _) => null,
|
|
overrides: [
|
|
savedPrefsProvider.overrideWithValue(prefs),
|
|
authProvider.overrideWith(_Auth.new),
|
|
ageSignalProvider.overrideWith(_Age.new),
|
|
relaySessionProvider.overrideWith(_Session.new),
|
|
activeCommunityProvider.overrideWith((_) async => community),
|
|
currentRelayPushDescriptorProvider.overrideWith(
|
|
(_) async => _descriptor,
|
|
),
|
|
channelsProvider.overrideWith(() => channels),
|
|
channelMutesProvider.overrideWith(_Mutes.new),
|
|
communityStorageProvider.overrideWithValue(storage),
|
|
communitySnapshotWriterProvider.overrideWithValue((_) async {}),
|
|
],
|
|
child: const BuzzPushBootstrap(child: App()),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
expect(find.byType(HomePage), findsOneWidget);
|
|
expect(find.byType(Navigator), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
final container = ProviderScope.containerOf(
|
|
tester.element(find.byType(App)),
|
|
);
|
|
expect(
|
|
container.read(buzzPushSetupStatusProvider).error,
|
|
isA<FormatException>(),
|
|
);
|
|
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
|
unawaited(
|
|
navigator.push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => const Text('Open conversation'),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Correcting the inputs retries the real provider, but asynchronous secure
|
|
// storage failure must also remain observable without replacing the route.
|
|
channels.useValidChannel();
|
|
await tester.pump();
|
|
await tester.pump();
|
|
expect(
|
|
container.read(buzzPushSetupStatusProvider).error,
|
|
isA<StateError>(),
|
|
);
|
|
expect(
|
|
(await storage.loadAll()).single.pushSubscriptionState.desired,
|
|
isEmpty,
|
|
);
|
|
expect(find.text('Open conversation'), findsOneWidget);
|
|
expect(
|
|
tester.state<NavigatorState>(find.byType(Navigator)),
|
|
same(navigator),
|
|
);
|
|
|
|
storage.fail = false;
|
|
await tester.pump(const Duration(seconds: 5));
|
|
await tester.pump();
|
|
expect(container.read(buzzPushSetupStatusProvider).hasError, isFalse);
|
|
expect(
|
|
(await storage.loadAll()).single.pushSubscriptionState.desired,
|
|
isNotEmpty,
|
|
);
|
|
expect(find.text('Open conversation'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
});
|
|
|
|
testWidgets(
|
|
'push dependency failure is visible and retry preserves navigation',
|
|
(tester) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
final semantics = tester.ensureSemantics();
|
|
await tester.binding.setSurfaceSize(const Size(390, 844));
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
await tester.runAsync(() async {
|
|
await (FontLoader(
|
|
'Inter',
|
|
)..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load();
|
|
await (FontLoader(
|
|
'BuzzTabler',
|
|
)..addFont(rootBundle.load('assets/fonts/TablerIcons.ttf'))).load();
|
|
});
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final community = Community.create(
|
|
name: 'Team',
|
|
relayUrl: 'https://relay.example',
|
|
).copyWith(pushNotificationsEnabled: true);
|
|
var fail = true;
|
|
var attempts = 0;
|
|
final journal = BuzzPushLeaseRevocationStorage(
|
|
secure: FakeSecureStorage(),
|
|
);
|
|
final storage = CommunityStorage(secure: FakeSecureStorage());
|
|
final removals = [for (var i = 0; i < 2; i++) _removableCommunity(i)];
|
|
for (final removal in removals) {
|
|
await storage.save(removal);
|
|
}
|
|
final published = <String>[];
|
|
const nativePush = MethodChannel('buzz/push');
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
nativePush,
|
|
(call) async {
|
|
if (call.method != 'endpointGrants') return null;
|
|
return [
|
|
{
|
|
'relayOrigin': 'wss://relay.example',
|
|
'relayPubkey': 'a' * 64,
|
|
'installationId': '0' * 32,
|
|
'endpointGrant': 'opaque',
|
|
'endpointHash': 'b' * 64,
|
|
'endpointEpoch': 1,
|
|
'generation': 1,
|
|
'expiresAt': DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600,
|
|
},
|
|
];
|
|
},
|
|
);
|
|
addTearDown(
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
nativePush,
|
|
null,
|
|
),
|
|
);
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
retry: (_, _) => null,
|
|
overrides: [
|
|
savedPrefsProvider.overrideWithValue(prefs),
|
|
communityStorageProvider.overrideWithValue(storage),
|
|
communitySnapshotWriterProvider.overrideWithValue((_) async {}),
|
|
authProvider.overrideWith(_Auth.new),
|
|
relaySessionProvider.overrideWith(_Session.new),
|
|
activeCommunityProvider.overrideWith((_) async => community),
|
|
currentRelayPushDescriptorProvider.overrideWith(
|
|
(_) async => _descriptor,
|
|
),
|
|
buzzPushLeaseRevocationOutboxProvider.overrideWith((ref) {
|
|
attempts++;
|
|
if (fail) throw StateError('injected push dependency failure');
|
|
final outbox = BuzzPushLeaseRevocationOutbox(
|
|
storage: journal,
|
|
publisher: (record) async =>
|
|
published.add(record.installationId),
|
|
);
|
|
ref.onDispose(outbox.dispose);
|
|
return outbox;
|
|
}),
|
|
],
|
|
child: BuzzPushBootstrap(
|
|
child: Consumer(
|
|
builder: (context, ref, _) => MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: TextButton(
|
|
onPressed: () => retryBuzzPushSetup(ref),
|
|
child: const Text('Core app'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(find.text('Core app'), findsOneWidget);
|
|
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
|
unawaited(
|
|
navigator.push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => SettingsPage(
|
|
profileHeader: const SizedBox.shrink(),
|
|
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Notification setup failed'), findsOneWidget);
|
|
expect(
|
|
tester
|
|
.getSemantics(find.byKey(const ValueKey('push-setup-retry')))
|
|
.getSemanticsData()
|
|
.hasAction(ui.SemanticsAction.tap),
|
|
isTrue,
|
|
);
|
|
await _capture(tester, 'notification-setup-failed');
|
|
final container = ProviderScope.containerOf(
|
|
tester.element(find.byType(SettingsPage)),
|
|
);
|
|
final communities = container.read(communityListProvider.notifier);
|
|
// A removal attempted while initialization is broken caches a failed
|
|
// callback provider. Recovery must refresh it as well as the worker.
|
|
await expectLater(
|
|
communities.removeCommunity(removals.first.id),
|
|
throwsA(
|
|
predicate(
|
|
(error) =>
|
|
error.toString().contains('injected push dependency failure'),
|
|
),
|
|
),
|
|
);
|
|
expect(
|
|
(await storage.loadAll()).map((value) => value.id),
|
|
contains(removals.first.id),
|
|
);
|
|
// Exhaust automatic retries. Persistent failures stay visible, but stop
|
|
// waking forever. Manual recovery remains available after exhaustion.
|
|
for (final seconds in [5, 10, 20, 60]) {
|
|
await tester.pump(Duration(seconds: seconds));
|
|
await tester.pump();
|
|
}
|
|
expect(attempts, 4);
|
|
fail = false;
|
|
await tester.ensureVisible(
|
|
find.byKey(const ValueKey('push-setup-retry')),
|
|
);
|
|
await tester.tap(find.byKey(const ValueKey('push-setup-retry')));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(SettingsPage), findsOneWidget);
|
|
expect(find.text('Notification setup failed'), findsNothing);
|
|
await _capture(tester, 'notification-setup-recovered');
|
|
expect(
|
|
tester.state<NavigatorState>(find.byType(Navigator)),
|
|
same(navigator),
|
|
);
|
|
await communities.removeCommunity(removals.first.id);
|
|
await tester.pump();
|
|
expect(published, [removals.first.pushLeaseInstallationId]);
|
|
expect(await journal.loadAll(), isEmpty);
|
|
|
|
// Community removal has now cached this worker's callbacks. Retrying an
|
|
// unrelated setup operation must not dispose that healthy worker.
|
|
navigator.pop();
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Core app'));
|
|
await tester.pumpAndSettle();
|
|
await communities.removeCommunity(removals.last.id);
|
|
await tester.pump();
|
|
expect(published, [
|
|
for (final removal in removals) removal.pushLeaseInstallationId,
|
|
]);
|
|
expect(await journal.loadAll(), isEmpty);
|
|
expect(tester.takeException(), isNull);
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
debugDefaultTargetPlatformOverride = null;
|
|
semantics.dispose();
|
|
},
|
|
);
|
|
|
|
testWidgets('cold-start native failure cannot prevent runApp', (
|
|
tester,
|
|
) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
SharedPreferences.setMockInitialValues({});
|
|
const channel = MethodChannel('buzz/push');
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
var fail = true;
|
|
final pendingResponse = Completer<Object?>();
|
|
addTearDown(() => pendingPushNotificationLink.value = null);
|
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
|
if (call.method == 'takePendingNotificationResponse' && fail) {
|
|
return pendingResponse.future;
|
|
}
|
|
return call.method == 'takePendingNotificationResponse'
|
|
? {
|
|
'eventId': 'event',
|
|
'channelId': 'channel',
|
|
'communityId': 'community',
|
|
}
|
|
: null;
|
|
});
|
|
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
|
await runBuzzApp(const MaterialApp(home: Text('Core app')));
|
|
await tester.pump();
|
|
await tester.pump();
|
|
expect(find.text('Core app'), findsOneWidget);
|
|
pendingResponse.completeError(
|
|
PlatformException(code: 'injected_native_failure'),
|
|
);
|
|
await tester.pump();
|
|
final container = ProviderScope.containerOf(
|
|
tester.element(find.text('Core app')),
|
|
);
|
|
expect(
|
|
container.read(buzzPushSetupStatusProvider).error,
|
|
isA<PlatformException>(),
|
|
);
|
|
fail = false;
|
|
await tester.pump(const Duration(seconds: 5));
|
|
await tester.pump();
|
|
expect(container.read(buzzPushSetupStatusProvider).hasError, isFalse);
|
|
expect(pendingPushNotificationLink.value?.messageId, 'event');
|
|
expect(tester.takeException(), isNull);
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
|
|
test('setup retries stop after bounded backoff', () {
|
|
final error = StateError('persistent failure');
|
|
expect(
|
|
[for (var i = 0; i < 5; i++) pushSetupRetry(i, error)],
|
|
[
|
|
const Duration(seconds: 5),
|
|
const Duration(seconds: 10),
|
|
const Duration(seconds: 20),
|
|
null,
|
|
null,
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
class _Auth extends AuthNotifier {
|
|
@override
|
|
Future<AuthState> build() async =>
|
|
const AuthState(status: AuthStatus.authenticated);
|
|
}
|
|
|
|
class _Age extends AgeSignalNotifier {
|
|
@override
|
|
AgeSignalState build() => AgeSignalState.allowed;
|
|
@override
|
|
Future<void> request() async {}
|
|
}
|
|
|
|
class _Session extends RelaySessionNotifier {
|
|
@override
|
|
SessionState build() =>
|
|
const SessionState(status: SessionStatus.disconnected);
|
|
}
|
|
|
|
const _descriptor = BuzzPushLeaseDescriptor(
|
|
origin: 'wss://relay.example',
|
|
executorKeyId: 'key',
|
|
executorPubkey: 'pubkey',
|
|
transport: 'apns',
|
|
maxLeaseTtlSeconds: 3600,
|
|
// The relay's NIP-11 limits, which fit a DM lease at the endpoint limit.
|
|
maxContentLength: 65536,
|
|
maxPlaintextLength: 32768,
|
|
maxEndpointLength: 2048,
|
|
maxStringLength: 512,
|
|
);
|
|
|
|
class _Channels extends ChannelsNotifier {
|
|
@override
|
|
Future<List<Channel>> build() async => [_channel('invalid-channel')];
|
|
void useValidChannel() =>
|
|
state = AsyncData([_channel('123e4567-e89b-42d3-a456-426614174000')]);
|
|
}
|
|
|
|
class _Mutes extends ChannelMutesNotifier {
|
|
@override
|
|
ChannelMutesState build() => const ChannelMutesState(isReady: true);
|
|
}
|
|
|
|
Channel _channel(String id) => Channel(
|
|
id: id,
|
|
name: 'DM',
|
|
channelType: 'dm',
|
|
visibility: 'private',
|
|
description: '',
|
|
createdBy: 'a' * 64,
|
|
createdAt: DateTime(2026),
|
|
memberCount: 2,
|
|
isMember: true,
|
|
);
|
|
|
|
class _FailingStorage extends CommunityStorage {
|
|
_FailingStorage() : super(secure: FakeSecureStorage());
|
|
bool fail = false;
|
|
@override
|
|
Future<void> save(Community community) async {
|
|
if (fail) throw StateError('injected secure storage failure');
|
|
await super.save(community);
|
|
}
|
|
}
|
|
|
|
Future<void> _capture(WidgetTester tester, String name) async {
|
|
final directory = Platform.environment['PUSH_SETUP_SCREENSHOTS'];
|
|
if (directory == null) return;
|
|
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
|
find.byType(RepaintBoundary).first,
|
|
);
|
|
await tester.runAsync(() async {
|
|
final image = await boundary.toImage();
|
|
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
await Directory(directory).create(recursive: true);
|
|
await File('$directory/$name.png').writeAsBytes(data!.buffer.asUint8List());
|
|
image.dispose();
|
|
});
|
|
}
|
|
|
|
Community _removableCommunity(int index) {
|
|
final keys = nostr.Keys('${index + 1}' * 64);
|
|
return Community.create(
|
|
name: 'Removed $index',
|
|
relayUrl: 'https://relay.example',
|
|
nsec: keys.nsec,
|
|
pubkey: keys.public,
|
|
).copyWith(
|
|
pushNotificationsEnabled: true,
|
|
pushSubscriptionState: const BuzzPushLeaseSubscriptionState.desired()
|
|
.withReservedGeneration(4),
|
|
);
|
|
}
|