Merge branch 'main' into tcp

This commit is contained in:
Winston Lowe
2026-03-12 23:22:30 -07:00
committed by GitHub
58 changed files with 870 additions and 286 deletions
+71 -11
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import 'package:meshcore_open/models/discovery_contact.dart';
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
@@ -123,7 +122,7 @@ class MeshCoreConnector extends ChangeNotifier {
final List<ScanResult> _scanResults = []; final List<ScanResult> _scanResults = [];
final List<Contact> _contacts = []; final List<Contact> _contacts = [];
final List<DiscoveryContact> _discoveredContacts = []; final List<Contact> _discoveredContacts = [];
final List<Channel> _channels = []; final List<Channel> _channels = [];
final Map<String, List<Message>> _conversations = {}; final Map<String, List<Message>> _conversations = {};
final Map<int, List<ChannelMessage>> _channelMessages = {}; final Map<int, List<ChannelMessage>> _channelMessages = {};
@@ -288,7 +287,7 @@ class MeshCoreConnector extends ChangeNotifier {
); );
} }
List<DiscoveryContact> get discoveredContacts { List<Contact> get discoveredContacts {
return List.unmodifiable(_discoveredContacts); return List.unmodifiable(_discoveredContacts);
} }
@@ -298,6 +297,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool get isLoadingChannels => _isLoadingChannels; bool get isLoadingChannels => _isLoadingChannels;
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream; Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey; Uint8List? get selfPublicKey => _selfPublicKey;
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
String? get selfName => _selfName; String? get selfName => _selfName;
double? get selfLatitude => _selfLatitude; double? get selfLatitude => _selfLatitude;
double? get selfLongitude => _selfLongitude; double? get selfLongitude => _selfLongitude;
@@ -699,7 +699,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
Future<void> loadDiscoveredContactCache() async { Future<void> _loadDiscoveredContactCache() async {
final cached = await _discoveryContactStore.loadContacts(); final cached = await _discoveryContactStore.loadContacts();
_discoveredContacts _discoveredContacts
..clear() ..clear()
@@ -1338,7 +1338,6 @@ class MeshCoreConnector extends ChangeNotifier {
await _requestDeviceInfo(); await _requestDeviceInfo();
_startBatteryPolling(); _startBatteryPolling();
unawaited(loadDiscoveredContactCache());
final gotSelfInfo = await _waitForSelfInfo( final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3), timeout: const Duration(seconds: 3),
@@ -2056,7 +2055,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> removeContact(Contact contact) async { Future<void> removeContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
_handleDiscovery(contact, Uint8List(0), noNotify: true); _handleDiscovery(
contact,
contact.rawPacket ?? Uint8List(0),
noNotify: true,
);
await sendFrame(buildRemoveContactFrame(contact.publicKey)); await sendFrame(buildRemoveContactFrame(contact.publicKey));
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex); _contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
@@ -2072,7 +2075,20 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> removeDiscoveredContact(DiscoveryContact contact) async { Future<void> 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<void> removeDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
_discoveredContacts.removeWhere( _discoveredContacts.removeWhere(
(c) => c.publicKeyHex == contact.publicKeyHex, (c) => c.publicKeyHex == contact.publicKeyHex,
@@ -2081,7 +2097,7 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> importDiscoveredContact(DiscoveryContact contact) async { Future<void> importDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
await sendFrame( await sendFrame(
@@ -2090,11 +2106,23 @@ class MeshCoreConnector extends ChangeNotifier {
contact.path, contact.path,
contact.pathLength, contact.pathLength,
type: contact.type, type: contact.type,
flags: 0, flags: contact.flags,
name: contact.name, 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( _handleContactAdvert(
Contact( Contact(
publicKey: contact.publicKey, publicKey: contact.publicKey,
@@ -2105,6 +2133,7 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: DateTime.now(), lastSeen: DateTime.now(),
flags: contact.flags,
), ),
); );
notifyListeners(); notifyListeners();
@@ -2121,6 +2150,8 @@ class MeshCoreConnector extends ChangeNotifier {
final existing = _contacts[existingIndex]; final existing = _contacts[existingIndex];
// Use copyWith to preserve pathOverride and pathOverrideBytes // Use copyWith to preserve pathOverride and pathOverrideBytes
_contacts[existingIndex] = existing.copyWith( _contacts[existingIndex] = existing.copyWith(
pathOverride: null,
pathOverrideBytes: null,
pathLength: -1, pathLength: -1,
path: Uint8List(0), path: Uint8List(0),
); );
@@ -2476,6 +2507,7 @@ class MeshCoreConnector extends ChangeNotifier {
debugPrint('Got END_OF_CONTACTS'); debugPrint('Got END_OF_CONTACTS');
_isLoadingContacts = false; _isLoadingContacts = false;
_preserveContactsOnRefresh = false; _preserveContactsOnRefresh = false;
unawaited(updateKnownDiscovered());
notifyListeners(); notifyListeners();
unawaited(_persistContacts()); unawaited(_persistContacts());
if (PlatformInfo.isWeb && if (PlatformInfo.isWeb &&
@@ -2643,6 +2675,28 @@ class MeshCoreConnector extends ChangeNotifier {
selfName.isNotEmpty) { selfName.isNotEmpty) {
_usbManager.updateConnectedLabel(selfName); _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; _awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null; _selfInfoRetryTimer = null;
@@ -4542,7 +4596,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
importDiscoveredContact( importDiscoveredContact(
DiscoveryContact( Contact(
rawPacket: frame, rawPacket: frame,
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
@@ -4613,6 +4667,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (isNewContact) { if (isNewContact) {
final newContact = Contact( final newContact = Contact(
rawPacket: rawPacket,
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
@@ -4758,13 +4813,15 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
flags: 0,
isActive: false,
); );
notifyListeners(); notifyListeners();
unawaited(_persistDiscoveredContacts()); unawaited(_persistDiscoveredContacts());
return; return;
} }
final disContact = DiscoveryContact( final disContact = Contact(
rawPacket: rawPacket, rawPacket: rawPacket,
publicKey: contact.publicKey, publicKey: contact.publicKey,
name: contact.name, name: contact.name,
@@ -4774,6 +4831,9 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
lastMessageAt: contact.lastMessageAt,
isActive: false,
flags: 0,
); );
_discoveredContacts.add(disContact); _discoveredContacts.add(disContact);
+40 -13
View File
@@ -148,6 +148,19 @@ class BufferWriter {
void writeHex(String hex) { void writeHex(String hex) {
writeBytes(hex2Uint8List(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) { Uint8List hex2Uint8List(String hex) {
@@ -676,14 +689,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
} }
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path // 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 buildUpdateContactPathFrame(
Uint8List pubKey, Uint8List pubKey,
Uint8List customPath, Uint8List path,
int pathLen, { int pathLen, {
int type = 1, // ADV_TYPE_CHAT int type = 1, // ADV_TYPE_CHAT
int flags = 0, int flags = 0,
String name = '', String name = '',
double? lat,
double? lon,
DateTime? lastModified,
}) { }) {
final writer = BufferWriter(); final writer = BufferWriter();
writer.writeByte(cmdAddUpdateContact); writer.writeByte(cmdAddUpdateContact);
@@ -692,17 +708,7 @@ Uint8List buildUpdateContactPathFrame(
writer.writeByte(flags); writer.writeByte(flags);
writer.writeByte(pathLen); writer.writeByte(pathLen);
// Path data (64 bytes, zero-padded) writer.writeBytesPadded(path, maxPathSize);
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);
// Name (32 bytes, null-padded) // Name (32 bytes, null-padded)
writer.writeCString(name, maxNameSize); writer.writeCString(name, maxNameSize);
@@ -711,6 +717,27 @@ Uint8List buildUpdateContactPathFrame(
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(timestamp); 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(); return writer.toBytes();
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.", "tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.", "tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
"tcpErrorTimedOut": "Връзката TCP изтекла.", "tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}" "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване"
} }
+2 -1
View File
@@ -1915,5 +1915,6 @@
"tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.", "tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.",
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.", "tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.", "tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}" "tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen"
} }
+1
View File
@@ -835,6 +835,7 @@
"map_markers": "Markers", "map_markers": "Markers",
"map_showSharedMarkers": "Show shared markers", "map_showSharedMarkers": "Show shared markers",
"map_showGuessedLocations": "Show guessed node locations", "map_showGuessedLocations": "Show guessed node locations",
"map_showDiscoveryContacts": "Show Discovery Contacts",
"map_guessedLocation": "Guessed location", "map_guessedLocation": "Guessed location",
"map_lastSeenTime": "Last Seen Time", "map_lastSeenTime": "Last Seen Time",
"map_sharedPin": "Shared pin", "map_sharedPin": "Shared pin",
+2 -1
View File
@@ -1915,5 +1915,6 @@
"tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.", "tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.",
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.", "tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
"tcpErrorTimedOut": "La conexión TCP ha caducado.", "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"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.", "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.", "tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
"tcpErrorTimedOut": "La connexion TCP a expiré.", "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"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.", "tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.",
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.", "tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
"tcpErrorTimedOut": "La connessione TCP è scaduta.", "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"
} }
+6
View File
@@ -2872,6 +2872,12 @@ abstract class AppLocalizations {
/// **'Show guessed node locations'** /// **'Show guessed node locations'**
String get map_showGuessedLocations; 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. /// No description provided for @map_guessedLocation.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+3
View File
@@ -1578,6 +1578,9 @@ class AppLocalizationsBg extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Покажете местоположенията на предположените възли.'; 'Покажете местоположенията на предположените възли.';
@override
String get map_showDiscoveryContacts => 'Покажи контакти за откриване';
@override @override
String get map_guessedLocation => 'Предполагано местоположение'; String get map_guessedLocation => 'Предполагано местоположение';
+3
View File
@@ -1581,6 +1581,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Zeige die vermuteten Knotenpositionen'; 'Zeige die vermuteten Knotenpositionen';
@override
String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen';
@override @override
String get map_guessedLocation => 'Geschätzter Ort'; String get map_guessedLocation => 'Geschätzter Ort';
+3
View File
@@ -1553,6 +1553,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Show guessed node locations'; String get map_showGuessedLocations => 'Show guessed node locations';
@override
String get map_showDiscoveryContacts => 'Show Discovery Contacts';
@override @override
String get map_guessedLocation => 'Guessed location'; String get map_guessedLocation => 'Guessed location';
+3
View File
@@ -1576,6 +1576,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Mostrar las ubicaciones estimadas de los nodos.'; 'Mostrar las ubicaciones estimadas de los nodos.';
@override
String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento';
@override @override
String get map_guessedLocation => 'Ubicación estimada'; String get map_guessedLocation => 'Ubicación estimada';
+3
View File
@@ -1585,6 +1585,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Afficher les emplacements des nœuds estimés'; 'Afficher les emplacements des nœuds estimés';
@override
String get map_showDiscoveryContacts => 'Afficher les contacts de découverte';
@override @override
String get map_guessedLocation => 'Lieu deviné'; String get map_guessedLocation => 'Lieu deviné';
+3
View File
@@ -1576,6 +1576,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi'; String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
@override
String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery';
@override @override
String get map_guessedLocation => 'Località indovinata'; String get map_guessedLocation => 'Località indovinata';
+3
View File
@@ -1569,6 +1569,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Toon de voorspelde locaties van de knopen'; 'Toon de voorspelde locaties van de knopen';
@override
String get map_showDiscoveryContacts => 'Ontdek contacten weergeven';
@override @override
String get map_guessedLocation => 'Geroerde locatie'; String get map_guessedLocation => 'Geroerde locatie';
+3
View File
@@ -1579,6 +1579,9 @@ class AppLocalizationsPl extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Wyświetl lokalizacje zgadanych węzłów'; 'Wyświetl lokalizacje zgadanych węzłów';
@override
String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania';
@override @override
String get map_guessedLocation => 'Wydana lokalizacja'; String get map_guessedLocation => 'Wydana lokalizacja';
+3
View File
@@ -1579,6 +1579,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Mostrar as localizações dos nós estimados'; 'Mostrar as localizações dos nós estimados';
@override
String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta';
@override @override
String get map_guessedLocation => 'Localização estimada'; String get map_guessedLocation => 'Localização estimada';
+3
View File
@@ -1580,6 +1580,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Отобразить предполагаемые места расположения узлов'; 'Отобразить предполагаемые места расположения узлов';
@override
String get map_showDiscoveryContacts => 'Показать контакты Discovery';
@override @override
String get map_guessedLocation => 'Угаданное место'; String get map_guessedLocation => 'Угаданное место';
+3
View File
@@ -1571,6 +1571,9 @@ class AppLocalizationsSk extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Zobraziť umiestnenia odhadnutých uzlov'; 'Zobraziť umiestnenia odhadnutých uzlov';
@override
String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov';
@override @override
String get map_guessedLocation => 'Odhadnutá lokalita'; String get map_guessedLocation => 'Odhadnutá lokalita';
+3
View File
@@ -1564,6 +1564,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.'; String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
@override
String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov';
@override @override
String get map_guessedLocation => 'Predpostavljena lokacija'; String get map_guessedLocation => 'Predpostavljena lokacija';
+3
View File
@@ -1561,6 +1561,9 @@ class AppLocalizationsSv extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Visa upp de antagna nodernas placeringar'; 'Visa upp de antagna nodernas placeringar';
@override
String get map_showDiscoveryContacts => 'Visa Discovery-kontakter';
@override @override
String get map_guessedLocation => 'Gissad plats'; String get map_guessedLocation => 'Gissad plats';
+3
View File
@@ -1577,6 +1577,9 @@ class AppLocalizationsUk extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Показати місцезнаходження передбачених вузлів'; 'Показати місцезнаходження передбачених вузлів';
@override
String get map_showDiscoveryContacts => 'Показати контакти Відкриття';
@override @override
String get map_guessedLocation => 'Визначено місцезнаходження'; String get map_guessedLocation => 'Визначено місцезнаходження';
+3
View File
@@ -1486,6 +1486,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_showGuessedLocations => '显示猜测的节点位置'; String get map_showGuessedLocations => '显示猜测的节点位置';
@override
String get map_showDiscoveryContacts => '显示发现联系人';
@override @override
String get map_guessedLocation => '猜测的位置'; String get map_guessedLocation => '猜测的位置';
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.", "tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.",
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.", "tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.", "tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}" "tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.", "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.", "tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.", "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"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.", "tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.",
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.", "tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
"tcpErrorTimedOut": "A conexão TCP expirou.", "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"
} }
+2 -1
View File
@@ -1127,5 +1127,6 @@
"tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.", "tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.",
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.", "tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
"tcpErrorTimedOut": "Соединение TCP не удалось установить.", "tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}" "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.", "tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.",
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.", "tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.", "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"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Port mora biti med 1 in 65535.", "tcpErrorPortInvalid": "Port mora biti med 1 in 65535.",
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.", "tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.", "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"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.", "tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.",
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.", "tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.", "tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}" "tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter"
} }
+2 -1
View File
@@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.", "tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.",
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.", "tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}" "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття"
} }
+2 -1
View File
@@ -1892,5 +1892,6 @@
"tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。", "tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。",
"tcpErrorUnsupported": "此平台不支持 TCP 传输。", "tcpErrorUnsupported": "此平台不支持 TCP 传输。",
"tcpErrorTimedOut": "TCP 连接超时。", "tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}" "tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人"
} }
+8
View File
@@ -39,6 +39,7 @@ class AppSettings {
final Map<String, String> batteryChemistryByRepeaterId; final Map<String, String> batteryChemistryByRepeaterId;
final UnitSystem unitSystem; final UnitSystem unitSystem;
final Set<String> mutedChannels; final Set<String> mutedChannels;
final bool mapShowDiscoveryContacts;
AppSettings({ AppSettings({
this.clearPathOnMaxRetry = false, this.clearPathOnMaxRetry = false,
@@ -66,6 +67,7 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId, Map<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric, this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels, Set<String>? mutedChannels,
this.mapShowDiscoveryContacts = true,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {}; mutedChannels = mutedChannels ?? {};
@@ -97,6 +99,7 @@ class AppSettings {
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
'unit_system': unitSystem.value, 'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(), 'muted_channels': mutedChannels.toList(),
'map_show_discovery_contacts': mapShowDiscoveryContacts,
}; };
} }
@@ -152,6 +155,8 @@ class AppSettings {
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toSet()) ?? .toSet()) ??
{}, {},
mapShowDiscoveryContacts:
json['map_show_discovery_contacts'] as bool? ?? true,
); );
} }
@@ -181,6 +186,7 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId, Map<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem, UnitSystem? unitSystem,
Set<String>? mutedChannels, Set<String>? mutedChannels,
bool? mapShowDiscoveryContacts,
}) { }) {
return AppSettings( return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@@ -217,6 +223,8 @@ class AppSettings {
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
unitSystem: unitSystem ?? this.unitSystem, unitSystem: unitSystem ?? this.unitSystem,
mutedChannels: mutedChannels ?? this.mutedChannels, mutedChannels: mutedChannels ?? this.mutedChannels,
mapShowDiscoveryContacts:
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
); );
} }
} }
+10
View File
@@ -17,6 +17,8 @@ class Contact {
final double? longitude; final double? longitude;
final DateTime lastSeen; final DateTime lastSeen;
final DateTime lastMessageAt; final DateTime lastMessageAt;
final bool isActive;
final Uint8List? rawPacket;
Contact({ Contact({
required this.publicKey, required this.publicKey,
@@ -31,6 +33,8 @@ class Contact {
this.longitude, this.longitude,
required this.lastSeen, required this.lastSeen,
DateTime? lastMessageAt, DateTime? lastMessageAt,
this.isActive = true,
this.rawPacket,
}) : lastMessageAt = lastMessageAt ?? lastSeen; }) : lastMessageAt = lastMessageAt ?? lastSeen;
String get publicKeyHex => pubKeyToHex(publicKey); String get publicKeyHex => pubKeyToHex(publicKey);
@@ -78,6 +82,8 @@ class Contact {
double? longitude, double? longitude,
DateTime? lastSeen, DateTime? lastSeen,
DateTime? lastMessageAt, DateTime? lastMessageAt,
bool? isActive,
Uint8List? rawPacket,
}) { }) {
return Contact( return Contact(
publicKey: publicKey ?? this.publicKey, publicKey: publicKey ?? this.publicKey,
@@ -96,6 +102,8 @@ class Contact {
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen, lastSeen: lastSeen ?? this.lastSeen,
lastMessageAt: lastMessageAt ?? this.lastMessageAt, lastMessageAt: lastMessageAt ?? this.lastMessageAt,
isActive: isActive ?? this.isActive,
rawPacket: rawPacket ?? this.rawPacket,
); );
} }
@@ -204,6 +212,8 @@ class Contact {
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
isActive: true,
rawPacket: null,
); );
} catch (e) { } catch (e) {
appLogger.error('Failed to parse contact frame: $e'); appLogger.error('Failed to parse contact frame: $e');
-105
View File
@@ -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 = <String>[];
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;
}
+13
View File
@@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
: Icons.download, : Icons.download,
size: 18, size: 18,
), ),
onLongPress: () async {
await Clipboard.setData(
ClipboardData(
text: entry.payload
.map(
(b) => b
.toRadixString(16)
.padLeft(2, '0'),
)
.join(''),
),
);
},
); );
} }
+10 -7
View File
@@ -40,8 +40,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList()) ? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp; : primaryPathTmp;
final contacts = <Contact>[
final hops = _buildPathHops(primaryPath, connector.contacts, l10n); ...connector.contacts,
...connector.discoveredContacts,
];
final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
primaryPath.length, primaryPath.length,
@@ -364,11 +367,11 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp; : selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops( final contacts = <Contact>[
selectedPath, ...connector.contacts,
connector.contacts, ...connector.discoveredContacts,
context.l10n, ];
); final hops = _buildPathHops(selectedPath, contacts, context.l10n);
final points = <LatLng>[]; final points = <LatLng>[];
+34 -10
View File
@@ -51,6 +51,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
// Cache of PSK hex -> Community for quick lookup // Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {}; final Map<String, Community> _pskToCommunity = {};
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -61,6 +63,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
Future<void> _loadCommunities() async { Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
final communities = await _communityStore.loadCommunities(); final communities = await _communityStore.loadCommunities();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -106,7 +110,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final channelMessageStore = ChannelMessageStore(); final channelMessageStore = ChannelMessageStore();
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
// Auto-navigate back to scanner if disconnected // Auto-navigate back to scanner if disconnected
if (!checkConnectionAndNavigate(connector)) { if (!checkConnectionAndNavigate(connector)) {
@@ -712,6 +718,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
bool isRegularHashtag = true; bool isRegularHashtag = true;
Community? selectedCommunity; Community? selectedCommunity;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
@@ -763,7 +771,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
); );
} }
Widget? buildExpandedContent() { Widget? buildExpandedContent(
ChannelMessageStore channelMessageStore,
) {
switch (selectedOption) { switch (selectedOption) {
case 0: // Create Private Channel case 0: // Create Private Channel
return Column( return Column(
@@ -788,7 +798,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [ children: [
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: () { onPressed: () async {
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -810,7 +820,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
psk[i] = random.nextInt(256); psk[i] = random.nextInt(256);
} }
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk); await connector.setChannel(
nextIndex,
name,
psk,
);
await channelMessageStore.clearChannelMessages(
nextIndex,
);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -1329,7 +1346,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_createPrivateChannelDesc, dialogContext.l10n.channels_createPrivateChannelDesc,
), ),
if (selectedOption == 0) buildExpandedContent()!, if (selectedOption == 0)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 1, optionIndex: 1,
@@ -1338,7 +1356,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinPrivateChannelDesc, dialogContext.l10n.channels_joinPrivateChannelDesc,
), ),
if (selectedOption == 1) buildExpandedContent()!, if (selectedOption == 1)
buildExpandedContent(_channelMessageStore)!,
if (!hasPublicChannel) ...[ if (!hasPublicChannel) ...[
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
@@ -1348,7 +1367,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinPublicChannelDesc, dialogContext.l10n.channels_joinPublicChannelDesc,
), ),
if (selectedOption == 2) buildExpandedContent()!, if (selectedOption == 2)
buildExpandedContent(_channelMessageStore)!,
], ],
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
@@ -1358,7 +1378,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinHashtagChannelDesc, dialogContext.l10n.channels_joinHashtagChannelDesc,
), ),
if (selectedOption == 3) buildExpandedContent()!, if (selectedOption == 3)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 4, optionIndex: 4,
@@ -1366,7 +1387,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_scanQr, title: dialogContext.l10n.community_scanQr,
subtitle: dialogContext.l10n.community_join, subtitle: dialogContext.l10n.community_join,
), ),
if (selectedOption == 4) buildExpandedContent()!, if (selectedOption == 4)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 5, optionIndex: 5,
@@ -1374,7 +1396,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_create, title: dialogContext.l10n.community_create,
subtitle: dialogContext.l10n.community_createDesc, subtitle: dialogContext.l10n.community_createDesc,
), ),
if (selectedOption == 5) buildExpandedContent()!, if (selectedOption == 5)
buildExpandedContent(_channelMessageStore)!,
], ],
), ),
), ),
@@ -1524,7 +1547,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try { try {
await connector.deleteChannel(channel.index); await connector.deleteChannel(channel.index);
channelMessageStore.clearChannelMessages(channel.index); await channelMessageStore.clearChannelMessages(channel.index);
if (!context.mounted) return; if (!context.mounted) return;
@@ -1749,6 +1772,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
final channelCount = communityChannels.length; final channelCount = communityChannels.length;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog( showDialog(
context: context, context: context,
@@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
_isProcessing = true; _isProcessing = true;
}); });
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
try { try {
// Parse the community data // Parse the community data
final community = Community.fromQrData(const Uuid().v4(), data); final community = Community.fromQrData(const Uuid().v4(), data);
@@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
bool addPublicChannel, bool addPublicChannel,
) async { ) async {
// Save community to local storage // Save community to local storage
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
await _communityStore.addCommunity(community); await _communityStore.addCommunity(community);
// Optionally add the community public channel to the device // Optionally add the community public channel to the device
+8 -7
View File
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/discovery_contact.dart'; import '../models/contact.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../widgets/app_bar.dart'; import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
@@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
Future<void> _showContactContextMenu( Future<void> _showContactContextMenu(
DiscoveryContact contact, Contact contact,
MeshCoreConnector connector, MeshCoreConnector connector,
) async { ) async {
final action = await showModalBottomSheet<String>( final action = await showModalBottomSheet<String>(
@@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
connector.importDiscoveredContact(contact); connector.importDiscoveredContact(contact);
break; break;
case 'copy_contact': case 'copy_contact':
final hexString = pubKeyToHex(contact.rawPacket); if (contact.rawPacket == null) return;
final hexString = pubKeyToHex(contact.rawPacket!);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
Widget _buildFilters( Widget _buildFilters(
List<DiscoveryContact> filteredAndSorted, List<Contact> filteredAndSorted,
MeshCoreConnector connector, MeshCoreConnector connector,
) { ) {
String hintText = ""; String hintText = "";
@@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
); );
} }
List<DiscoveryContact> _filterAndSortContacts( List<Contact> _filterAndSortContacts(
List<DiscoveryContact> contacts, List<Contact> contacts,
MeshCoreConnector connector, MeshCoreConnector connector,
) { ) {
var filtered = contacts.where((contact) { var filtered = contacts.where((contact) {
@@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return filtered; return filtered;
} }
bool _matchesTypeFilter(DiscoveryContact contact) { bool _matchesTypeFilter(Contact contact) {
switch (typeFilter) { switch (typeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
return true; return true;
+120 -39
View File
@@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget {
} }
class _MapScreenState extends State<MapScreen> { class _MapScreenState extends State<MapScreen> {
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 MapController _mapController = MapController();
final MapMarkerService _markerService = MapMarkerService(); final MapMarkerService _markerService = MapMarkerService();
@@ -91,6 +93,15 @@ class _MapScreenState extends State<MapScreen> {
}); });
} }
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<double> values) { double _standardDeviation(List<double> values) {
if (values.length <= 1) { if (values.length <= 1) {
return 0.0; return 0.0;
@@ -126,7 +137,15 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) { builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final settings = settingsService.settings; final settings = settingsService.settings;
final contacts = connector.contacts; final allContacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts.where((c) => !c.isActive),
];
final contacts = settings.mapShowDiscoveryContacts
? allContacts
: allContacts.where((c) => c.isActive).toList();
final highlightPosition = widget.highlightPosition; final highlightPosition = widget.highlightPosition;
final sharedMarkers = settings.mapShowMarkers final sharedMarkers = settings.mapShowMarkers
? _collectSharedMarkers(connector) ? _collectSharedMarkers(connector)
@@ -159,14 +178,21 @@ class _MapScreenState extends State<MapScreen> {
: filteredByTime; : filteredByTime;
// Filter by location // Filter by location
final contactsWithLocation = filteredByKeyPrefix final contactsWithLocation = filteredByKeyPrefix.where((c) {
.where((c) => c.hasLocation) if (!c.hasLocation) {
.toList(); return false;
}
return _checkLocationPlausibility(c.latitude!, c.longitude!);
}).toList();
// All contacts with a known location used as anchors regardless of // All contacts with a known location used as anchors regardless of
// time/key-prefix filters so that repeaters are always available. // time/key-prefix filters so that repeaters are always available.
final allContactsWithLocation = contacts final allContactsWithLocation = allContacts
.where((c) => c.hasLocation) .where(
(c) =>
c.hasLocation &&
_checkLocationPlausibility(c.latitude!, c.longitude!),
)
.toList(); .toList();
// Compute guessed locations with caching // Compute guessed locations with caching
@@ -468,7 +494,10 @@ class _MapScreenState extends State<MapScreen> {
), ),
), ),
if (!_isBuildingPathTrace) if (!_isBuildingPathTrace)
...guessedLocations.map(_buildGuessedMarker), ..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
),
..._buildMarkers( ..._buildMarkers(
contactsWithLocation, contactsWithLocation,
settings, settings,
@@ -630,6 +659,13 @@ class _MapScreenState extends State<MapScreen> {
anchors[0].latitude + offsetDeg * cos(angle), anchors[0].latitude + offsetDeg * cos(angle),
anchors[0].longitude + offsetDeg * sin(angle), anchors[0].longitude + offsetDeg * sin(angle),
); );
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0)
}
} else { } else {
double lat = 0, lon = 0; double lat = 0, lon = 0;
for (final a in anchors) { for (final a in anchors) {
@@ -637,6 +673,12 @@ class _MapScreenState extends State<MapScreen> {
lon += a.longitude; lon += a.longitude;
} }
position = LatLng(lat / anchors.length, lon / anchors.length); position = LatLng(lat / anchors.length, lon / anchors.length);
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0
}
} }
result.add( result.add(
_GuessedLocation( _GuessedLocation(
@@ -710,40 +752,61 @@ class _MapScreenState extends State<MapScreen> {
.toList(); .toList();
} }
Marker _buildGuessedMarker(_GuessedLocation guess) { List<Marker> _buildGuessedMarker(
final color = _getNodeColor(guess.contact.type); List<_GuessedLocation> guessed, {
return Marker( required bool showLabels,
point: guess.position, }) {
width: 35, final markers = <Marker>[];
height: 35,
child: GestureDetector( for (final guess in guessed) {
onTap: () => _showNodeInfo( final color = _getNodeColor(guess.contact.type);
context, final marker = Marker(
guess.contact, point: guess.position,
guessedPosition: guess.position, width: 35,
), height: 35,
child: Container( child: GestureDetector(
padding: const EdgeInsets.all(4), onTap: () => _showNodeInfo(
decoration: BoxDecoration( context,
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30), guess.contact,
shape: BoxShape.circle, guessedPosition: guess.position,
border: Border.all(color: Colors.white, width: 2), ),
boxShadow: [ child: Container(
BoxShadow( padding: const EdgeInsets.all(4),
color: Colors.black.withValues(alpha: 0.3), decoration: BoxDecoration(
blurRadius: 4, color: color.withValues(
offset: const Offset(0, 2), alpha: guess.highConfidence ? 0.55 : 0.30,
), ),
], shape: BoxShape.circle,
), border: Border.all(color: Colors.white, width: 2),
child: const Icon( boxShadow: [
Icons.not_listed_location, BoxShadow(
color: Colors.white, color: Colors.black.withValues(alpha: 0.3),
size: 20, 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<Marker> _buildMarkers( List<Marker> _buildMarkers(
@@ -1203,6 +1266,7 @@ class _MapScreenState extends State<MapScreen> {
Contact contact, { Contact contact, {
LatLng? guessedPosition, LatLng? guessedPosition,
}) { }) {
final connector = context.read<MeshCoreConnector>();
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -1248,6 +1312,9 @@ class _MapScreenState extends State<MapScreen> {
advTypeChat) // Only show chat button for chat nodes advTypeChat) // Only show chat button for chat nodes
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
Navigator.push( Navigator.push(
context, context,
@@ -1261,6 +1328,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRepeater) if (contact.type == advTypeRepeater)
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
_showRepeaterLogin(context, contact); _showRepeaterLogin(context, contact);
}, },
@@ -1269,6 +1339,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRoom) if (contact.type == advTypeRoom)
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
_showRoomLogin(context, contact); _showRoomLogin(context, contact);
}, },
@@ -1745,6 +1818,14 @@ class _MapScreenState extends State<MapScreen> {
}, },
contentPadding: EdgeInsets.zero, 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), const SizedBox(height: 16),
Text( Text(
context.l10n.map_keyPrefix, context.l10n.map_keyPrefix,
+5 -3
View File
@@ -124,12 +124,14 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
try { try {
final neighborCount = buffer.readUInt16LE(); final neighborCount = buffer.readUInt16LE();
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
repeater,
) {
for (var neighborData in parsedNeighbors) { for (var neighborData in parsedNeighbors) {
final publicKey = neighborData['publicKey']; final publicKey = neighborData['publicKey'];
if (listEquals( if (listEquals(
+42 -13
View File
@@ -114,14 +114,37 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
super.dispose(); super.dispose();
} }
Uint8List addReturnPath(Uint8List pathBytes) { Uint8List buildPath(Uint8List pathBytes) {
Uint8List? traceBytes; Uint8List traceBytes;
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len); if (pathBytes.isEmpty) {
for (int i = 0; i < pathBytes.length; i++) { traceBytes = Uint8List(1);
traceBytes[i] = pathBytes[i]; traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
if (i < pathBytes.length - 1) { return traceBytes;
traceBytes[len - 1 - i] = pathBytes[i]; }
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; return traceBytes;
@@ -142,11 +165,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
: widget.path; : widget.path;
if (widget.flipPathRound) { if (widget.flipPathRound) {
path = addReturnPath(pathTmp); path = buildPath(pathTmp);
} else { } else {
path = pathTmp; path = pathTmp;
} }
appLogger.info(
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
tag: 'PathTraceMapScreen',
);
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq( final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000, DateTime.now().millisecondsSinceEpoch ~/ 1000,
@@ -235,10 +263,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList(); .toList();
Map<int, Contact> pathContacts = {}; Map<int, Contact> pathContacts = {};
final contacts = <Contact>[
connector.contacts.where((c) => c.type != advTypeChat).forEach(( ...connector.contacts,
repeater, ...connector.discoveredContacts,
) { ];
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) { for (var repeaterData in pathData) {
if (listEquals( if (listEquals(
repeater.publicKey.sublist(0, 1), repeater.publicKey.sublist(0, 1),
+4
View File
@@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value); appLogger.setEnabled(value);
} }
Future<void> setMapShowDiscoveryContacts(bool value) async {
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
}
Future<void> setBatteryChemistryForDevice( Future<void> setBatteryChemistryForDevice(
String deviceId, String deviceId,
String chemistry, String chemistry,
+44 -7
View File
@@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
@@ -7,13 +9,25 @@ import 'prefs_manager.dart';
class ChannelMessageStore { class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_'; 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 /// Save messages for a specific channel
Future<void> saveChannelMessages( Future<void> saveChannelMessages(
int channelIndex, int channelIndex,
List<ChannelMessage> messages, List<ChannelMessage> messages,
) async { ) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save channel messages.',
);
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
// Convert messages to JSON // Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
@@ -24,12 +38,35 @@ class ChannelMessageStore {
/// Load messages for a specific channel /// Load messages for a specific channel
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async { Future<List<ChannelMessage>> 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 prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
final oldKey = '$_keyPrefix$channelIndex';
final jsonString = prefs.getString(key);
if (jsonString == null) return [];
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 { try {
final jsonList = jsonDecode(jsonString) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList(); return jsonList.map((json) => _messageFromJson(json)).toList();
@@ -42,14 +79,14 @@ class ChannelMessageStore {
/// Clear messages for a specific channel /// Clear messages for a specific channel
Future<void> clearChannelMessages(int channelIndex) async { Future<void> clearChannelMessages(int channelIndex) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
await prefs.remove(key); await prefs.remove(key);
} }
/// Clear all channel messages /// Clear all channel messages
Future<void> clearAllChannelMessages() async { Future<void> clearAllChannelMessages() async {
final prefs = PrefsManager.instance; 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) { for (var key in keys) {
await prefs.remove(key); await prefs.remove(key);
} }
+35 -6
View File
@@ -1,20 +1,49 @@
import 'dart:convert'; import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ChannelOrderStore { 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<void> saveChannelOrder(List<int> order) async { Future<void> saveChannelOrder(List<int> order) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save channel order.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.setString(_key, jsonEncode(order)); await prefs.setString(keyFor, jsonEncode(order));
} }
Future<List<int>> loadChannelOrder() async { Future<List<int>> loadChannelOrder() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load channel order.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final raw = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (raw == null || raw.isEmpty) return []; 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 { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(jsonString);
if (decoded is List) { if (decoded is List) {
return decoded return decoded
.map((value) => value is int ? value : int.tryParse('$value')) .map((value) => value is int ? value : int.tryParse('$value'))
@@ -24,7 +53,7 @@ class ChannelOrderStore {
} catch (_) { } catch (_) {
// fall through to legacy parse // fall through to legacy parse
} }
return raw return jsonString
.split(',') .split(',')
.map((value) => int.tryParse(value)) .map((value) => int.tryParse(value))
.whereType<int>() .whereType<int>()
+36 -4
View File
@@ -1,17 +1,49 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ChannelSettingsStore { 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<bool> loadSmazEnabled(int channelIndex) async { Future<bool> 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 prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
return prefs.getBool(key) ?? false; 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<void> saveSmazEnabled(int channelIndex, bool enabled) async { Future<void> 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 prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$channelIndex'; final key = '$keyFor$channelIndex';
await prefs.setBool(key, enabled); await prefs.setBool(key, enabled);
} }
} }
+37 -5
View File
@@ -2,18 +2,46 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/channel.dart'; import '../models/channel.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ChannelStore { 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<List<Channel>> loadChannels() async { Future<List<Channel>> loadChannels() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load channels.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (jsonStr == null) return []; 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 { try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
@@ -23,9 +51,13 @@ class ChannelStore {
} }
Future<void> saveChannels(List<Channel> channels) async { Future<void> saveChannels(List<Channel> channels) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save channels.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = channels.map(_toJson).toList(); final jsonList = channels.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList)); await prefs.setString(keyFor, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(Channel channel) { Map<String, dynamic> _toJson(Channel channel) {
+33 -3
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import '../models/community.dart'; import '../models/community.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
/// Persists communities to local storage using SharedPreferences. /// 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 /// Each community contains its secret K, so this data should
/// be considered sensitive (though device encryption handles security). /// be considered sensitive (though device encryption handles security).
class CommunityStore { 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 /// Load all communities from storage
Future<List<Community>> loadCommunities() async { Future<List<Community>> loadCommunities() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load communities.');
return [];
}
final prefs = PrefsManager.instance; 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) { if (jsonString == null || jsonString.isEmpty) {
return []; return [];
} }
@@ -32,9 +58,13 @@ class CommunityStore {
/// Save all communities to storage /// Save all communities to storage
Future<void> saveCommunities(List<Community> communities) async { Future<void> saveCommunities(List<Community> communities) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save communities.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = communities.map((c) => c.toJson()).toList(); final jsonList = communities.map((c) => c.toJson()).toList();
await prefs.setString(_communitiesKey, jsonEncode(jsonList)); await prefs.setString(keyFor, jsonEncode(jsonList));
} }
/// Add a new community /// Add a new community
+33 -11
View File
@@ -1,15 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/discovery_contact.dart'; import '../models/contact.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactDiscoveryStore { class ContactDiscoveryStore {
static const String _key = 'discovered_contacts'; static const String _keyPrefix = 'discovered_contacts';
Future<List<DiscoveryContact>> loadContacts() async { Future<List<Contact>> loadContacts() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key); final jsonStr = prefs.getString(_keyPrefix);
if (jsonStr == null) return []; if (jsonStr == null) return [];
try { try {
@@ -22,40 +22,62 @@ class ContactDiscoveryStore {
} }
} }
Future<void> saveContacts(List<DiscoveryContact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList(); final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList)); await prefs.setString(_keyPrefix, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(DiscoveryContact contact) { Map<String, dynamic> _toJson(Contact contact) {
return { return {
'rawPacket': base64Encode(contact.rawPacket),
'publicKey': base64Encode(contact.publicKey), 'publicKey': base64Encode(contact.publicKey),
'name': contact.name, 'name': contact.name,
'type': contact.type, 'type': contact.type,
'flags': contact.flags,
'pathLength': contact.pathLength, 'pathLength': contact.pathLength,
'path': base64Encode(contact.path), 'path': base64Encode(contact.path),
'pathOverride': contact.pathOverride,
'pathOverrideBytes': contact.pathOverrideBytes != null
? base64Encode(contact.pathOverrideBytes!)
: null,
'latitude': contact.latitude, 'latitude': contact.latitude,
'longitude': contact.longitude, 'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
'rawPacket': contact.rawPacket != null
? base64Encode(contact.rawPacket!)
: null,
}; };
} }
DiscoveryContact _fromJson(Map<String, dynamic> json) { Contact _fromJson(Map<String, dynamic> json) {
final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastSeenMs = json['lastSeen'] as int? ?? 0;
return DiscoveryContact( final lastMessageMs = json['lastMessageAt'] as int?;
rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)), return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown', name: json['name'] as String? ?? 'Unknown',
type: json['type'] as int? ?? 0, type: json['type'] as int? ?? 0,
flags: json['flags'] as int? ?? 0,
pathLength: json['pathLength'] as int? ?? -1, pathLength: json['pathLength'] as int? ?? -1,
path: json['path'] != null path: json['path'] != null
? Uint8List.fromList(base64Decode(json['path'] as String)) ? Uint8List.fromList(base64Decode(json['path'] as String))
: Uint8List(0), : 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(), latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs,
),
isActive: false,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
); );
} }
} }
+37 -5
View File
@@ -1,17 +1,45 @@
import 'dart:convert'; import 'dart:convert';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactGroupStore { 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<List<ContactGroup>> loadGroups() async { Future<List<ContactGroup>> loadGroups() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load contact groups.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final raw = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (raw == null || raw.isEmpty) return []; 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 { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(jsonString);
if (decoded is List) { if (decoded is List) {
return decoded return decoded
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
@@ -25,8 +53,12 @@ class ContactGroupStore {
} }
Future<void> saveGroups(List<ContactGroup> groups) async { Future<void> saveGroups(List<ContactGroup> groups) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save contact groups.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList()); final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
await prefs.setString(_key, encoded); await prefs.setString(keyFor, encoded);
} }
} }
+35 -3
View File
@@ -1,17 +1,49 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactSettingsStore { 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<bool> loadSmazEnabled(String contactKeyHex) async { Future<bool> 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 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; return prefs.getBool(key) ?? false;
} }
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async { Future<void> 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 prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
await prefs.setBool(key, enabled); await prefs.setBool(key, enabled);
} }
} }
+45 -5
View File
@@ -2,18 +2,46 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/contact.dart'; import '../models/contact.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactStore { 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<List<Contact>> loadContacts() async { Future<List<Contact>> loadContacts() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load contacts.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key); String? jsonString = prefs.getString(keyFor);
if (jsonStr == null) return []; 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 { try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
@@ -23,9 +51,13 @@ class ContactStore {
} }
Future<void> saveContacts(List<Contact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save contacts.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList(); final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList)); await prefs.setString(keyFor, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(Contact contact) { Map<String, dynamic> _toJson(Contact contact) {
@@ -44,6 +76,10 @@ class ContactStore {
'longitude': contact.longitude, 'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.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( lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs, lastMessageMs ?? lastSeenMs,
), ),
isActive: json['isActive'] as bool? ?? true,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
); );
} }
} }
+42 -5
View File
@@ -2,26 +2,59 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/message.dart'; import '../models/message.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class MessageStore { class MessageStore {
static const String _keyPrefix = 'messages_'; 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<void> saveMessages( Future<void> saveMessages(
String contactKeyHex, String contactKeyHex,
List<Message> messages, List<Message> messages,
) async { ) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save messages.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList(); final jsonList = messages.map(_messageToJson).toList();
await prefs.setString(key, jsonEncode(jsonList)); await prefs.setString(key, jsonEncode(jsonList));
} }
Future<List<Message>> loadMessages(String contactKeyHex) async { Future<List<Message>> loadMessages(String contactKeyHex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load messages.');
return [];
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final jsonString = prefs.getString(key); final oldKey = '$_keyPrefix$contactKeyHex';
if (jsonString == null) return []; 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 { try {
final jsonList = jsonDecode(jsonString) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
@@ -32,8 +65,12 @@ class MessageStore {
} }
Future<void> clearMessages(String contactKeyHex) async { Future<void> clearMessages(String contactKeyHex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot clear messages.');
return;
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$keyFor$contactKeyHex';
await prefs.remove(key); await prefs.remove(key);
} }
+37 -5
View File
@@ -1,11 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
/// Storage for unread message tracking with debounced writes to reduce I/O. /// Storage for unread message tracking with debounced writes to reduce I/O.
class UnreadStore { 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 // Debounce timers to batch rapid writes
Timer? _contactUnreadSaveTimer; Timer? _contactUnreadSaveTimer;
@@ -20,12 +27,33 @@ class UnreadStore {
} }
Future<Map<String, int>> loadContactUnreadCount() async { Future<Map<String, int>> loadContactUnreadCount() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load unread counts.');
return {};
}
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_contactUnreadCountKey); String? jsonString = prefs.getString(keyFor);
if (jsonStr == null) return {}; 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 { try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>; final json = jsonDecode(jsonString) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int)); return json.map((key, value) => MapEntry(key, value as int));
} catch (_) { } catch (_) {
return {}; return {};
@@ -33,6 +61,10 @@ class UnreadStore {
} }
void saveContactUnreadCount(Map<String, int> counts) { void saveContactUnreadCount(Map<String, int> counts) {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save unread counts.');
return;
}
_pendingContactUnreadCount = counts; _pendingContactUnreadCount = counts;
_contactUnreadSaveTimer?.cancel(); _contactUnreadSaveTimer?.cancel();
@@ -49,7 +81,7 @@ class UnreadStore {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(_pendingContactUnreadCount); final jsonStr = jsonEncode(_pendingContactUnreadCount);
await prefs.setString(_contactUnreadCountKey, jsonStr); await prefs.setString(keyFor, jsonStr);
_pendingContactUnreadCount = null; _pendingContactUnreadCount = null;
} }
+1 -3
View File
@@ -1,5 +1,3 @@
import 'package:meshcore_open/models/discovery_contact.dart';
import '../models/contact.dart'; import '../models/contact.dart';
bool matchesContactQuery(Contact contact, String query) { bool matchesContactQuery(Contact contact, String query) {
@@ -16,7 +14,7 @@ bool matchesContactQuery(Contact contact, String query) {
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix); return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
} }
bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) { bool matchesDiscoveryContactQuery(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return true; if (normalizedQuery.isEmpty) return true;
+5 -2
View File
@@ -157,8 +157,11 @@ class _SNRIndicatorState extends State<SNRIndicator> {
repeater.snr, repeater.snr,
widget.connector.currentSf, widget.connector.currentSf,
); );
final allContacts = [
final name = widget.connector.contacts ...widget.connector.contacts,
...widget.connector.discoveredContacts,
];
final name = allContacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte) .where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name) .map((c) => c.name)
.firstOrNull; .firstOrNull;