feat(#629): show and scan a contact QR
Both halves of the visual exchange, sharing one format and one parser. Render: showMyContactQrDialog puts this device's own identity on screen as a QR plus the same link as selectable, copyable text. It refuses to render when not connected, since an empty key would encode a QR nobody can add. Scan: ContactQrScannerScreen validates with Contact.isValidShareUri, the same check the paste path uses, and pops the raw string. The add dialog routes a scan through the same handler as a paste, so a QR gets no separate code path. The scan affordance is gated to platforms mobile_scanner supports (Android, iOS, macOS, web). On Windows and Linux the QR half is the render side, which is the better desktop flow anyway: put your code on the big screen and let the other person scan it with a phone. Fixes a real defect found by the new test, not a test artifact: QrCodeDisplay built its QrImageView through a LayoutBuilder, which cannot answer intrinsic dimension queries, so any intrinsic-measuring parent threw. AlertDialog measures its content's max intrinsic height, so the dialog crashed on open. The QR is now bounded by a tight SizedBox, which answers the intrinsic itself. This was the widget's first real call site, so the bug had never been exercised. Entry point is provisional alongside Add by key; #632 reorganises. Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>fix/652-release-prerelease-flag
parent
3f427d2c41
commit
cfe533ec18
@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../widgets/qr_scanner_widget.dart';
|
||||
|
||||
/// Scans a contact QR and pops the raw `meshcore://contact/add` string. (#629)
|
||||
///
|
||||
/// A contact QR is only that URI rendered visually, so this validates with the
|
||||
/// same [Contact.isValidShareUri] the paste path uses. There is no second
|
||||
/// format and no second parser.
|
||||
class ContactQrScannerScreen extends StatelessWidget {
|
||||
const ContactQrScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.contacts_scanContactQr),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: QrScannerWidget(
|
||||
instructions: context.l10n.contacts_scanContactQrInstructions,
|
||||
validator: Contact.isValidShareUri,
|
||||
onScanned: (data) => Navigator.of(context).pop(data),
|
||||
onValidationFailed: (_) => showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.contacts_invalidContactQr),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import 'qr_code_display.dart';
|
||||
|
||||
/// Shows this device's own identity as a scannable QR plus a copyable link.
|
||||
/// (#629)
|
||||
///
|
||||
/// This is the fast, definitive way to hand someone your identity: it carries
|
||||
/// the public key itself, so it cannot fail on a name mismatch and it does not
|
||||
/// ask them to wait for an advert. It also costs the mesh no airtime, which
|
||||
/// asking for a couple of flood adverts does.
|
||||
///
|
||||
/// Rendering works on every platform, which matters on desktop: put this on
|
||||
/// screen and the other person scans it with their phone.
|
||||
Future<void> showMyContactQrDialog(BuildContext context) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final keyHex = connector.selfPublicKeyHex;
|
||||
|
||||
// Without a connection we do not know our own key, so there is nothing
|
||||
// truthful to render.
|
||||
if (keyHex.length != pubKeySize * 2) {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.contacts_qrNeedsConnection),
|
||||
);
|
||||
return Future<void>.value();
|
||||
}
|
||||
|
||||
final uri = Contact.buildShareUri(
|
||||
publicKeyHex: keyHex,
|
||||
name: connector.selfName ?? '',
|
||||
// This device is a companion.
|
||||
type: advTypeChat,
|
||||
);
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(dialogContext.l10n.contacts_myContactQr),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
QrCodeDisplay(
|
||||
data: uri,
|
||||
instructions: dialogContext.l10n.contacts_myContactQrInstructions,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
uri,
|
||||
style: Theme.of(dialogContext).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: Theme.of(dialogContext).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: uri));
|
||||
if (!dialogContext.mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
dialogContext,
|
||||
content: Text(dialogContext.l10n.contacts_contactLinkCopied),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: Text(dialogContext.l10n.common_copy),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(dialogContext.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
// Widget tests for sharing this device's own identity as a QR (#629).
|
||||
//
|
||||
// The QR is only the meshcore://contact/add link rendered visually. qr_flutter
|
||||
// keeps its payload private (`final String? _data`), so these assert on the
|
||||
// link the dialog renders beside the code, which is the same string, and on
|
||||
// that link parsing back into the right key, name and type.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/widgets/my_contact_qr_dialog.dart';
|
||||
|
||||
const _key = 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899';
|
||||
|
||||
class _FakeConn extends MeshCoreConnector {
|
||||
_FakeConn({required this.keyHex, this.nodeName});
|
||||
|
||||
final String keyHex;
|
||||
final String? nodeName;
|
||||
|
||||
@override
|
||||
String get selfPublicKeyHex => keyHex;
|
||||
|
||||
@override
|
||||
String? get selfName => nodeName;
|
||||
}
|
||||
|
||||
Future<void> _open(WidgetTester tester, MeshCoreConnector conn) async {
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MeshCoreConnector>.value(
|
||||
value: conn,
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => showMyContactQrDialog(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('renders a QR plus the link it encodes', (tester) async {
|
||||
await _open(tester, _FakeConn(keyHex: _key, nodeName: 'Strycher-RK4'));
|
||||
|
||||
final expected = Contact.buildShareUri(
|
||||
publicKeyHex: _key,
|
||||
name: 'Strycher-RK4',
|
||||
type: advTypeChat,
|
||||
);
|
||||
|
||||
expect(find.byType(QrImageView), findsOneWidget);
|
||||
// The link is shown so it can be copied or pasted into a chat, which is
|
||||
// the path that works when the other person is not in the room.
|
||||
expect(find.text(expected), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the rendered link parses back to this device', (tester) async {
|
||||
await _open(tester, _FakeConn(keyHex: _key, nodeName: 'Strycher-RK4'));
|
||||
|
||||
final expected = Contact.buildShareUri(
|
||||
publicKeyHex: _key,
|
||||
name: 'Strycher-RK4',
|
||||
type: advTypeChat,
|
||||
);
|
||||
final parsed = Contact.fromShareUri(expected);
|
||||
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.publicKeyHex, _key);
|
||||
expect(parsed.name, 'Strycher-RK4');
|
||||
// This device is a companion, not a repeater.
|
||||
expect(parsed.type, advTypeChat);
|
||||
});
|
||||
|
||||
testWidgets('a node with no name still produces an addable link', (
|
||||
tester,
|
||||
) async {
|
||||
await _open(tester, _FakeConn(keyHex: _key));
|
||||
|
||||
final expected = Contact.buildShareUri(
|
||||
publicKeyHex: _key,
|
||||
name: '',
|
||||
type: advTypeChat,
|
||||
);
|
||||
expect(find.text(expected), findsOneWidget);
|
||||
// The key is the identity, so a nameless link is still usable.
|
||||
expect(Contact.fromShareUri(expected)?.publicKeyHex, _key);
|
||||
});
|
||||
|
||||
testWidgets('with no connection nothing is rendered, since we lack our key', (
|
||||
tester,
|
||||
) async {
|
||||
// An empty key would encode a QR that cannot be added, which is worse
|
||||
// than refusing to show one.
|
||||
await _open(tester, _FakeConn(keyHex: ''));
|
||||
|
||||
expect(find.byType(QrImageView), findsNothing);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue