diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 779f87a..4ef40f6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2517,6 +2517,10 @@ "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_verifiedByAdvert": "Confirmed on air. This node sent a signed advert.", + "contacts_verifiedByMessage": "Key confirmed. A message with this contact went through, which only works with the matching key.", + "contacts_verifiedKeyOnly": "Added from a key. Nothing has confirmed it on air yet.", + "contacts_lastSeenNever": "Not heard yet", "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 2fa3ea3..8de8748 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7552,6 +7552,30 @@ abstract class AppLocalizations { /// **'That QR is not a MeshCore contact'** String get contacts_invalidContactQr; + /// No description provided for @contacts_verifiedByAdvert. + /// + /// In en, this message translates to: + /// **'Confirmed on air. This node sent a signed advert.'** + String get contacts_verifiedByAdvert; + + /// No description provided for @contacts_verifiedByMessage. + /// + /// In en, this message translates to: + /// **'Key confirmed. A message with this contact went through, which only works with the matching key.'** + String get contacts_verifiedByMessage; + + /// No description provided for @contacts_verifiedKeyOnly. + /// + /// In en, this message translates to: + /// **'Added from a key. Nothing has confirmed it on air yet.'** + String get contacts_verifiedKeyOnly; + + /// No description provided for @contacts_lastSeenNever. + /// + /// In en, this message translates to: + /// **'Not heard yet'** + String get contacts_lastSeenNever; + /// 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 1539e42..afc947b 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -4410,6 +4410,21 @@ class AppLocalizationsBg extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @override String get contacts_ShareContact => 'Копирай контакт в клипборда'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f7cac5a..24b318c 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4422,6 +4422,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 7cf67ed..80fa2cf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4344,6 +4344,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 fe21ea9..c44095e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4411,6 +4411,21 @@ class AppLocalizationsEs extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 e6da2a2..33929a1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4433,6 +4433,21 @@ class AppLocalizationsFr extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 1f1fe9f..92bd608 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -4428,6 +4428,21 @@ class AppLocalizationsHu extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 eb9baac..3f6b2d7 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -4416,6 +4416,21 @@ class AppLocalizationsIt extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 10c1b22..13d85ec 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4213,6 +4213,21 @@ class AppLocalizationsJa extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @override String get contacts_ShareContact => '連絡先をクリップボードにコピー'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 21924a7..ab428ae 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4215,6 +4215,21 @@ class AppLocalizationsKo extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @override String get contacts_ShareContact => '연락처를 복사'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 604fde2..942c1b4 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4396,6 +4396,21 @@ class AppLocalizationsNl extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 d2edb69..486a9ca 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -4426,6 +4426,21 @@ class AppLocalizationsPl extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 e8f5935..41f6820 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4408,6 +4408,21 @@ class AppLocalizationsPt extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 40a8bbe..42b8fad 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4418,6 +4418,21 @@ class AppLocalizationsRu extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @override String get contacts_ShareContact => 'Копировать контакт в буфер обмена'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index f9558e4..bcebfb7 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -4392,6 +4392,21 @@ class AppLocalizationsSk extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 98974c8..ac0cef7 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -4386,6 +4386,21 @@ class AppLocalizationsSl extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 2a839b0..8965079 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -4365,6 +4365,21 @@ class AppLocalizationsSv extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @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 5ad27c6..d78ea3a 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4417,6 +4417,21 @@ class AppLocalizationsUk extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @override String get contacts_ShareContact => 'Копіювати контакт у буфер обміну'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f461447..8dcc0e2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4116,6 +4116,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get contacts_invalidContactQr => 'That QR is not a MeshCore contact'; + @override + String get contacts_verifiedByAdvert => + 'Confirmed on air. This node sent a signed advert.'; + + @override + String get contacts_verifiedByMessage => + 'Key confirmed. A message with this contact went through, which only works with the matching key.'; + + @override + String get contacts_verifiedKeyOnly => + 'Added from a key. Nothing has confirmed it on air yet.'; + + @override + String get contacts_lastSeenNever => 'Not heard yet'; + @override String get contacts_ShareContact => '复制联系人信息到剪贴板'; diff --git a/lib/models/contact.dart b/lib/models/contact.dart index de338e1..07613e2 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -260,6 +260,15 @@ class Contact { } } + /// True once this contact has been confirmed on air by a signed advert. + /// + /// [lastSeen] maps to the firmware `last_advert_timestamp`, which a contact + /// created from a bare key deliberately carries as the epoch so the advert + /// replay guard cannot mute it (#627). That same sentinel doubles as the + /// verification signal, for free, and it clears itself the moment a genuine + /// advert arrives and the radio rewrites the field. (#630) + bool get isAdvertVerified => lastSeen.millisecondsSinceEpoch != 0; + /// Reference-app contact share URI for this contact, the inverse of /// [fromShareUri]. This is what the stock app accepts, so emitting it is /// what makes Offband cards and QRs importable by non-Offband users. (#626) diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 3b5b956..c29acb0 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -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/contact_verification_badge.dart'; import '../widgets/my_contact_qr_dialog.dart'; import '../widgets/unread_badge.dart'; import '../helpers/snack_bar_builder.dart'; @@ -1641,7 +1642,22 @@ class _ContactTile extends StatelessWidget { ), ], ) - : Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), + : Row( + children: [ + // Reads as a column of state down the list. Calm by design: + // a key-added contact is not a problem, just less confirmed + // (#630). + ContactVerificationBadge(contact: contact), + const SizedBox(width: 6), + Expanded( + child: Text( + contact.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1747,6 +1763,14 @@ class _ContactTile extends StatelessWidget { } String _formatLastSeen(BuildContext context, DateTime lastSeen) { + // A contact added from a bare key carries the epoch deliberately, so the + // firmware advert replay guard cannot mute it (#627). Rendering that + // through the relative formatter would claim it was last seen tens of + // thousands of days ago, which is worse than saying nothing. (#630) + if (lastSeen.millisecondsSinceEpoch == 0) { + return context.l10n.contacts_lastSeenNever; + } + final now = DateTime.now(); final diff = now.difference(lastSeen); diff --git a/lib/widgets/contact_verification_badge.dart b/lib/widgets/contact_verification_badge.dart new file mode 100644 index 0000000..5e150c5 --- /dev/null +++ b/lib/widgets/contact_verification_badge.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; +import '../l10n/l10n.dart'; +import '../models/contact.dart'; +import '../models/message.dart'; + +/// How far a contact identity has actually been confirmed. (#630) +enum ContactVerification { + /// Added from a bare key. Nothing has confirmed it on air. + keyOnly, + + /// A message with this contact went through. That is cryptographic proof + /// the holder of the matching private key is live and reachable, because + /// direct messages are encrypted with an ECDH secret derived from this + /// contact key, and the ACK is computed over the decrypted plaintext + /// (firmware `BaseChatMesh.cpp:442,451`). It does NOT prove the person is + /// who the name claims: names are display, keys are identity. + keyConfirmed, + + /// A signed advert has been received. Strongest state: the node itself + /// asserted its name, type and position, and the raw advert is stored, so + /// the contact can also be re-shared. + advertVerified, +} + +/// Resolves the verification state for [contact]. +/// +/// The advert check is first because it is free and covers most contacts; only +/// an unverified contact pays for a message scan, which keeps this cheap on a +/// long contact list. +ContactVerification resolveContactVerification( + Contact contact, + MeshCoreConnector connector, +) { + if (contact.isAdvertVerified) return ContactVerification.advertVerified; + final delivered = connector + .getMessages(contact) + .any((m) => m.status == MessageStatus.delivered); + return delivered + ? ContactVerification.keyConfirmed + : ContactVerification.keyOnly; +} + +/// A small, deliberately calm indicator of how far a contact is confirmed. +/// +/// Owner steer (#630): a green check for fully verified and a different icon +/// otherwise. Explicitly NOT amber and NOT a hazard glyph, because nothing is +/// wrong with a key-added contact. The scale reads as "how much we know", +/// never as "how risky". +class ContactVerificationBadge extends StatelessWidget { + const ContactVerificationBadge({ + super.key, + required this.contact, + this.size = 15, + }); + + final Contact contact; + final double size; + + @override + Widget build(BuildContext context) { + final state = resolveContactVerification( + contact, + context.read(), + ); + final scheme = Theme.of(context).colorScheme; + final l10n = context.l10n; + + final (IconData icon, Color color, String tooltip) = switch (state) { + ContactVerification.advertVerified => ( + Icons.verified, + // The one deliberately positive colour in the set. + Colors.green, + l10n.contacts_verifiedByAdvert, + ), + ContactVerification.keyConfirmed => ( + Icons.check_circle_outline, + scheme.onSurfaceVariant, + l10n.contacts_verifiedByMessage, + ), + ContactVerification.keyOnly => ( + Icons.key_outlined, + scheme.onSurfaceVariant, + l10n.contacts_verifiedKeyOnly, + ), + }; + + return Tooltip( + message: tooltip, + child: Icon(icon, size: size, color: color), + ); + } +} diff --git a/test/widgets/contact_verification_badge_test.dart b/test/widgets/contact_verification_badge_test.dart new file mode 100644 index 0000000..3eea4ac --- /dev/null +++ b/test/widgets/contact_verification_badge_test.dart @@ -0,0 +1,171 @@ +// Verification-state tests (#630). +// +// The three states are not cosmetic. advertVerified means the node itself +// asserted its identity in a signed advert. keyConfirmed means a message went +// through, which only works with the matching private key. keyOnly means +// someone typed a key and nothing has confirmed it on air. + +import 'dart:typed_data'; + +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/models/message.dart'; +import 'package:meshcore_open/widgets/contact_verification_badge.dart'; + +const _key = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'; + +Contact _contact({required DateTime lastSeen}) => Contact( + publicKey: hex2Uint8List(_key), + name: 'Bob', + type: advTypeChat, + pathLength: -1, + path: Uint8List(0), + lastSeen: lastSeen, +); + +Contact _keyOnly() => + _contact(lastSeen: DateTime.fromMillisecondsSinceEpoch(0)); +Contact _adverted() => _contact(lastSeen: DateTime(2026, 9, 6, 12)); + +Message _msg(MessageStatus status) => Message( + senderKey: hex2Uint8List(_key), + text: 'hi', + isOutgoing: true, + timestamp: DateTime(2026, 9, 6), + status: status, +); + +class _Conn extends MeshCoreConnector { + _Conn(this.messages); + final List messages; + + @override + List getMessages(Contact contact) => messages; +} + +void main() { + group('Contact.isAdvertVerified (#630)', () { + test('a key-only contact carries the epoch and is not advert-verified', () { + expect(_keyOnly().isAdvertVerified, isFalse); + }); + + test('any real advert timestamp counts as verified', () { + expect(_adverted().isAdvertVerified, isTrue); + }); + + test('the parser output is unverified by construction', () { + // Ties the model getter to what fromShareUri actually produces, so the + // two cannot drift. + final parsed = Contact.fromShareUri( + 'meshcore://contact/add?name=Bob&public_key=$_key&type=1', + )!; + expect(parsed.isAdvertVerified, isFalse); + }); + }); + + group('resolveContactVerification (#630)', () { + test('an adverted contact is advertVerified even with no messages', () { + expect( + resolveContactVerification(_adverted(), _Conn(const [])), + ContactVerification.advertVerified, + ); + }); + + test('an advert wins over message history', () { + // The advert is strictly stronger: it is signed and it stores the raw + // packet that makes the contact re-shareable. + expect( + resolveContactVerification( + _adverted(), + _Conn([_msg(MessageStatus.delivered)]), + ), + ContactVerification.advertVerified, + ); + }); + + test('a key-only contact with no traffic is keyOnly', () { + expect( + resolveContactVerification(_keyOnly(), _Conn(const [])), + ContactVerification.keyOnly, + ); + }); + + test('a delivered message upgrades a key-only contact to keyConfirmed', () { + expect( + resolveContactVerification( + _keyOnly(), + _Conn([_msg(MessageStatus.sent), _msg(MessageStatus.delivered)]), + ), + ContactVerification.keyConfirmed, + ); + }); + + test('unacked traffic does NOT confirm the key', () { + // sent means it left the radio. failed and pending prove nothing at all. + // Only delivered means the far end produced the right ACK, which needs + // the plaintext, which needs the matching private key. + for (final s in [ + MessageStatus.pending, + MessageStatus.sent, + MessageStatus.failed, + ]) { + expect( + resolveContactVerification(_keyOnly(), _Conn([_msg(s)])), + ContactVerification.keyOnly, + reason: '$s must not count as confirmation', + ); + } + }); + }); + + group('ContactVerificationBadge rendering (#630)', () { + Future pump(WidgetTester tester, Contact c, _Conn conn) => + tester.pumpWidget( + ChangeNotifierProvider.value( + value: conn, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: ContactVerificationBadge(contact: c)), + ), + ), + ); + + testWidgets('advert-verified shows the green check', (tester) async { + await pump(tester, _adverted(), _Conn(const [])); + final icon = tester.widget(find.byType(Icon)); + expect(icon.icon, Icons.verified); + expect(icon.color, Colors.green); + }); + + testWidgets('key-confirmed shows a neutral check, not green', ( + tester, + ) async { + await pump(tester, _keyOnly(), _Conn([_msg(MessageStatus.delivered)])); + final icon = tester.widget(find.byType(Icon)); + expect(icon.icon, Icons.check_circle_outline); + expect(icon.color, isNot(Colors.green)); + }); + + testWidgets('key-only shows a muted key and nothing alarming', ( + tester, + ) async { + await pump(tester, _keyOnly(), _Conn(const [])); + final icon = tester.widget(find.byType(Icon)); + expect(icon.icon, Icons.key_outlined); + // Owner steer: no amber, no hazard glyph. Nothing is wrong with this + // contact, it is just less confirmed. + expect(icon.icon, isNot(Icons.warning)); + expect(icon.icon, isNot(Icons.warning_amber)); + expect(icon.icon, isNot(Icons.error_outline)); + expect(icon.color, isNot(Colors.amber)); + expect(icon.color, isNot(Colors.red)); + }); + }); +} diff --git a/untranslated.json b/untranslated.json index 65ba63e..a75aef0 100644 --- a/untranslated.json +++ b/untranslated.json @@ -111,6 +111,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -248,6 +252,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -385,6 +393,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -522,6 +534,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -659,6 +675,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -796,6 +816,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -933,6 +957,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1070,6 +1098,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1207,6 +1239,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1344,6 +1380,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1481,6 +1521,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1618,6 +1662,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1755,6 +1803,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -1892,6 +1944,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -2029,6 +2085,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -2166,6 +2226,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock", @@ -2303,6 +2367,10 @@ "contacts_scanContactQr", "contacts_scanContactQrInstructions", "contacts_invalidContactQr", + "contacts_verifiedByAdvert", + "contacts_verifiedByMessage", + "contacts_verifiedKeyOnly", + "contacts_lastSeenNever", "snrIndicator_noNeighbors", "block_block", "block_unblock",