mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-15 23:24:29 +10:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f870e77e98 | |||
| e6658a6026 | |||
| c81791cf1e | |||
| 6da54e13c3 | |||
| 1fba5312a2 | |||
| a1b77bb29b | |||
| 4eecfc92dc | |||
| 90c8cf5f3e | |||
| 06fa176367 | |||
| e4285774a0 | |||
| b2da695102 | |||
| e1327a93c7 | |||
| 421bc71bb7 | |||
| 84ec139ce6 | |||
| b748b96237 | |||
| c2671ac2ae |
@@ -291,6 +291,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;
|
||||
@@ -663,6 +664,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
// Initialize notification service
|
||||
_notificationService.initialize();
|
||||
_loadChannelOrder();
|
||||
_loadDiscoveredContactCache();
|
||||
|
||||
// Initialize retry service callbacks
|
||||
_retryService?.initialize(
|
||||
@@ -691,7 +693,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadDiscoveredContactCache() async {
|
||||
Future<void> _loadDiscoveredContactCache() async {
|
||||
final cached = await _discoveryContactStore.loadContacts();
|
||||
_discoveredContacts
|
||||
..clear()
|
||||
@@ -906,10 +908,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
try {
|
||||
await _usbFrameSubscription?.cancel();
|
||||
_usbFrameSubscription = null;
|
||||
_appDebugLogService?.info(
|
||||
'connectUsb: opening serial port…',
|
||||
tag: 'USB',
|
||||
);
|
||||
_appDebugLogService?.info('connectUsb: opening serial port…', tag: 'USB');
|
||||
await _usbManager.connect(portName: portName, baudRate: baudRate);
|
||||
_appDebugLogService?.info(
|
||||
'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
|
||||
@@ -1196,7 +1195,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
await _requestDeviceInfo();
|
||||
_startBatteryPolling();
|
||||
unawaited(loadDiscoveredContactCache());
|
||||
|
||||
final gotSelfInfo = await _waitForSelfInfo(
|
||||
timeout: const Duration(seconds: 3),
|
||||
@@ -1952,6 +1950,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
type: contact.type,
|
||||
pathLength: contact.pathLength,
|
||||
path: contact.path,
|
||||
latitude: contact.latitude,
|
||||
longitude: contact.longitude,
|
||||
lastSeen: DateTime.now(),
|
||||
),
|
||||
);
|
||||
@@ -2250,6 +2250,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_hasLoadedChannels = true;
|
||||
_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
|
||||
}
|
||||
|
||||
@@ -2306,7 +2314,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
case pushCodeNewAdvert:
|
||||
debugPrint('Got New CONTACT');
|
||||
// It's the same format as respCodeContact, so we can reuse the handler
|
||||
_handleContact(frame);
|
||||
_handleContact(frame, isContact: false);
|
||||
break;
|
||||
case respCodeContact:
|
||||
debugPrint('Got CONTACT');
|
||||
@@ -2482,6 +2490,27 @@ 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();
|
||||
|
||||
_awaitingSelfInfo = false;
|
||||
_selfInfoRetryTimer?.cancel();
|
||||
_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);
|
||||
if (contact != null) {
|
||||
if (contact.type == advTypeRepeater) {
|
||||
@@ -2694,11 +2723,23 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
tag: 'Connector',
|
||||
);
|
||||
} else {
|
||||
_contacts.add(contact);
|
||||
appLogger.info(
|
||||
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
|
||||
tag: 'Connector',
|
||||
);
|
||||
if ((_autoAddUsers && contact.type == advTypeChat) ||
|
||||
(_autoAddRepeaters && contact.type == advTypeRepeater) ||
|
||||
(_autoAddRoomServers && contact.type == advTypeRoom) ||
|
||||
(_autoAddSensors && contact.type == advTypeSensor) ||
|
||||
isContact) {
|
||||
_contacts.add(contact);
|
||||
appLogger.info(
|
||||
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
|
||||
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);
|
||||
_loadMessagesForContact(contact.publicKeyHex);
|
||||
@@ -4270,44 +4311,40 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
void _handleRxData(Uint8List frame) {
|
||||
final packet = BufferReader(frame);
|
||||
double snr = 0.0;
|
||||
int routeType = 0;
|
||||
int payloadType = 0;
|
||||
Uint8List pathBytes = Uint8List(0);
|
||||
Uint8List payload = Uint8List(0);
|
||||
try {
|
||||
packet.skipBytes(1); // Skip frame type byte
|
||||
snr = packet.readInt8() / 4.0;
|
||||
final snr = packet.readInt8() / 4.0;
|
||||
packet.skipBytes(1); // Skip RSSI byte
|
||||
//final rssi = packet.readByte();
|
||||
final header = packet.readByte();
|
||||
routeType = header & 0x03;
|
||||
payloadType = (header >> 2) & 0x0F;
|
||||
final routeType = header & 0x03;
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
if (routeType == _routeTransportFlood ||
|
||||
routeType == _routeTransportDirect) {
|
||||
packet.skipBytes(4); // Skip transport-specific bytes
|
||||
}
|
||||
//final payloadVer = (header >> 6) & 0x03;
|
||||
final pathLen = packet.readByte();
|
||||
pathBytes = packet.readBytes(pathLen);
|
||||
payload = packet.readBytes(packet.remaining);
|
||||
final pathBytes = packet.readBytes(pathLen);
|
||||
final payload = packet.readBytes(packet.remaining);
|
||||
|
||||
final rawPacket = frame.sublist(3);
|
||||
switch (payloadType) {
|
||||
case payloadTypeADVERT:
|
||||
_handlePayloadAdvertReceived(
|
||||
rawPacket,
|
||||
payload,
|
||||
pathBytes,
|
||||
routeType,
|
||||
snr,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
||||
return;
|
||||
}
|
||||
final rawPacket = frame.sublist(3);
|
||||
switch (payloadType) {
|
||||
case payloadTypeADVERT:
|
||||
_handlePayloadAdvertReceived(
|
||||
rawPacket,
|
||||
payload,
|
||||
pathBytes,
|
||||
routeType,
|
||||
snr,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
void importContact(Uint8List frame) {
|
||||
@@ -4332,8 +4369,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
||||
return;
|
||||
}
|
||||
double latitude = 0.0;
|
||||
double longitude = 0.0;
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
String name = '';
|
||||
Uint8List publicKey = Uint8List(0);
|
||||
int type = 0;
|
||||
@@ -4374,7 +4411,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
publicKey: publicKey,
|
||||
name: name,
|
||||
type: type,
|
||||
pathLength: pathBytes.length,
|
||||
pathLength: pathBytes.isEmpty ? -1 : pathBytes.length,
|
||||
path: Uint8List.fromList(
|
||||
pathBytes.reversed.toList(),
|
||||
), // Store path in reverse for easier use in outgoing messages
|
||||
@@ -4393,8 +4430,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
double snr,
|
||||
) {
|
||||
final advert = BufferReader(payload);
|
||||
double latitude = 0.0;
|
||||
double longitude = 0.0;
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
String name = '';
|
||||
String contactKeyHex = '';
|
||||
Uint8List publicKey = Uint8List(0);
|
||||
|
||||
@@ -24,8 +24,7 @@ class MeshCoreUsbManager {
|
||||
// --- Configuration ---
|
||||
Future<List<String>> listPorts() => _service.listPorts();
|
||||
|
||||
void setRequestPortLabel(String label) =>
|
||||
_service.setRequestPortLabel(label);
|
||||
void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
|
||||
|
||||
void setFallbackDeviceName(String label) =>
|
||||
_service.setFallbackDeviceName(label);
|
||||
@@ -36,7 +35,10 @@ class MeshCoreUsbManager {
|
||||
}
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
Future<void> connect({required String portName, int baudRate = 115200}) async {
|
||||
Future<void> connect({
|
||||
required String portName,
|
||||
int baudRate = 115200,
|
||||
}) async {
|
||||
_debugLog?.info(
|
||||
'UsbManager.connect: portName=$portName baud=$baudRate',
|
||||
tag: 'USB',
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||
class BufferReader {
|
||||
int _pointer = 0;
|
||||
int _lastPointer = 0;
|
||||
final Uint8List _buffer;
|
||||
|
||||
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
|
||||
@@ -13,6 +14,7 @@ class BufferReader {
|
||||
int readByte() => readBytes(1)[0];
|
||||
|
||||
Uint8List readBytes(int count) {
|
||||
_lastPointer = _pointer;
|
||||
if (_pointer + count > _buffer.length) {
|
||||
throw RangeError(
|
||||
'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) {
|
||||
_lastPointer = _pointer;
|
||||
if (_pointer + count > _buffer.length) {
|
||||
throw RangeError(
|
||||
'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);
|
||||
|
||||
String readString() {
|
||||
_lastPointer = _pointer;
|
||||
final value = readRemainingBytes();
|
||||
try {
|
||||
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 bytes = readBytes(maxLength);
|
||||
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 readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
|
||||
int readUInt16LE() =>
|
||||
@@ -78,6 +101,9 @@ class BufferReader {
|
||||
if ((value & 0x800000) != 0) value -= 0x1000000;
|
||||
return value;
|
||||
}
|
||||
|
||||
void resetPointer() => _pointer = 0;
|
||||
void rewind() => _pointer = _lastPointer;
|
||||
}
|
||||
|
||||
// Buffer Writer - accumulating binary data builder
|
||||
|
||||
+20
-19
@@ -1,4 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class Contact {
|
||||
@@ -166,28 +168,27 @@ class Contact {
|
||||
|
||||
static Contact? fromFrame(Uint8List data) {
|
||||
if (data.isEmpty) return null;
|
||||
if (data[0] != respCodeContact) return null;
|
||||
final reader = BufferReader(data);
|
||||
try {
|
||||
final pubKey = Uint8List.fromList(
|
||||
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
|
||||
);
|
||||
final type = data[contactTypeOffset];
|
||||
final flags = data[contactFlagsOffset];
|
||||
final pathLen = data[contactPathLenOffset].toSigned(8);
|
||||
final respCode = reader.readByte();
|
||||
if (respCode != respCodeContact && respCode != pushCodeNewAdvert) {
|
||||
return null;
|
||||
}
|
||||
final pubKey = reader.readBytes(pubKeySize);
|
||||
final type = reader.readByte();
|
||||
final flags = reader.readByte();
|
||||
final pathLen = reader.readByte();
|
||||
final safePathLen = pathLen > 0
|
||||
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
||||
: 0;
|
||||
final pathBytes = safePathLen > 0
|
||||
? Uint8List.fromList(
|
||||
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
|
||||
)
|
||||
: Uint8List(0);
|
||||
final name = readCString(data, contactNameOffset, maxNameSize);
|
||||
final lastmod = readUint32LE(data, contactLastModOffset);
|
||||
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
||||
final name = reader.readCStringGreedy(maxNameSize);
|
||||
|
||||
final lastMod = reader.readUInt32LE();
|
||||
|
||||
double? lat, lon;
|
||||
final latRaw = readInt32LE(data, contactLatOffset);
|
||||
final lonRaw = readInt32LE(data, contactLonOffset);
|
||||
final latRaw = reader.readInt32LE();
|
||||
final lonRaw = reader.readInt32LE();
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
@@ -198,14 +199,14 @@ class Contact {
|
||||
name: name.isEmpty ? 'Unknown' : name,
|
||||
type: type,
|
||||
flags: flags,
|
||||
pathLength: pathLen,
|
||||
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
|
||||
path: pathBytes,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
|
||||
);
|
||||
} catch (e) {
|
||||
// If parsing fails, return null
|
||||
appLogger.error('Failed to parse contact frame: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<MeshCoreConnector>().getChannels();
|
||||
_loadCommunities();
|
||||
@@ -106,7 +108,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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -107,7 +107,8 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
bottomNavigationBar: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final isLoading = _isLoadingPorts;
|
||||
final showBle = PlatformInfo.isWeb ||
|
||||
final showBle =
|
||||
PlatformInfo.isWeb ||
|
||||
PlatformInfo.isAndroid ||
|
||||
PlatformInfo.isIOS;
|
||||
|
||||
@@ -238,7 +239,7 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
|
||||
final isConnecting =
|
||||
connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.usb;
|
||||
connector.activeTransport == MeshCoreTransportType.usb;
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -259,8 +260,7 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
),
|
||||
subtitle: showRawName ? Text(rawName) : null,
|
||||
trailing: ElevatedButton(
|
||||
onPressed:
|
||||
isConnecting ? null : () => _connectPort(port),
|
||||
onPressed: isConnecting ? null : () => _connectPort(port),
|
||||
child: Text(l10n.common_connect),
|
||||
),
|
||||
onTap: isConnecting ? null : () => _connectPort(port),
|
||||
@@ -329,8 +329,10 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
if (_connector.state != MeshCoreConnectionState.disconnected) return;
|
||||
|
||||
final rawPortName = normalizeUsbPortName(port);
|
||||
appLogger.info('Connect tapped for $port (raw: $rawPortName)',
|
||||
tag: 'UsbScreen');
|
||||
appLogger.info(
|
||||
'Connect tapped for $port (raw: $rawPortName)',
|
||||
tag: 'UsbScreen',
|
||||
);
|
||||
|
||||
try {
|
||||
await _connector.connectUsb(portName: rawPortName);
|
||||
|
||||
@@ -101,8 +101,7 @@ class NotificationService {
|
||||
final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
|
||||
if (addr != null && addr.isNotEmpty) return true;
|
||||
// Fallback: check the default socket for the current user.
|
||||
final uid = Platform.environment['UID'] ??
|
||||
Platform.environment['EUID'];
|
||||
final uid = Platform.environment['UID'] ?? Platform.environment['EUID'];
|
||||
final path = '/run/user/${uid ?? '1000'}/bus';
|
||||
return File(path).existsSync();
|
||||
}
|
||||
|
||||
@@ -118,10 +118,7 @@ class UsbSerialService {
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
} catch (error) {
|
||||
_debugLogService?.error(
|
||||
'Web connect failed: $error',
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
_debugLogService?.error('Web connect failed: $error', tag: 'USB Serial');
|
||||
await _cleanupFailedConnect();
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
_connectedPortName = null;
|
||||
@@ -268,9 +265,23 @@ class UsbSerialService {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _openPort(JSObject port, int baudRate) {
|
||||
final options = JSObject()..['baudRate'] = baudRate.toJS;
|
||||
return port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
|
||||
Future<void> _openPort(JSObject port, int baudRate) async {
|
||||
final options = JSObject()
|
||||
..['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 {
|
||||
@@ -324,8 +335,12 @@ class UsbSerialService {
|
||||
|
||||
Future<void> _pumpReads() async {
|
||||
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 {
|
||||
while (_status == UsbSerialStatus.connected &&
|
||||
identical(reader, _reader)) {
|
||||
@@ -333,6 +348,7 @@ class UsbSerialService {
|
||||
.callMethod<JSPromise<JSAny?>>('read'.toJS)
|
||||
.toDart;
|
||||
if (result == null) {
|
||||
_debugLogService?.warn('_pumpReads: null result', tag: 'USB Serial');
|
||||
break;
|
||||
}
|
||||
final resultObject = result as JSObject;
|
||||
@@ -340,20 +356,27 @@ class UsbSerialService {
|
||||
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
|
||||
final done = doneValue != null && doneValue.dartify() == true;
|
||||
if (done) {
|
||||
_debugLogService?.info('_pumpReads: done=true', tag: 'USB Serial');
|
||||
break;
|
||||
}
|
||||
|
||||
final value = resultObject.getProperty<JSAny?>('value'.toJS);
|
||||
final bytes = _coerceBytes(value);
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
_debugLogService?.info(
|
||||
'USB RX raw: ${bytes.length} byte(s)',
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
_ingestRawBytes(bytes);
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
|
||||
if (_status == UsbSerialStatus.connected) {
|
||||
_addFrameError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
|
||||
_releaseLock(reader);
|
||||
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
|
||||
_addFrameError(StateError('USB serial connection closed'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,50 @@
|
||||
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 +54,7 @@ class ChannelOrderStore {
|
||||
} catch (_) {
|
||||
// fall through to legacy parse
|
||||
}
|
||||
return raw
|
||||
return jsonString
|
||||
.split(',')
|
||||
.map((value) => int.tryParse(value))
|
||||
.whereType<int>()
|
||||
|
||||
@@ -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';
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,43 @@ 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) {
|
||||
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 +48,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) {
|
||||
|
||||
@@ -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,38 @@ 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 +59,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
|
||||
|
||||
@@ -5,11 +5,11 @@ import '../models/discovery_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 {
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_key);
|
||||
final jsonStr = prefs.getString(_keyPrefix);
|
||||
if (jsonStr == null) return [];
|
||||
|
||||
try {
|
||||
@@ -25,7 +25,7 @@ class ContactDiscoveryStore {
|
||||
Future<void> saveContacts(List<DiscoveryContact> 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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,26 +2,60 @@ 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 +66,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<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>
|
||||
<array>
|
||||
<string>/dev/</string>
|
||||
<string>/dev/cu.</string>
|
||||
<string>/dev/tty.</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<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>
|
||||
<array>
|
||||
<string>/dev/</string>
|
||||
<string>/dev/cu.</string>
|
||||
<string>/dev/tty.</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
|
||||
@@ -38,6 +38,7 @@ dependencies:
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_blue_plus: ^2.1.0
|
||||
# TODO: Switch to official flserial repo once changes are upstreamed
|
||||
flserial:
|
||||
git:
|
||||
url: https://github.com/MeshEnvy/flserial.git
|
||||
|
||||
@@ -116,10 +116,12 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
));
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(connector.connectUsbCalls, 0);
|
||||
@@ -131,28 +133,29 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'UsbScreen sends raw port name when tapping Connect',
|
||||
(tester) async {
|
||||
final connector = _FakeMeshCoreConnector(
|
||||
ports: <String>['COM6 - USB Serial Device (COM6)'],
|
||||
);
|
||||
testWidgets('UsbScreen sends raw port name when tapping Connect', (
|
||||
tester,
|
||||
) async {
|
||||
final connector = _FakeMeshCoreConnector(
|
||||
ports: <String>['COM6 - USB Serial Device (COM6)'],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(connector.connectUsbCalls, 1);
|
||||
expect(connector.lastConnectPortName, 'COM6');
|
||||
},
|
||||
);
|
||||
expect(connector.connectUsbCalls, 1);
|
||||
expect(connector.lastConnectPortName, 'COM6');
|
||||
});
|
||||
|
||||
testWidgets('ScannerScreen USB action reflects platform support', (
|
||||
tester,
|
||||
@@ -177,8 +180,9 @@ void main() {
|
||||
});
|
||||
|
||||
group('Error Handling', () {
|
||||
testWidgets('shows error SnackBar when listing ports fails',
|
||||
(tester) async {
|
||||
testWidgets('shows error SnackBar when listing ports fails', (
|
||||
tester,
|
||||
) async {
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
connector.listUsbPortsImpl = () async {
|
||||
throw PlatformException(
|
||||
@@ -195,9 +199,7 @@ void main() {
|
||||
expect(find.text('USB permission was denied.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('connection failure shows SnackBar error', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('connection failure shows SnackBar error', (tester) async {
|
||||
final connector = _FakeMeshCoreConnector(ports: <String>['COM1']);
|
||||
var connectAttempted = false;
|
||||
connector.connectUsbImpl = ({required String portName}) async {
|
||||
@@ -210,10 +212,12 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
));
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(connectAttempted, isTrue);
|
||||
|
||||
Reference in New Issue
Block a user