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
Strycher 3 weeks ago
parent 3f427d2c41
commit cfe533ec18

@ -2510,6 +2510,13 @@
}
},
"contacts_addByKeyFailed": "Could not add the contact. Check the radio connection.",
"contacts_myContactQr": "My contact QR",
"contacts_myContactQrInstructions": "Have them scan this, or send them the link below. It works even if they have never heard your advert.",
"contacts_contactLinkCopied": "Contact link copied",
"contacts_qrNeedsConnection": "Connect to a radio first, so the app knows your public key.",
"contacts_scanContactQr": "Scan contact QR",
"contacts_scanContactQrInstructions": "Point the camera at a MeshCore contact QR",
"contacts_invalidContactQr": "That QR is not a MeshCore contact",
"contacts_ShareContact": "Copy contact to Clipboard",
"contacts_ShareContactZeroHop": "Share contact by advert",
"contacts_zeroHopContactAdvertSent": "Sent contact by advert.",

@ -7510,6 +7510,48 @@ abstract class AppLocalizations {
/// **'Could not add the contact. Check the radio connection.'**
String get contacts_addByKeyFailed;
/// No description provided for @contacts_myContactQr.
///
/// In en, this message translates to:
/// **'My contact QR'**
String get contacts_myContactQr;
/// No description provided for @contacts_myContactQrInstructions.
///
/// In en, this message translates to:
/// **'Have them scan this, or send them the link below. It works even if they have never heard your advert.'**
String get contacts_myContactQrInstructions;
/// No description provided for @contacts_contactLinkCopied.
///
/// In en, this message translates to:
/// **'Contact link copied'**
String get contacts_contactLinkCopied;
/// No description provided for @contacts_qrNeedsConnection.
///
/// In en, this message translates to:
/// **'Connect to a radio first, so the app knows your public key.'**
String get contacts_qrNeedsConnection;
/// No description provided for @contacts_scanContactQr.
///
/// In en, this message translates to:
/// **'Scan contact QR'**
String get contacts_scanContactQr;
/// No description provided for @contacts_scanContactQrInstructions.
///
/// In en, this message translates to:
/// **'Point the camera at a MeshCore contact QR'**
String get contacts_scanContactQrInstructions;
/// No description provided for @contacts_invalidContactQr.
///
/// In en, this message translates to:
/// **'That QR is not a MeshCore contact'**
String get contacts_invalidContactQr;
/// No description provided for @contacts_ShareContact.
///
/// In en, this message translates to:

@ -4386,6 +4386,30 @@ class AppLocalizationsBg extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Копирай контакт в клипборда';

@ -4398,6 +4398,30 @@ class AppLocalizationsDe extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Kontakt in die Zwischenablage kopieren';

@ -4320,6 +4320,30 @@ class AppLocalizationsEn extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Copy contact to Clipboard';

@ -4387,6 +4387,30 @@ class AppLocalizationsEs extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Copiar contacto al Portapapeles';

@ -4409,6 +4409,30 @@ class AppLocalizationsFr extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact =>
'Copier le contact dans le presse-papiers';

@ -4404,6 +4404,30 @@ class AppLocalizationsHu extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Másolja a kapcsolatot a kiválasztóba';

@ -4392,6 +4392,30 @@ class AppLocalizationsIt extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Copia contatto negli Appunti';

@ -4189,6 +4189,30 @@ class AppLocalizationsJa extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => '連絡先をクリップボードにコピー';

@ -4191,6 +4191,30 @@ class AppLocalizationsKo extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => '연락처를 복사';

@ -4372,6 +4372,30 @@ class AppLocalizationsNl extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Contact naar Klembord kopiëren';

@ -4402,6 +4402,30 @@ class AppLocalizationsPl extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Kopiuj kontakt do schowka';

@ -4384,6 +4384,30 @@ class AppLocalizationsPt extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact =>
'Copiar contato para Área de Transferência';

@ -4394,6 +4394,30 @@ class AppLocalizationsRu extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Копировать контакт в буфер обмена';

@ -4368,6 +4368,30 @@ class AppLocalizationsSk extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Kopírovať kontakt do schránky';

@ -4362,6 +4362,30 @@ class AppLocalizationsSl extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Kopiraj stik v Odložišče';

@ -4341,6 +4341,30 @@ class AppLocalizationsSv extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Kopiera kontakt till Urklipp';

@ -4393,6 +4393,30 @@ class AppLocalizationsUk extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => 'Копіювати контакт у буфер обміну';

@ -4092,6 +4092,30 @@ class AppLocalizationsZh extends AppLocalizations {
String get contacts_addByKeyFailed =>
'Could not add the contact. Check the radio connection.';
@override
String get contacts_myContactQr => 'My contact QR';
@override
String get contacts_myContactQrInstructions =>
'Have them scan this, or send them the link below. It works even if they have never heard your advert.';
@override
String get contacts_contactLinkCopied => 'Contact link copied';
@override
String get contacts_qrNeedsConnection =>
'Connect to a radio first, so the app knows your public key.';
@override
String get contacts_scanContactQr => 'Scan contact QR';
@override
String get contacts_scanContactQrInstructions =>
'Point the camera at a MeshCore contact QR';
@override
String get contacts_invalidContactQr => 'That QR is not a MeshCore contact';
@override
String get contacts_ShareContact => '复制联系人信息到剪贴板';

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

@ -35,6 +35,7 @@ import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/add_contact_by_key_dialog.dart';
import '../widgets/my_contact_qr_dialog.dart';
import '../widgets/unread_badge.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
@ -425,6 +426,16 @@ class _ContactsScreenState extends State<ContactsScreen>
),
onTap: () => showAddContactByKeyDialog(context),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.qr_code_2),
const SizedBox(width: 8),
Text(context.l10n.contacts_myContactQr),
],
),
onTap: () => showMyContactQrDialog(context),
),
],
icon: const Icon(Icons.more_vert),
),

@ -6,6 +6,8 @@ import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../helpers/snack_bar_builder.dart';
import '../models/contact.dart';
import '../screens/contact_qr_scanner_screen.dart';
import '../utils/platform_info.dart';
/// Adds a contact from an identity alone: a public key, a name and a type.
///
@ -73,6 +75,23 @@ class _AddContactByKeyDialogState extends State<_AddContactByKeyDialog> {
if (_keyError != null) setState(() => _keyError = null);
}
/// mobile_scanner covers Android, iOS, macOS and web, but not Windows or
/// Linux. On those the QR half of the exchange is the RENDER side: show your
/// own code from the desktop and let the other person scan it with a phone.
static bool get _cameraAvailable =>
PlatformInfo.isMobile || PlatformInfo.isMacOS || PlatformInfo.isWeb;
Future<void> _scan() async {
final scanned = await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => const ContactQrScannerScreen()),
);
if (!mounted || scanned == null) return;
// Route the scan through the same handler as a paste. A QR is only the
// link rendered visually, so it must not get its own code path.
_keyController.text = scanned;
_onKeyChanged(scanned);
}
Future<void> _submit() async {
final l10n = context.l10n;
if (!_keyLooksValid) {
@ -138,6 +157,13 @@ class _AddContactByKeyDialogState extends State<_AddContactByKeyDialog> {
errorText: _keyError,
errorMaxLines: 2,
border: const OutlineInputBorder(),
suffixIcon: _cameraAvailable
? IconButton(
icon: const Icon(Icons.qr_code_scanner),
tooltip: l10n.contacts_scanContactQr,
onPressed: _submitting ? null : _scan,
)
: null,
),
onChanged: _onKeyChanged,
),

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

@ -96,10 +96,19 @@ class QrCodeDisplay extends StatelessWidget {
),
],
),
// Bound the QR to a fixed square. QrImageView builds through a
// LayoutBuilder, which cannot answer intrinsic dimension queries,
// so an intrinsic-measuring parent (AlertDialog measures its
// content's max intrinsic height) throws without this. A tight
// SizedBox answers the intrinsic itself and never asks the child.
// (#629, first real use of this widget.)
child: SizedBox.square(
dimension: size,
child: embeddedImage != null
? _buildQrWithEmbeddedImage(fgColor, bgColor)
: _buildSimpleQr(fgColor, bgColor),
),
),
if (instructions != null) ...[
const SizedBox(height: 16),

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

@ -104,6 +104,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -234,6 +241,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -364,6 +378,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -494,6 +515,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -624,6 +652,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -754,6 +789,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -884,6 +926,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -1014,6 +1063,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -1144,6 +1200,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -1274,6 +1337,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -1404,6 +1474,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -1534,6 +1611,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -1664,6 +1748,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -1794,6 +1885,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -1924,6 +2022,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -2054,6 +2159,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",
@ -2184,6 +2296,13 @@
"contacts_addByKeyUnverifiedNote",
"contacts_addByKeyAdded",
"contacts_addByKeyFailed",
"contacts_myContactQr",
"contacts_myContactQrInstructions",
"contacts_contactLinkCopied",
"contacts_qrNeedsConnection",
"contacts_scanContactQr",
"contacts_scanContactQrInstructions",
"contacts_invalidContactQr",
"snrIndicator_noNeighbors",
"block_block",
"block_unblock",

Loading…
Cancel
Save

Powered by TurnKey Linux.