From 14037e32780ec8be103ec4caa42fb57adc731d60 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Tue, 3 Nov 2026 19:12:57 -0800 Subject: [PATCH] fix(mobile): show progress after pairing QR scans (#9009) ## Summary After a pairing QR scan, the app shows connection progress until code entry or a connection error. A WebSocket handshake that remains pending stops after ten seconds. Cancel is available until storage starts. It resets pairing and restores the entry form. Pasted codes remain available after an error or cancellation. Back navigation stays blocked during identity transfer and storage. Cancel on Send identity to desktop returns to Settings, so another attempt starts through the authorization step. ### Related issue Related to [#9001](https://github.com/block/buzz/pull/9001), which covers organization sign-in after identity transfer. This change covers the earlier connection stage. ### Testing A simulator build with PR #9001 completes identity transfer and saves the community. The community chooser still blocks organization sign-in. [PR #9028](https://github.com/block/buzz/pull/9028) covers that step. Physical iPhone testing and successful real-relay checks in light and dark mode remain pending. On a build with this change, scan a desktop pairing QR code. Check that progress appears, Cancel returns to the entry form, and a fresh attempt reaches code entry. Paste a pairing code, cancel during connection, and check that the form keeps the code. Test a failed connection and retry. In Settings, open Send identity to desktop, start a recovery connection, and cancel. Check that Settings appears and another attempt asks for authorization. Camera recognition speed and the reported intermittent phone network failure remain unverified. Generated with Codex --------- Signed-off-by: Daniel Rouleau Co-authored-by: Sol --- mobile/lib/features/pairing/pairing_page.dart | 36 ++++- .../features/pairing/pairing_provider.dart | 1 + .../lib/features/pairing/pairing_socket.dart | 10 +- mobile/lib/onboarding_app_entry.dart | 6 + .../features/pairing/pairing_page_test.dart | 43 +++++- .../pairing/pairing_provider_test.dart | 60 ++++++++ .../pairing/pairing_qr_scanner_test.dart | 132 ++++++++++++++++++ .../features/pairing/pairing_socket_test.dart | 22 +++ 8 files changed, 302 insertions(+), 8 deletions(-) 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);