From 81758adc61d5efb84cefe5979185254038adf0e8 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Thu, 12 Mar 2026 23:08:46 -0700 Subject: [PATCH] Dev discovery (#291) * Refactor contact handling: replace DiscoveryContact with Contact, update related methods and settings * Enhance contact handling: include latitude, longitude, and last modified timestamp in contact updates; refactor path handling to accommodate discovered contacts across multiple screens * Enhance SNRIndicator: include discovered contacts in name resolution for repeaters * Refactor path handling: replace addReturnPath with buildPath to improve path construction logic and handle target contact types * Update lib/screens/map_screen.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add localization for "Show Discovery Contacts" in multiple languages and refactor location plausibility check in map screen * Enhance contact management: update discovered contacts' active status and improve contact handling with flags and raw packet data * Refactor ChannelsScreen: pass ChannelMessageStore to buildExpandedContent and ensure messages are cleared after channel creation * Update MapScreen: adjust label zoom threshold and refactor guessed marker building to include labels * Refactor ChannelsScreen: change channelMessageStore to a private getter and update its usage in buildExpandedContent calls * Enhance location plausibility check: add latitude and longitude bounds to ensure valid coordinates * Update lib/connector/meshcore_connector.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor MeshCoreConnector and related stores: update discovered contacts handling, migrate legacy keys, and set public key in community store * Refactor MeshCoreConnector and ChannelsScreen: update discovered contacts handling and set public key in community store; enhance location plausibility check in MapScreen * Update CMD_ADD_UPDATE_CONTACT frame format to include optional latitude and longitude fields --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/connector/meshcore_connector.dart | 58 +++++-- lib/connector/meshcore_protocol.dart | 53 +++++-- lib/l10n/app_bg.arb | 3 +- lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 3 +- lib/l10n/app_fr.arb | 3 +- lib/l10n/app_it.arb | 3 +- lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_bg.dart | 3 + lib/l10n/app_localizations_de.dart | 3 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_es.dart | 3 + lib/l10n/app_localizations_fr.dart | 3 + lib/l10n/app_localizations_it.dart | 3 + lib/l10n/app_localizations_nl.dart | 3 + lib/l10n/app_localizations_pl.dart | 3 + lib/l10n/app_localizations_pt.dart | 3 + lib/l10n/app_localizations_ru.dart | 3 + lib/l10n/app_localizations_sk.dart | 3 + lib/l10n/app_localizations_sl.dart | 3 + lib/l10n/app_localizations_sv.dart | 3 + lib/l10n/app_localizations_uk.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_nl.arb | 3 +- lib/l10n/app_pl.arb | 3 +- lib/l10n/app_pt.arb | 3 +- lib/l10n/app_ru.arb | 3 +- lib/l10n/app_sk.arb | 3 +- lib/l10n/app_sl.arb | 3 +- lib/l10n/app_sv.arb | 3 +- lib/l10n/app_uk.arb | 3 +- lib/l10n/app_zh.arb | 3 +- lib/models/app_settings.dart | 8 + lib/models/contact.dart | 10 ++ lib/models/discovery_contact.dart | 105 ------------ lib/screens/ble_debug_log_screen.dart | 13 ++ lib/screens/channel_message_path_screen.dart | 17 +- lib/screens/channels_screen.dart | 42 +++-- lib/screens/community_qr_scanner_screen.dart | 5 + lib/screens/discovery_screen.dart | 15 +- lib/screens/map_screen.dart | 159 ++++++++++++++----- lib/screens/neighbors_screen.dart | 8 +- lib/screens/path_trace_map.dart | 55 +++++-- lib/services/app_settings_service.dart | 4 + lib/storage/channel_message_store.dart | 2 +- lib/storage/channel_order_store.dart | 2 +- lib/storage/channel_settings_store.dart | 2 +- lib/storage/channel_store.dart | 2 +- lib/storage/community_store.dart | 2 +- lib/storage/contact_discovery_store.dart | 38 ++++- lib/storage/contact_group_store.dart | 2 +- lib/storage/contact_store.dart | 8 + lib/storage/unread_store.dart | 2 +- lib/utils/contact_search.dart | 4 +- lib/widgets/snr_indicator.dart | 7 +- 56 files changed, 476 insertions(+), 241 deletions(-) delete mode 100644 lib/models/discovery_contact.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 2ea09cac..9ee6e926 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart' as crypto; -import 'package:meshcore_open/models/discovery_contact.dart'; import 'package:pointycastle/export.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -120,7 +119,7 @@ class MeshCoreConnector extends ChangeNotifier { final List _scanResults = []; final List _contacts = []; - final List _discoveredContacts = []; + final List _discoveredContacts = []; final List _channels = []; final Map> _conversations = {}; final Map> _channelMessages = {}; @@ -281,7 +280,7 @@ class MeshCoreConnector extends ChangeNotifier { ); } - List get discoveredContacts { + List get discoveredContacts { return List.unmodifiable(_discoveredContacts); } @@ -664,7 +663,6 @@ class MeshCoreConnector extends ChangeNotifier { // Initialize notification service _notificationService.initialize(); _loadChannelOrder(); - _loadDiscoveredContactCache(); // Initialize retry service callbacks _retryService?.initialize( @@ -1904,7 +1902,11 @@ class MeshCoreConnector extends ChangeNotifier { Future removeContact(Contact contact) async { if (!isConnected) return; - _handleDiscovery(contact, Uint8List(0), noNotify: true); + _handleDiscovery( + contact, + contact.rawPacket ?? Uint8List(0), + noNotify: true, + ); await sendFrame(buildRemoveContactFrame(contact.publicKey)); _contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex); @@ -1920,7 +1922,20 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } - Future removeDiscoveredContact(DiscoveryContact contact) async { + Future updateKnownDiscovered() async { + if (!isConnected) return; + for (int i = 0; i < _discoveredContacts.length; i++) { + _discoveredContacts[i] = _discoveredContacts[i].copyWith( + isActive: _knownContactKeys.contains( + _discoveredContacts[i].publicKeyHex, + ), + ); + } + unawaited(_persistDiscoveredContacts()); + notifyListeners(); + } + + Future removeDiscoveredContact(Contact contact) async { if (!isConnected) return; _discoveredContacts.removeWhere( (c) => c.publicKeyHex == contact.publicKeyHex, @@ -1929,7 +1944,7 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } - Future importDiscoveredContact(DiscoveryContact contact) async { + Future importDiscoveredContact(Contact contact) async { if (!isConnected) return; await sendFrame( @@ -1938,11 +1953,23 @@ class MeshCoreConnector extends ChangeNotifier { contact.path, contact.pathLength, type: contact.type, - flags: 0, + flags: contact.flags, name: contact.name, + lat: contact.latitude, + lon: contact.longitude, + lastModified: contact.lastSeen, ), ); + // Update the discovered contact to mark it as active (imported) + final discoveredIndex = _discoveredContacts.indexWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + ); + if (discoveredIndex >= 0) { + _discoveredContacts[discoveredIndex] = + _discoveredContacts[discoveredIndex].copyWith(isActive: true); + } + _handleContactAdvert( Contact( publicKey: contact.publicKey, @@ -1953,6 +1980,7 @@ class MeshCoreConnector extends ChangeNotifier { latitude: contact.latitude, longitude: contact.longitude, lastSeen: DateTime.now(), + flags: contact.flags, ), ); notifyListeners(); @@ -1969,6 +1997,8 @@ class MeshCoreConnector extends ChangeNotifier { final existing = _contacts[existingIndex]; // Use copyWith to preserve pathOverride and pathOverrideBytes _contacts[existingIndex] = existing.copyWith( + pathOverride: null, + pathOverrideBytes: null, pathLength: -1, path: Uint8List(0), ); @@ -2324,6 +2354,7 @@ class MeshCoreConnector extends ChangeNotifier { debugPrint('Got END_OF_CONTACTS'); _isLoadingContacts = false; _preserveContactsOnRefresh = false; + unawaited(updateKnownDiscovered()); notifyListeners(); unawaited(_persistContacts()); if (PlatformInfo.isWeb && @@ -2510,6 +2541,7 @@ class MeshCoreConnector extends ChangeNotifier { // Load persisted channel messages loadAllChannelMessages(); loadUnreadState(); + _loadDiscoveredContactCache(); _awaitingSelfInfo = false; _selfInfoRetryTimer?.cancel(); @@ -4406,7 +4438,7 @@ class MeshCoreConnector extends ChangeNotifier { } importDiscoveredContact( - DiscoveryContact( + Contact( rawPacket: frame, publicKey: publicKey, name: name, @@ -4477,6 +4509,7 @@ class MeshCoreConnector extends ChangeNotifier { if (isNewContact) { final newContact = Contact( + rawPacket: rawPacket, publicKey: publicKey, name: name, type: type, @@ -4622,13 +4655,15 @@ class MeshCoreConnector extends ChangeNotifier { latitude: contact.latitude, longitude: contact.longitude, lastSeen: contact.lastSeen, + flags: 0, + isActive: false, ); notifyListeners(); unawaited(_persistDiscoveredContacts()); return; } - final disContact = DiscoveryContact( + final disContact = Contact( rawPacket: rawPacket, publicKey: contact.publicKey, name: contact.name, @@ -4638,6 +4673,9 @@ class MeshCoreConnector extends ChangeNotifier { latitude: contact.latitude, longitude: contact.longitude, lastSeen: contact.lastSeen, + lastMessageAt: contact.lastMessageAt, + isActive: false, + flags: 0, ); _discoveredContacts.add(disContact); diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 3484d47f..dc9a9f50 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -148,6 +148,19 @@ class BufferWriter { void writeHex(String hex) { writeBytes(hex2Uint8List(hex)); } + + void writeBytesPadded(Uint8List bytes, int totalLength) { + // Path data (64 bytes, zero-padded) + final bytesPadded = Uint8List(totalLength); + final len = bytes.length < totalLength ? bytes.length : totalLength; + if (bytes.isNotEmpty && len > 0) { + final copyLen = bytes.length < totalLength ? bytes.length : totalLength; + for (int i = 0; i < copyLen; i++) { + bytesPadded[i] = bytes[i]; + } + } + writeBytes(bytesPadded); + } } Uint8List hex2Uint8List(String hex) { @@ -676,14 +689,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) { } // Build CMD_ADD_UPDATE_CONTACT frame to set custom path -// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4] +// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4] Uint8List buildUpdateContactPathFrame( Uint8List pubKey, - Uint8List customPath, + Uint8List path, int pathLen, { int type = 1, // ADV_TYPE_CHAT int flags = 0, String name = '', + double? lat, + double? lon, + DateTime? lastModified, }) { final writer = BufferWriter(); writer.writeByte(cmdAddUpdateContact); @@ -692,17 +708,7 @@ Uint8List buildUpdateContactPathFrame( writer.writeByte(flags); writer.writeByte(pathLen); - // Path data (64 bytes, zero-padded) - final pathPadded = Uint8List(maxPathSize); - if (customPath.isNotEmpty && pathLen > 0) { - final copyLen = customPath.length < maxPathSize - ? customPath.length - : maxPathSize; - for (int i = 0; i < copyLen; i++) { - pathPadded[i] = customPath[i]; - } - } - writer.writeBytes(pathPadded); + writer.writeBytesPadded(path, maxPathSize); // Name (32 bytes, null-padded) writer.writeCString(name, maxNameSize); @@ -711,6 +717,27 @@ Uint8List buildUpdateContactPathFrame( final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; writer.writeUInt32LE(timestamp); + if ((lat == null || lon == null) && lastModified != null) { + // If lat/lon not provided, write zeros + writer.writeInt32LE(0); + writer.writeInt32LE(0); + } else { + // Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6 + // Latitude + final latitude = lat ?? 0.0; + writer.writeInt32LE((latitude * 1e6).round()); + + // Longitude + final longitude = lon ?? 0.0; + writer.writeInt32LE((longitude * 1e6).round()); + } + + if (lastModified != null) { + // Last modified + final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000; + writer.writeUInt32LE(lastModifiedTimestamp); + } + return writer.toBytes(); } diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 94d89975..a2723d11 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1859,5 +1859,6 @@ "usbConnectionFailed": "Неуспешно свързване през USB: {error}", "usbStatus_notConnected": "Изберете USB устройство", "usbStatus_searching": "Търсене на USB устройства...", - "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка." + "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.", + "map_showDiscoveryContacts": "Покажи контакти за откриване" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9ba0f516..2f513605 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1887,5 +1887,6 @@ "usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus", "usbStatus_connecting": "Verbindung zum USB-Gerät...", "usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}", - "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält." + "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.", + "map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 26056289..ab33cea7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -807,6 +807,7 @@ "map_markers": "Markers", "map_showSharedMarkers": "Show shared markers", "map_showGuessedLocations": "Show guessed node locations", + "map_showDiscoveryContacts": "Show Discovery Contacts", "map_guessedLocation": "Guessed location", "map_lastSeenTime": "Last Seen Time", "map_sharedPin": "Shared pin", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9b791d3f..98d7c049 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1887,5 +1887,6 @@ "usbStatus_searching": "Buscando dispositivos USB...", "usbStatus_notConnected": "Seleccione un dispositivo USB", "usbConnectionFailed": "Error al conectar mediante USB: {error}", - "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion." + "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.", + "map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a7bedc96..fe437e69 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1859,5 +1859,6 @@ "usbConnectionFailed": "Échec de la connexion USB : {error}", "usbStatus_connecting": "Connexion au périphérique USB...", "usbStatus_searching": "Recherche de périphériques USB...", - "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion." + "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.", + "map_showDiscoveryContacts": "Afficher les contacts de découverte" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 423ff40c..6368ab3a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1859,5 +1859,6 @@ "usbConnectionFailed": "Errore nella connessione USB: {error}", "usbStatus_notConnected": "Seleziona un dispositivo USB", "usbStatus_connecting": "Connessione al dispositivo USB...", - "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion." + "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.", + "map_showDiscoveryContacts": "Mostra Contatti di Discovery" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8d3f86bc..a2a25ec0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2788,6 +2788,12 @@ abstract class AppLocalizations { /// **'Show guessed node locations'** String get map_showGuessedLocations; + /// No description provided for @map_showDiscoveryContacts. + /// + /// In en, this message translates to: + /// **'Show Discovery Contacts'** + String get map_showDiscoveryContacts; + /// No description provided for @map_guessedLocation. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 356106e3..ce9c061c 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1531,6 +1531,9 @@ class AppLocalizationsBg extends AppLocalizations { String get map_showGuessedLocations => 'Покажете местоположенията на предположените възли.'; + @override + String get map_showDiscoveryContacts => 'Покажи контакти за откриване'; + @override String get map_guessedLocation => 'Предполагано местоположение'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6353f350..14cb58ce 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1531,6 +1531,9 @@ class AppLocalizationsDe extends AppLocalizations { String get map_showGuessedLocations => 'Zeige die vermuteten Knotenpositionen'; + @override + String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen'; + @override String get map_guessedLocation => 'Geschätzter Ort'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9c20df7a..9d9dab46 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1506,6 +1506,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get map_showGuessedLocations => 'Show guessed node locations'; + @override + String get map_showDiscoveryContacts => 'Show Discovery Contacts'; + @override String get map_guessedLocation => 'Guessed location'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index eecbd48a..98c0f370 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1529,6 +1529,9 @@ class AppLocalizationsEs extends AppLocalizations { String get map_showGuessedLocations => 'Mostrar las ubicaciones estimadas de los nodos.'; + @override + String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento'; + @override String get map_guessedLocation => 'Ubicación estimada'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5cabc862..697db200 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1536,6 +1536,9 @@ class AppLocalizationsFr extends AppLocalizations { String get map_showGuessedLocations => 'Afficher les emplacements des nœuds estimés'; + @override + String get map_showDiscoveryContacts => 'Afficher les contacts de découverte'; + @override String get map_guessedLocation => 'Lieu deviné'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index d1705403..3655cdd5 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1528,6 +1528,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi'; + @override + String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery'; + @override String get map_guessedLocation => 'Località indovinata'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 323ba348..1acb14dd 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1521,6 +1521,9 @@ class AppLocalizationsNl extends AppLocalizations { String get map_showGuessedLocations => 'Toon de voorspelde locaties van de knopen'; + @override + String get map_showDiscoveryContacts => 'Ontdek contacten weergeven'; + @override String get map_guessedLocation => 'Geroerde locatie'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 9175c3e2..5d8e335c 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1530,6 +1530,9 @@ class AppLocalizationsPl extends AppLocalizations { String get map_showGuessedLocations => 'Wyświetl lokalizacje zgadanych węzłów'; + @override + String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania'; + @override String get map_guessedLocation => 'Wydana lokalizacja'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ff092138..a533fa5d 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1530,6 +1530,9 @@ class AppLocalizationsPt extends AppLocalizations { String get map_showGuessedLocations => 'Mostrar as localizações dos nós estimados'; + @override + String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta'; + @override String get map_guessedLocation => 'Localização estimada'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 69a5891e..7bf4418c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1532,6 +1532,9 @@ class AppLocalizationsRu extends AppLocalizations { String get map_showGuessedLocations => 'Отобразить предполагаемые места расположения узлов'; + @override + String get map_showDiscoveryContacts => 'Показать контакты Discovery'; + @override String get map_guessedLocation => 'Угаданное место'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index d0e75b0c..516fb6ca 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1524,6 +1524,9 @@ class AppLocalizationsSk extends AppLocalizations { String get map_showGuessedLocations => 'Zobraziť umiestnenia odhadnutých uzlov'; + @override + String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov'; + @override String get map_guessedLocation => 'Odhadnutá lokalita'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 21e3d9d6..f6f0df87 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1517,6 +1517,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.'; + @override + String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov'; + @override String get map_guessedLocation => 'Predpostavljena lokacija'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 5951faed..9595dc0d 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1514,6 +1514,9 @@ class AppLocalizationsSv extends AppLocalizations { String get map_showGuessedLocations => 'Visa upp de antagna nodernas placeringar'; + @override + String get map_showDiscoveryContacts => 'Visa Discovery-kontakter'; + @override String get map_guessedLocation => 'Gissad plats'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index b8fd60a2..2e2537b2 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1529,6 +1529,9 @@ class AppLocalizationsUk extends AppLocalizations { String get map_showGuessedLocations => 'Показати місцезнаходження передбачених вузлів'; + @override + String get map_showDiscoveryContacts => 'Показати контакти Відкриття'; + @override String get map_guessedLocation => 'Визначено місцезнаходження'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 27e6c21a..058dce10 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1440,6 +1440,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get map_showGuessedLocations => '显示猜测的节点位置'; + @override + String get map_showDiscoveryContacts => '显示发现联系人'; + @override String get map_guessedLocation => '猜测的位置'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 94df130d..0a295953 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1859,5 +1859,6 @@ "usbStatus_notConnected": "Selecteer een USB-apparaat", "usbStatus_connecting": "Verbinding maken met USB-apparaat...", "usbStatus_searching": "Zoeken naar USB-apparaten...", - "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft." + "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.", + "map_showDiscoveryContacts": "Ontdek contacten weergeven" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index d020e0ec..43ab9dda 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1859,5 +1859,6 @@ "usbStatus_connecting": "Połączenie z urządzeniem USB...", "usbStatus_notConnected": "Wybierz urządzenie USB", "usbConnectionFailed": "Błąd połączenia USB: {error}", - "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\"." + "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".", + "map_showDiscoveryContacts": "Pokaż kontakty odkrywania" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index d52cb418..11aa84d7 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1859,5 +1859,6 @@ "usbStatus_notConnected": "Selecione um dispositivo USB", "usbConnectionFailed": "Falha na conexão USB: {error}", "usbStatus_connecting": "Conectando ao dispositivo USB...", - "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion." + "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.", + "map_showDiscoveryContacts": "Mostrar Contatos de Descoberta" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 92fd55e3..af9c220d 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1099,5 +1099,6 @@ "usbStatus_connecting": "Подключение к USB-устройству...", "usbConnectionFailed": "Не удалось установить соединение через USB: {error}", "usbStatus_notConnected": "Выберите USB-устройство", - "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion." + "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.", + "map_showDiscoveryContacts": "Показать контакты Discovery" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 141147cd..e844f603 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1859,5 +1859,6 @@ "usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}", "usbStatus_notConnected": "Vyberte USB zariadenie", "usbStatus_connecting": "Pripojenie k USB zariadeniu...", - "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion." + "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.", + "map_showDiscoveryContacts": "Zobraziť kontakty objavov" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 12529d65..939aad65 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1859,5 +1859,6 @@ "usbStatus_connecting": "Povezava z USB napravo...", "usbStatus_searching": "Iskanje USB naprav...", "usbConnectionFailed": "Napaka pri povezavi preko USB: {error}", - "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion." + "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.", + "map_showDiscoveryContacts": "Prikaži odkritja kontaktov" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index f7615df5..9611f189 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1859,5 +1859,6 @@ "usbStatus_notConnected": "Välj en USB-enhet", "usbConnectionFailed": "Fel vid USB-anslutning: {error}", "usbStatus_searching": "Söker efter USB-enheter...", - "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware." + "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.", + "map_showDiscoveryContacts": "Visa Discovery-kontakter" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 77940984..389184ca 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1859,5 +1859,6 @@ "usbStatus_notConnected": "Виберіть пристрій USB", "usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}", "usbStatus_connecting": "Підключення до USB-пристрою...", - "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion." + "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.", + "map_showDiscoveryContacts": "Показати контакти Відкриття" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index dfc8e646..8a529832 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1864,5 +1864,6 @@ "usbStatus_connecting": "连接USB设备...", "usbStatus_notConnected": "选择一个 USB 设备", "usbConnectionFailed": "USB 连接失败:{error}", - "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。" + "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。", + "map_showDiscoveryContacts": "显示发现联系人" } diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index abcc729d..c89ac276 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -39,6 +39,7 @@ class AppSettings { final Map batteryChemistryByRepeaterId; final UnitSystem unitSystem; final Set mutedChannels; + final bool mapShowDiscoveryContacts; AppSettings({ this.clearPathOnMaxRetry = false, @@ -66,6 +67,7 @@ class AppSettings { Map? batteryChemistryByRepeaterId, this.unitSystem = UnitSystem.metric, Set? mutedChannels, + this.mapShowDiscoveryContacts = true, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, mutedChannels = mutedChannels ?? {}; @@ -97,6 +99,7 @@ class AppSettings { 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'unit_system': unitSystem.value, 'muted_channels': mutedChannels.toList(), + 'map_show_discovery_contacts': mapShowDiscoveryContacts, }; } @@ -152,6 +155,8 @@ class AppSettings { ?.map((e) => e.toString()) .toSet()) ?? {}, + mapShowDiscoveryContacts: + json['map_show_discovery_contacts'] as bool? ?? true, ); } @@ -181,6 +186,7 @@ class AppSettings { Map? batteryChemistryByRepeaterId, UnitSystem? unitSystem, Set? mutedChannels, + bool? mapShowDiscoveryContacts, }) { return AppSettings( clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, @@ -217,6 +223,8 @@ class AppSettings { batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, unitSystem: unitSystem ?? this.unitSystem, mutedChannels: mutedChannels ?? this.mutedChannels, + mapShowDiscoveryContacts: + mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts, ); } } diff --git a/lib/models/contact.dart b/lib/models/contact.dart index b4acff7b..cab58cb6 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -17,6 +17,8 @@ class Contact { final double? longitude; final DateTime lastSeen; final DateTime lastMessageAt; + final bool isActive; + final Uint8List? rawPacket; Contact({ required this.publicKey, @@ -31,6 +33,8 @@ class Contact { this.longitude, required this.lastSeen, DateTime? lastMessageAt, + this.isActive = true, + this.rawPacket, }) : lastMessageAt = lastMessageAt ?? lastSeen; String get publicKeyHex => pubKeyToHex(publicKey); @@ -78,6 +82,8 @@ class Contact { double? longitude, DateTime? lastSeen, DateTime? lastMessageAt, + bool? isActive, + Uint8List? rawPacket, }) { return Contact( publicKey: publicKey ?? this.publicKey, @@ -96,6 +102,8 @@ class Contact { longitude: longitude ?? this.longitude, lastSeen: lastSeen ?? this.lastSeen, lastMessageAt: lastMessageAt ?? this.lastMessageAt, + isActive: isActive ?? this.isActive, + rawPacket: rawPacket ?? this.rawPacket, ); } @@ -204,6 +212,8 @@ class Contact { latitude: lat, longitude: lon, lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000), + isActive: true, + rawPacket: null, ); } catch (e) { appLogger.error('Failed to parse contact frame: $e'); diff --git a/lib/models/discovery_contact.dart b/lib/models/discovery_contact.dart deleted file mode 100644 index f6c6a529..00000000 --- a/lib/models/discovery_contact.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'dart:typed_data'; -import '../connector/meshcore_protocol.dart'; - -class DiscoveryContact { - final Uint8List rawPacket; - final Uint8List publicKey; - final String name; - final int type; - final int pathLength; // -1 = flood, 0+ = direct hops (from device) - final Uint8List path; // Path bytes from device - final double? latitude; - final double? longitude; - final DateTime lastSeen; - - DiscoveryContact({ - required this.rawPacket, - required this.publicKey, - required this.name, - required this.type, - required this.pathLength, - required this.path, - this.latitude, - this.longitude, - required this.lastSeen, - }); - - String get publicKeyHex => pubKeyToHex(publicKey); - - String get typeLabel { - switch (type) { - case advTypeChat: - return 'Chat'; - case advTypeRepeater: - return 'Repeater'; - case advTypeRoom: - return 'Room'; - case advTypeSensor: - return 'Sensor'; - default: - return 'Unknown'; - } - } - - String get pathLabel { - if (pathLength < 0) return 'Flood'; - if (pathLength == 0) return 'Direct'; - return '$pathLength hops'; - } - - bool get hasLocation => latitude != null && longitude != null; - - DiscoveryContact copyWith({ - Uint8List? rawPacket, - Uint8List? publicKey, - String? name, - int? type, - int? pathLength, - Uint8List? path, - double? latitude, - double? longitude, - DateTime? lastSeen, - }) { - return DiscoveryContact( - rawPacket: rawPacket ?? this.rawPacket, - publicKey: publicKey ?? this.publicKey, - name: name ?? this.name, - type: type ?? this.type, - pathLength: pathLength ?? this.pathLength, - path: path ?? this.path, - latitude: latitude ?? this.latitude, - longitude: longitude ?? this.longitude, - lastSeen: lastSeen ?? this.lastSeen, - ); - } - - String get pathIdList { - final pathBytes = path; - if (pathBytes.isEmpty) return ''; - final parts = []; - final groupSize = pathHashSize; - for (int i = 0; i < pathBytes.length; i += groupSize) { - final end = (i + groupSize) <= pathBytes.length - ? (i + groupSize) - : pathBytes.length; - final chunk = pathBytes.sublist(i, end); - parts.add( - chunk - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(), - ); - } - return parts.join(','); - } - - String get shortPubKeyHex { - return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>"; - } - - @override - bool operator ==(Object other) => - other is DiscoveryContact && publicKeyHex == other.publicKeyHex; - - @override - int get hashCode => publicKeyHex.hashCode; -} diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 88f734bc..a90f9f0d 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State { : Icons.download, size: 18, ), + onLongPress: () async { + await Clipboard.setData( + ClipboardData( + text: entry.payload + .map( + (b) => b + .toRadixString(16) + .padLeft(2, '0'), + ) + .join(''), + ), + ); + }, ); } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 44dfe797..c2c57f0d 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -40,8 +40,11 @@ class ChannelMessagePathScreen extends StatelessWidget { final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; - - final hops = _buildPathHops(primaryPath, connector.contacts, l10n); + final contacts = [ + ...connector.contacts, + ...connector.discoveredContacts, + ]; + final hops = _buildPathHops(primaryPath, contacts, l10n); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( primaryPath.length, @@ -364,11 +367,11 @@ class _ChannelMessagePathMapScreenState : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); - final hops = _buildPathHops( - selectedPath, - connector.contacts, - context.l10n, - ); + final contacts = [ + ...connector.contacts, + ...connector.discoveredContacts, + ]; + final hops = _buildPathHops(selectedPath, contacts, context.l10n); final points = []; diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 00820ed7..b56b5631 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -51,6 +51,8 @@ class _ChannelsScreenState extends State // Cache of PSK hex -> Community for quick lookup final Map _pskToCommunity = {}; + ChannelMessageStore get _channelMessageStore => ChannelMessageStore(); + @override void initState() { super.initState(); @@ -61,6 +63,8 @@ class _ChannelsScreenState extends State } Future _loadCommunities() async { + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; final communities = await _communityStore.loadCommunities(); if (mounted) { setState(() { @@ -714,6 +718,8 @@ class _ChannelsScreenState extends State bool isRegularHashtag = true; Community? selectedCommunity; + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + showDialog( context: context, builder: (dialogContext) => StatefulBuilder( @@ -765,7 +771,9 @@ class _ChannelsScreenState extends State ); } - Widget? buildExpandedContent() { + Widget? buildExpandedContent( + ChannelMessageStore channelMessageStore, + ) { switch (selectedOption) { case 0: // Create Private Channel return Column( @@ -790,7 +798,7 @@ class _ChannelsScreenState extends State children: [ Expanded( child: FilledButton( - onPressed: () { + onPressed: () async { final name = nameController.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of( @@ -812,7 +820,14 @@ class _ChannelsScreenState extends State psk[i] = random.nextInt(256); } Navigator.pop(dialogContext); - connector.setChannel(nextIndex, name, psk); + await connector.setChannel( + nextIndex, + name, + psk, + ); + await channelMessageStore.clearChannelMessages( + nextIndex, + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1331,7 +1346,8 @@ class _ChannelsScreenState extends State subtitle: dialogContext.l10n.channels_createPrivateChannelDesc, ), - if (selectedOption == 0) buildExpandedContent()!, + if (selectedOption == 0) + buildExpandedContent(_channelMessageStore)!, const Divider(height: 1), buildOptionTile( optionIndex: 1, @@ -1340,7 +1356,8 @@ class _ChannelsScreenState extends State subtitle: dialogContext.l10n.channels_joinPrivateChannelDesc, ), - if (selectedOption == 1) buildExpandedContent()!, + if (selectedOption == 1) + buildExpandedContent(_channelMessageStore)!, if (!hasPublicChannel) ...[ const Divider(height: 1), buildOptionTile( @@ -1350,7 +1367,8 @@ class _ChannelsScreenState extends State subtitle: dialogContext.l10n.channels_joinPublicChannelDesc, ), - if (selectedOption == 2) buildExpandedContent()!, + if (selectedOption == 2) + buildExpandedContent(_channelMessageStore)!, ], const Divider(height: 1), buildOptionTile( @@ -1360,7 +1378,8 @@ class _ChannelsScreenState extends State subtitle: dialogContext.l10n.channels_joinHashtagChannelDesc, ), - if (selectedOption == 3) buildExpandedContent()!, + if (selectedOption == 3) + buildExpandedContent(_channelMessageStore)!, const Divider(height: 1), buildOptionTile( optionIndex: 4, @@ -1368,7 +1387,8 @@ class _ChannelsScreenState extends State title: dialogContext.l10n.community_scanQr, subtitle: dialogContext.l10n.community_join, ), - if (selectedOption == 4) buildExpandedContent()!, + if (selectedOption == 4) + buildExpandedContent(_channelMessageStore)!, const Divider(height: 1), buildOptionTile( optionIndex: 5, @@ -1376,7 +1396,8 @@ class _ChannelsScreenState extends State title: dialogContext.l10n.community_create, subtitle: dialogContext.l10n.community_createDesc, ), - if (selectedOption == 5) buildExpandedContent()!, + if (selectedOption == 5) + buildExpandedContent(_channelMessageStore)!, ], ), ), @@ -1526,7 +1547,7 @@ class _ChannelsScreenState extends State try { await connector.deleteChannel(channel.index); - channelMessageStore.clearChannelMessages(channel.index); + await channelMessageStore.clearChannelMessages(channel.index); if (!context.mounted) return; @@ -1751,6 +1772,7 @@ class _ChannelsScreenState extends State } final channelCount = communityChannels.length; + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; showDialog( context: context, diff --git a/lib/screens/community_qr_scanner_screen.dart b/lib/screens/community_qr_scanner_screen.dart index 9f8602d3..6852dfa2 100644 --- a/lib/screens/community_qr_scanner_screen.dart +++ b/lib/screens/community_qr_scanner_screen.dart @@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State { _isProcessing = true; }); + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + try { // Parse the community data final community = Community.fromQrData(const Uuid().v4(), data); @@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State { bool addPublicChannel, ) async { // Save community to local storage + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; await _communityStore.addCommunity(community); // Optionally add the community public channel to the device diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index f1226547..7f065aa5 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; -import '../models/discovery_contact.dart'; +import '../models/contact.dart'; import '../utils/contact_search.dart'; import '../widgets/app_bar.dart'; import '../widgets/list_filter_widget.dart'; @@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State { } Future _showContactContextMenu( - DiscoveryContact contact, + Contact contact, MeshCoreConnector connector, ) async { final action = await showModalBottomSheet( @@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State { connector.importDiscoveredContact(contact); break; case 'copy_contact': - final hexString = pubKeyToHex(contact.rawPacket); + if (contact.rawPacket == null) return; + final hexString = pubKeyToHex(contact.rawPacket!); Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State { } Widget _buildFilters( - List filteredAndSorted, + List filteredAndSorted, MeshCoreConnector connector, ) { String hintText = ""; @@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State { ); } - List _filterAndSortContacts( - List contacts, + List _filterAndSortContacts( + List contacts, MeshCoreConnector connector, ) { var filtered = contacts.where((contact) { @@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State { return filtered; } - bool _matchesTypeFilter(DiscoveryContact contact) { + bool _matchesTypeFilter(Contact contact) { switch (typeFilter) { case ContactTypeFilter.all: return true; diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 3d947011..7ffec567 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget { } class _MapScreenState extends State { - static const double _labelZoomThreshold = 8.5; + // Zoom level at which node labels start to appear + static const double _labelZoomThreshold = 12.0; final MapController _mapController = MapController(); final MapMarkerService _markerService = MapMarkerService(); @@ -91,6 +93,15 @@ class _MapScreenState extends State { }); } + bool _checkLocationPlausibility(double lat, double lon) { + const double epsilon = 1e-6; + return (lat.abs() > epsilon || lon.abs() > epsilon) && + lat >= -90.0 && + lat <= 90.0 && + lon >= -180.0 && + lon <= 180.0; + } + double _standardDeviation(List values) { if (values.length <= 1) { return 0.0; @@ -126,7 +137,15 @@ class _MapScreenState extends State { builder: (context, connector, settingsService, pathHistory, child) { final tileCache = context.read(); final settings = settingsService.settings; - final contacts = connector.contacts; + final allContacts = [ + ...connector.contacts, + ...connector.discoveredContacts.where((c) => !c.isActive), + ]; + + final contacts = settings.mapShowDiscoveryContacts + ? allContacts + : allContacts.where((c) => c.isActive).toList(); + final highlightPosition = widget.highlightPosition; final sharedMarkers = settings.mapShowMarkers ? _collectSharedMarkers(connector) @@ -159,14 +178,21 @@ class _MapScreenState extends State { : filteredByTime; // Filter by location - final contactsWithLocation = filteredByKeyPrefix - .where((c) => c.hasLocation) - .toList(); + final contactsWithLocation = filteredByKeyPrefix.where((c) { + if (!c.hasLocation) { + return false; + } + return _checkLocationPlausibility(c.latitude!, c.longitude!); + }).toList(); // All contacts with a known location — used as anchors regardless of // time/key-prefix filters so that repeaters are always available. - final allContactsWithLocation = contacts - .where((c) => c.hasLocation) + final allContactsWithLocation = allContacts + .where( + (c) => + c.hasLocation && + _checkLocationPlausibility(c.latitude!, c.longitude!), + ) .toList(); // Compute guessed locations with caching @@ -468,7 +494,10 @@ class _MapScreenState extends State { ), ), if (!_isBuildingPathTrace) - ...guessedLocations.map(_buildGuessedMarker), + ..._buildGuessedMarker( + guessedLocations, + showLabels: _showNodeLabels, + ), ..._buildMarkers( contactsWithLocation, settings, @@ -630,6 +659,13 @@ class _MapScreenState extends State { anchors[0].latitude + offsetDeg * cos(angle), anchors[0].longitude + offsetDeg * sin(angle), ); + + if (!_checkLocationPlausibility( + position.latitude, + position.longitude, + )) { + continue; // discard implausible guesses near (0, 0) + } } else { double lat = 0, lon = 0; for (final a in anchors) { @@ -637,6 +673,12 @@ class _MapScreenState extends State { lon += a.longitude; } position = LatLng(lat / anchors.length, lon / anchors.length); + if (!_checkLocationPlausibility( + position.latitude, + position.longitude, + )) { + continue; // discard implausible guesses near (0, 0 + } } result.add( _GuessedLocation( @@ -710,40 +752,61 @@ class _MapScreenState extends State { .toList(); } - Marker _buildGuessedMarker(_GuessedLocation guess) { - final color = _getNodeColor(guess.contact.type); - return Marker( - point: guess.position, - width: 35, - height: 35, - child: GestureDetector( - onTap: () => _showNodeInfo( - context, - guess.contact, - guessedPosition: guess.position, - ), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), + List _buildGuessedMarker( + List<_GuessedLocation> guessed, { + required bool showLabels, + }) { + final markers = []; + + for (final guess in guessed) { + final color = _getNodeColor(guess.contact.type); + final marker = Marker( + point: guess.position, + width: 35, + height: 35, + child: GestureDetector( + onTap: () => _showNodeInfo( + context, + guess.contact, + guessedPosition: guess.position, + ), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: color.withValues( + alpha: guess.highConfidence ? 0.55 : 0.30, ), - ], - ), - child: const Icon( - Icons.not_listed_location, - color: Colors.white, - size: 20, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.not_listed_location, + color: Colors.white, + size: 20, + ), ), ), - ), - ); + ); + + markers.add(marker); + + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: guess.position, + label: guess.contact.name, + ), + ); + } + } + return markers; } List _buildMarkers( @@ -1203,6 +1266,7 @@ class _MapScreenState extends State { Contact contact, { LatLng? guessedPosition, }) { + final connector = context.read(); showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -1248,6 +1312,9 @@ class _MapScreenState extends State { advTypeChat) // Only show chat button for chat nodes TextButton( onPressed: () { + if (!contact.isActive) { + connector.importDiscoveredContact(contact); + } Navigator.pop(dialogContext); Navigator.push( context, @@ -1261,6 +1328,9 @@ class _MapScreenState extends State { if (contact.type == advTypeRepeater) TextButton( onPressed: () { + if (!contact.isActive) { + connector.importDiscoveredContact(contact); + } Navigator.pop(dialogContext); _showRepeaterLogin(context, contact); }, @@ -1269,6 +1339,9 @@ class _MapScreenState extends State { if (contact.type == advTypeRoom) TextButton( onPressed: () { + if (!contact.isActive) { + connector.importDiscoveredContact(contact); + } Navigator.pop(dialogContext); _showRoomLogin(context, contact); }, @@ -1745,6 +1818,14 @@ class _MapScreenState extends State { }, contentPadding: EdgeInsets.zero, ), + CheckboxListTile( + title: Text(context.l10n.map_showDiscoveryContacts), + value: settings.mapShowDiscoveryContacts, + onChanged: (value) { + service.setMapShowDiscoveryContacts(value ?? true); + }, + contentPadding: EdgeInsets.zero, + ), const SizedBox(height: 16), Text( context.l10n.map_keyPrefix, diff --git a/lib/screens/neighbors_screen.dart b/lib/screens/neighbors_screen.dart index 3dee3391..5cb8e45a 100644 --- a/lib/screens/neighbors_screen.dart +++ b/lib/screens/neighbors_screen.dart @@ -124,12 +124,14 @@ class _NeighborsScreenState extends State { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { final buffer = BufferReader(frame); + final contacts = [ + ...connector.contacts, + ...connector.discoveredContacts, + ]; try { final neighborCount = buffer.readUInt16LE(); final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); - connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( - repeater, - ) { + contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) { for (var neighborData in parsedNeighbors) { final publicKey = neighborData['publicKey']; if (listEquals( diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index c6d800ee..ceb60a65 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -114,14 +114,37 @@ class _PathTraceMapScreenState extends State { super.dispose(); } - Uint8List addReturnPath(Uint8List pathBytes) { - Uint8List? traceBytes; - final len = (pathBytes.length + pathBytes.length - 1); - traceBytes = Uint8List(len); - for (int i = 0; i < pathBytes.length; i++) { - traceBytes[i] = pathBytes[i]; - if (i < pathBytes.length - 1) { - traceBytes[len - 1 - i] = pathBytes[i]; + Uint8List buildPath(Uint8List pathBytes) { + Uint8List traceBytes; + + if (pathBytes.isEmpty) { + traceBytes = Uint8List(1); + traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0; + return traceBytes; + } + + if (widget.targetContact?.type == advTypeRepeater || + widget.targetContact?.type == advTypeRoom) { + final len = (pathBytes.length + pathBytes.length + 1); + traceBytes = Uint8List(len); + traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0; + for (int i = 0; i < pathBytes.length; i++) { + traceBytes[i] = pathBytes[i]; + if (i < pathBytes.length) { + traceBytes[len - 1 - i] = pathBytes[i]; + } + } + } else { + if (pathBytes.length < 2) { + return pathBytes[0] == 0 ? Uint8List(0) : pathBytes; + } + final len = (pathBytes.length + pathBytes.length - 1); + traceBytes = Uint8List(len); + for (int i = 0; i < pathBytes.length; i++) { + traceBytes[i] = pathBytes[i]; + if (i < pathBytes.length - 1) { + traceBytes[len - 1 - i] = pathBytes[i]; + } } } return traceBytes; @@ -142,11 +165,16 @@ class _PathTraceMapScreenState extends State { : widget.path; if (widget.flipPathRound) { - path = addReturnPath(pathTmp); + path = buildPath(pathTmp); } else { path = pathTmp; } + appLogger.info( + 'Initiating path trace with path: ${_formatPathPrefixes(path)}', + tag: 'PathTraceMapScreen', + ); + final connector = Provider.of(context, listen: false); final frame = buildTraceReq( DateTime.now().millisecondsSinceEpoch ~/ 1000, @@ -235,10 +263,11 @@ class _PathTraceMapScreenState extends State { .toList(); Map pathContacts = {}; - - connector.contacts.where((c) => c.type != advTypeChat).forEach(( - repeater, - ) { + final contacts = [ + ...connector.contacts, + ...connector.discoveredContacts, + ]; + contacts.where((c) => c.type != advTypeChat).forEach((repeater) { for (var repeaterData in pathData) { if (listEquals( repeater.publicKey.sublist(0, 1), diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index c74fa40f..a52e3647 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier { appLogger.setEnabled(value); } + Future setMapShowDiscoveryContacts(bool value) async { + await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value)); + } + Future setBatteryChemistryForDevice( String deviceId, String chemistry, diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index 50d13f7e..7bf44bd7 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -48,7 +48,7 @@ class ChannelMessageStore { final key = '$keyFor$channelIndex'; final oldKey = '$_keyPrefix$channelIndex'; - String? jsonString = prefs.getString(oldKey); + String? jsonString = prefs.getString(key); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(oldKey); diff --git a/lib/storage/channel_order_store.dart b/lib/storage/channel_order_store.dart index 48a80f22..88d3f7a6 100644 --- a/lib/storage/channel_order_store.dart +++ b/lib/storage/channel_order_store.dart @@ -26,7 +26,7 @@ class ChannelOrderStore { return []; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/storage/channel_settings_store.dart b/lib/storage/channel_settings_store.dart index 3fb00eb6..276826d7 100644 --- a/lib/storage/channel_settings_store.dart +++ b/lib/storage/channel_settings_store.dart @@ -32,7 +32,7 @@ class ChannelSettingsStore { await prefs.setBool(key, enabled); } } - return prefs.getBool(key) ?? false; + return enabled ?? false; } Future saveSmazEnabled(int channelIndex, bool enabled) async { diff --git a/lib/storage/channel_store.dart b/lib/storage/channel_store.dart index 775398e4..4f40482a 100644 --- a/lib/storage/channel_store.dart +++ b/lib/storage/channel_store.dart @@ -19,7 +19,7 @@ class ChannelStore { return []; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/storage/community_store.dart b/lib/storage/community_store.dart index 6df859ac..c69d0b80 100644 --- a/lib/storage/community_store.dart +++ b/lib/storage/community_store.dart @@ -25,7 +25,7 @@ class CommunityStore { return []; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/storage/contact_discovery_store.dart b/lib/storage/contact_discovery_store.dart index ac47615d..89ca0273 100644 --- a/lib/storage/contact_discovery_store.dart +++ b/lib/storage/contact_discovery_store.dart @@ -1,13 +1,13 @@ import 'dart:convert'; import 'dart:typed_data'; -import '../models/discovery_contact.dart'; +import '../models/contact.dart'; import 'prefs_manager.dart'; class ContactDiscoveryStore { static const String _keyPrefix = 'discovered_contacts'; - Future> loadContacts() async { + Future> loadContacts() async { final prefs = PrefsManager.instance; final jsonStr = prefs.getString(_keyPrefix); if (jsonStr == null) return []; @@ -22,40 +22,62 @@ class ContactDiscoveryStore { } } - Future saveContacts(List contacts) async { + Future saveContacts(List contacts) async { final prefs = PrefsManager.instance; final jsonList = contacts.map(_toJson).toList(); await prefs.setString(_keyPrefix, jsonEncode(jsonList)); } - Map _toJson(DiscoveryContact contact) { + Map _toJson(Contact contact) { return { - 'rawPacket': base64Encode(contact.rawPacket), 'publicKey': base64Encode(contact.publicKey), 'name': contact.name, 'type': contact.type, + 'flags': contact.flags, 'pathLength': contact.pathLength, 'path': base64Encode(contact.path), + 'pathOverride': contact.pathOverride, + 'pathOverrideBytes': contact.pathOverrideBytes != null + ? base64Encode(contact.pathOverrideBytes!) + : null, 'latitude': contact.latitude, 'longitude': contact.longitude, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch, + 'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch, + 'rawPacket': contact.rawPacket != null + ? base64Encode(contact.rawPacket!) + : null, }; } - DiscoveryContact _fromJson(Map json) { + Contact _fromJson(Map json) { final lastSeenMs = json['lastSeen'] as int? ?? 0; - return DiscoveryContact( - rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)), + final lastMessageMs = json['lastMessageAt'] as int?; + return Contact( publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), name: json['name'] as String? ?? 'Unknown', type: json['type'] as int? ?? 0, + flags: json['flags'] as int? ?? 0, pathLength: json['pathLength'] as int? ?? -1, path: json['path'] != null ? Uint8List.fromList(base64Decode(json['path'] as String)) : Uint8List(0), + pathOverride: json['pathOverride'] as int?, + pathOverrideBytes: json['pathOverrideBytes'] != null + ? Uint8List.fromList( + base64Decode(json['pathOverrideBytes'] as String), + ) + : null, latitude: (json['latitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), + lastMessageAt: DateTime.fromMillisecondsSinceEpoch( + lastMessageMs ?? lastSeenMs, + ), + isActive: false, + rawPacket: json['rawPacket'] != null + ? Uint8List.fromList(base64Decode(json['rawPacket'] as String)) + : null, ); } } diff --git a/lib/storage/contact_group_store.dart b/lib/storage/contact_group_store.dart index 986bfddd..ce6a0c6e 100644 --- a/lib/storage/contact_group_store.dart +++ b/lib/storage/contact_group_store.dart @@ -18,7 +18,7 @@ class ContactGroupStore { return []; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index a4e2f0d2..0e2e3ad1 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -76,6 +76,10 @@ class ContactStore { 'longitude': contact.longitude, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch, + 'isActive': contact.isActive, + 'rawPacket': contact.rawPacket != null + ? base64Encode(contact.rawPacket!) + : null, }; } @@ -103,6 +107,10 @@ class ContactStore { lastMessageAt: DateTime.fromMillisecondsSinceEpoch( lastMessageMs ?? lastSeenMs, ), + isActive: json['isActive'] as bool? ?? true, + rawPacket: json['rawPacket'] != null + ? Uint8List.fromList(base64Decode(json['rawPacket'] as String)) + : null, ); } } diff --git a/lib/storage/unread_store.dart b/lib/storage/unread_store.dart index d46fb410..3b615b1d 100644 --- a/lib/storage/unread_store.dart +++ b/lib/storage/unread_store.dart @@ -32,7 +32,7 @@ class UnreadStore { return {}; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/utils/contact_search.dart b/lib/utils/contact_search.dart index beec8809..1f05fdce 100644 --- a/lib/utils/contact_search.dart +++ b/lib/utils/contact_search.dart @@ -1,5 +1,3 @@ -import 'package:meshcore_open/models/discovery_contact.dart'; - import '../models/contact.dart'; bool matchesContactQuery(Contact contact, String query) { @@ -16,7 +14,7 @@ bool matchesContactQuery(Contact contact, String query) { return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix); } -bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) { +bool matchesDiscoveryContactQuery(Contact contact, String query) { final normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.isEmpty) return true; diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index 1f592eb3..f122836b 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -157,8 +157,11 @@ class _SNRIndicatorState extends State { repeater.snr, widget.connector.currentSf, ); - - final name = widget.connector.contacts + final allContacts = [ + ...widget.connector.contacts, + ...widget.connector.discoveredContacts, + ]; + final name = allContacts .where((c) => c.publicKey.first == repeater.pubkeyFirstByte) .map((c) => c.name) .firstOrNull;