Compare commits

...

16 Commits

Author SHA1 Message Date
Winston Lowe f870e77e98 Merge branch 'main' into dev-dbDevicePrefix 2026-03-12 09:37:44 -07:00
Winston Lowe e6658a6026 Migrate storage keys to scoped keys across multiple store classes 2026-03-12 09:29:46 -07:00
Winston Lowe c81791cf1e Migrate legacy storage keys to scoped keys in various store classes (#289) 2026-03-12 08:39:17 -07:00
Winston Lowe 6da54e13c3 Migrate legacy storage keys to scoped keys in various store classes 2026-03-12 08:29:56 -07:00
Winston Lowe 1fba5312a2 Refactor storage classes to include companion's public key (#277)
* Refactor storage classes to include public key handling and improve data loading/saving logic

* Remove redundant publicKeyHex handling from ContactDiscoveryStore and fix key reference in saveContacts method

* Remove unused app_logger import from ContactDiscoveryStore

* Add warning log for empty publicKeyHex in saveChannelMessages method

* Add warning log for empty publicKeyHex in clearMessages method

* Migrate legacy storage keys to scoped keys across multiple stores

* Remove legacy unscoped keys during migration in storage classes

* Update lib/storage/contact_store.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-12 00:14:48 -07:00
zjs81 a1b77bb29b Merge pull request #269 from zjs81/dev-latLonFix
Changed contacts latitude and longitude fields to be null until parsed and set
2026-03-07 13:53:09 -07:00
zjs81 4eecfc92dc Merge pull request #252 from just-stuff-tm/feature/usb
Feature/usb
2026-03-07 13:16:39 -07:00
zjs81 90c8cf5f3e Add TODO to switch flserial to official repo 2026-03-07 13:12:45 -07:00
zjs81 06fa176367 Narrow macOS sandbox entitlement to /dev/cu. and /dev/tty. only
The /dev/ prefix granted read/write to all device nodes. The app only
needs access to serial port devices (/dev/cu.* and /dev/tty.*) for USB
LoRa communication.
2026-03-07 13:10:42 -07:00
zjs81 e4285774a0 Merge branch 'main' into feature/usb 2026-03-07 13:03:15 -07:00
zjs81 b2da695102 Run dart format 2026-03-07 13:01:27 -07:00
zjs81 e1327a93c7 Fix contact sync fallback when channel 0 never arrives
On web BLE, contact sync is deferred until channel 0 arrives via
_handleChannelInfo. If channel 0 times out or channel sync completes
without it, _pendingInitialContactsSync stays true and contacts never
load. Add fallback in _cleanupChannelSync to trigger getContacts() if
the flag is still set when channel sync ends.
2026-03-07 13:00:23 -07:00
zjs81 421bc71bb7 Enhance USB port opening and reading logic with improved error handling and debug logging 2026-03-07 12:55:15 -07:00
Winston Lowe 84ec139ce6 Add latitude and longitude fields to contact handling in MeshCoreConnector 2026-03-07 11:02:47 -08:00
Winston Lowe b748b96237 Enhance contact handling logic in MeshCoreConnector to support conditional addition based on auto-add settings (#268) 2026-03-07 01:45:53 -08:00
Winston Lowe c2671ac2ae Refactor data handling of contacts (#267)
* Refactor data handling in MeshCoreConnector and BufferReader for improved readability and efficiency

* Update lib/connector/meshcore_connector.dart

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix pointer tracking in BufferReader's readCString method

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-07 01:23:46 -08:00
24 changed files with 597 additions and 164 deletions
+64 -27
View File
@@ -291,6 +291,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;
@@ -663,6 +664,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Initialize notification service // Initialize notification service
_notificationService.initialize(); _notificationService.initialize();
_loadChannelOrder(); _loadChannelOrder();
_loadDiscoveredContactCache();
// Initialize retry service callbacks // Initialize retry service callbacks
_retryService?.initialize( _retryService?.initialize(
@@ -691,7 +693,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()
@@ -906,10 +908,7 @@ class MeshCoreConnector extends ChangeNotifier {
try { try {
await _usbFrameSubscription?.cancel(); await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null; _usbFrameSubscription = null;
_appDebugLogService?.info( _appDebugLogService?.info('connectUsb: opening serial port…', tag: 'USB');
'connectUsb: opening serial port…',
tag: 'USB',
);
await _usbManager.connect(portName: portName, baudRate: baudRate); await _usbManager.connect(portName: portName, baudRate: baudRate);
_appDebugLogService?.info( _appDebugLogService?.info(
'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}', 'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
@@ -1196,7 +1195,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),
@@ -1952,6 +1950,8 @@ class MeshCoreConnector extends ChangeNotifier {
type: contact.type, type: contact.type,
pathLength: contact.pathLength, pathLength: contact.pathLength,
path: contact.path, path: contact.path,
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: DateTime.now(), lastSeen: DateTime.now(),
), ),
); );
@@ -2250,6 +2250,14 @@ class MeshCoreConnector extends ChangeNotifier {
_hasLoadedChannels = true; _hasLoadedChannels = true;
_previousChannelsCache.clear(); _previousChannelsCache.clear();
} }
// Fallback: if contact sync was deferred waiting for channel 0 but
// channel sync finished without triggering it, start contacts now.
if (_pendingInitialContactsSync && isConnected) {
_pendingInitialContactsSync = false;
unawaited(getContacts());
}
// Keep cache on failure/disconnection for future attempts // Keep cache on failure/disconnection for future attempts
} }
@@ -2306,7 +2314,7 @@ class MeshCoreConnector extends ChangeNotifier {
case pushCodeNewAdvert: case pushCodeNewAdvert:
debugPrint('Got New CONTACT'); debugPrint('Got New CONTACT');
// It's the same format as respCodeContact, so we can reuse the handler // It's the same format as respCodeContact, so we can reuse the handler
_handleContact(frame); _handleContact(frame, isContact: false);
break; break;
case respCodeContact: case respCodeContact:
debugPrint('Got CONTACT'); debugPrint('Got CONTACT');
@@ -2482,6 +2490,27 @@ 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();
_awaitingSelfInfo = false; _awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null; _selfInfoRetryTimer = null;
@@ -2655,7 +2684,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
void _handleContact(Uint8List frame) { void _handleContact(Uint8List frame, {bool isContact = true}) {
final contact = Contact.fromFrame(frame); final contact = Contact.fromFrame(frame);
if (contact != null) { if (contact != null) {
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
@@ -2694,11 +2723,23 @@ class MeshCoreConnector extends ChangeNotifier {
tag: 'Connector', tag: 'Connector',
); );
} else { } else {
if ((_autoAddUsers && contact.type == advTypeChat) ||
(_autoAddRepeaters && contact.type == advTypeRepeater) ||
(_autoAddRoomServers && contact.type == advTypeRoom) ||
(_autoAddSensors && contact.type == advTypeSensor) ||
isContact) {
_contacts.add(contact); _contacts.add(contact);
appLogger.info( appLogger.info(
'Added new contact ${contact.name}: pathLen=${contact.pathLength}', 'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
tag: 'Connector', tag: 'Connector',
); );
} else {
appLogger.info(
"Discovered contact ${contact.name} (type ${contact.typeLabel}) not added due to auto-add settings",
tag: 'Connector',
);
return;
}
} }
_knownContactKeys.add(contact.publicKeyHex); _knownContactKeys.add(contact.publicKeyHex);
_loadMessagesForContact(contact.publicKeyHex); _loadMessagesForContact(contact.publicKeyHex);
@@ -4270,31 +4311,23 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleRxData(Uint8List frame) { void _handleRxData(Uint8List frame) {
final packet = BufferReader(frame); final packet = BufferReader(frame);
double snr = 0.0;
int routeType = 0;
int payloadType = 0;
Uint8List pathBytes = Uint8List(0);
Uint8List payload = Uint8List(0);
try { try {
packet.skipBytes(1); // Skip frame type byte packet.skipBytes(1); // Skip frame type byte
snr = packet.readInt8() / 4.0; final snr = packet.readInt8() / 4.0;
packet.skipBytes(1); // Skip RSSI byte packet.skipBytes(1); // Skip RSSI byte
//final rssi = packet.readByte(); //final rssi = packet.readByte();
final header = packet.readByte(); final header = packet.readByte();
routeType = header & 0x03; final routeType = header & 0x03;
payloadType = (header >> 2) & 0x0F; final payloadType = (header >> 2) & 0x0F;
if (routeType == _routeTransportFlood || if (routeType == _routeTransportFlood ||
routeType == _routeTransportDirect) { routeType == _routeTransportDirect) {
packet.skipBytes(4); // Skip transport-specific bytes packet.skipBytes(4); // Skip transport-specific bytes
} }
//final payloadVer = (header >> 6) & 0x03; //final payloadVer = (header >> 6) & 0x03;
final pathLen = packet.readByte(); final pathLen = packet.readByte();
pathBytes = packet.readBytes(pathLen); final pathBytes = packet.readBytes(pathLen);
payload = packet.readBytes(packet.remaining); final payload = packet.readBytes(packet.remaining);
} catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return;
}
final rawPacket = frame.sublist(3); final rawPacket = frame.sublist(3);
switch (payloadType) { switch (payloadType) {
case payloadTypeADVERT: case payloadTypeADVERT:
@@ -4308,6 +4341,10 @@ class MeshCoreConnector extends ChangeNotifier {
break; break;
default: default:
} }
} catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return;
}
} }
void importContact(Uint8List frame) { void importContact(Uint8List frame) {
@@ -4332,8 +4369,8 @@ class MeshCoreConnector extends ChangeNotifier {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return; return;
} }
double latitude = 0.0; double? latitude;
double longitude = 0.0; double? longitude;
String name = ''; String name = '';
Uint8List publicKey = Uint8List(0); Uint8List publicKey = Uint8List(0);
int type = 0; int type = 0;
@@ -4374,7 +4411,7 @@ class MeshCoreConnector extends ChangeNotifier {
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
pathLength: pathBytes.length, pathLength: pathBytes.isEmpty ? -1 : pathBytes.length,
path: Uint8List.fromList( path: Uint8List.fromList(
pathBytes.reversed.toList(), pathBytes.reversed.toList(),
), // Store path in reverse for easier use in outgoing messages ), // Store path in reverse for easier use in outgoing messages
@@ -4393,8 +4430,8 @@ class MeshCoreConnector extends ChangeNotifier {
double snr, double snr,
) { ) {
final advert = BufferReader(payload); final advert = BufferReader(payload);
double latitude = 0.0; double? latitude;
double longitude = 0.0; double? longitude;
String name = ''; String name = '';
String contactKeyHex = ''; String contactKeyHex = '';
Uint8List publicKey = Uint8List(0); Uint8List publicKey = Uint8List(0);
+5 -3
View File
@@ -24,8 +24,7 @@ class MeshCoreUsbManager {
// --- Configuration --- // --- Configuration ---
Future<List<String>> listPorts() => _service.listPorts(); Future<List<String>> listPorts() => _service.listPorts();
void setRequestPortLabel(String label) => void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
_service.setRequestPortLabel(label);
void setFallbackDeviceName(String label) => void setFallbackDeviceName(String label) =>
_service.setFallbackDeviceName(label); _service.setFallbackDeviceName(label);
@@ -36,7 +35,10 @@ class MeshCoreUsbManager {
} }
// --- Connection lifecycle --- // --- Connection lifecycle ---
Future<void> connect({required String portName, int baudRate = 115200}) async { Future<void> connect({
required String portName,
int baudRate = 115200,
}) async {
_debugLog?.info( _debugLog?.info(
'UsbManager.connect: portName=$portName baud=$baudRate', 'UsbManager.connect: portName=$portName baud=$baudRate',
tag: 'USB', tag: 'USB',
+27 -1
View File
@@ -4,6 +4,7 @@ import 'dart:typed_data';
// Buffer Reader - sequential binary data reader with pointer tracking // Buffer Reader - sequential binary data reader with pointer tracking
class BufferReader { class BufferReader {
int _pointer = 0; int _pointer = 0;
int _lastPointer = 0;
final Uint8List _buffer; final Uint8List _buffer;
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data); BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
@@ -13,6 +14,7 @@ class BufferReader {
int readByte() => readBytes(1)[0]; int readByte() => readBytes(1)[0];
Uint8List readBytes(int count) { Uint8List readBytes(int count) {
_lastPointer = _pointer;
if (_pointer + count > _buffer.length) { if (_pointer + count > _buffer.length) {
throw RangeError( throw RangeError(
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', 'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
@@ -24,6 +26,7 @@ class BufferReader {
} }
void skipBytes(int count) { void skipBytes(int count) {
_lastPointer = _pointer;
if (_pointer + count > _buffer.length) { if (_pointer + count > _buffer.length) {
throw RangeError( throw RangeError(
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', 'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
@@ -35,6 +38,7 @@ class BufferReader {
Uint8List readRemainingBytes() => readBytes(remaining); Uint8List readRemainingBytes() => readBytes(remaining);
String readString() { String readString() {
_lastPointer = _pointer;
final value = readRemainingBytes(); final value = readRemainingBytes();
try { try {
return utf8.decode(Uint8List.fromList(value), allowMalformed: true); return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
@@ -43,7 +47,8 @@ class BufferReader {
} }
} }
String readCString(int maxLength) { String readCStringGreedy(int maxLength) {
_lastPointer = _pointer;
final value = <int>[]; final value = <int>[];
final bytes = readBytes(maxLength); final bytes = readBytes(maxLength);
for (final byte in bytes) { for (final byte in bytes) {
@@ -57,6 +62,24 @@ class BufferReader {
} }
} }
String readCString(int maxLength) {
final backupPointer = _pointer;
final value = <int>[];
int counter = 0;
while (counter < maxLength) {
final byte = readByte();
if (byte == 0) break;
value.add(byte);
counter++;
}
_lastPointer = backupPointer;
try {
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
} catch (e) {
return String.fromCharCodes(value); // Latin-1 fallback
}
}
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0); int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0); int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
int readUInt16LE() => int readUInt16LE() =>
@@ -78,6 +101,9 @@ class BufferReader {
if ((value & 0x800000) != 0) value -= 0x1000000; if ((value & 0x800000) != 0) value -= 0x1000000;
return value; return value;
} }
void resetPointer() => _pointer = 0;
void rewind() => _pointer = _lastPointer;
} }
// Buffer Writer - accumulating binary data builder // Buffer Writer - accumulating binary data builder
+20 -19
View File
@@ -1,4 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
class Contact { class Contact {
@@ -166,28 +168,27 @@ class Contact {
static Contact? fromFrame(Uint8List data) { static Contact? fromFrame(Uint8List data) {
if (data.isEmpty) return null; if (data.isEmpty) return null;
if (data[0] != respCodeContact) return null; final reader = BufferReader(data);
try { try {
final pubKey = Uint8List.fromList( final respCode = reader.readByte();
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), if (respCode != respCodeContact && respCode != pushCodeNewAdvert) {
); return null;
final type = data[contactTypeOffset]; }
final flags = data[contactFlagsOffset]; final pubKey = reader.readBytes(pubKeySize);
final pathLen = data[contactPathLenOffset].toSigned(8); final type = reader.readByte();
final flags = reader.readByte();
final pathLen = reader.readByte();
final safePathLen = pathLen > 0 final safePathLen = pathLen > 0
? (pathLen > maxPathSize ? maxPathSize : pathLen) ? (pathLen > maxPathSize ? maxPathSize : pathLen)
: 0; : 0;
final pathBytes = safePathLen > 0 final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
? Uint8List.fromList( final name = reader.readCStringGreedy(maxNameSize);
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
) final lastMod = reader.readUInt32LE();
: Uint8List(0);
final name = readCString(data, contactNameOffset, maxNameSize);
final lastmod = readUint32LE(data, contactLastModOffset);
double? lat, lon; double? lat, lon;
final latRaw = readInt32LE(data, contactLatOffset); final latRaw = reader.readInt32LE();
final lonRaw = readInt32LE(data, contactLonOffset); final lonRaw = reader.readInt32LE();
if (latRaw != 0 || lonRaw != 0) { if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6; lat = latRaw / 1e6;
lon = lonRaw / 1e6; lon = lonRaw / 1e6;
@@ -198,14 +199,14 @@ class Contact {
name: name.isEmpty ? 'Unknown' : name, name: name.isEmpty ? 'Unknown' : name,
type: type, type: type,
flags: flags, flags: flags,
pathLength: pathLen, pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
path: pathBytes, path: pathBytes,
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
); );
} catch (e) { } catch (e) {
// If parsing fails, return null appLogger.error('Failed to parse contact frame: $e');
return null; return null;
} }
} }
+4
View File
@@ -54,6 +54,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MeshCoreConnector>().getChannels(); context.read<MeshCoreConnector>().getChannels();
_loadCommunities(); _loadCommunities();
@@ -106,7 +108,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)) {
@@ -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
+7 -5
View File
@@ -107,7 +107,8 @@ class _UsbScreenState extends State<UsbScreen> {
bottomNavigationBar: Consumer<MeshCoreConnector>( bottomNavigationBar: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final isLoading = _isLoadingPorts; final isLoading = _isLoadingPorts;
final showBle = PlatformInfo.isWeb || final showBle =
PlatformInfo.isWeb ||
PlatformInfo.isAndroid || PlatformInfo.isAndroid ||
PlatformInfo.isIOS; PlatformInfo.isIOS;
@@ -259,8 +260,7 @@ class _UsbScreenState extends State<UsbScreen> {
), ),
subtitle: showRawName ? Text(rawName) : null, subtitle: showRawName ? Text(rawName) : null,
trailing: ElevatedButton( trailing: ElevatedButton(
onPressed: onPressed: isConnecting ? null : () => _connectPort(port),
isConnecting ? null : () => _connectPort(port),
child: Text(l10n.common_connect), child: Text(l10n.common_connect),
), ),
onTap: isConnecting ? null : () => _connectPort(port), onTap: isConnecting ? null : () => _connectPort(port),
@@ -329,8 +329,10 @@ class _UsbScreenState extends State<UsbScreen> {
if (_connector.state != MeshCoreConnectionState.disconnected) return; if (_connector.state != MeshCoreConnectionState.disconnected) return;
final rawPortName = normalizeUsbPortName(port); final rawPortName = normalizeUsbPortName(port);
appLogger.info('Connect tapped for $port (raw: $rawPortName)', appLogger.info(
tag: 'UsbScreen'); 'Connect tapped for $port (raw: $rawPortName)',
tag: 'UsbScreen',
);
try { try {
await _connector.connectUsb(portName: rawPortName); await _connector.connectUsb(portName: rawPortName);
+1 -2
View File
@@ -101,8 +101,7 @@ class NotificationService {
final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS']; final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
if (addr != null && addr.isNotEmpty) return true; if (addr != null && addr.isNotEmpty) return true;
// Fallback: check the default socket for the current user. // Fallback: check the default socket for the current user.
final uid = Platform.environment['UID'] ?? final uid = Platform.environment['UID'] ?? Platform.environment['EUID'];
Platform.environment['EUID'];
final path = '/run/user/${uid ?? '1000'}/bus'; final path = '/run/user/${uid ?? '1000'}/bus';
return File(path).existsSync(); return File(path).existsSync();
} }
+31 -8
View File
@@ -118,10 +118,7 @@ class UsbSerialService {
tag: 'USB Serial', tag: 'USB Serial',
); );
} catch (error) { } catch (error) {
_debugLogService?.error( _debugLogService?.error('Web connect failed: $error', tag: 'USB Serial');
'Web connect failed: $error',
tag: 'USB Serial',
);
await _cleanupFailedConnect(); await _cleanupFailedConnect();
_status = UsbSerialStatus.disconnected; _status = UsbSerialStatus.disconnected;
_connectedPortName = null; _connectedPortName = null;
@@ -268,9 +265,23 @@ class UsbSerialService {
return null; return null;
} }
Future<void> _openPort(JSObject port, int baudRate) { Future<void> _openPort(JSObject port, int baudRate) async {
final options = JSObject()..['baudRate'] = baudRate.toJS; final options = JSObject()
return port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart; ..['baudRate'] = baudRate.toJS
..['flowControl'] = 'none'.toJS;
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
try {
final signals = JSObject()
..['dataTerminalReady'] = true.toJS
..['requestToSend'] = false.toJS;
await port
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
.toDart;
} catch (_) {
// setSignals may not be supported on all browsers/devices.
}
} }
Future<void> _cleanupFailedConnect() async { Future<void> _cleanupFailedConnect() async {
@@ -324,8 +335,12 @@ class UsbSerialService {
Future<void> _pumpReads() async { Future<void> _pumpReads() async {
final reader = _reader; final reader = _reader;
if (reader == null) return; if (reader == null) {
_debugLogService?.warn('_pumpReads: reader is null', tag: 'USB Serial');
return;
}
_debugLogService?.info('_pumpReads: started', tag: 'USB Serial');
try { try {
while (_status == UsbSerialStatus.connected && while (_status == UsbSerialStatus.connected &&
identical(reader, _reader)) { identical(reader, _reader)) {
@@ -333,6 +348,7 @@ class UsbSerialService {
.callMethod<JSPromise<JSAny?>>('read'.toJS) .callMethod<JSPromise<JSAny?>>('read'.toJS)
.toDart; .toDart;
if (result == null) { if (result == null) {
_debugLogService?.warn('_pumpReads: null result', tag: 'USB Serial');
break; break;
} }
final resultObject = result as JSObject; final resultObject = result as JSObject;
@@ -340,20 +356,27 @@ class UsbSerialService {
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS); final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
final done = doneValue != null && doneValue.dartify() == true; final done = doneValue != null && doneValue.dartify() == true;
if (done) { if (done) {
_debugLogService?.info('_pumpReads: done=true', tag: 'USB Serial');
break; break;
} }
final value = resultObject.getProperty<JSAny?>('value'.toJS); final value = resultObject.getProperty<JSAny?>('value'.toJS);
final bytes = _coerceBytes(value); final bytes = _coerceBytes(value);
if (bytes != null && bytes.isNotEmpty) { if (bytes != null && bytes.isNotEmpty) {
_debugLogService?.info(
'USB RX raw: ${bytes.length} byte(s)',
tag: 'USB Serial',
);
_ingestRawBytes(bytes); _ingestRawBytes(bytes);
} }
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
if (_status == UsbSerialStatus.connected) { if (_status == UsbSerialStatus.connected) {
_addFrameError(error, stackTrace); _addFrameError(error, stackTrace);
} }
} finally { } finally {
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
_releaseLock(reader); _releaseLock(reader);
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) { if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
_addFrameError(StateError('USB serial connection closed')); _addFrameError(StateError('USB serial connection closed'));
+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);
} }
+36 -6
View File
@@ -1,20 +1,50 @@
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 +54,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>()
+35 -3
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';
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 prefs.getBool(key) ?? false; return prefs.getBool(key) ?? 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);
} }
} }
+34 -5
View File
@@ -2,18 +2,43 @@ 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) {
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 +48,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) {
+34 -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,38 @@ 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 +59,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
+3 -3
View File
@@ -5,11 +5,11 @@ import '../models/discovery_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<DiscoveryContact>> 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 {
@@ -25,7 +25,7 @@ class ContactDiscoveryStore {
Future<void> saveContacts(List<DiscoveryContact> contacts) async { Future<void> saveContacts(List<DiscoveryContact> 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(DiscoveryContact contact) {
+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);
} }
} }
+37 -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) {
+43 -5
View File
@@ -2,26 +2,60 @@ 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 +66,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;
} }
+3 -1
View File
@@ -14,9 +14,11 @@
<true/> <true/>
<key>com.apple.security.device.usb</key> <key>com.apple.security.device.usb</key>
<true/> <true/>
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key> <key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array> <array>
<string>/dev/</string> <string>/dev/cu.</string>
<string>/dev/tty.</string>
</array> </array>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
+3 -1
View File
@@ -10,9 +10,11 @@
<true/> <true/>
<key>com.apple.security.device.usb</key> <key>com.apple.security.device.usb</key>
<true/> <true/>
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key> <key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array> <array>
<string>/dev/</string> <string>/dev/cu.</string>
<string>/dev/tty.</string>
</array> </array>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
+1
View File
@@ -38,6 +38,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_blue_plus: ^2.1.0 flutter_blue_plus: ^2.1.0
# TODO: Switch to official flserial repo once changes are upstreamed
flserial: flserial:
git: git:
url: https://github.com/MeshEnvy/flserial.git url: https://github.com/MeshEnvy/flserial.git
+20 -16
View File
@@ -116,10 +116,12 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.ancestor( await tester.tap(
find.ancestor(
of: find.text('Connect'), of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(), matching: find.bySubtype<ElevatedButton>(),
)); ),
);
await tester.pump(); await tester.pump();
expect(connector.connectUsbCalls, 0); expect(connector.connectUsbCalls, 0);
@@ -131,9 +133,9 @@ void main() {
}, },
); );
testWidgets( testWidgets('UsbScreen sends raw port name when tapping Connect', (
'UsbScreen sends raw port name when tapping Connect', tester,
(tester) async { ) async {
final connector = _FakeMeshCoreConnector( final connector = _FakeMeshCoreConnector(
ports: <String>['COM6 - USB Serial Device (COM6)'], ports: <String>['COM6 - USB Serial Device (COM6)'],
); );
@@ -143,16 +145,17 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.ancestor( await tester.tap(
find.ancestor(
of: find.text('Connect'), of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(), matching: find.bySubtype<ElevatedButton>(),
)); ),
);
await tester.pump(); await tester.pump();
expect(connector.connectUsbCalls, 1); expect(connector.connectUsbCalls, 1);
expect(connector.lastConnectPortName, 'COM6'); expect(connector.lastConnectPortName, 'COM6');
}, });
);
testWidgets('ScannerScreen USB action reflects platform support', ( testWidgets('ScannerScreen USB action reflects platform support', (
tester, tester,
@@ -177,8 +180,9 @@ void main() {
}); });
group('Error Handling', () { group('Error Handling', () {
testWidgets('shows error SnackBar when listing ports fails', testWidgets('shows error SnackBar when listing ports fails', (
(tester) async { tester,
) async {
final connector = _FakeMeshCoreConnector(); final connector = _FakeMeshCoreConnector();
connector.listUsbPortsImpl = () async { connector.listUsbPortsImpl = () async {
throw PlatformException( throw PlatformException(
@@ -195,9 +199,7 @@ void main() {
expect(find.text('USB permission was denied.'), findsOneWidget); expect(find.text('USB permission was denied.'), findsOneWidget);
}); });
testWidgets('connection failure shows SnackBar error', ( testWidgets('connection failure shows SnackBar error', (tester) async {
tester,
) async {
final connector = _FakeMeshCoreConnector(ports: <String>['COM1']); final connector = _FakeMeshCoreConnector(ports: <String>['COM1']);
var connectAttempted = false; var connectAttempted = false;
connector.connectUsbImpl = ({required String portName}) async { connector.connectUsbImpl = ({required String portName}) async {
@@ -210,10 +212,12 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.ancestor( await tester.tap(
find.ancestor(
of: find.text('Connect'), of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(), matching: find.bySubtype<ElevatedButton>(),
)); ),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(connectAttempted, isTrue); expect(connectAttempted, isTrue);