Merge branch 'main' into tcp

This commit is contained in:
Winston Lowe 2026-03-12 23:22:30 -07:00 committed by GitHub
commit 1ad5db27ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 870 additions and 286 deletions

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart' as crypto;
import 'package:meshcore_open/models/discovery_contact.dart';
import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
@ -123,7 +122,7 @@ class MeshCoreConnector extends ChangeNotifier {
final List<ScanResult> _scanResults = [];
final List<Contact> _contacts = [];
final List<DiscoveryContact> _discoveredContacts = [];
final List<Contact> _discoveredContacts = [];
final List<Channel> _channels = [];
final Map<String, List<Message>> _conversations = {};
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);
}
@ -298,6 +297,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool get isLoadingChannels => _isLoadingChannels;
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey;
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
String? get selfName => _selfName;
double? get selfLatitude => _selfLatitude;
double? get selfLongitude => _selfLongitude;
@ -699,7 +699,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
Future<void> loadDiscoveredContactCache() async {
Future<void> _loadDiscoveredContactCache() async {
final cached = await _discoveryContactStore.loadContacts();
_discoveredContacts
..clear()
@ -1338,7 +1338,6 @@ class MeshCoreConnector extends ChangeNotifier {
await _requestDeviceInfo();
_startBatteryPolling();
unawaited(loadDiscoveredContactCache());
final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
@ -2056,7 +2055,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> removeContact(Contact contact) async {
if (!isConnected) return;
_handleDiscovery(contact, Uint8List(0), noNotify: true);
_handleDiscovery(
contact,
contact.rawPacket ?? Uint8List(0),
noNotify: true,
);
await sendFrame(buildRemoveContactFrame(contact.publicKey));
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
@ -2072,7 +2075,20 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
}
Future<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;
_discoveredContacts.removeWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
@ -2081,7 +2097,7 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
}
Future<void> importDiscoveredContact(DiscoveryContact contact) async {
Future<void> importDiscoveredContact(Contact contact) async {
if (!isConnected) return;
await sendFrame(
@ -2090,11 +2106,23 @@ class MeshCoreConnector extends ChangeNotifier {
contact.path,
contact.pathLength,
type: contact.type,
flags: 0,
flags: contact.flags,
name: contact.name,
lat: contact.latitude,
lon: contact.longitude,
lastModified: contact.lastSeen,
),
);
// Update the discovered contact to mark it as active (imported)
final discoveredIndex = _discoveredContacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (discoveredIndex >= 0) {
_discoveredContacts[discoveredIndex] =
_discoveredContacts[discoveredIndex].copyWith(isActive: true);
}
_handleContactAdvert(
Contact(
publicKey: contact.publicKey,
@ -2105,6 +2133,7 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: DateTime.now(),
flags: contact.flags,
),
);
notifyListeners();
@ -2121,6 +2150,8 @@ class MeshCoreConnector extends ChangeNotifier {
final existing = _contacts[existingIndex];
// Use copyWith to preserve pathOverride and pathOverrideBytes
_contacts[existingIndex] = existing.copyWith(
pathOverride: null,
pathOverrideBytes: null,
pathLength: -1,
path: Uint8List(0),
);
@ -2476,6 +2507,7 @@ class MeshCoreConnector extends ChangeNotifier {
debugPrint('Got END_OF_CONTACTS');
_isLoadingContacts = false;
_preserveContactsOnRefresh = false;
unawaited(updateKnownDiscovered());
notifyListeners();
unawaited(_persistContacts());
if (PlatformInfo.isWeb &&
@ -2643,6 +2675,28 @@ class MeshCoreConnector extends ChangeNotifier {
selfName.isNotEmpty) {
_usbManager.updateConnectedLabel(selfName);
}
//set all the stores' public key so they can load the correct data
_channelMessageStore.setPublicKeyHex = selfPublicKeyHex;
_messageStore.setPublicKeyHex = selfPublicKeyHex;
_channelOrderStore.setPublicKeyHex = selfPublicKeyHex;
_channelSettingsStore.setPublicKeyHex = selfPublicKeyHex;
_contactSettingsStore.setPublicKeyHex = selfPublicKeyHex;
_contactStore.setPublicKeyHex = selfPublicKeyHex;
_channelStore.setPublicKeyHex = selfPublicKeyHex;
_unreadStore.setPublicKeyHex = selfPublicKeyHex;
// Now that we have self info, we can load all the persisted data for this node
_loadChannelOrder();
loadContactCache();
loadChannelSettings();
loadCachedChannels();
// Load persisted channel messages
loadAllChannelMessages();
loadUnreadState();
_loadDiscoveredContactCache();
_awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null;
@ -4542,7 +4596,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
importDiscoveredContact(
DiscoveryContact(
Contact(
rawPacket: frame,
publicKey: publicKey,
name: name,
@ -4613,6 +4667,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (isNewContact) {
final newContact = Contact(
rawPacket: rawPacket,
publicKey: publicKey,
name: name,
type: type,
@ -4758,13 +4813,15 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: contact.lastSeen,
flags: 0,
isActive: false,
);
notifyListeners();
unawaited(_persistDiscoveredContacts());
return;
}
final disContact = DiscoveryContact(
final disContact = Contact(
rawPacket: rawPacket,
publicKey: contact.publicKey,
name: contact.name,
@ -4774,6 +4831,9 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: contact.lastSeen,
lastMessageAt: contact.lastMessageAt,
isActive: false,
flags: 0,
);
_discoveredContacts.add(disContact);

View file

@ -148,6 +148,19 @@ class BufferWriter {
void writeHex(String hex) {
writeBytes(hex2Uint8List(hex));
}
void writeBytesPadded(Uint8List bytes, int totalLength) {
// Path data (64 bytes, zero-padded)
final bytesPadded = Uint8List(totalLength);
final len = bytes.length < totalLength ? bytes.length : totalLength;
if (bytes.isNotEmpty && len > 0) {
final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
for (int i = 0; i < copyLen; i++) {
bytesPadded[i] = bytes[i];
}
}
writeBytes(bytesPadded);
}
}
Uint8List hex2Uint8List(String hex) {
@ -676,14 +689,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
}
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
Uint8List buildUpdateContactPathFrame(
Uint8List pubKey,
Uint8List customPath,
Uint8List path,
int pathLen, {
int type = 1, // ADV_TYPE_CHAT
int flags = 0,
String name = '',
double? lat,
double? lon,
DateTime? lastModified,
}) {
final writer = BufferWriter();
writer.writeByte(cmdAddUpdateContact);
@ -692,17 +708,7 @@ Uint8List buildUpdateContactPathFrame(
writer.writeByte(flags);
writer.writeByte(pathLen);
// Path data (64 bytes, zero-padded)
final pathPadded = Uint8List(maxPathSize);
if (customPath.isNotEmpty && pathLen > 0) {
final copyLen = customPath.length < maxPathSize
? customPath.length
: maxPathSize;
for (int i = 0; i < copyLen; i++) {
pathPadded[i] = customPath[i];
}
}
writer.writeBytes(pathPadded);
writer.writeBytesPadded(path, maxPathSize);
// Name (32 bytes, null-padded)
writer.writeCString(name, maxNameSize);
@ -711,6 +717,27 @@ Uint8List buildUpdateContactPathFrame(
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(timestamp);
if ((lat == null || lon == null) && lastModified != null) {
// If lat/lon not provided, write zeros
writer.writeInt32LE(0);
writer.writeInt32LE(0);
} else {
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
// Latitude
final latitude = lat ?? 0.0;
writer.writeInt32LE((latitude * 1e6).round());
// Longitude
final longitude = lon ?? 0.0;
writer.writeInt32LE((longitude * 1e6).round());
}
if (lastModified != null) {
// Last modified
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(lastModifiedTimestamp);
}
return writer.toBytes();
}

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
"tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}"
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване"
}

View file

@ -1915,5 +1915,6 @@
"tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.",
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}"
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen"
}

View file

@ -835,6 +835,7 @@
"map_markers": "Markers",
"map_showSharedMarkers": "Show shared markers",
"map_showGuessedLocations": "Show guessed node locations",
"map_showDiscoveryContacts": "Show Discovery Contacts",
"map_guessedLocation": "Guessed location",
"map_lastSeenTime": "Last Seen Time",
"map_sharedPin": "Shared pin",

View file

@ -1915,5 +1915,6 @@
"tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.",
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
"tcpConnectionFailed": "Error en la conexión TCP: {error}"
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento"
}

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.",
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
"tcpErrorTimedOut": "La connexion TCP a expiré.",
"tcpConnectionFailed": "Échec de la connexion TCP : {error}"
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
"map_showDiscoveryContacts": "Afficher les contacts de découverte"
}

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.",
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}"
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
"map_showDiscoveryContacts": "Mostra Contatti di Discovery"
}

View file

@ -2872,6 +2872,12 @@ abstract class AppLocalizations {
/// **'Show guessed node locations'**
String get map_showGuessedLocations;
/// No description provided for @map_showDiscoveryContacts.
///
/// In en, this message translates to:
/// **'Show Discovery Contacts'**
String get map_showDiscoveryContacts;
/// No description provided for @map_guessedLocation.
///
/// In en, this message translates to:

View file

@ -1578,6 +1578,9 @@ class AppLocalizationsBg extends AppLocalizations {
String get map_showGuessedLocations =>
'Покажете местоположенията на предположените възли.';
@override
String get map_showDiscoveryContacts => 'Покажи контакти за откриване';
@override
String get map_guessedLocation => 'Предполагано местоположение';

View file

@ -1581,6 +1581,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get map_showGuessedLocations =>
'Zeige die vermuteten Knotenpositionen';
@override
String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen';
@override
String get map_guessedLocation => 'Geschätzter Ort';

View file

@ -1553,6 +1553,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get map_showGuessedLocations => 'Show guessed node locations';
@override
String get map_showDiscoveryContacts => 'Show Discovery Contacts';
@override
String get map_guessedLocation => 'Guessed location';

View file

@ -1576,6 +1576,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get map_showGuessedLocations =>
'Mostrar las ubicaciones estimadas de los nodos.';
@override
String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento';
@override
String get map_guessedLocation => 'Ubicación estimada';

View file

@ -1585,6 +1585,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get map_showGuessedLocations =>
'Afficher les emplacements des nœuds estimés';
@override
String get map_showDiscoveryContacts => 'Afficher les contacts de découverte';
@override
String get map_guessedLocation => 'Lieu deviné';

View file

@ -1576,6 +1576,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
@override
String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery';
@override
String get map_guessedLocation => 'Località indovinata';

View file

@ -1569,6 +1569,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get map_showGuessedLocations =>
'Toon de voorspelde locaties van de knopen';
@override
String get map_showDiscoveryContacts => 'Ontdek contacten weergeven';
@override
String get map_guessedLocation => 'Geroerde locatie';

View file

@ -1579,6 +1579,9 @@ class AppLocalizationsPl extends AppLocalizations {
String get map_showGuessedLocations =>
'Wyświetl lokalizacje zgadanych węzłów';
@override
String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania';
@override
String get map_guessedLocation => 'Wydana lokalizacja';

View file

@ -1579,6 +1579,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get map_showGuessedLocations =>
'Mostrar as localizações dos nós estimados';
@override
String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta';
@override
String get map_guessedLocation => 'Localização estimada';

View file

@ -1580,6 +1580,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get map_showGuessedLocations =>
'Отобразить предполагаемые места расположения узлов';
@override
String get map_showDiscoveryContacts => 'Показать контакты Discovery';
@override
String get map_guessedLocation => 'Угаданное место';

View file

@ -1571,6 +1571,9 @@ class AppLocalizationsSk extends AppLocalizations {
String get map_showGuessedLocations =>
'Zobraziť umiestnenia odhadnutých uzlov';
@override
String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov';
@override
String get map_guessedLocation => 'Odhadnutá lokalita';

View file

@ -1564,6 +1564,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
@override
String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov';
@override
String get map_guessedLocation => 'Predpostavljena lokacija';

View file

@ -1561,6 +1561,9 @@ class AppLocalizationsSv extends AppLocalizations {
String get map_showGuessedLocations =>
'Visa upp de antagna nodernas placeringar';
@override
String get map_showDiscoveryContacts => 'Visa Discovery-kontakter';
@override
String get map_guessedLocation => 'Gissad plats';

View file

@ -1577,6 +1577,9 @@ class AppLocalizationsUk extends AppLocalizations {
String get map_showGuessedLocations =>
'Показати місцезнаходження передбачених вузлів';
@override
String get map_showDiscoveryContacts => 'Показати контакти Відкриття';
@override
String get map_guessedLocation => 'Визначено місцезнаходження';

View file

@ -1486,6 +1486,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_showGuessedLocations => '显示猜测的节点位置';
@override
String get map_showDiscoveryContacts => '显示发现联系人';
@override
String get map_guessedLocation => '猜测的位置';

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.",
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}"
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven"
}

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.",
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
"tcpConnectionFailed": "Błąd połączenia TCP: {error}"
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania"
}

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.",
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
"tcpErrorTimedOut": "A conexão TCP expirou.",
"tcpConnectionFailed": "Falha na conexão TCP: {error}"
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta"
}

View file

@ -1127,5 +1127,6 @@
"tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.",
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}"
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery"
}

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.",
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}"
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov"
}

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Port mora biti med 1 in 65535.",
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}"
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov"
}

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.",
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}"
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter"
}

View file

@ -1887,5 +1887,6 @@
"tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.",
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}"
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття"
}

View file

@ -1892,5 +1892,6 @@
"tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。",
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
"tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}"
"tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人"
}

View file

@ -39,6 +39,7 @@ class AppSettings {
final Map<String, String> batteryChemistryByRepeaterId;
final UnitSystem unitSystem;
final Set<String> mutedChannels;
final bool mapShowDiscoveryContacts;
AppSettings({
this.clearPathOnMaxRetry = false,
@ -66,6 +67,7 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels,
this.mapShowDiscoveryContacts = true,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {};
@ -97,6 +99,7 @@ class AppSettings {
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(),
'map_show_discovery_contacts': mapShowDiscoveryContacts,
};
}
@ -152,6 +155,8 @@ class AppSettings {
?.map((e) => e.toString())
.toSet()) ??
{},
mapShowDiscoveryContacts:
json['map_show_discovery_contacts'] as bool? ?? true,
);
}
@ -181,6 +186,7 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem,
Set<String>? mutedChannels,
bool? mapShowDiscoveryContacts,
}) {
return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@ -217,6 +223,8 @@ class AppSettings {
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
unitSystem: unitSystem ?? this.unitSystem,
mutedChannels: mutedChannels ?? this.mutedChannels,
mapShowDiscoveryContacts:
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
);
}
}

View file

@ -17,6 +17,8 @@ class Contact {
final double? longitude;
final DateTime lastSeen;
final DateTime lastMessageAt;
final bool isActive;
final Uint8List? rawPacket;
Contact({
required this.publicKey,
@ -31,6 +33,8 @@ class Contact {
this.longitude,
required this.lastSeen,
DateTime? lastMessageAt,
this.isActive = true,
this.rawPacket,
}) : lastMessageAt = lastMessageAt ?? lastSeen;
String get publicKeyHex => pubKeyToHex(publicKey);
@ -78,6 +82,8 @@ class Contact {
double? longitude,
DateTime? lastSeen,
DateTime? lastMessageAt,
bool? isActive,
Uint8List? rawPacket,
}) {
return Contact(
publicKey: publicKey ?? this.publicKey,
@ -96,6 +102,8 @@ class Contact {
longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen,
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
isActive: isActive ?? this.isActive,
rawPacket: rawPacket ?? this.rawPacket,
);
}
@ -204,6 +212,8 @@ class Contact {
latitude: lat,
longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
isActive: true,
rawPacket: null,
);
} catch (e) {
appLogger.error('Failed to parse contact frame: $e');

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;
}

View file

@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
: Icons.download,
size: 18,
),
onLongPress: () async {
await Clipboard.setData(
ClipboardData(
text: entry.payload
.map(
(b) => b
.toRadixString(16)
.padLeft(2, '0'),
)
.join(''),
),
);
},
);
}

View file

@ -40,8 +40,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp;
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
@ -364,11 +367,11 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(
selectedPath,
connector.contacts,
context.l10n,
);
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
final points = <LatLng>[];

View file

@ -51,6 +51,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
// Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {};
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@override
void initState() {
super.initState();
@ -61,6 +63,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
}
Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
final communities = await _communityStore.loadCommunities();
if (mounted) {
setState(() {
@ -106,7 +110,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final channelMessageStore = ChannelMessageStore();
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
// Auto-navigate back to scanner if disconnected
if (!checkConnectionAndNavigate(connector)) {
@ -712,6 +718,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
bool isRegularHashtag = true;
Community? selectedCommunity;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
@ -763,7 +771,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
);
}
Widget? buildExpandedContent() {
Widget? buildExpandedContent(
ChannelMessageStore channelMessageStore,
) {
switch (selectedOption) {
case 0: // Create Private Channel
return Column(
@ -788,7 +798,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [
Expanded(
child: FilledButton(
onPressed: () {
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
@ -810,7 +820,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
psk[i] = random.nextInt(256);
}
Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk);
await connector.setChannel(
nextIndex,
name,
psk,
);
await channelMessageStore.clearChannelMessages(
nextIndex,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -1329,7 +1346,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle:
dialogContext.l10n.channels_createPrivateChannelDesc,
),
if (selectedOption == 0) buildExpandedContent()!,
if (selectedOption == 0)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1),
buildOptionTile(
optionIndex: 1,
@ -1338,7 +1356,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle:
dialogContext.l10n.channels_joinPrivateChannelDesc,
),
if (selectedOption == 1) buildExpandedContent()!,
if (selectedOption == 1)
buildExpandedContent(_channelMessageStore)!,
if (!hasPublicChannel) ...[
const Divider(height: 1),
buildOptionTile(
@ -1348,7 +1367,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle:
dialogContext.l10n.channels_joinPublicChannelDesc,
),
if (selectedOption == 2) buildExpandedContent()!,
if (selectedOption == 2)
buildExpandedContent(_channelMessageStore)!,
],
const Divider(height: 1),
buildOptionTile(
@ -1358,7 +1378,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle:
dialogContext.l10n.channels_joinHashtagChannelDesc,
),
if (selectedOption == 3) buildExpandedContent()!,
if (selectedOption == 3)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1),
buildOptionTile(
optionIndex: 4,
@ -1366,7 +1387,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_scanQr,
subtitle: dialogContext.l10n.community_join,
),
if (selectedOption == 4) buildExpandedContent()!,
if (selectedOption == 4)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1),
buildOptionTile(
optionIndex: 5,
@ -1374,7 +1396,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_create,
subtitle: dialogContext.l10n.community_createDesc,
),
if (selectedOption == 5) buildExpandedContent()!,
if (selectedOption == 5)
buildExpandedContent(_channelMessageStore)!,
],
),
),
@ -1524,7 +1547,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try {
await connector.deleteChannel(channel.index);
channelMessageStore.clearChannelMessages(channel.index);
await channelMessageStore.clearChannelMessages(channel.index);
if (!context.mounted) return;
@ -1749,6 +1772,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
}
final channelCount = communityChannels.length;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog(
context: context,

View file

@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
_isProcessing = true;
});
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
try {
// Parse the community data
final community = Community.fromQrData(const Uuid().v4(), data);
@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
bool addPublicChannel,
) async {
// Save community to local storage
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
await _communityStore.addCommunity(community);
// Optionally add the community public channel to the device

View file

@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/discovery_contact.dart';
import '../models/contact.dart';
import '../utils/contact_search.dart';
import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart';
@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}
Future<void> _showContactContextMenu(
DiscoveryContact contact,
Contact contact,
MeshCoreConnector connector,
) async {
final action = await showModalBottomSheet<String>(
@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
connector.importDiscoveredContact(contact);
break;
case 'copy_contact':
final hexString = pubKeyToHex(contact.rawPacket);
if (contact.rawPacket == null) return;
final hexString = pubKeyToHex(contact.rawPacket!);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}
Widget _buildFilters(
List<DiscoveryContact> filteredAndSorted,
List<Contact> filteredAndSorted,
MeshCoreConnector connector,
) {
String hintText = "";
@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
);
}
List<DiscoveryContact> _filterAndSortContacts(
List<DiscoveryContact> contacts,
List<Contact> _filterAndSortContacts(
List<Contact> contacts,
MeshCoreConnector connector,
) {
var filtered = contacts.where((contact) {
@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return filtered;
}
bool _matchesTypeFilter(DiscoveryContact contact) {
bool _matchesTypeFilter(Contact contact) {
switch (typeFilter) {
case ContactTypeFilter.all:
return true;

View file

@ -1,6 +1,7 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget {
}
class _MapScreenState extends State<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 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) {
if (values.length <= 1) {
return 0.0;
@ -126,7 +137,15 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>();
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 sharedMarkers = settings.mapShowMarkers
? _collectSharedMarkers(connector)
@ -159,14 +178,21 @@ class _MapScreenState extends State<MapScreen> {
: filteredByTime;
// Filter by location
final contactsWithLocation = filteredByKeyPrefix
.where((c) => c.hasLocation)
.toList();
final contactsWithLocation = filteredByKeyPrefix.where((c) {
if (!c.hasLocation) {
return false;
}
return _checkLocationPlausibility(c.latitude!, c.longitude!);
}).toList();
// All contacts with a known location used as anchors regardless of
// time/key-prefix filters so that repeaters are always available.
final allContactsWithLocation = contacts
.where((c) => c.hasLocation)
final allContactsWithLocation = allContacts
.where(
(c) =>
c.hasLocation &&
_checkLocationPlausibility(c.latitude!, c.longitude!),
)
.toList();
// Compute guessed locations with caching
@ -468,7 +494,10 @@ class _MapScreenState extends State<MapScreen> {
),
),
if (!_isBuildingPathTrace)
...guessedLocations.map(_buildGuessedMarker),
..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
),
..._buildMarkers(
contactsWithLocation,
settings,
@ -630,6 +659,13 @@ class _MapScreenState extends State<MapScreen> {
anchors[0].latitude + offsetDeg * cos(angle),
anchors[0].longitude + offsetDeg * sin(angle),
);
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0)
}
} else {
double lat = 0, lon = 0;
for (final a in anchors) {
@ -637,6 +673,12 @@ class _MapScreenState extends State<MapScreen> {
lon += a.longitude;
}
position = LatLng(lat / anchors.length, lon / anchors.length);
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0
}
}
result.add(
_GuessedLocation(
@ -710,40 +752,61 @@ class _MapScreenState extends State<MapScreen> {
.toList();
}
Marker _buildGuessedMarker(_GuessedLocation guess) {
final color = _getNodeColor(guess.contact.type);
return Marker(
point: guess.position,
width: 35,
height: 35,
child: GestureDetector(
onTap: () => _showNodeInfo(
context,
guess.contact,
guessedPosition: guess.position,
),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
List<Marker> _buildGuessedMarker(
List<_GuessedLocation> guessed, {
required bool showLabels,
}) {
final markers = <Marker>[];
for (final guess in guessed) {
final color = _getNodeColor(guess.contact.type);
final marker = Marker(
point: guess.position,
width: 35,
height: 35,
child: GestureDetector(
onTap: () => _showNodeInfo(
context,
guess.contact,
guessedPosition: guess.position,
),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: color.withValues(
alpha: guess.highConfidence ? 0.55 : 0.30,
),
],
),
child: const Icon(
Icons.not_listed_location,
color: Colors.white,
size: 20,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.not_listed_location,
color: Colors.white,
size: 20,
),
),
),
),
);
);
markers.add(marker);
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: guess.position,
label: guess.contact.name,
),
);
}
}
return markers;
}
List<Marker> _buildMarkers(
@ -1203,6 +1266,7 @@ class _MapScreenState extends State<MapScreen> {
Contact contact, {
LatLng? guessedPosition,
}) {
final connector = context.read<MeshCoreConnector>();
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
@ -1248,6 +1312,9 @@ class _MapScreenState extends State<MapScreen> {
advTypeChat) // Only show chat button for chat nodes
TextButton(
onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext);
Navigator.push(
context,
@ -1261,6 +1328,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRepeater)
TextButton(
onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext);
_showRepeaterLogin(context, contact);
},
@ -1269,6 +1339,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRoom)
TextButton(
onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext);
_showRoomLogin(context, contact);
},
@ -1745,6 +1818,14 @@ class _MapScreenState extends State<MapScreen> {
},
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: Text(context.l10n.map_showDiscoveryContacts),
value: settings.mapShowDiscoveryContacts,
onChanged: (value) {
service.setMapShowDiscoveryContacts(value ?? true);
},
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
Text(
context.l10n.map_keyPrefix,

View file

@ -124,12 +124,14 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame);
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
try {
final neighborCount = buffer.readUInt16LE();
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
connector.contacts.where((c) => c.type == advTypeRepeater).forEach((
repeater,
) {
contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
for (var neighborData in parsedNeighbors) {
final publicKey = neighborData['publicKey'];
if (listEquals(

View file

@ -114,14 +114,37 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
super.dispose();
}
Uint8List addReturnPath(Uint8List pathBytes) {
Uint8List? traceBytes;
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
Uint8List buildPath(Uint8List pathBytes) {
Uint8List traceBytes;
if (pathBytes.isEmpty) {
traceBytes = Uint8List(1);
traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
return traceBytes;
}
if (widget.targetContact?.type == advTypeRepeater ||
widget.targetContact?.type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}
return traceBytes;
@ -142,11 +165,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
: widget.path;
if (widget.flipPathRound) {
path = addReturnPath(pathTmp);
path = buildPath(pathTmp);
} else {
path = pathTmp;
}
appLogger.info(
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
tag: 'PathTraceMapScreen',
);
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
@ -235,10 +263,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList();
Map<int, Contact> pathContacts = {};
connector.contacts.where((c) => c.type != advTypeChat).forEach((
repeater,
) {
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) {
if (listEquals(
repeater.publicKey.sublist(0, 1),

View file

@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value);
}
Future<void> setMapShowDiscoveryContacts(bool value) async {
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
}
Future<void> setBatteryChemistryForDevice(
String deviceId,
String chemistry,

View file

@ -1,5 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../models/channel_message.dart';
import '../helpers/smaz.dart';
import 'prefs_manager.dart';
@ -7,13 +9,25 @@ import 'prefs_manager.dart';
class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
/// Save messages for a specific channel
Future<void> saveChannelMessages(
int channelIndex,
List<ChannelMessage> messages,
) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save channel messages.',
);
return;
}
final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex';
final key = '$keyFor$channelIndex';
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
@ -24,12 +38,35 @@ class ChannelMessageStore {
/// Load messages for a specific channel
Future<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 key = '$_keyPrefix$channelIndex';
final jsonString = prefs.getString(key);
if (jsonString == null) return [];
final key = '$keyFor$channelIndex';
final oldKey = '$_keyPrefix$channelIndex';
String? jsonString = prefs.getString(key);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(oldKey);
prefs.remove(oldKey);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $oldKey to scoped key $key',
);
await prefs.setString(key, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try {
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList();
@ -42,14 +79,14 @@ class ChannelMessageStore {
/// Clear messages for a specific channel
Future<void> clearChannelMessages(int channelIndex) async {
final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex';
final key = '$keyFor$channelIndex';
await prefs.remove(key);
}
/// Clear all channel messages
Future<void> clearAllChannelMessages() async {
final prefs = PrefsManager.instance;
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix));
final keys = prefs.getKeys().where((k) => k.startsWith(keyFor));
for (var key in keys) {
await prefs.remove(key);
}

View file

@ -1,20 +1,49 @@
import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class ChannelOrderStore {
static const String _key = 'channel_order';
static const String _keyPrefix = 'channel_order_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<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;
await prefs.setString(_key, jsonEncode(order));
await prefs.setString(keyFor, jsonEncode(order));
}
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 raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) return [];
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try {
final decoded = jsonDecode(raw);
final decoded = jsonDecode(jsonString);
if (decoded is List) {
return decoded
.map((value) => value is int ? value : int.tryParse('$value'))
@ -24,7 +53,7 @@ class ChannelOrderStore {
} catch (_) {
// fall through to legacy parse
}
return raw
return jsonString
.split(',')
.map((value) => int.tryParse(value))
.whereType<int>()

View file

@ -1,17 +1,49 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class ChannelSettingsStore {
static const String _smazKeyPrefix = 'channel_smaz_';
static const String _keyPrefix = 'channel_smaz_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<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 key = '$_smazKeyPrefix$channelIndex';
return prefs.getBool(key) ?? false;
final key = '$keyFor$channelIndex';
final oldKey = '$_keyPrefix$channelIndex';
bool? enabled = prefs.getBool(oldKey);
if (enabled == null) {
// Attempt migration from legacy unscoped key on first load
enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) {
appLogger.info(
'Migrating channel settings from legacy key $oldKey to scoped key $key',
);
await prefs.setBool(key, enabled);
}
}
return enabled ?? false;
}
Future<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 key = '$_smazKeyPrefix$channelIndex';
final key = '$keyFor$channelIndex';
await prefs.setBool(key, enabled);
}
}

View file

@ -2,18 +2,46 @@ import 'dart:convert';
import 'dart:typed_data';
import '../models/channel.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class ChannelStore {
static const String _key = 'channels';
static const String _keyPrefix = 'channels';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<List<Channel>> loadChannels() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load channels.');
return [];
}
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key);
if (jsonStr == null) return [];
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
@ -23,9 +51,13 @@ class ChannelStore {
}
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 jsonList = channels.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList));
await prefs.setString(keyFor, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Channel channel) {

View file

@ -1,6 +1,7 @@
import 'dart:convert';
import '../models/community.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
/// Persists communities to local storage using SharedPreferences.
@ -9,12 +10,37 @@ import 'prefs_manager.dart';
/// Each community contains its secret K, so this data should
/// be considered sensitive (though device encryption handles security).
class CommunityStore {
static const String _communitiesKey = 'communities_v1';
static const String _keyPrefix = 'communities_v1';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
/// Load all communities from storage
Future<List<Community>> loadCommunities() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load communities.');
return [];
}
final prefs = PrefsManager.instance;
final jsonString = prefs.getString(_communitiesKey);
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
@ -32,9 +58,13 @@ class CommunityStore {
/// Save all communities to storage
Future<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 jsonList = communities.map((c) => c.toJson()).toList();
await prefs.setString(_communitiesKey, jsonEncode(jsonList));
await prefs.setString(keyFor, jsonEncode(jsonList));
}
/// Add a new community

View file

@ -1,15 +1,15 @@
import 'dart:convert';
import 'dart:typed_data';
import '../models/discovery_contact.dart';
import '../models/contact.dart';
import 'prefs_manager.dart';
class ContactDiscoveryStore {
static const String _key = 'discovered_contacts';
static const String _keyPrefix = 'discovered_contacts';
Future<List<DiscoveryContact>> loadContacts() async {
Future<List<Contact>> loadContacts() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key);
final jsonStr = prefs.getString(_keyPrefix);
if (jsonStr == null) return [];
try {
@ -22,40 +22,62 @@ class ContactDiscoveryStore {
}
}
Future<void> saveContacts(List<DiscoveryContact> contacts) async {
Future<void> saveContacts(List<Contact> contacts) async {
final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList));
await prefs.setString(_keyPrefix, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(DiscoveryContact contact) {
Map<String, dynamic> _toJson(Contact contact) {
return {
'rawPacket': base64Encode(contact.rawPacket),
'publicKey': base64Encode(contact.publicKey),
'name': contact.name,
'type': contact.type,
'flags': contact.flags,
'pathLength': contact.pathLength,
'path': base64Encode(contact.path),
'pathOverride': contact.pathOverride,
'pathOverrideBytes': contact.pathOverrideBytes != null
? base64Encode(contact.pathOverrideBytes!)
: null,
'latitude': contact.latitude,
'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
'rawPacket': contact.rawPacket != null
? base64Encode(contact.rawPacket!)
: null,
};
}
DiscoveryContact _fromJson(Map<String, dynamic> json) {
Contact _fromJson(Map<String, dynamic> json) {
final lastSeenMs = json['lastSeen'] as int? ?? 0;
return DiscoveryContact(
rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)),
final lastMessageMs = json['lastMessageAt'] as int?;
return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown',
type: json['type'] as int? ?? 0,
flags: json['flags'] as int? ?? 0,
pathLength: json['pathLength'] as int? ?? -1,
path: json['path'] != null
? Uint8List.fromList(base64Decode(json['path'] as String))
: Uint8List(0),
pathOverride: json['pathOverride'] as int?,
pathOverrideBytes: json['pathOverrideBytes'] != null
? Uint8List.fromList(
base64Decode(json['pathOverrideBytes'] as String),
)
: null,
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs,
),
isActive: false,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
);
}
}

View file

@ -1,17 +1,45 @@
import 'dart:convert';
import '../models/contact_group.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class ContactGroupStore {
static const String _key = 'contact_groups';
static const String _keyPrefix = 'contact_groups';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<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 raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) return [];
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try {
final decoded = jsonDecode(raw);
final decoded = jsonDecode(jsonString);
if (decoded is List) {
return decoded
.whereType<Map<String, dynamic>>()
@ -25,8 +53,12 @@ class ContactGroupStore {
}
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 encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
await prefs.setString(_key, encoded);
await prefs.setString(keyFor, encoded);
}
}

View file

@ -1,17 +1,49 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class ContactSettingsStore {
static const String _smazKeyPrefix = 'contact_smaz_';
static const String _keyPrefix = 'contact_smaz_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<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 key = '$_smazKeyPrefix$contactKeyHex';
final key = '$keyFor$contactKeyHex';
final oldKey = '$_keyPrefix$contactKeyHex';
bool? enabled = prefs.getBool(key);
if (enabled == null) {
// Attempt migration from legacy unscoped key on first load
enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) {
appLogger.info(
'Migrating contact settings from legacy key $oldKey to scoped key $key',
);
await prefs.setBool(key, enabled);
}
}
return prefs.getBool(key) ?? false;
}
Future<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 key = '$_smazKeyPrefix$contactKeyHex';
final key = '$keyFor$contactKeyHex';
await prefs.setBool(key, enabled);
}
}

View file

@ -2,18 +2,46 @@ import 'dart:convert';
import 'dart:typed_data';
import '../models/contact.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class ContactStore {
static const String _key = 'contacts';
static const String _keyPrefix = 'contacts';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<List<Contact>> loadContacts() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load contacts.');
return [];
}
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key);
if (jsonStr == null) return [];
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
@ -23,9 +51,13 @@ class ContactStore {
}
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 jsonList = contacts.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList));
await prefs.setString(keyFor, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Contact contact) {
@ -44,6 +76,10 @@ class ContactStore {
'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
'isActive': contact.isActive,
'rawPacket': contact.rawPacket != null
? base64Encode(contact.rawPacket!)
: null,
};
}
@ -71,6 +107,10 @@ class ContactStore {
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs,
),
isActive: json['isActive'] as bool? ?? true,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
);
}
}

View file

@ -2,26 +2,59 @@ import 'dart:convert';
import 'dart:typed_data';
import '../models/message.dart';
import '../helpers/smaz.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class MessageStore {
static const String _keyPrefix = 'messages_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<void> saveMessages(
String contactKeyHex,
List<Message> messages,
) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save messages.');
return;
}
final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex';
final key = '$keyFor$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList();
await prefs.setString(key, jsonEncode(jsonList));
}
Future<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 key = '$_keyPrefix$contactKeyHex';
final jsonString = prefs.getString(key);
if (jsonString == null) return [];
final key = '$keyFor$contactKeyHex';
final oldKey = '$_keyPrefix$contactKeyHex';
String? jsonString = prefs.getString(key);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(oldKey);
prefs.remove(oldKey);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating messages from legacy key $oldKey to scoped key $key',
);
await prefs.setString(key, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try {
final jsonList = jsonDecode(jsonString) as List<dynamic>;
@ -32,8 +65,12 @@ class MessageStore {
}
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 key = '$_keyPrefix$contactKeyHex';
final key = '$keyFor$contactKeyHex';
await prefs.remove(key);
}

View file

@ -1,11 +1,18 @@
import 'dart:async';
import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
/// Storage for unread message tracking with debounced writes to reduce I/O.
class UnreadStore {
static const String _contactUnreadCountKey = 'contact_unread_count';
static const String _keyPrefix = 'contact_unread_count';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
// Debounce timers to batch rapid writes
Timer? _contactUnreadSaveTimer;
@ -20,12 +27,33 @@ class UnreadStore {
}
Future<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 jsonStr = prefs.getString(_contactUnreadCountKey);
if (jsonStr == null) return {};
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return {};
}
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
final json = jsonDecode(jsonString) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int));
} catch (_) {
return {};
@ -33,6 +61,10 @@ class UnreadStore {
}
void saveContactUnreadCount(Map<String, int> counts) {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save unread counts.');
return;
}
_pendingContactUnreadCount = counts;
_contactUnreadSaveTimer?.cancel();
@ -49,7 +81,7 @@ class UnreadStore {
final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(_pendingContactUnreadCount);
await prefs.setString(_contactUnreadCountKey, jsonStr);
await prefs.setString(keyFor, jsonStr);
_pendingContactUnreadCount = null;
}

View file

@ -1,5 +1,3 @@
import 'package:meshcore_open/models/discovery_contact.dart';
import '../models/contact.dart';
bool matchesContactQuery(Contact contact, String query) {
@ -16,7 +14,7 @@ bool matchesContactQuery(Contact contact, String query) {
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
}
bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) {
bool matchesDiscoveryContactQuery(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return true;

View file

@ -157,8 +157,11 @@ class _SNRIndicatorState extends State<SNRIndicator> {
repeater.snr,
widget.connector.currentSf,
);
final name = widget.connector.contacts
final allContacts = [
...widget.connector.contacts,
...widget.connector.discoveredContacts,
];
final name = allContacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name)
.firstOrNull;