diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index c89e37f..b800cfa 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'; @@ -123,7 +122,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 = {}; @@ -288,7 +287,7 @@ class MeshCoreConnector extends ChangeNotifier { ); } - List get discoveredContacts { + List get discoveredContacts { return List.unmodifiable(_discoveredContacts); } @@ -298,6 +297,7 @@ class MeshCoreConnector extends ChangeNotifier { bool get isLoadingChannels => _isLoadingChannels; Stream get receivedFrames => _receivedFramesController.stream; Uint8List? get selfPublicKey => _selfPublicKey; + String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); String? get selfName => _selfName; double? get selfLatitude => _selfLatitude; double? get selfLongitude => _selfLongitude; @@ -699,7 +699,7 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future loadDiscoveredContactCache() async { + Future _loadDiscoveredContactCache() async { final cached = await _discoveryContactStore.loadContacts(); _discoveredContacts ..clear() @@ -1338,7 +1338,6 @@ class MeshCoreConnector extends ChangeNotifier { await _requestDeviceInfo(); _startBatteryPolling(); - unawaited(loadDiscoveredContactCache()); final gotSelfInfo = await _waitForSelfInfo( timeout: const Duration(seconds: 3), @@ -2056,7 +2055,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); @@ -2072,7 +2075,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, @@ -2081,7 +2097,7 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } - Future importDiscoveredContact(DiscoveryContact contact) async { + Future importDiscoveredContact(Contact contact) async { if (!isConnected) return; await sendFrame( @@ -2090,11 +2106,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, @@ -2105,6 +2133,7 @@ class MeshCoreConnector extends ChangeNotifier { latitude: contact.latitude, longitude: contact.longitude, lastSeen: DateTime.now(), + flags: contact.flags, ), ); notifyListeners(); @@ -2121,6 +2150,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), ); @@ -2476,6 +2507,7 @@ class MeshCoreConnector extends ChangeNotifier { debugPrint('Got END_OF_CONTACTS'); _isLoadingContacts = false; _preserveContactsOnRefresh = false; + unawaited(updateKnownDiscovered()); notifyListeners(); unawaited(_persistContacts()); if (PlatformInfo.isWeb && @@ -2643,6 +2675,28 @@ class MeshCoreConnector extends ChangeNotifier { selfName.isNotEmpty) { _usbManager.updateConnectedLabel(selfName); } + + //set all the stores' public key so they can load the correct data + _channelMessageStore.setPublicKeyHex = selfPublicKeyHex; + _messageStore.setPublicKeyHex = selfPublicKeyHex; + _channelOrderStore.setPublicKeyHex = selfPublicKeyHex; + _channelSettingsStore.setPublicKeyHex = selfPublicKeyHex; + _contactSettingsStore.setPublicKeyHex = selfPublicKeyHex; + _contactStore.setPublicKeyHex = selfPublicKeyHex; + _channelStore.setPublicKeyHex = selfPublicKeyHex; + _unreadStore.setPublicKeyHex = selfPublicKeyHex; + + // Now that we have self info, we can load all the persisted data for this node + _loadChannelOrder(); + loadContactCache(); + loadChannelSettings(); + loadCachedChannels(); + + // Load persisted channel messages + loadAllChannelMessages(); + loadUnreadState(); + _loadDiscoveredContactCache(); + _awaitingSelfInfo = false; _selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer = null; @@ -4542,7 +4596,7 @@ class MeshCoreConnector extends ChangeNotifier { } importDiscoveredContact( - DiscoveryContact( + Contact( rawPacket: frame, publicKey: publicKey, name: name, @@ -4613,6 +4667,7 @@ class MeshCoreConnector extends ChangeNotifier { if (isNewContact) { final newContact = Contact( + rawPacket: rawPacket, publicKey: publicKey, name: name, type: type, @@ -4758,13 +4813,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, @@ -4774,6 +4831,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 3484d47..dc9a9f5 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 972d376..fa84e50 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.", "tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.", "tcpErrorTimedOut": "Връзката TCP изтекла.", - "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}" + "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}", + "map_showDiscoveryContacts": "Покажи контакти за откриване" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d6ff0b0..eae33e5 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1915,5 +1915,6 @@ "tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.", "tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.", "tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.", - "tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}" + "tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}", + "map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 544d574..0acf9a5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -835,6 +835,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 dcad901..974e2c3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1915,5 +1915,6 @@ "tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.", "tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.", "tcpErrorTimedOut": "La conexión TCP ha caducado.", - "tcpConnectionFailed": "Error en la conexión TCP: {error}" + "tcpConnectionFailed": "Error en la conexión TCP: {error}", + "map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e934b7e..3c36a96 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.", "tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.", "tcpErrorTimedOut": "La connexion TCP a expiré.", - "tcpConnectionFailed": "Échec de la connexion TCP : {error}" + "tcpConnectionFailed": "Échec de la connexion TCP : {error}", + "map_showDiscoveryContacts": "Afficher les contacts de découverte" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 30b19b0..8a1c29d 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.", "tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.", "tcpErrorTimedOut": "La connessione TCP è scaduta.", - "tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}" + "tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}", + "map_showDiscoveryContacts": "Mostra Contatti di Discovery" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 536d7fb..36be955 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2872,6 +2872,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 2ea0e0f..fd61be0 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1578,6 +1578,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 495b37b..0400237 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1581,6 +1581,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 c20a6df..d01dc36 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1553,6 +1553,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 56bba0e..7e5f806 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1576,6 +1576,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 0dd88f7..c7a225b 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1585,6 +1585,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 ce7b658..121c2f8 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1576,6 +1576,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 70dbfe7..48fe379 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1569,6 +1569,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 121c5fe..97681be 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1579,6 +1579,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 9545143..eb2fc7f 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1579,6 +1579,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 a0af1b3..1a28e8d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1580,6 +1580,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 f29c1e6..eb53c40 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1571,6 +1571,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 f4c6df0..be87248 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1564,6 +1564,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 140e3eb..55ac5c6 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1561,6 +1561,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 5b28e57..8466adf 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1577,6 +1577,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 2c0034c..85026c2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1486,6 +1486,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 29b9eca..51c045c 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.", "tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.", "tcpErrorTimedOut": "De TCP-verbinding is verlopen.", - "tcpConnectionFailed": "Verbinding met TCP mislukt: {error}" + "tcpConnectionFailed": "Verbinding met TCP mislukt: {error}", + "map_showDiscoveryContacts": "Ontdek contacten weergeven" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 7576cf7..e711a99 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.", "tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.", "tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.", - "tcpConnectionFailed": "Błąd połączenia TCP: {error}" + "tcpConnectionFailed": "Błąd połączenia TCP: {error}", + "map_showDiscoveryContacts": "Pokaż kontakty odkrywania" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 428772c..6c1ad9e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.", "tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.", "tcpErrorTimedOut": "A conexão TCP expirou.", - "tcpConnectionFailed": "Falha na conexão TCP: {error}" + "tcpConnectionFailed": "Falha na conexão TCP: {error}", + "map_showDiscoveryContacts": "Mostrar Contatos de Descoberta" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f42e959..d90c387 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1127,5 +1127,6 @@ "tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.", "tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.", "tcpErrorTimedOut": "Соединение TCP не удалось установить.", - "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}" + "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}", + "map_showDiscoveryContacts": "Показать контакты Discovery" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 57f95ab..ae55843 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.", "tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.", "tcpErrorTimedOut": "Pripojenie TCP vypršalo.", - "tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}" + "tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}", + "map_showDiscoveryContacts": "Zobraziť kontakty objavov" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 252c7d7..6ad2449 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "Port mora biti med 1 in 65535.", "tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.", "tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.", - "tcpConnectionFailed": "Napaka pri povezavi TCP: {error}" + "tcpConnectionFailed": "Napaka pri povezavi TCP: {error}", + "map_showDiscoveryContacts": "Prikaži odkritja kontaktov" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 349a4d3..b70049f 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.", "tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.", "tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.", - "tcpConnectionFailed": "Fel vid TCP-anslutning: {error}" + "tcpConnectionFailed": "Fel vid TCP-anslutning: {error}", + "map_showDiscoveryContacts": "Visa Discovery-kontakter" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 8d1ea80..9dcf88f 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1887,5 +1887,6 @@ "tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.", "tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.", "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", - "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}" + "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}", + "map_showDiscoveryContacts": "Показати контакти Відкриття" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1df60a7..199f85c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1892,5 +1892,6 @@ "tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。", "tcpErrorUnsupported": "此平台不支持 TCP 传输。", "tcpErrorTimedOut": "TCP 连接超时。", - "tcpConnectionFailed": "TCP 连接失败:{error}" + "tcpConnectionFailed": "TCP 连接失败:{error}", + "map_showDiscoveryContacts": "显示发现联系人" } diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index abcc729..c89ac27 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 b4acff7..cab58cb 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 f6c6a52..0000000 --- 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 88f734b..a90f9f0 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 44dfe79..c2c57f0 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 582fee7..b56b563 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(() { @@ -106,7 +110,9 @@ class _ChannelsScreenState extends State @override Widget build(BuildContext context) { final connector = context.watch(); + final channelMessageStore = ChannelMessageStore(); + channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex; // Auto-navigate back to scanner if disconnected if (!checkConnectionAndNavigate(connector)) { @@ -712,6 +718,8 @@ class _ChannelsScreenState extends State bool isRegularHashtag = true; Community? selectedCommunity; + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + showDialog( context: context, builder: (dialogContext) => StatefulBuilder( @@ -763,7 +771,9 @@ class _ChannelsScreenState extends State ); } - Widget? buildExpandedContent() { + Widget? buildExpandedContent( + ChannelMessageStore channelMessageStore, + ) { switch (selectedOption) { case 0: // Create Private Channel return Column( @@ -788,7 +798,7 @@ class _ChannelsScreenState extends State children: [ Expanded( child: FilledButton( - onPressed: () { + onPressed: () async { final name = nameController.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of( @@ -810,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( @@ -1329,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, @@ -1338,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( @@ -1348,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( @@ -1358,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, @@ -1366,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, @@ -1374,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)!, ], ), ), @@ -1524,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; @@ -1749,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 9f8602d..6852dfa 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 f122654..7f065aa 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 3d94701..7ffec56 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 3dee339..5cb8e45 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 c6d800e..ceb60a6 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 c74fa40..a52e364 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 1151514..7bf44bd 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -1,5 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:meshcore_open/utils/app_logger.dart'; + import '../models/channel_message.dart'; import '../helpers/smaz.dart'; import 'prefs_manager.dart'; @@ -7,13 +9,25 @@ import 'prefs_manager.dart'; class ChannelMessageStore { static const String _keyPrefix = 'channel_messages_'; + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; + /// Save messages for a specific channel Future saveChannelMessages( int channelIndex, List messages, ) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot save channel messages.', + ); + return; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$channelIndex'; + final key = '$keyFor$channelIndex'; // Convert messages to JSON final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); @@ -24,12 +38,35 @@ class ChannelMessageStore { /// Load messages for a specific channel Future> loadChannelMessages(int channelIndex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot load channel messages.', + ); + return []; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$channelIndex'; - - final jsonString = prefs.getString(key); - if (jsonString == null) return []; + final key = '$keyFor$channelIndex'; + final oldKey = '$_keyPrefix$channelIndex'; + String? jsonString = prefs.getString(key); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(oldKey); + prefs.remove(oldKey); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel messages from legacy key $oldKey to scoped key $key', + ); + await prefs.setString(key, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { final jsonList = jsonDecode(jsonString) as List; return jsonList.map((json) => _messageFromJson(json)).toList(); @@ -42,14 +79,14 @@ class ChannelMessageStore { /// Clear messages for a specific channel Future clearChannelMessages(int channelIndex) async { final prefs = PrefsManager.instance; - final key = '$_keyPrefix$channelIndex'; + final key = '$keyFor$channelIndex'; await prefs.remove(key); } /// Clear all channel messages Future clearAllChannelMessages() async { final prefs = PrefsManager.instance; - final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix)); + final keys = prefs.getKeys().where((k) => k.startsWith(keyFor)); for (var key in keys) { await prefs.remove(key); } diff --git a/lib/storage/channel_order_store.dart b/lib/storage/channel_order_store.dart index b9657c4..88d3f7a 100644 --- a/lib/storage/channel_order_store.dart +++ b/lib/storage/channel_order_store.dart @@ -1,20 +1,49 @@ import 'dart:convert'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ChannelOrderStore { - static const String _key = 'channel_order'; + static const String _keyPrefix = 'channel_order_'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future saveChannelOrder(List order) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save channel order.'); + return; + } final prefs = PrefsManager.instance; - await prefs.setString(_key, jsonEncode(order)); + await prefs.setString(keyFor, jsonEncode(order)); } Future> loadChannelOrder() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load channel order.'); + return []; + } final prefs = PrefsManager.instance; - final raw = prefs.getString(_key); - if (raw == null || raw.isEmpty) return []; + String? jsonString = prefs.getString(keyFor); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { - final decoded = jsonDecode(raw); + final decoded = jsonDecode(jsonString); if (decoded is List) { return decoded .map((value) => value is int ? value : int.tryParse('$value')) @@ -24,7 +53,7 @@ class ChannelOrderStore { } catch (_) { // fall through to legacy parse } - return raw + return jsonString .split(',') .map((value) => int.tryParse(value)) .whereType() diff --git a/lib/storage/channel_settings_store.dart b/lib/storage/channel_settings_store.dart index eee97aa..276826d 100644 --- a/lib/storage/channel_settings_store.dart +++ b/lib/storage/channel_settings_store.dart @@ -1,17 +1,49 @@ +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ChannelSettingsStore { - static const String _smazKeyPrefix = 'channel_smaz_'; + static const String _keyPrefix = 'channel_smaz_'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future loadSmazEnabled(int channelIndex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot load channel settings.', + ); + return false; + } final prefs = PrefsManager.instance; - final key = '$_smazKeyPrefix$channelIndex'; - return prefs.getBool(key) ?? false; + final key = '$keyFor$channelIndex'; + final oldKey = '$_keyPrefix$channelIndex'; + bool? enabled = prefs.getBool(oldKey); + if (enabled == null) { + // Attempt migration from legacy unscoped key on first load + enabled = prefs.getBool(oldKey); + prefs.remove(oldKey); + if (enabled != null) { + appLogger.info( + 'Migrating channel settings from legacy key $oldKey to scoped key $key', + ); + await prefs.setBool(key, enabled); + } + } + return enabled ?? false; } Future saveSmazEnabled(int channelIndex, bool enabled) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot save channel settings.', + ); + return; + } final prefs = PrefsManager.instance; - final key = '$_smazKeyPrefix$channelIndex'; + final key = '$keyFor$channelIndex'; await prefs.setBool(key, enabled); } } diff --git a/lib/storage/channel_store.dart b/lib/storage/channel_store.dart index eaa7a61..4f40482 100644 --- a/lib/storage/channel_store.dart +++ b/lib/storage/channel_store.dart @@ -2,18 +2,46 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/channel.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ChannelStore { - static const String _key = 'channels'; + static const String _keyPrefix = 'channels'; + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future> loadChannels() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load channels.'); + return []; + } final prefs = PrefsManager.instance; - final jsonStr = prefs.getString(_key); - if (jsonStr == null) return []; + String? jsonString = prefs.getString(keyFor); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { - final jsonList = jsonDecode(jsonStr) as List; + final jsonList = jsonDecode(jsonString) as List; return jsonList .map((entry) => _fromJson(entry as Map)) .toList(); @@ -23,9 +51,13 @@ class ChannelStore { } Future saveChannels(List channels) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save channels.'); + return; + } final prefs = PrefsManager.instance; final jsonList = channels.map(_toJson).toList(); - await prefs.setString(_key, jsonEncode(jsonList)); + await prefs.setString(keyFor, jsonEncode(jsonList)); } Map _toJson(Channel channel) { diff --git a/lib/storage/community_store.dart b/lib/storage/community_store.dart index a81cccd..c69d0b8 100644 --- a/lib/storage/community_store.dart +++ b/lib/storage/community_store.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import '../models/community.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; /// Persists communities to local storage using SharedPreferences. @@ -9,12 +10,37 @@ import 'prefs_manager.dart'; /// Each community contains its secret K, so this data should /// be considered sensitive (though device encryption handles security). class CommunityStore { - static const String _communitiesKey = 'communities_v1'; + static const String _keyPrefix = 'communities_v1'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; /// Load all communities from storage Future> loadCommunities() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load communities.'); + return []; + } final prefs = PrefsManager.instance; - final jsonString = prefs.getString(_communitiesKey); + String? jsonString = prefs.getString(keyFor); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } if (jsonString == null || jsonString.isEmpty) { return []; } @@ -32,9 +58,13 @@ class CommunityStore { /// Save all communities to storage Future saveCommunities(List communities) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save communities.'); + return; + } final prefs = PrefsManager.instance; final jsonList = communities.map((c) => c.toJson()).toList(); - await prefs.setString(_communitiesKey, jsonEncode(jsonList)); + await prefs.setString(keyFor, jsonEncode(jsonList)); } /// Add a new community diff --git a/lib/storage/contact_discovery_store.dart b/lib/storage/contact_discovery_store.dart index 37bfbb4..89ca027 100644 --- a/lib/storage/contact_discovery_store.dart +++ b/lib/storage/contact_discovery_store.dart @@ -1,15 +1,15 @@ 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 _key = 'discovered_contacts'; + static const String _keyPrefix = 'discovered_contacts'; - Future> loadContacts() async { + Future> loadContacts() async { final prefs = PrefsManager.instance; - final jsonStr = prefs.getString(_key); + final jsonStr = prefs.getString(_keyPrefix); if (jsonStr == null) return []; try { @@ -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(_key, jsonEncode(jsonList)); + 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 907cc5c..ce6a0c6 100644 --- a/lib/storage/contact_group_store.dart +++ b/lib/storage/contact_group_store.dart @@ -1,17 +1,45 @@ import 'dart:convert'; import '../models/contact_group.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ContactGroupStore { - static const String _key = 'contact_groups'; + static const String _keyPrefix = 'contact_groups'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future> loadGroups() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load contact groups.'); + return []; + } final prefs = PrefsManager.instance; - final raw = prefs.getString(_key); - if (raw == null || raw.isEmpty) return []; + String? jsonString = prefs.getString(keyFor); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { - final decoded = jsonDecode(raw); + final decoded = jsonDecode(jsonString); if (decoded is List) { return decoded .whereType>() @@ -25,8 +53,12 @@ class ContactGroupStore { } Future saveGroups(List groups) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save contact groups.'); + return; + } final prefs = PrefsManager.instance; final encoded = jsonEncode(groups.map((group) => group.toJson()).toList()); - await prefs.setString(_key, encoded); + await prefs.setString(keyFor, encoded); } } diff --git a/lib/storage/contact_settings_store.dart b/lib/storage/contact_settings_store.dart index 5a7949d..94c6430 100644 --- a/lib/storage/contact_settings_store.dart +++ b/lib/storage/contact_settings_store.dart @@ -1,17 +1,49 @@ +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ContactSettingsStore { - static const String _smazKeyPrefix = 'contact_smaz_'; + static const String _keyPrefix = 'contact_smaz_'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future loadSmazEnabled(String contactKeyHex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot load contact settings.', + ); + return false; + } final prefs = PrefsManager.instance; - final key = '$_smazKeyPrefix$contactKeyHex'; + final key = '$keyFor$contactKeyHex'; + final oldKey = '$_keyPrefix$contactKeyHex'; + bool? enabled = prefs.getBool(key); + if (enabled == null) { + // Attempt migration from legacy unscoped key on first load + enabled = prefs.getBool(oldKey); + prefs.remove(oldKey); + if (enabled != null) { + appLogger.info( + 'Migrating contact settings from legacy key $oldKey to scoped key $key', + ); + await prefs.setBool(key, enabled); + } + } return prefs.getBool(key) ?? false; } Future saveSmazEnabled(String contactKeyHex, bool enabled) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot save contact settings.', + ); + return; + } final prefs = PrefsManager.instance; - final key = '$_smazKeyPrefix$contactKeyHex'; + final key = '$keyFor$contactKeyHex'; await prefs.setBool(key, enabled); } } diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 504ff16..0e2e3ad 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -2,18 +2,46 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/contact.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ContactStore { - static const String _key = 'contacts'; + static const String _keyPrefix = 'contacts'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future> loadContacts() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load contacts.'); + return []; + } final prefs = PrefsManager.instance; - final jsonStr = prefs.getString(_key); - if (jsonStr == null) return []; + String? jsonString = prefs.getString(keyFor); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { - final jsonList = jsonDecode(jsonStr) as List; + final jsonList = jsonDecode(jsonString) as List; return jsonList .map((entry) => _fromJson(entry as Map)) .toList(); @@ -23,9 +51,13 @@ class ContactStore { } Future saveContacts(List contacts) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save contacts.'); + return; + } final prefs = PrefsManager.instance; final jsonList = contacts.map(_toJson).toList(); - await prefs.setString(_key, jsonEncode(jsonList)); + await prefs.setString(keyFor, jsonEncode(jsonList)); } Map _toJson(Contact contact) { @@ -44,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, }; } @@ -71,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/message_store.dart b/lib/storage/message_store.dart index 9526ef3..9a39e3f 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -2,26 +2,59 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/message.dart'; import '../helpers/smaz.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class MessageStore { static const String _keyPrefix = 'messages_'; + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; + Future saveMessages( String contactKeyHex, List messages, ) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save messages.'); + return; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$contactKeyHex'; + final key = '$keyFor$contactKeyHex'; final jsonList = messages.map(_messageToJson).toList(); await prefs.setString(key, jsonEncode(jsonList)); } Future> loadMessages(String contactKeyHex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load messages.'); + return []; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$contactKeyHex'; - final jsonString = prefs.getString(key); - if (jsonString == null) return []; + final key = '$keyFor$contactKeyHex'; + final oldKey = '$_keyPrefix$contactKeyHex'; + String? jsonString = prefs.getString(key); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(oldKey); + prefs.remove(oldKey); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating messages from legacy key $oldKey to scoped key $key', + ); + await prefs.setString(key, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { final jsonList = jsonDecode(jsonString) as List; @@ -32,8 +65,12 @@ class MessageStore { } Future clearMessages(String contactKeyHex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot clear messages.'); + return; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$contactKeyHex'; + final key = '$keyFor$contactKeyHex'; await prefs.remove(key); } diff --git a/lib/storage/unread_store.dart b/lib/storage/unread_store.dart index 201d25e..3b615b1 100644 --- a/lib/storage/unread_store.dart +++ b/lib/storage/unread_store.dart @@ -1,11 +1,18 @@ import 'dart:async'; import 'dart:convert'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; /// Storage for unread message tracking with debounced writes to reduce I/O. class UnreadStore { - static const String _contactUnreadCountKey = 'contact_unread_count'; + static const String _keyPrefix = 'contact_unread_count'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; // Debounce timers to batch rapid writes Timer? _contactUnreadSaveTimer; @@ -20,12 +27,33 @@ class UnreadStore { } Future> loadContactUnreadCount() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load unread counts.'); + return {}; + } final prefs = PrefsManager.instance; - final jsonStr = prefs.getString(_contactUnreadCountKey); - if (jsonStr == null) return {}; + String? jsonString = prefs.getString(keyFor); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } + if (jsonString == null || jsonString.isEmpty) { + return {}; + } try { - final json = jsonDecode(jsonStr) as Map; + final json = jsonDecode(jsonString) as Map; return json.map((key, value) => MapEntry(key, value as int)); } catch (_) { return {}; @@ -33,6 +61,10 @@ class UnreadStore { } void saveContactUnreadCount(Map counts) { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save unread counts.'); + return; + } _pendingContactUnreadCount = counts; _contactUnreadSaveTimer?.cancel(); @@ -49,7 +81,7 @@ class UnreadStore { final prefs = PrefsManager.instance; final jsonStr = jsonEncode(_pendingContactUnreadCount); - await prefs.setString(_contactUnreadCountKey, jsonStr); + await prefs.setString(keyFor, jsonStr); _pendingContactUnreadCount = null; } diff --git a/lib/utils/contact_search.dart b/lib/utils/contact_search.dart index beec880..1f05fdc 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 1f592eb..f122836 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;