From 85239b0606e20f3bf5650e6c66fda604b007c6b0 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 6 Sep 2026 05:57:16 -0400 Subject: [PATCH] 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 --- lib/l10n/app_en.arb | 17 + lib/l10n/app_localizations.dart | 60 +++ lib/l10n/app_localizations_bg.dart | 37 ++ lib/l10n/app_localizations_de.dart | 37 ++ lib/l10n/app_localizations_en.dart | 37 ++ lib/l10n/app_localizations_es.dart | 37 ++ lib/l10n/app_localizations_fr.dart | 37 ++ lib/l10n/app_localizations_hu.dart | 37 ++ lib/l10n/app_localizations_it.dart | 37 ++ lib/l10n/app_localizations_ja.dart | 37 ++ lib/l10n/app_localizations_ko.dart | 37 ++ lib/l10n/app_localizations_nl.dart | 37 ++ lib/l10n/app_localizations_pl.dart | 37 ++ lib/l10n/app_localizations_pt.dart | 37 ++ lib/l10n/app_localizations_ru.dart | 37 ++ lib/l10n/app_localizations_sk.dart | 37 ++ lib/l10n/app_localizations_sl.dart | 37 ++ lib/l10n/app_localizations_sv.dart | 37 ++ lib/l10n/app_localizations_uk.dart | 37 ++ lib/l10n/app_localizations_zh.dart | 37 ++ lib/screens/contacts_screen.dart | 14 + lib/widgets/add_contact_by_key_dialog.dart | 217 ++++++++++ .../add_contact_by_key_dialog_test.dart | 155 ++++++++ untranslated.json | 374 ++++++++++++++++++ 24 files changed, 1503 insertions(+) create mode 100644 lib/widgets/add_contact_by_key_dialog.dart create mode 100644 test/widgets/add_contact_by_key_dialog_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c90a330..05054c3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2493,6 +2493,23 @@ "contacts_floodAdvert": "Flood Advert", "contacts_copyAdvertToClipboard": "Copy Advert to Clipboard", "contacts_addContactFromClipboard": "Add Contact from Clipboard", + "contacts_addByKey": "Add by public key", + "contacts_addByKeyDescription": "Add someone using the public key they gave you. Works even if you have never heard their advert.", + "contacts_publicKeyLabel": "Public key", + "contacts_publicKeyHelper": "64 hex characters, or paste a meshcore:// contact link", + "contacts_publicKeyInvalid": "Enter 64 hex characters, or a meshcore:// contact link", + "contacts_nameLabel": "Name", + "contacts_typeLabel": "Type", + "contacts_addByKeyUnverifiedNote": "Not confirmed on air yet. The name is whatever you type here until this node sends an advert.", + "contacts_addByKeyAdded": "Added {name}", + "@contacts_addByKeyAdded": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "contacts_addByKeyFailed": "Could not add the contact. Check the radio connection.", "contacts_ShareContact": "Copy contact to Clipboard", "contacts_ShareContactZeroHop": "Share contact by advert", "contacts_zeroHopContactAdvertSent": "Sent contact by advert.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d73c671..3d62feb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7450,6 +7450,66 @@ abstract class AppLocalizations { /// **'Add Contact from Clipboard'** String get contacts_addContactFromClipboard; + /// No description provided for @contacts_addByKey. + /// + /// In en, this message translates to: + /// **'Add by public key'** + String get contacts_addByKey; + + /// No description provided for @contacts_addByKeyDescription. + /// + /// In en, this message translates to: + /// **'Add someone using the public key they gave you. Works even if you have never heard their advert.'** + String get contacts_addByKeyDescription; + + /// No description provided for @contacts_publicKeyLabel. + /// + /// In en, this message translates to: + /// **'Public key'** + String get contacts_publicKeyLabel; + + /// No description provided for @contacts_publicKeyHelper. + /// + /// In en, this message translates to: + /// **'64 hex characters, or paste a meshcore:// contact link'** + String get contacts_publicKeyHelper; + + /// No description provided for @contacts_publicKeyInvalid. + /// + /// In en, this message translates to: + /// **'Enter 64 hex characters, or a meshcore:// contact link'** + String get contacts_publicKeyInvalid; + + /// No description provided for @contacts_nameLabel. + /// + /// In en, this message translates to: + /// **'Name'** + String get contacts_nameLabel; + + /// No description provided for @contacts_typeLabel. + /// + /// In en, this message translates to: + /// **'Type'** + String get contacts_typeLabel; + + /// No description provided for @contacts_addByKeyUnverifiedNote. + /// + /// In en, this message translates to: + /// **'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'** + String get contacts_addByKeyUnverifiedNote; + + /// No description provided for @contacts_addByKeyAdded. + /// + /// In en, this message translates to: + /// **'Added {name}'** + String contacts_addByKeyAdded(String name); + + /// No description provided for @contacts_addByKeyFailed. + /// + /// In en, this message translates to: + /// **'Could not add the contact. Check the radio connection.'** + String get contacts_addByKeyFailed; + /// No description provided for @contacts_ShareContact. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 5420409..3448993 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -4349,6 +4349,43 @@ class AppLocalizationsBg extends AppLocalizations { @override String get contacts_addContactFromClipboard => 'Добави контакт от клипборда'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Копирай контакт в клипборда'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index be54da5..6d2927d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4361,6 +4361,43 @@ class AppLocalizationsDe extends AppLocalizations { String get contacts_addContactFromClipboard => 'Kontakt aus Zwischenablage hinzufügen'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Kontakt in die Zwischenablage kopieren'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7e0ab51..a0ab4d3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4283,6 +4283,43 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contacts_addContactFromClipboard => 'Add Contact from Clipboard'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Copy contact to Clipboard'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index d9c137a..22c4a43 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4350,6 +4350,43 @@ class AppLocalizationsEs extends AppLocalizations { String get contacts_addContactFromClipboard => 'Agregar contacto desde el portapapeles'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Copiar contacto al Portapapeles'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index a5f67de..f82d58f 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4372,6 +4372,43 @@ class AppLocalizationsFr extends AppLocalizations { String get contacts_addContactFromClipboard => 'Ajouter un contact depuis le presse-papiers'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Copier le contact dans le presse-papiers'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index e2df173..1b32e33 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -4367,6 +4367,43 @@ class AppLocalizationsHu extends AppLocalizations { String get contacts_addContactFromClipboard => 'Adjon hozzá egy kapcsolatot a kiválasztott listából'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Másolja a kapcsolatot a kiválasztóba'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 9a94d64..bab9a0f 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -4355,6 +4355,43 @@ class AppLocalizationsIt extends AppLocalizations { String get contacts_addContactFromClipboard => 'Aggiungere contatto dalla clipboard'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Copia contatto negli Appunti'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 662782a..ba56ead 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4152,6 +4152,43 @@ class AppLocalizationsJa extends AppLocalizations { @override String get contacts_addContactFromClipboard => 'クリップボードから連絡先を追加する'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => '連絡先をクリップボードにコピー'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 181f1e2..880f524 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4154,6 +4154,43 @@ class AppLocalizationsKo extends AppLocalizations { @override String get contacts_addContactFromClipboard => '복사본에서 연락처 추가'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => '연락처를 복사'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 6f76fea..ddbbe3f 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4335,6 +4335,43 @@ class AppLocalizationsNl extends AppLocalizations { String get contacts_addContactFromClipboard => 'Contact uit klembord toevoegen'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Contact naar Klembord kopiëren'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index dc6b388..77e7f54 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -4365,6 +4365,43 @@ class AppLocalizationsPl extends AppLocalizations { @override String get contacts_addContactFromClipboard => 'Dodaj kontakt z schowka'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Kopiuj kontakt do schowka'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 6eb1aac..a50e6b4 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4347,6 +4347,43 @@ class AppLocalizationsPt extends AppLocalizations { String get contacts_addContactFromClipboard => 'Adicionar Contato da Área de Transferência'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Copiar contato para Área de Transferência'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e437aa2..cd0d8f1 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4357,6 +4357,43 @@ class AppLocalizationsRu extends AppLocalizations { String get contacts_addContactFromClipboard => 'Добавить контакт из буфера обмена'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Копировать контакт в буфер обмена'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 5544a87..826e796 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -4331,6 +4331,43 @@ class AppLocalizationsSk extends AppLocalizations { @override String get contacts_addContactFromClipboard => 'Pridať kontakt z schránky'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Kopírovať kontakt do schránky'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 23c380b..a53562a 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -4325,6 +4325,43 @@ class AppLocalizationsSl extends AppLocalizations { @override String get contacts_addContactFromClipboard => 'Dodaj stik iz odložišča'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Kopiraj stik v Odložišče'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 4bc637a..c4ea817 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -4304,6 +4304,43 @@ class AppLocalizationsSv extends AppLocalizations { String get contacts_addContactFromClipboard => 'Lägg till kontakt från urklipp'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Kopiera kontakt till Urklipp'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 46d8b63..239dc2d 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4356,6 +4356,43 @@ class AppLocalizationsUk extends AppLocalizations { @override String get contacts_addContactFromClipboard => 'Додати контакт з буфера'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => 'Копіювати контакт у буфер обміну'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6955358..e83864f 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4055,6 +4055,43 @@ class AppLocalizationsZh extends AppLocalizations { @override String get contacts_addContactFromClipboard => '从剪贴板添加联系人'; + @override + String get contacts_addByKey => 'Add by public key'; + + @override + String get contacts_addByKeyDescription => + 'Add someone using the public key they gave you. Works even if you have never heard their advert.'; + + @override + String get contacts_publicKeyLabel => 'Public key'; + + @override + String get contacts_publicKeyHelper => + '64 hex characters, or paste a meshcore:// contact link'; + + @override + String get contacts_publicKeyInvalid => + 'Enter 64 hex characters, or a meshcore:// contact link'; + + @override + String get contacts_nameLabel => 'Name'; + + @override + String get contacts_typeLabel => 'Type'; + + @override + String get contacts_addByKeyUnverifiedNote => + 'Not confirmed on air yet. The name is whatever you type here until this node sends an advert.'; + + @override + String contacts_addByKeyAdded(String name) { + return 'Added $name'; + } + + @override + String get contacts_addByKeyFailed => + 'Could not add the contact. Check the radio connection.'; + @override String get contacts_ShareContact => '复制联系人信息到剪贴板'; diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 3934973..edd078b 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -34,6 +34,7 @@ import '../widgets/path_selection_dialog.dart'; 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/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; @@ -411,6 +412,19 @@ class _ContactsScreenState extends State ), ), ), + // Provisional home so key entry is reachable. The proper + // add-contact surface, split away from the advert affordance, + // is #632 under epic #623. + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.key_outlined), + const SizedBox(width: 8), + Text(context.l10n.contacts_addByKey), + ], + ), + onTap: () => showAddContactByKeyDialog(context), + ), ], icon: const Icon(Icons.more_vert), ), diff --git a/lib/widgets/add_contact_by_key_dialog.dart b/lib/widgets/add_contact_by_key_dialog.dart new file mode 100644 index 0000000..4c5ee6b --- /dev/null +++ b/lib/widgets/add_contact_by_key_dialog.dart @@ -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 showAddContactByKeyDialog(BuildContext context) { + return showDialog( + 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 _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(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( + 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), + ), + ], + ); + } +} diff --git a/test/widgets/add_contact_by_key_dialog_test.dart b/test/widgets/add_contact_by_key_dialog_test.dart new file mode 100644 index 0000000..1337a4b --- /dev/null +++ b/test/widgets/add_contact_by_key_dialog_test.dart @@ -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 added = []; + + @override + Future addContactByKey(Contact stub) async { + added.add(stub); + return true; + } +} + +Future _open(WidgetTester tester, _CapturingConn conn) async { + await tester.pumpWidget( + ChangeNotifierProvider.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'); + }); +} diff --git a/untranslated.json b/untranslated.json index 79eeb12..da87696 100644 --- a/untranslated.json +++ b/untranslated.json @@ -60,9 +60,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -82,6 +94,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -168,9 +190,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -190,6 +224,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -276,9 +320,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -298,6 +354,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -384,9 +450,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -406,6 +484,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -492,9 +580,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -514,6 +614,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -600,9 +710,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -622,6 +744,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -708,9 +840,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -730,6 +874,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -816,9 +970,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -838,6 +1004,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -924,9 +1100,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -946,6 +1134,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1032,9 +1230,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -1054,6 +1264,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1140,9 +1360,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -1162,6 +1394,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1248,9 +1490,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -1270,6 +1524,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1356,9 +1620,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -1378,6 +1654,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1464,9 +1750,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -1486,6 +1784,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1572,9 +1880,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -1594,6 +1914,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1680,9 +2010,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -1702,6 +2044,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1788,9 +2140,21 @@ "channel_coreScopeRefresh", "channel_coreScopeRefreshFailed", "channel_coreScopeCounts", + "channel_senderNameUnknown", + "channel_senderNameUnknownHint", + "channel_senderAlreadyContact", + "channel_senderMultipleMatches", + "channel_senderAddNotConnected", + "channel_senderAddFailed", + "channel_senderCandidate", "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_cliCommandTimeout", + "repeater_cliLateResponse", + "repeater_cliUnmatchedResponse", + "repeater_lateResponseDiscarded", + "repeater_lateResponseReceived", "channelPath_routeDirect", "channelPath_routeFlood", "channelPath_snrLabel", @@ -1810,6 +2174,16 @@ "channels_qrExists", "listFilter_sensors", "contacts_repeaterPathTraceVia", + "contacts_addByKey", + "contacts_addByKeyDescription", + "contacts_publicKeyLabel", + "contacts_publicKeyHelper", + "contacts_publicKeyInvalid", + "contacts_nameLabel", + "contacts_typeLabel", + "contacts_addByKeyUnverifiedNote", + "contacts_addByKeyAdded", + "contacts_addByKeyFailed", "snrIndicator_noNeighbors", "block_block", "block_unblock",