diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 1764a5ea3e..bf2251e92a 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -432,12 +432,46 @@ class PairingPage extends HookConsumerWidget { final appSurface = PopScope( key: const Key('pairing-pop-scope'), + canPop: !showLoading, onPopInvokedWithResult: (didPop, _) { if (didPop && pairingState.status != PairingStatus.success) { ref.read(pairingProvider.notifier).reset(); } }, - child: pairingScaffold, + child: Stack( + fit: StackFit.expand, + children: [ + ExcludeFocus( + excluding: pairingState.status == PairingStatus.connecting, + child: Offstage( + offstage: pairingState.status == PairingStatus.connecting, + child: pairingScaffold, + ), + ), + if (pairingState.status == PairingStatus.connecting) + const CommunityLoadingSurface( + key: Key('pairing-community-loading'), + ), + if (pairingState.status == PairingStatus.connecting) + SafeArea( + child: Align( + alignment: Alignment.topLeft, + child: Material( + color: Colors.transparent, + child: TextButton( + onPressed: () { + ref.read(pairingProvider.notifier).reset(); + if (identityRecoveryOnly) { + Navigator.of(context).pop(); + } + }, + child: const Text('Cancel'), + ), + ), + ), + ), + ], + ), ); if (!fallbackScannerVisible.value) { diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 951082bf80..435b992d2b 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -887,6 +887,7 @@ class PairingNotifier extends Notifier { await validator(relayUrl: community.relayUrl, nsec: community.nsec); if (generation != _pairingGeneration) return; + state = const PairingState(status: PairingStatus.storing); await ref .read(authProvider.notifier) .authenticateWithCommunity( diff --git a/mobile/lib/features/pairing/pairing_socket.dart b/mobile/lib/features/pairing/pairing_socket.dart index b5de8238b1..efc0fb4c30 100644 --- a/mobile/lib/features/pairing/pairing_socket.dart +++ b/mobile/lib/features/pairing/pairing_socket.dart @@ -66,7 +66,15 @@ class PairingSocket { } final channel = _channelFactory(Uri.parse(_wsUrl)); _channel = channel; - await Future.any([channel.ready, _closed.future]); + try { + await Future.any([ + channel.ready, + _closed.future, + ]).timeout(const Duration(seconds: 10)); + } catch (_) { + dispose(); + rethrow; + } if (_closed.isCompleted) { throw const PairingAuthException('Pairing connection cancelled'); } diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index bac5678aa0..a625e695c5 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -151,6 +151,12 @@ class OnboardingAppEntry extends HookConsumerWidget { final handlingBack = useRef(false); void back() { + final pairingStatus = ref.read(pairingProvider).status; + if (pairingStatus == PairingStatus.transferring || + pairingStatus == PairingStatus.storing || + pairingStatus == PairingStatus.success) { + return; + } // Nested page and entry PopScopes receive the same system Back event. if (handlingBack.value) return; handlingBack.value = true; diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 7bc93e5e62..dbe3499c28 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -486,7 +486,7 @@ void main() { expect(find.text('Connect'), findsNothing); }); - testWidgets('pairing actions are disabled when connecting', (tester) async { + testWidgets('connection progress replaces pairing actions', (tester) async { await tester.pumpWidget( WidgetHelpers.testable( overrides: [ @@ -497,13 +497,44 @@ void main() { ); await tester.pump(); - final scanButton = tester.widget(find.byType(FilledButton)); - final pairingCodeButton = tester.widget( - find.widgetWithText(TextButton, 'Use pairing code'), + expect( + find.byKey(const Key('pairing-community-loading')), + findsOneWidget, ); + expect(find.text('Scan a QR code'), findsNothing); + expect(find.text('Use pairing code'), findsNothing); + }); - expect(scanButton.onPressed, isNull); - expect(pairingCodeButton.onPressed, isNull); + testWidgets('connection Cancel closes the identity recovery route', ( + tester, + ) async { + final navigator = GlobalKey(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith(() => _ConnectingPairingNotifier()), + ], + child: MaterialApp( + navigatorKey: navigator, + theme: AppTheme.light(), + home: const Scaffold(body: Text('Settings')), + ), + ), + ); + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => const PairingPage( + addingCommunity: true, + identityRecoveryOnly: true, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + expect(find.byType(PairingPage), findsNothing); + expect(find.text('Settings'), findsOneWidget); }); testWidgets('recovery entry rejects ordinary nostrpair codes', ( diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 730f13c8b4..d25fd861ea 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -1,5 +1,6 @@ import '../../helpers/controllable_pairing_socket.dart'; import 'package:buzz/features/pairing/pairing_page.dart'; +import 'package:buzz/onboarding_app_entry.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:buzz/shared/community/paired_community_landing.dart'; @@ -110,6 +111,65 @@ void main() { }, ); + for (final entry in ['pairing', 'add', 'inline']) { + testWidgets('legacy storage blocks Back from $entry', (tester) async { + final auth = FakeAuthNotifier() + ..entered = Completer() + ..authentication = Completer(); + final notifier = PairingNotifier( + credentialValidator: ({required relayUrl, required nsec}) async {}, + ); + container = ProviderContainer( + overrides: [ + pairingProvider.overrideWith(() => notifier), + authProvider.overrideWith(() => auth), + ], + ); + var exits = 0; + final navigator = GlobalKey(); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AppTheme.light(), + navigatorKey: navigator, + home: const Scaffold(body: Text('Previous page')), + ), + ), + ); + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => entry != 'pairing' + ? OnboardingAppEntry( + addingCommunity: true, + restoreAccount: false, + onExit: entry == 'inline' ? () => exits++ : null, + ) + : const PairingPage(), + ), + ); + await tester.pumpAndSettle(); + final pending = notifier.pair( + _encodePairingCode(nsec: nostr.Keys.generate().nsec), + ); + await auth.entered!.future; + await tester.pump(); + expect(container.read(pairingProvider).status, PairingStatus.storing); + expect(find.text('Cancel'), findsNothing); + expect(await navigator.currentState!.maybePop(), isTrue); + await tester.pump(); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(PairingPage), findsOneWidget); + expect(exits, 0); + expect(container.read(pairingProvider).status, PairingStatus.storing); + auth.authentication!.complete(); + await pending; + expect(container.read(pairingProvider).status, PairingStatus.success); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + test( 'organization relays are admitted by their session, not pre-validated', () async { diff --git a/mobile/test/features/pairing/pairing_qr_scanner_test.dart b/mobile/test/features/pairing/pairing_qr_scanner_test.dart index 35c225a11c..ae222f7b7e 100644 --- a/mobile/test/features/pairing/pairing_qr_scanner_test.dart +++ b/mobile/test/features/pairing/pairing_qr_scanner_test.dart @@ -1,8 +1,16 @@ +import 'dart:io'; + +import 'package:buzz/features/pairing/pairing_provider.dart'; + +import '../../helpers/controllable_pairing_socket.dart'; + import 'dart:async'; import 'package:buzz/features/pairing/pairing_qr_scanner.dart'; import 'package:buzz/features/pairing/pairing_page.dart'; + import '../../helpers/widget_helpers.dart'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -29,6 +37,115 @@ void main() { MobileScannerPlatform.instance = defaultScannerPlatform; }); + for (final (paste, cancel) in [ + (false, false), + (true, false), + (false, true), + (true, true), + ]) { + testWidgets('pairing entry paste=$paste cancel=$cancel permits retry', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _qrScannerPlatformChannel, + (call) async => + call.method == 'usesDynamicIslandQrScannerPortal' ? true : null, + ); + final readiness = Completer(); + final notifier = PairingNotifier( + socketFactory: + ({ + required wsUrl, + required ephemeralPrivkey, + required onMessage, + required onDisconnected, + }) => _PendingPairingSocket( + readiness.future, + ephemeralPrivkey: ephemeralPrivkey, + onMessage: onMessage, + onDisconnected: onDisconnected, + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [pairingProvider.overrideWith(() => notifier)], + child: PairingPage( + addingCommunity: paste, + existingUser: paste, + connectionLanding: true, + showConnectionOptions: false, + ), + ), + ); + const code = + 'nostrpair://79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' + '?secret=abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789' + '&relay=wss%3A%2F%2Fpairing.example&v=1'; + if (paste) { + await tester.enterText( + find.byKey(const Key('community-pairing-input')), + code, + ); + await tester.tap(find.text('Connect')); + } else { + await tester.tap(find.text('Scan a QR code')); + await tester.pump(); + await tester.pump(); + fakeScannerPlatform._barcodes.add( + BarcodeCapture(barcodes: [Barcode(rawValue: code)]), + ); + } + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect( + find.byKey(const Key('pairing-community-loading')), + findsOneWidget, + ); + expect(find.text('Scan a QR code'), findsNothing); + if (cancel) { + await tester.tap(find.text('Cancel')); + await tester.pump(); + expect( + find.byKey(const Key('pairing-community-loading')), + findsNothing, + ); + } + readiness.completeError(const SocketException('Network is unreachable')); + await tester.pump(); + await tester.pump(); + expect( + find.textContaining('Could not reach the pairing relay'), + cancel ? findsNothing : findsOneWidget, + ); + if (paste) { + expect( + tester + .widget( + find.byKey(const Key('community-pairing-input')), + ) + .controller! + .text, + code, + ); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Connect'), + ) + .onPressed, + isNotNull, + ); + } else { + expect(find.text('Scan a QR code'), findsOneWidget); + } + debugDefaultTargetPlatformOverride = null; + }); + } + group('DynamicIslandQrScannerGeometry', () { const viewport = Size(393, 852); const geometry = DynamicIslandQrScannerGeometry( @@ -582,3 +699,18 @@ class _FakeMobileScannerPlatform extends MobileScannerPlatform { await _barcodes.close(); } } + +class _PendingPairingSocket extends ControllablePairingSocket { + _PendingPairingSocket( + this.readiness, { + required super.ephemeralPrivkey, + required super.onMessage, + required super.onDisconnected, + }); + final Future readiness; + @override + Future connect() async { + await readiness; + await super.connect(); + } +} diff --git a/mobile/test/features/pairing/pairing_socket_test.dart b/mobile/test/features/pairing/pairing_socket_test.dart index 99e3fe44de..f43f39ad2f 100644 --- a/mobile/test/features/pairing/pairing_socket_test.dart +++ b/mobile/test/features/pairing/pairing_socket_test.dart @@ -43,6 +43,28 @@ void main() { }); } + testWidgets('a stalled upgrade stops within ten seconds', (tester) async { + final readiness = Completer(); + final channel = _ControlledWebSocketChannel(readiness: readiness.future); + final socket = _socket('ws://unused', channelFactory: (_) => channel); + Object? failure; + final connection = socket.connect().then( + (_) {}, + onError: (Object error) { + failure = error; + }, + ); + try { + await tester.pump(const Duration(seconds: 11)); + expect(failure, isA()); + expect(socket.isConnected, isFalse); + } finally { + socket.dispose(); + readiness.complete(); + await connection; + } + }); + test('connects when the pairing relay sends no AUTH challenge', () async { final server = await _TestRelay.start((_) {}); addTearDown(server.close);