feat(#628): add a contact by typing or pasting a public key
First user-reachable piece of the identity exchange. A dialog takes a public key, a name and a contact type, and hands the stub to addContactByKey. The key field also accepts a whole meshcore://contact/add link and absorbs every field from it, because that is what someone actually pastes, and a QR is only that link rendered visually. Whitespace is stripped so a key copied across a line break still works. The stub is built by round-tripping through buildShareUri and fromShareUri rather than constructing a Contact directly, so manual entry and a scanned QR cannot drift apart. One code path, one set of invariants. An informational note states plainly that the contact is not confirmed on air and that the name is whatever the user typed until the node adverts. Deliberately informational rather than a warning: nothing is wrong with a key-added contact (#630). Entry point is provisional, sitting in the contacts overflow menu so the feature is reachable. The proper add-contact surface, split away from the advert affordance, is #632 under epic #623. Five widget tests pin what reaches the connector: key, typed name, chosen type, the flood sentinel, and the epoch lastSeen that keeps the firmware replay guard from muting the contact. Also covered: pasted-link prefill, wrapped-key whitespace, an invalid key sending nothing, and the missing-name fallback. Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/619-contact-identity-uri
parent
808bc2b7b3
commit
85239b0606
@ -0,0 +1,217 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
|
import '../helpers/snack_bar_builder.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
/// Adds a contact from an identity alone: a public key, a name and a type.
|
||||||
|
///
|
||||||
|
/// This is the manual half of the identity exchange (#628). It exists because
|
||||||
|
/// a public key is never present in channel traffic, so for anyone who has not
|
||||||
|
/// recently adverted there is otherwise no way to reach them at all (#620).
|
||||||
|
///
|
||||||
|
/// The field also accepts a whole `meshcore://contact/add` link, since that is
|
||||||
|
/// what someone is most likely to paste, and a QR is only that same link
|
||||||
|
/// rendered visually.
|
||||||
|
Future<void> showAddContactByKeyDialog(BuildContext context) {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const _AddContactByKeyDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddContactByKeyDialog extends StatefulWidget {
|
||||||
|
const _AddContactByKeyDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AddContactByKeyDialog> createState() => _AddContactByKeyDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddContactByKeyDialogState extends State<_AddContactByKeyDialog> {
|
||||||
|
final _keyController = TextEditingController();
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
int _type = advTypeChat;
|
||||||
|
String? _keyError;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_keyController.dispose();
|
||||||
|
_nameController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips whitespace so a key copied across a line break still works.
|
||||||
|
String get _cleanedKey =>
|
||||||
|
_keyController.text.replaceAll(RegExp(r'\s+'), '').toLowerCase();
|
||||||
|
|
||||||
|
bool get _keyLooksValid => Contact.isValidShareUri(
|
||||||
|
Contact.buildShareUri(publicKeyHex: _cleanedKey, name: 'x', type: _type),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// If a full contact link was pasted, absorb every field from it rather than
|
||||||
|
/// making the user retype a name they already have.
|
||||||
|
void _onKeyChanged(String raw) {
|
||||||
|
final pasted = Contact.fromShareUri(raw);
|
||||||
|
if (pasted != null) {
|
||||||
|
setState(() {
|
||||||
|
_keyController.value = TextEditingValue(
|
||||||
|
text: pasted.publicKeyHex,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: pasted.publicKeyHex.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (pasted.name != 'Unknown') _nameController.text = pasted.name;
|
||||||
|
_type = pasted.type;
|
||||||
|
_keyError = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_keyError != null) setState(() => _keyError = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
if (!_keyLooksValid) {
|
||||||
|
setState(() => _keyError = l10n.contacts_publicKeyInvalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
// Round-trip through the share URI so a manually typed contact and a
|
||||||
|
// scanned QR produce byte-identical state. One code path, no drift.
|
||||||
|
final stub = Contact.fromShareUri(
|
||||||
|
Contact.buildShareUri(publicKeyHex: _cleanedKey, name: name, type: _type),
|
||||||
|
);
|
||||||
|
if (stub == null) {
|
||||||
|
setState(() => _keyError = l10n.contacts_publicKeyInvalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
final added = await connector.addContactByKey(stub);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showDismissibleSnackBar(
|
||||||
|
context,
|
||||||
|
content: Text(
|
||||||
|
added
|
||||||
|
? l10n.contacts_addByKeyAdded(stub.name)
|
||||||
|
: l10n.contacts_addByKeyFailed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.contacts_addByKey),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.contacts_addByKeyDescription,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _keyController,
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: 2,
|
||||||
|
minLines: 1,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.contacts_publicKeyLabel,
|
||||||
|
helperText: l10n.contacts_publicKeyHelper,
|
||||||
|
helperMaxLines: 2,
|
||||||
|
errorText: _keyError,
|
||||||
|
errorMaxLines: 2,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: _onKeyChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.contacts_nameLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
initialValue: _type,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.contacts_typeLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: advTypeChat,
|
||||||
|
child: Text(l10n.contact_typeChat),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: advTypeRepeater,
|
||||||
|
child: Text(l10n.contact_typeRepeater),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: advTypeRoom,
|
||||||
|
child: Text(l10n.contact_typeRoom),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: advTypeSensor,
|
||||||
|
child: Text(l10n.contact_typeSensor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => _type = v ?? advTypeChat),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Honest about what a key-only add actually gives you. Deliberately
|
||||||
|
// informational, not a warning: nothing is wrong with this contact
|
||||||
|
// (#630).
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.contacts_addByKeyUnverifiedNote,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.common_cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _submitting ? null : _submit,
|
||||||
|
child: Text(l10n.common_add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
// Widget tests for manual key entry (#628).
|
||||||
|
//
|
||||||
|
// The point of this dialog is that it must produce exactly the same contact
|
||||||
|
// stub a scanned QR would, so these pin what actually reaches the connector:
|
||||||
|
// the key, the typed name, the chosen type, and the epoch lastSeen that keeps
|
||||||
|
// the firmware advert replay guard from muting the contact (#620).
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.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/add_contact_by_key_dialog.dart';
|
||||||
|
|
||||||
|
const _key = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
|
||||||
|
|
||||||
|
class _CapturingConn extends MeshCoreConnector {
|
||||||
|
final List<Contact> added = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> addContactByKey(Contact stub) async {
|
||||||
|
added.add(stub);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _open(WidgetTester tester, _CapturingConn 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: () => showAddContactByKeyDialog(context),
|
||||||
|
child: const Text('open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('a valid key produces an unverified stub with the typed name', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final conn = _CapturingConn();
|
||||||
|
await _open(tester, conn);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, _key);
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'Ka8sbi');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(conn.added, hasLength(1));
|
||||||
|
final stub = conn.added.single;
|
||||||
|
expect(stub.publicKeyHex, _key);
|
||||||
|
expect(stub.name, 'Ka8sbi');
|
||||||
|
expect(stub.type, advTypeChat);
|
||||||
|
// The reason this whole path exists. Anything else here and the contact
|
||||||
|
// goes permanently deaf to its own adverts.
|
||||||
|
expect(stub.lastSeen, DateTime.fromMillisecondsSinceEpoch(0));
|
||||||
|
expect(stub.pathLength, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pasting a full contact link fills in every field', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final conn = _CapturingConn();
|
||||||
|
await _open(tester, conn);
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextField).first,
|
||||||
|
'meshcore://contact/add?name=Two+Words&public_key=$_key&type=2',
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(conn.added, hasLength(1));
|
||||||
|
final stub = conn.added.single;
|
||||||
|
expect(stub.publicKeyHex, _key);
|
||||||
|
// Name and type came from the pasted link, not retyped by hand.
|
||||||
|
expect(stub.name, 'Two Words');
|
||||||
|
expect(stub.type, advTypeRepeater);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('whitespace in a pasted key is tolerated', (tester) async {
|
||||||
|
final conn = _CapturingConn();
|
||||||
|
await _open(tester, conn);
|
||||||
|
|
||||||
|
// A key copied out of a chat log often arrives wrapped.
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextField).first,
|
||||||
|
'${_key.substring(0, 32)}\n${_key.substring(32)}',
|
||||||
|
);
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'Bob');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(conn.added, hasLength(1));
|
||||||
|
expect(conn.added.single.publicKeyHex, _key);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an invalid key shows an error and sends nothing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final conn = _CapturingConn();
|
||||||
|
await _open(tester, conn);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, 'not-a-key');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(conn.added, isEmpty);
|
||||||
|
// Dialog stays open so the user can correct it.
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('64 hex characters', findRichText: true),
|
||||||
|
findsWidgets,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a missing name falls back rather than blocking the add', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final conn = _CapturingConn();
|
||||||
|
await _open(tester, conn);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, _key);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(conn.added, hasLength(1));
|
||||||
|
expect(conn.added.single.name, 'Unknown');
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in new issue