diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 87a67553..ee9533e9 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -257,6 +257,9 @@ class MeshCoreConnector extends ChangeNotifier { int? _activeChannelIndex; List _channelOrder = []; + int _storageUsedKb = -1; + int _storageTotalKb = -1; + // Getters MeshCoreConnectionState get state => _state; BluetoothDevice? get device => _device; @@ -338,6 +341,8 @@ class MeshCoreConnector extends ChangeNotifier { int? get firmwareVerCode => _firmwareVerCode; Map? get currentCustomVars => _currentCustomVars; int? get batteryMillivolts => _batteryMillivolts; + int? get storageUsedKb => _storageUsedKb; + int? get storageTotalKb => _storageTotalKb; int get maxContacts => _maxContacts; int get maxChannels => _maxChannels; Set get knownContactKeys => Set.unmodifiable(_knownContactKeys); @@ -2122,9 +2127,7 @@ class MeshCoreConnector extends ChangeNotifier { outboundText, selfKey, ); - final ackHashHex = ackHash - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); + final ackHashHex = ackHashToHex(ackHash); final messageBytes = utf8.encode(outboundText).length; _pendingRepeaterAcks[ackHashHex]?.timeout?.cancel(); _pendingRepeaterAcks[ackHashHex] = _RepeaterAckContext( @@ -2896,7 +2899,7 @@ class MeshCoreConnector extends ChangeNotifier { _currentSf = reader.readByte(); _currentCr = reader.readByte(); - _selfName = reader.readString(); + _selfName = reader.readCString(); } catch (e) { _appDebugLogService?.error( 'Error parsing SELF_INFO frame: $e', @@ -3037,14 +3040,23 @@ class MeshCoreConnector extends ChangeNotifier { // [1-2] = battery_mv (uint16 LE) // [3-6] = storage_used_kb (uint32 LE) // [7-10] = storage_total_kb (uint32 LE) - if (frame.length >= 3) { - _batteryMillivolts = readUint16LE(frame, 1); + try { + final reader = BufferReader(frame); + reader.skipBytes(1); + _batteryMillivolts = reader.readUInt16LE(); + _storageUsedKb = reader.readUInt32LE(); + _storageTotalKb = reader.readUInt32LE(); final volts = (_batteryMillivolts! / 1000.0).toStringAsFixed(2); _appDebugLogService?.info( 'Pulled battery: $volts V ($_batteryMillivolts mV)', tag: 'Battery', ); notifyListeners(); + } catch (e) { + _appDebugLogService?.error( + 'Error parsing battery and storage frame: $e', + tag: 'Connector', + ); } } @@ -3540,7 +3552,7 @@ class MeshCoreConnector extends ChangeNotifier { reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants } - final msgText = reader.readString(); + final msgText = reader.readCString(); final flags = txtType; final shiftedType = flags >> 2; @@ -3702,68 +3714,87 @@ class MeshCoreConnector extends ChangeNotifier { void _handleLogRxData(Uint8List frame) { if (frame.length < 4) return; - final raw = Uint8List.fromList(frame.sublist(3)); - final packet = _parseRawPacket(raw); - if (packet == null || packet.payloadType != _payloadTypeGroupText) return; + try { + final reader = BufferReader(frame); + reader.skipBytes(3); // Skip header - final payload = packet.payload; - if (payload.length <= _cipherMacSize) return; - final channelHash = payload[0]; - final encrypted = Uint8List.fromList(payload.sublist(1)); + final raw = reader.readRemainingBytes(); + final packet = _parseRawPacket(raw); + if (packet == null || packet.payloadType != _payloadTypeGroupText) return; - // Use cached channels as fallback if live channels not yet loaded - final channelsToSearch = _channels.isNotEmpty ? _channels : _cachedChannels; - for (final channel in channelsToSearch) { - if (channel.isEmpty) continue; - final hash = _computeChannelHash(channel.psk); - if (hash != channelHash) continue; + final payload = BufferReader(packet.payload); + final channelHash = payload.readByte(); + final encrypted = Uint8List.fromList(payload.readRemainingBytes()); - final decrypted = _decryptPayload(channel.psk, encrypted); - if (decrypted == null || decrypted.length < 6) return; + // Use cached channels as fallback if live channels not yet loaded + final channelsToSearch = _channels.isNotEmpty + ? _channels + : _cachedChannels; + for (final channel in channelsToSearch) { + if (channel.isEmpty) continue; + final hash = _computeChannelHash(channel.psk); + if (hash != channelHash) continue; + try { + final decryptedBytes = _decryptPayload(channel.psk, encrypted); + if (decryptedBytes == null || decryptedBytes.length < 6) return; + final decrypted = BufferReader(decryptedBytes); - final txtType = decrypted[4]; - if ((txtType >> 2) != 0) { - return; + final timestampRaw = decrypted.readUInt32LE(); + final txtType = decrypted.readByte(); + if ((txtType >> 2) != 0) { + return; + } + + final text = decrypted.readCString(); + final parsed = _splitSenderText(text); + final decodedText = + Smaz.tryDecodePrefixed(parsed.text) ?? parsed.text; + if (_shouldDropSelfChannelMessage( + parsed.senderName, + packet.pathBytes, + )) { + return; + } + + final pktHash = _computePacketHash( + packet.payloadType, + packet.payload, + ); + + final message = ChannelMessage( + senderKey: null, + senderName: parsed.senderName, + text: decodedText, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000), + isOutgoing: false, + status: ChannelMessageStatus.sent, + pathLength: packet.isFlood ? packet.hopCount : 0, + pathBytes: packet.pathBytes, + channelIndex: channel.index, + packetHash: pktHash, + ); + + _updateContactLastMessageAtByName( + parsed.senderName, + message.timestamp, + pathBytes: message.pathBytes, + ); + final isNew = _addChannelMessage(channel.index, message); + _maybeIncrementChannelUnread(message, isNew: isNew); + notifyListeners(); + if (isNew) { + final label = channel.name.isEmpty + ? 'Channel ${channel.index}' + : channel.name; + _maybeNotifyChannelMessage(message, channelName: label); + } + return; + } catch (e) { + appLogger.warn('Decryption failed for channel ${channel.index}: $e'); + } } - - final timestampRaw = readUint32LE(decrypted, 0); - final text = readCString(decrypted, 5, decrypted.length - 5); - final parsed = _splitSenderText(text); - final decodedText = Smaz.tryDecodePrefixed(parsed.text) ?? parsed.text; - if (_shouldDropSelfChannelMessage(parsed.senderName, packet.pathBytes)) { - return; - } - - final pktHash = _computePacketHash(packet.payloadType, packet.payload); - - final message = ChannelMessage( - senderKey: null, - senderName: parsed.senderName, - text: decodedText, - timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000), - isOutgoing: false, - status: ChannelMessageStatus.sent, - pathLength: packet.isFlood ? packet.hopCount : 0, - pathBytes: packet.pathBytes, - channelIndex: channel.index, - packetHash: pktHash, - ); - - _updateContactLastMessageAtByName( - parsed.senderName, - message.timestamp, - pathBytes: message.pathBytes, - ); - final isNew = _addChannelMessage(channel.index, message); - _maybeIncrementChannelUnread(message, isNew: isNew); - notifyListeners(); - if (isNew) { - final label = channel.name.isEmpty - ? 'Channel ${channel.index}' - : channel.name; - _maybeNotifyChannelMessage(message, channelName: label); - } - return; + } catch (e) { + appLogger.warn('Error handling log RX data frame: $e'); } } @@ -3774,15 +3805,15 @@ class MeshCoreConnector extends ChangeNotifier { // [2-5] = expected_ack_hash (uint32) // [6-9] = estimated_timeout_ms (uint32) - if (frame.length >= 10) { - final ackHash = Uint8List.fromList(frame.sublist(2, 6)); - final timeoutMs = readUint32LE(frame, 6); + try { + final reader = BufferReader(frame); + reader.skipBytes(2); //Skip code and is_flood + final ackHash = reader.readUInt32LE(); + final timeoutMs = reader.readUInt32LE(); // Check if this is a CLI command ACK - if so, ignore it if (_lastSentWasCliCommand) { - final ackHashHex = ackHash - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); + final ackHashHex = ackHashToHex(ackHash); debugPrint('Ignoring CLI command ACK (sent): $ackHashHex'); _lastSentWasCliCommand = false; return; @@ -3801,7 +3832,8 @@ class MeshCoreConnector extends ChangeNotifier { if (_markNextPendingChannelMessageSent()) { return; } - } else { + } catch (e) { + appLogger.warn('Error handling message sent frame: $e'); // Fallback to old behavior for (var messages in _conversations.values) { for (int i = messages.length - 1; i >= 0; i--) { @@ -3880,9 +3912,11 @@ class MeshCoreConnector extends ChangeNotifier { // [1-4] = ack_hash (uint32) // [5-8] = trip_time_ms (uint32) - if (frame.length >= 9) { - final ackHash = Uint8List.fromList(frame.sublist(1, 5)); - final tripTimeMs = readUint32LE(frame, 5); + try { + final reader = BufferReader(frame); + reader.skipBytes(1); // Skip code + final ackHash = reader.readUInt32LE(); + final tripTimeMs = reader.readUInt32LE(); // CLI command ACKs are already filtered in _handleMessageSent, so this should only see real messages @@ -3894,7 +3928,8 @@ class MeshCoreConnector extends ChangeNotifier { if (_retryService != null) { _retryService!.handleAckReceived(ackHash, tripTimeMs); } - } else { + } catch (e) { + appLogger.warn('Error handling send confirmed frame: $e'); // Fallback to old behavior for (var messages in _conversations.values) { for (int i = messages.length - 1; i >= 0; i--) { @@ -3909,10 +3944,8 @@ class MeshCoreConnector extends ChangeNotifier { } } - bool _handleRepeaterCommandSent(Uint8List ackHash, int timeoutMs) { - final ackHashHex = ackHash - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); + bool _handleRepeaterCommandSent(int ackHash, int timeoutMs) { + final ackHashHex = ackHashToHex(ackHash); final entry = _pendingRepeaterAcks[ackHashHex]; if (entry == null) return false; @@ -3930,10 +3963,8 @@ class MeshCoreConnector extends ChangeNotifier { return true; } - bool _handleRepeaterCommandAck(Uint8List ackHash, int tripTimeMs) { - final ackHashHex = ackHash - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); + bool _handleRepeaterCommandAck(int ackHash, int tripTimeMs) { + final ackHashHex = ackHashToHex(ackHash); final entry = _pendingRepeaterAcks.remove(ackHashHex); if (entry == null) return false; entry.timeout?.cancel(); @@ -4284,36 +4315,35 @@ class MeshCoreConnector extends ChangeNotifier { } _RawPacket? _parseRawPacket(Uint8List raw) { - if (raw.length < 3) return null; - var index = 0; - final header = raw[index++]; - final routeType = header & _phRouteMask; - final hasTransport = - routeType == _routeTransportFlood || routeType == _routeTransportDirect; - if (hasTransport) { - if (raw.length < index + 4) return null; - index += 4; - } - if (raw.length <= index) return null; - final pathLenRaw = raw[index++]; - final pathByteLen = _decodePathByteLen(pathLenRaw); - if (raw.length < index + pathByteLen) return null; - final pathBytes = Uint8List.fromList( - raw.sublist(index, index + pathByteLen), - ); - index += pathByteLen; - if (raw.length <= index) return null; - final payload = Uint8List.fromList(raw.sublist(index)); + try { + final reader = BufferReader(raw); + final header = reader.readByte(); + final routeType = header & _phRouteMask; + final hasTransport = + routeType == _routeTransportFlood || + routeType == _routeTransportDirect; + if (hasTransport) { + // Skip reserved bytes in transport header made up of two u16 fields + reader.skipBytes(4); + } + final pathLenRaw = reader.readByte(); + final pathByteLen = _decodePathByteLen(pathLenRaw); + final pathBytes = reader.readBytes(pathByteLen); + final payload = reader.readBytes(reader.remaining); - return _RawPacket( - header: header, - routeType: routeType, - payloadType: (header >> _phTypeShift) & _phTypeMask, - payloadVer: (header >> _phVerShift) & _phVerMask, - pathLenRaw: pathLenRaw, - pathBytes: pathBytes, - payload: payload, - ); + return _RawPacket( + header: header, + routeType: routeType, + payloadType: (header >> _phTypeShift) & _phTypeMask, + payloadVer: (header >> _phVerShift) & _phVerMask, + pathLenRaw: pathLenRaw, + pathBytes: pathBytes, + payload: payload, + ); + } catch (e) { + appLogger.warn('Error parsing raw packet: $e'); + return null; + } } int _computeChannelHash(Uint8List psk) { @@ -4732,7 +4762,7 @@ class MeshCoreConnector extends ChangeNotifier { void _handleCustomVars(Uint8List frame) { final buf = BufferReader(frame.sublist(1)); try { - _currentCustomVars = _parseKeyValueString(buf.readString()); + _currentCustomVars = _parseKeyValueString(buf.readCString()); } catch (e) { appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector'); } @@ -4889,7 +4919,7 @@ class MeshCoreConnector extends ChangeNotifier { longitude = packet.readInt32LE() / 1e6; } if (hasName && packet.remaining > 0) { - name = packet.readString(); + name = packet.readCString(); } } catch (e) { appLogger.warn('Malformed advert frame: $e', tag: 'Connector'); @@ -4951,7 +4981,7 @@ class MeshCoreConnector extends ChangeNotifier { longitude = advert.readInt32LE() / 1e6; } if (hasName && advert.remaining > 0) { - name = advert.readString(); + name = advert.readCString(); } } catch (e) { appLogger.warn('Malformed advert frame: $e', tag: 'Connector'); diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 01b41d4b..b3687563 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:flutter/widgets.dart'; + // Buffer Reader - sequential binary data reader with pointer tracking class BufferReader { int _pointer = 0; @@ -37,16 +39,6 @@ class BufferReader { Uint8List readRemainingBytes() => readBytes(remaining); - String readString() { - _lastPointer = _pointer; - final value = readRemainingBytes(); - try { - return utf8.decode(Uint8List.fromList(value), allowMalformed: true); - } catch (e) { - return String.fromCharCodes(value); // Latin-1 fallback - } - } - String readCStringGreedy(int maxLength) { _lastPointer = _pointer; final value = []; @@ -62,11 +54,12 @@ class BufferReader { } } - String readCString(int maxLength) { + String readCString({int maxLength = -1}) { final backupPointer = _pointer; final value = []; int counter = 0; - while (counter < maxLength) { + final maxLen = maxLength >= 0 ? maxLength : remaining; + while (counter < maxLen) { final byte = readByte(); if (byte == 0) break; value.add(byte); @@ -220,6 +213,7 @@ const int cmdGetAutoAddConfig = 59; // Text message types const int txtTypePlain = 0; const int txtTypeCliData = 1; +const int txtTypeSigned = 2; // Repeater request types (for server requests) const int reqTypeGetStatus = 0x01; @@ -314,6 +308,7 @@ const int autoAddSensorFlag = // Sizes const int pubKeySize = 32; +const int signatureSize = 64; const int maxPathSize = 64; const int pathHashSize = 1; const int maxNameSize = 32; @@ -377,52 +372,44 @@ const int msgTextOffset = 38; class ParsedContactText { final Uint8List senderPrefix; final String text; - const ParsedContactText({required this.senderPrefix, required this.text}); } ParsedContactText? parseContactMessageText(Uint8List frame) { if (frame.isEmpty) return null; - final code = frame[0]; - if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) { + + final message = BufferReader(frame); + try { + final code = message.readByte(); + if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) { + return null; + } + + // Companion radio layout: + // [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...] + if (code == respCodeContactMsgRecvV3) { + // Skip SNR and reserved bytes in v3 layout + message.skipBytes(3); + } + final senderPrefix = message.readBytes(6); // public key + message.skipBytes(1); // path length + final textType = message.readByte(); + message.skipBytes(4); // timestamp (4 bytes) + + final shiftedType = textType >> 2; + final isSigned = shiftedType == txtTypeSigned || textType == txtTypeSigned; + if (isSigned) { + // Signed messages have a 4-byte signature after the timestamp, before the text + message.skipBytes(4); + } + final text = message.readCString(); + if (text.isEmpty) return null; + + return ParsedContactText(senderPrefix: senderPrefix, text: text); + } catch (e) { + debugPrint('Error parsing contact message text: $e'); return null; } - - // Companion radio layout: - // [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...] - final isV3 = code == respCodeContactMsgRecvV3; - final prefixOffset = isV3 ? 4 : 1; - const prefixLen = 6; - final txtTypeOffset = prefixOffset + prefixLen + 1; - final timestampOffset = txtTypeOffset + 1; - final baseTextOffset = timestampOffset + 4; - if (frame.length <= baseTextOffset) return null; - - final flags = frame[txtTypeOffset]; - final shiftedType = flags >> 2; - final rawType = flags; - final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain; - final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData; - if (!isPlain && !isCli) { - return null; - } - - var text = readCString( - frame, - baseTextOffset, - frame.length - baseTextOffset, - ).trim(); - if (text.isEmpty && frame.length > baseTextOffset + 4) { - text = readCString( - frame, - baseTextOffset + 4, - frame.length - (baseTextOffset + 4), - ).trim(); - } - if (text.isEmpty) return null; - - final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen); - return ParsedContactText(senderPrefix: senderPrefix, text: text); } // Helper to read uint32 little-endian @@ -445,18 +432,9 @@ int readInt32LE(Uint8List data, int offset) { return val; } -// Helper to read null-terminated UTF-8 string -String readCString(Uint8List data, int offset, int maxLen) { - int end = offset; - while (end < offset + maxLen && end < data.length && data[end] != 0) { - end++; - } - try { - return utf8.decode(data.sublist(offset, end), allowMalformed: true); - } catch (e) { - // Fallback to Latin-1 if UTF-8 decoding fails - return String.fromCharCodes(data.sublist(offset, end)); - } +// Helper to convert uint32 to hex string +String ackHashToHex(int ackHash) { + return ackHash.toRadixString(16).padLeft(8, '0'); } // Helper to convert public key to hex string diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 13788b8f..545ec9dc 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1892,7 +1892,7 @@ "map_setAsMyLocation": "Задайте като моя местоположение", "@path_routeWeight": { "placeholders": { - "value": { + "weight": { "type": "String" }, "max": { diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 1a2ecdc9..4fdd6270 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -24,20 +24,23 @@ class Channel { bool get isPublicChannel => pskHex == publicChannelPsk; - static Channel? fromFrame(Uint8List data) { + static Channel? fromFrame(Uint8List frame) { // CHANNEL_INFO format: // [0] = RESP_CODE_CHANNEL_INFO (18) // [1] = channel_idx // [2-33] = name (32 bytes, null-terminated) // [34-49] = psk (16 bytes) - if (data.length < 50) return null; - if (data[0] != respCodeChannelInfo) return null; - - final index = data[1]; - final name = readCString(data, 2, 32); - final psk = Uint8List.fromList(data.sublist(34, 50)); - - return Channel(index: index, name: name, psk: psk); + if (frame.length < 50) return null; + final reader = BufferReader(frame); + try { + if (reader.readByte() != respCodeChannelInfo) return null; + final index = reader.readByte(); + final name = reader.readCStringGreedy(32); + final psk = reader.readBytes(16); + return Channel(index: index, name: name, psk: psk); + } catch (e) { + return null; + } } static Channel empty(int index) { diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index b0af3ebc..7c090896 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import '../connector/meshcore_protocol.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; +import '../utils/app_logger.dart'; enum ChannelMessageStatus { pending, sent, failed } @@ -109,89 +110,82 @@ class ChannelMessage { ); } - static ChannelMessage? fromFrame(Uint8List data) { + static ChannelMessage? fromFrame(Uint8List frame) { // CHANNEL_MSG_RECV format varies by version: // V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...] // Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text - if (data.length < 8) return null; + if (frame.length < 8) return null; + try { + final reader = BufferReader(frame); + final code = reader.readByte(); + if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) { + return null; + } - final code = data[0]; - if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) { + int pathLen; + int txtType; + Uint8List pathBytes = Uint8List(0); + int channelIdx; + if (code == respCodeChannelMsgRecvV3) { + reader.skipBytes(1); // Skip SNR + final flags = reader.readByte(); + final hasPath = (flags & 0x01) != 0; + reader.skipBytes(1); // Skip reserved byte + channelIdx = reader.readByte(); + pathLen = reader.readInt8(); + txtType = reader.readByte(); + if (hasPath && pathLen > 0) { + reader.rewind(); // Rewind to read path length again for pathBytes + pathBytes = reader.readBytes(pathLen); + } + } else { + channelIdx = reader.readByte(); + pathLen = reader.readInt8(); + txtType = reader.readByte(); + } + final timestampRaw = reader.readUInt32LE(); + + if (txtType != txtTypePlain) { + return null; + } + + final text = reader.readCString(); + + // Extract sender name and actual message from "name: msg" format + String senderName = 'Unknown'; + String actualText = text; + + final colonIndex = text.indexOf(':'); + if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) { + final potentialSender = text.substring(0, colonIndex); + if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) { + senderName = potentialSender; + final offset = + (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ') + ? colonIndex + 2 + : colonIndex + 1; + actualText = text.substring(offset); + } + } + + final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText; + + return ChannelMessage( + senderKey: null, + senderName: senderName, + text: decodedText, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000), + isOutgoing: false, + status: ChannelMessageStatus.sent, + pathLength: pathLen, + pathBytes: pathBytes, + channelIndex: channelIdx, + ); + } catch (e) { + appLogger.error('Error parsing channel message frame: $e'); + // If parsing fails, return null to avoid crashes return null; } - - int timestampOffset, textOffset, pathLenOffset, txtTypeOffset; - Uint8List pathBytes = Uint8List(0); - int channelIdx; - - if (code == respCodeChannelMsgRecvV3) { - channelIdx = data[4]; - pathLenOffset = 5; - final pathLen = data[pathLenOffset].toSigned(8); - var cursor = 6; - final hasPathBytesFlag = (data[2] & 0x01) != 0; - final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5; - final hasValidTxtType = - cursor < data.length && - (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData); - if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && - canFitPath) { - pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen)); - cursor += pathLen; - } - txtTypeOffset = cursor; - cursor += 1; // txt_type - timestampOffset = cursor; - textOffset = cursor + 4; - } else { - channelIdx = data[1]; - pathLenOffset = 2; - txtTypeOffset = 3; - timestampOffset = 4; - textOffset = 8; - } - - if (data.length < textOffset + 1) return null; - - final txtType = data[txtTypeOffset]; - if (txtType != txtTypePlain) { - return null; - } - - final pathLen = data[pathLenOffset].toSigned(8); - final timestampRaw = readUint32LE(data, timestampOffset); - final text = readCString(data, textOffset, data.length - textOffset); - - // Extract sender name and actual message from "name: msg" format - String senderName = 'Unknown'; - String actualText = text; - - final colonIndex = text.indexOf(':'); - if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) { - final potentialSender = text.substring(0, colonIndex); - if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) { - senderName = potentialSender; - final offset = - (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ') - ? colonIndex + 2 - : colonIndex + 1; - actualText = text.substring(offset); - } - } - - final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText; - - return ChannelMessage( - senderKey: null, - senderName: senderName, - text: decodedText, - timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000), - isOutgoing: false, - status: ChannelMessageStatus.sent, - pathLength: pathLen, - pathBytes: pathBytes, - channelIndex: channelIdx, - ); } static ChannelMessage outgoing( diff --git a/lib/models/contact.dart b/lib/models/contact.dart index acd1da9a..5c80893a 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -18,6 +18,7 @@ class Contact { final DateTime lastSeen; final DateTime lastMessageAt; final bool isActive; + final bool wasPulled; final Uint8List? rawPacket; Contact({ @@ -34,6 +35,7 @@ class Contact { required this.lastSeen, DateTime? lastMessageAt, this.isActive = true, + this.wasPulled = false, this.rawPacket, }) : lastMessageAt = lastMessageAt ?? lastSeen; diff --git a/lib/models/message.dart b/lib/models/message.dart index 6f6ed886..6b930c08 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -16,7 +16,7 @@ class Message { final String? messageId; final int retryCount; final int? estimatedTimeoutMs; - final Uint8List? expectedAckHash; + final int? expectedAckHash; final DateTime? sentAt; final DateTime? deliveredAt; final int? tripTimeMs; @@ -56,7 +56,7 @@ class Message { MessageStatus? status, int? retryCount, int? estimatedTimeoutMs, - Uint8List? expectedAckHash, + int? expectedAckHash, DateTime? sentAt, DateTime? deliveredAt, int? tripTimeMs, @@ -90,33 +90,35 @@ class Message { ); } - static Message? fromFrame(Uint8List data, Uint8List selfPubKey) { - if (data.length < msgTextOffset + 1) return null; + static Message? fromFrame(Uint8List frame, Uint8List selfPubKey) { + if (frame.length < msgTextOffset + 1) return null; + final reader = BufferReader(frame); + try { + final code = reader.readByte(); + if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) { + return null; + } - final code = data[0]; - if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) { + final senderKey = reader.readBytes(pubKeySize); + final timestampRaw = reader.readInt32LE(); + final flags = reader.readByte(); + if ((flags >> 2) != txtTypePlain) { + return null; + } + final text = reader.readCString(); + + return Message( + senderKey: senderKey, + text: text, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000), + isOutgoing: false, + isCli: false, + status: MessageStatus.delivered, + pathBytes: Uint8List(0), + ); + } catch (e) { return null; } - - final senderKey = Uint8List.fromList( - data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize), - ); - final timestampRaw = readUint32LE(data, msgTimestampOffset); - final flags = data[msgFlagsOffset]; - if ((flags >> 2) != txtTypePlain) { - return null; - } - final text = readCString(data, msgTextOffset, data.length - msgTextOffset); - - return Message( - senderKey: senderKey, - text: text, - timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000), - isOutgoing: false, - isCli: false, - status: MessageStatus.delivered, - pathBytes: Uint8List(0), - ); } static Message outgoing( diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index a90f9f0d..1009bc40 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -283,66 +283,66 @@ class _BleDebugLogScreenState extends State { if (payload.length < 101) { return 'ADVERT (short)'; } - var offset = 0; - final pubKey = _bytesToHex( - payload.sublist(offset, offset + 32), - spaced: false, - ); - offset += 32; - final timestamp = readUint32LE(payload, offset); - offset += 4; - offset += 64; // signature - final flags = payload[offset++]; - final role = _deviceRoleLabel(flags & 0x0F); - final hasLocation = (flags & 0x10) != 0; - final hasFeature1 = (flags & 0x20) != 0; - final hasFeature2 = (flags & 0x40) != 0; - final hasName = (flags & 0x80) != 0; - String? name; - double? lat; - double? lon; - if (hasLocation && payload.length >= offset + 8) { - lat = readInt32LE(payload, offset) / 1000000.0; - lon = readInt32LE(payload, offset + 4) / 1000000.0; - offset += 8; + final reader = BufferReader(payload); + try { + final pubKey = _bytesToHex(reader.readBytes(pubKeySize), spaced: false); + + final timestamp = reader.readUInt32LE(); + reader.skipBytes(signatureSize); + final flags = reader.readByte(); + final role = _deviceRoleLabel(flags & 0x0F); + final hasLocation = (flags & 0x10) != 0; + final hasFeature1 = (flags & 0x20) != 0; + final hasFeature2 = (flags & 0x40) != 0; + final hasName = (flags & 0x80) != 0; + String? name; + double? lat; + double? lon; + if (hasLocation) { + lat = reader.readInt32LE() / 1000000.0; + lon = reader.readInt32LE() / 1000000.0; + } + if (hasFeature1) reader.skipBytes(2); + if (hasFeature2) reader.skipBytes(2); + if (hasName) { + name = reader.readCStringGreedy(maxNameSize); + } + final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : ''; + final locPart = (lat != null && lon != null) + ? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}' + : ''; + return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}…'; + } catch (e) { + return 'ADVERT (invalid)'; } - if (hasFeature1) offset += 2; - if (hasFeature2) offset += 2; - if (hasName && payload.length > offset) { - final rawName = String.fromCharCodes(payload.sublist(offset)); - final nul = rawName.indexOf('\u0000'); - name = nul >= 0 ? rawName.substring(0, nul) : rawName; - name = name.trim(); - } - final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : ''; - final locPart = (lat != null && lon != null) - ? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}' - : ''; - return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}…'; } String _decodeControlSummary(Uint8List payload) { - if (payload.isEmpty) return 'CONTROL (empty)'; - final flags = payload[0]; - final subType = flags & 0xF0; - if (subType == 0x80) { - if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)'; - final typeFilter = payload[1]; - final tag = readUint32LE(payload, 2); - final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0; - return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since'; + final reader = BufferReader(payload); + try { + final flags = reader.readByte(); + final subType = flags & 0xF0; + if (subType == 0x80) { + if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)'; + final typeFilter = reader.readByte(); + final tag = reader.readInt32LE(); + final since = payload.length >= 10 ? reader.readInt32LE() : 0; + return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since'; + } + if (subType == 0x90) { + if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)'; + final nodeType = flags & 0x0F; + final snrRaw = payload[1]; + final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw; + final snr = snrSigned / 4.0; + final tag = reader.readInt32LE(); + final keyLen = payload.length - 6; + return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen'; + } + return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}'; + } catch (e) { + return 'CONTROL (invalid)'; } - if (subType == 0x90) { - if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)'; - final nodeType = flags & 0x0F; - final snrRaw = payload[1]; - final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw; - final snr = snrSigned / 4.0; - final tag = readUint32LE(payload, 2); - final keyLen = payload.length - 6; - return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen'; - } - return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}'; } String _payloadTypeLabel(int payloadType) { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index cc61143b..46d63522 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -975,7 +975,6 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) { value: advertLocPolicy, onChanged: (value) { setDialogState(() => advertLocPolicy = value); - advertLocPolicy = value; }, ), const SizedBox(height: 8), diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index b2844252..c8e89aa8 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -11,7 +11,7 @@ import 'app_debug_log_service.dart'; class _AckHistoryEntry { final String messageId; - final List ackHashes; + final List ackHashes; final DateTime timestamp; _AckHistoryEntry({ @@ -77,7 +77,7 @@ class MessageRetryService extends ChangeNotifier { final Map _pendingContacts = {}; final Map> _attemptPathHistory = {}; final Map _ackHashToMessageId = {}; - final Map> _expectedAckHashes = {}; + final Map> _expectedAckHashes = {}; final List<_AckHistoryEntry> _ackHistory = []; final Map> _sendQueue = {}; final Set _activeMessages = {}; @@ -98,7 +98,7 @@ class MessageRetryService extends ChangeNotifier { /// Compute expected ACK hash using same algorithm as firmware: /// SHA256([timestamp(4)][attempt(1)][text][sender_pubkey(32)]) -> first 4 bytes - static Uint8List computeExpectedAckHash( + static int computeExpectedAckHash( int timestampSeconds, int attempt, String text, @@ -126,7 +126,8 @@ class MessageRetryService extends ChangeNotifier { // Compute SHA256 and return first 4 bytes final hash = sha256.convert(buffer); - return Uint8List.fromList(hash.bytes.sublist(0, 4)); + final bytes = Uint8List.fromList(hash.bytes.sublist(0, 4)); + return (bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0]; } Future sendMessageWithRetry({ @@ -324,9 +325,7 @@ class MessageRetryService extends ChangeNotifier { outboundText, selfPubKey, ); - final expectedHashHex = expectedHash - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); + final expectedHashHex = expectedHash.toRadixString(16).padLeft(8, '0'); _expectedHashToMessageId[expectedHashHex] = messageId; final shortText = message.text.length > 20 @@ -341,13 +340,11 @@ class MessageRetryService extends ChangeNotifier { config.sendMessage(contact, message.text, attempt, timestampSeconds); } - bool updateMessageFromSent(Uint8List ackHash, int timeoutMs) { + bool updateMessageFromSent(int ackHash, int timeoutMs) { final config = _config; if (config == null) return false; - final ackHashHex = ackHash - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); + final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0'); // Try hash-based matching (fixes LoRa message drops causing mismatches) String? messageId = _expectedHashToMessageId.remove(ackHashHex); @@ -389,10 +386,8 @@ class MessageRetryService extends ChangeNotifier { // Add this ACK hash to the list of expected ACKs for this message (for history) _expectedAckHashes[messageId] ??= []; - if (!_expectedAckHashes[messageId]!.any( - (hash) => listEquals(hash, ackHash), - )) { - _expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash)); + if (!_expectedAckHashes[messageId]!.any((hash) => hash == ackHash)) { + _expectedAckHashes[messageId]!.add(ackHash); } // Calculate timeout: prefer ML prediction, then device-provided, then physics fallback @@ -559,10 +554,10 @@ class MessageRetryService extends ChangeNotifier { } } - bool _checkAckHistory(Uint8List ackHash) { + bool _checkAckHistory(int ackHash) { for (final entry in _ackHistory) { for (final expectedHash in entry.ackHashes) { - if (listEquals(expectedHash, ackHash)) { + if (expectedHash == ackHash) { return true; } } @@ -570,13 +565,11 @@ class MessageRetryService extends ChangeNotifier { return false; } - void handleAckReceived(Uint8List ackHash, int tripTimeMs) { + void handleAckReceived(int ackHash, int tripTimeMs) { final config = _config; String? matchedMessageId; int? matchedAttemptIndex; - final ackHashHex = ackHash - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); + final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0'); // Clean up old ACK hash mappings (older than 15 minutes) final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15)); @@ -606,7 +599,7 @@ class MessageRetryService extends ChangeNotifier { final expectedHashes = entry.value; for (final expectedHash in expectedHashes) { - if (listEquals(expectedHash, ackHash)) { + if (expectedHash == ackHash) { matchedMessageId = messageId; matchedAttemptIndex = expectedHashes.indexOf(expectedHash); break; @@ -685,11 +678,11 @@ class MessageRetryService extends ChangeNotifier { } } - String? getContactKeyForAckHash(Uint8List ackHash) { + String? getContactKeyForAckHash(int ackHash) { for (var entry in _pendingMessages.entries) { final message = entry.value; if (message.expectedAckHash != null && - listEquals(message.expectedAckHash, ackHash)) { + message.expectedAckHash == ackHash) { final contact = _pendingContacts[entry.key]; return contact?.publicKeyHex; } diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 44d3621c..55509113 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -85,9 +85,7 @@ class MessageStore { 'messageId': msg.messageId, 'retryCount': msg.retryCount, 'estimatedTimeoutMs': msg.estimatedTimeoutMs, - 'expectedAckHash': msg.expectedAckHash != null - ? base64Encode(msg.expectedAckHash!) - : null, + 'expectedAckHash': msg.expectedAckHash, 'sentAt': msg.sentAt?.millisecondsSinceEpoch, 'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch, 'tripTimeMs': msg.tripTimeMs, @@ -119,9 +117,7 @@ class MessageStore { messageId: json['messageId'] as String?, retryCount: json['retryCount'] as int? ?? 0, estimatedTimeoutMs: json['estimatedTimeoutMs'] as int?, - expectedAckHash: json['expectedAckHash'] != null - ? Uint8List.fromList(base64Decode(json['expectedAckHash'] as String)) - : null, + expectedAckHash: json['expectedAckHash'] as int? ?? 0, sentAt: json['sentAt'] != null ? DateTime.fromMillisecondsSinceEpoch(json['sentAt'] as int) : null, diff --git a/test/services/retry_and_protocol_test.dart b/test/services/retry_and_protocol_test.dart index b58da450..b6c53b6c 100644 --- a/test/services/retry_and_protocol_test.dart +++ b/test/services/retry_and_protocol_test.dart @@ -14,7 +14,7 @@ import 'package:meshcore_open/services/message_retry_service.dart'; /// Replicates the SHA-256 computation from [MessageRetryService.computeExpectedAckHash] /// so tests can cross-check without calling the real implementation twice. -Uint8List _manualAckHash( +int _manualAckHash( int timestampSeconds, int attemptMasked, // already masked to 0x03 String text, @@ -35,7 +35,8 @@ Uint8List _manualAckHash( buffer.setRange(offset, offset + senderPubKey.length, senderPubKey); final hash = sha256.convert(buffer); - return Uint8List.fromList(hash.bytes.sublist(0, 4)); + final bytes = Uint8List.fromList(hash.bytes.sublist(0, 4)); + return (bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0]; } Uint8List _makeKey(int seed) { @@ -169,16 +170,6 @@ void main() { expect(first, equals(second)); }); - test('hash is exactly 4 bytes long', () { - final hash = MessageRetryService.computeExpectedAckHash( - fixedTs, - 0, - fixedText, - fixedKey, - ); - expect(hash.length, equals(4)); - }); - test('hash matches manual SHA-256 computation', () { for (int attempt = 0; attempt < 4; attempt++) { final actual = MessageRetryService.computeExpectedAckHash( @@ -509,7 +500,7 @@ void main() { fixedText, fixedKey, ); - final hex = hash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final hex = hash.toRadixString(16).padLeft(8, '0'); expect( hashes.containsKey(hex), isFalse,