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 <drouleau@squareup.com>
Co-authored-by: Sol <c35e7cc95f6fdbfb7e861dccc02bdca567b6d6b20f16b5115c3c6ae082bbeede@buzz.block.builderlab.xyz>
This commit is contained in:
Daniel Rouleau
2026-11-03 19:12:57 -08:00
committed by GitHub
parent 069cb9a0fe
commit 14037e3278
8 changed files with 302 additions and 8 deletions

View File

@@ -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) {

View File

@@ -887,6 +887,7 @@ class PairingNotifier extends Notifier<PairingState> {
await validator(relayUrl: community.relayUrl, nsec: community.nsec);
if (generation != _pairingGeneration) return;
state = const PairingState(status: PairingStatus.storing);
await ref
.read(authProvider.notifier)
.authenticateWithCommunity(

View File

@@ -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');
}

View File

@@ -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;

View File

@@ -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<FilledButton>(find.byType(FilledButton));
final pairingCodeButton = tester.widget<TextButton>(
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<NavigatorState>();
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<void>(
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', (

View File

@@ -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<void>()
..authentication = Completer<void>();
final notifier = PairingNotifier(
credentialValidator: ({required relayUrl, required nsec}) async {},
);
container = ProviderContainer(
overrides: [
pairingProvider.overrideWith(() => notifier),
authProvider.overrideWith(() => auth),
],
);
var exits = 0;
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: AppTheme.light(),
navigatorKey: navigator,
home: const Scaffold(body: Text('Previous page')),
),
),
);
navigator.currentState!.push(
MaterialPageRoute<void>(
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 {

View File

@@ -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<void>();
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<TextField>(
find.byKey(const Key('community-pairing-input')),
)
.controller!
.text,
code,
);
expect(
tester
.widget<FilledButton>(
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<void> readiness;
@override
Future<void> connect() async {
await readiness;
await super.connect();
}
}

View File

@@ -43,6 +43,28 @@ void main() {
});
}
testWidgets('a stalled upgrade stops within ten seconds', (tester) async {
final readiness = Completer<void>();
final channel = _ControlledWebSocketChannel(readiness: readiness.future);
final socket = _socket('ws://unused', channelFactory: (_) => channel);
Object? failure;
final connection = socket.connect().then<void>(
(_) {},
onError: (Object error) {
failure = error;
},
);
try {
await tester.pump(const Duration(seconds: 11));
expect(failure, isA<TimeoutException>());
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);