Merge branch 'main' into dev-mapOverlap

This commit is contained in:
zjs81
2026-03-23 18:49:19 -07:00
committed by GitHub
17 changed files with 990 additions and 898 deletions
+1
View File
@@ -58,6 +58,7 @@ secrets.dart
.DS_Store .DS_Store
.AppleDouble .AppleDouble
.LSOverride .LSOverride
macos/Flutter/GeneratedPluginRegistrant.swift
# iOS # iOS
**/ios/Pods/ **/ios/Pods/
+89 -59
View File
@@ -257,6 +257,9 @@ class MeshCoreConnector extends ChangeNotifier {
int? _activeChannelIndex; int? _activeChannelIndex;
List<int> _channelOrder = []; List<int> _channelOrder = [];
int _storageUsedKb = -1;
int _storageTotalKb = -1;
// Getters // Getters
MeshCoreConnectionState get state => _state; MeshCoreConnectionState get state => _state;
BluetoothDevice? get device => _device; BluetoothDevice? get device => _device;
@@ -338,6 +341,8 @@ class MeshCoreConnector extends ChangeNotifier {
int? get firmwareVerCode => _firmwareVerCode; int? get firmwareVerCode => _firmwareVerCode;
Map<String, String>? get currentCustomVars => _currentCustomVars; Map<String, String>? get currentCustomVars => _currentCustomVars;
int? get batteryMillivolts => _batteryMillivolts; int? get batteryMillivolts => _batteryMillivolts;
int? get storageUsedKb => _storageUsedKb;
int? get storageTotalKb => _storageTotalKb;
int get maxContacts => _maxContacts; int get maxContacts => _maxContacts;
int get maxChannels => _maxChannels; int get maxChannels => _maxChannels;
Set<String> get knownContactKeys => Set.unmodifiable(_knownContactKeys); Set<String> get knownContactKeys => Set.unmodifiable(_knownContactKeys);
@@ -2122,9 +2127,7 @@ class MeshCoreConnector extends ChangeNotifier {
outboundText, outboundText,
selfKey, selfKey,
); );
final ackHashHex = ackHash final ackHashHex = ackHashToHex(ackHash);
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final messageBytes = utf8.encode(outboundText).length; final messageBytes = utf8.encode(outboundText).length;
_pendingRepeaterAcks[ackHashHex]?.timeout?.cancel(); _pendingRepeaterAcks[ackHashHex]?.timeout?.cancel();
_pendingRepeaterAcks[ackHashHex] = _RepeaterAckContext( _pendingRepeaterAcks[ackHashHex] = _RepeaterAckContext(
@@ -2896,7 +2899,7 @@ class MeshCoreConnector extends ChangeNotifier {
_currentSf = reader.readByte(); _currentSf = reader.readByte();
_currentCr = reader.readByte(); _currentCr = reader.readByte();
_selfName = reader.readString(); _selfName = reader.readCString();
} catch (e) { } catch (e) {
_appDebugLogService?.error( _appDebugLogService?.error(
'Error parsing SELF_INFO frame: $e', 'Error parsing SELF_INFO frame: $e',
@@ -3037,14 +3040,23 @@ class MeshCoreConnector extends ChangeNotifier {
// [1-2] = battery_mv (uint16 LE) // [1-2] = battery_mv (uint16 LE)
// [3-6] = storage_used_kb (uint32 LE) // [3-6] = storage_used_kb (uint32 LE)
// [7-10] = storage_total_kb (uint32 LE) // [7-10] = storage_total_kb (uint32 LE)
if (frame.length >= 3) { try {
_batteryMillivolts = readUint16LE(frame, 1); final reader = BufferReader(frame);
reader.skipBytes(1);
_batteryMillivolts = reader.readUInt16LE();
_storageUsedKb = reader.readUInt32LE();
_storageTotalKb = reader.readUInt32LE();
final volts = (_batteryMillivolts! / 1000.0).toStringAsFixed(2); final volts = (_batteryMillivolts! / 1000.0).toStringAsFixed(2);
_appDebugLogService?.info( _appDebugLogService?.info(
'Pulled battery: $volts V ($_batteryMillivolts mV)', 'Pulled battery: $volts V ($_batteryMillivolts mV)',
tag: 'Battery', tag: 'Battery',
); );
notifyListeners(); 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 reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants
} }
final msgText = reader.readString(); final msgText = reader.readCString();
final flags = txtType; final flags = txtType;
final shiftedType = flags >> 2; final shiftedType = flags >> 2;
@@ -3702,39 +3714,52 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleLogRxData(Uint8List frame) { void _handleLogRxData(Uint8List frame) {
if (frame.length < 4) return; if (frame.length < 4) return;
final raw = Uint8List.fromList(frame.sublist(3)); try {
final reader = BufferReader(frame);
reader.skipBytes(3); // Skip header
final raw = reader.readRemainingBytes();
final packet = _parseRawPacket(raw); final packet = _parseRawPacket(raw);
if (packet == null || packet.payloadType != _payloadTypeGroupText) return; if (packet == null || packet.payloadType != _payloadTypeGroupText) return;
final payload = packet.payload; final payload = BufferReader(packet.payload);
if (payload.length <= _cipherMacSize) return; final channelHash = payload.readByte();
final channelHash = payload[0]; final encrypted = Uint8List.fromList(payload.readRemainingBytes());
final encrypted = Uint8List.fromList(payload.sublist(1));
// Use cached channels as fallback if live channels not yet loaded // Use cached channels as fallback if live channels not yet loaded
final channelsToSearch = _channels.isNotEmpty ? _channels : _cachedChannels; final channelsToSearch = _channels.isNotEmpty
? _channels
: _cachedChannels;
for (final channel in channelsToSearch) { for (final channel in channelsToSearch) {
if (channel.isEmpty) continue; if (channel.isEmpty) continue;
final hash = _computeChannelHash(channel.psk); final hash = _computeChannelHash(channel.psk);
if (hash != channelHash) continue; if (hash != channelHash) continue;
try {
final decryptedBytes = _decryptPayload(channel.psk, encrypted);
if (decryptedBytes == null || decryptedBytes.length < 6) return;
final decrypted = BufferReader(decryptedBytes);
final decrypted = _decryptPayload(channel.psk, encrypted); final timestampRaw = decrypted.readUInt32LE();
if (decrypted == null || decrypted.length < 6) return; final txtType = decrypted.readByte();
final txtType = decrypted[4];
if ((txtType >> 2) != 0) { if ((txtType >> 2) != 0) {
return; return;
} }
final timestampRaw = readUint32LE(decrypted, 0); final text = decrypted.readCString();
final text = readCString(decrypted, 5, decrypted.length - 5);
final parsed = _splitSenderText(text); final parsed = _splitSenderText(text);
final decodedText = Smaz.tryDecodePrefixed(parsed.text) ?? parsed.text; final decodedText =
if (_shouldDropSelfChannelMessage(parsed.senderName, packet.pathBytes)) { Smaz.tryDecodePrefixed(parsed.text) ?? parsed.text;
if (_shouldDropSelfChannelMessage(
parsed.senderName,
packet.pathBytes,
)) {
return; return;
} }
final pktHash = _computePacketHash(packet.payloadType, packet.payload); final pktHash = _computePacketHash(
packet.payloadType,
packet.payload,
);
final message = ChannelMessage( final message = ChannelMessage(
senderKey: null, senderKey: null,
@@ -3764,6 +3789,12 @@ class MeshCoreConnector extends ChangeNotifier {
_maybeNotifyChannelMessage(message, channelName: label); _maybeNotifyChannelMessage(message, channelName: label);
} }
return; return;
} catch (e) {
appLogger.warn('Decryption failed for channel ${channel.index}: $e');
}
}
} 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) // [2-5] = expected_ack_hash (uint32)
// [6-9] = estimated_timeout_ms (uint32) // [6-9] = estimated_timeout_ms (uint32)
if (frame.length >= 10) { try {
final ackHash = Uint8List.fromList(frame.sublist(2, 6)); final reader = BufferReader(frame);
final timeoutMs = readUint32LE(frame, 6); 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 // Check if this is a CLI command ACK - if so, ignore it
if (_lastSentWasCliCommand) { if (_lastSentWasCliCommand) {
final ackHashHex = ackHash final ackHashHex = ackHashToHex(ackHash);
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
debugPrint('Ignoring CLI command ACK (sent): $ackHashHex'); debugPrint('Ignoring CLI command ACK (sent): $ackHashHex');
_lastSentWasCliCommand = false; _lastSentWasCliCommand = false;
return; return;
@@ -3801,7 +3832,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (_markNextPendingChannelMessageSent()) { if (_markNextPendingChannelMessageSent()) {
return; return;
} }
} else { } catch (e) {
appLogger.warn('Error handling message sent frame: $e');
// Fallback to old behavior // Fallback to old behavior
for (var messages in _conversations.values) { for (var messages in _conversations.values) {
for (int i = messages.length - 1; i >= 0; i--) { for (int i = messages.length - 1; i >= 0; i--) {
@@ -3880,9 +3912,11 @@ class MeshCoreConnector extends ChangeNotifier {
// [1-4] = ack_hash (uint32) // [1-4] = ack_hash (uint32)
// [5-8] = trip_time_ms (uint32) // [5-8] = trip_time_ms (uint32)
if (frame.length >= 9) { try {
final ackHash = Uint8List.fromList(frame.sublist(1, 5)); final reader = BufferReader(frame);
final tripTimeMs = readUint32LE(frame, 5); 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 // 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) { if (_retryService != null) {
_retryService!.handleAckReceived(ackHash, tripTimeMs); _retryService!.handleAckReceived(ackHash, tripTimeMs);
} }
} else { } catch (e) {
appLogger.warn('Error handling send confirmed frame: $e');
// Fallback to old behavior // Fallback to old behavior
for (var messages in _conversations.values) { for (var messages in _conversations.values) {
for (int i = messages.length - 1; i >= 0; i--) { for (int i = messages.length - 1; i >= 0; i--) {
@@ -3909,10 +3944,8 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
bool _handleRepeaterCommandSent(Uint8List ackHash, int timeoutMs) { bool _handleRepeaterCommandSent(int ackHash, int timeoutMs) {
final ackHashHex = ackHash final ackHashHex = ackHashToHex(ackHash);
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final entry = _pendingRepeaterAcks[ackHashHex]; final entry = _pendingRepeaterAcks[ackHashHex];
if (entry == null) return false; if (entry == null) return false;
@@ -3930,10 +3963,8 @@ class MeshCoreConnector extends ChangeNotifier {
return true; return true;
} }
bool _handleRepeaterCommandAck(Uint8List ackHash, int tripTimeMs) { bool _handleRepeaterCommandAck(int ackHash, int tripTimeMs) {
final ackHashHex = ackHash final ackHashHex = ackHashToHex(ackHash);
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final entry = _pendingRepeaterAcks.remove(ackHashHex); final entry = _pendingRepeaterAcks.remove(ackHashHex);
if (entry == null) return false; if (entry == null) return false;
entry.timeout?.cancel(); entry.timeout?.cancel();
@@ -4284,26 +4315,21 @@ class MeshCoreConnector extends ChangeNotifier {
} }
_RawPacket? _parseRawPacket(Uint8List raw) { _RawPacket? _parseRawPacket(Uint8List raw) {
if (raw.length < 3) return null; try {
var index = 0; final reader = BufferReader(raw);
final header = raw[index++]; final header = reader.readByte();
final routeType = header & _phRouteMask; final routeType = header & _phRouteMask;
final hasTransport = final hasTransport =
routeType == _routeTransportFlood || routeType == _routeTransportDirect; routeType == _routeTransportFlood ||
routeType == _routeTransportDirect;
if (hasTransport) { if (hasTransport) {
if (raw.length < index + 4) return null; // Skip reserved bytes in transport header made up of two u16 fields
index += 4; reader.skipBytes(4);
} }
if (raw.length <= index) return null; final pathLenRaw = reader.readByte();
final pathLenRaw = raw[index++];
final pathByteLen = _decodePathByteLen(pathLenRaw); final pathByteLen = _decodePathByteLen(pathLenRaw);
if (raw.length < index + pathByteLen) return null; final pathBytes = reader.readBytes(pathByteLen);
final pathBytes = Uint8List.fromList( final payload = reader.readBytes(reader.remaining);
raw.sublist(index, index + pathByteLen),
);
index += pathByteLen;
if (raw.length <= index) return null;
final payload = Uint8List.fromList(raw.sublist(index));
return _RawPacket( return _RawPacket(
header: header, header: header,
@@ -4314,6 +4340,10 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: pathBytes, pathBytes: pathBytes,
payload: payload, payload: payload,
); );
} catch (e) {
appLogger.warn('Error parsing raw packet: $e');
return null;
}
} }
int _computeChannelHash(Uint8List psk) { int _computeChannelHash(Uint8List psk) {
@@ -4732,7 +4762,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleCustomVars(Uint8List frame) { void _handleCustomVars(Uint8List frame) {
final buf = BufferReader(frame.sublist(1)); final buf = BufferReader(frame.sublist(1));
try { try {
_currentCustomVars = _parseKeyValueString(buf.readString()); _currentCustomVars = _parseKeyValueString(buf.readCString());
} catch (e) { } catch (e) {
appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector'); appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector');
} }
@@ -4889,7 +4919,7 @@ class MeshCoreConnector extends ChangeNotifier {
longitude = packet.readInt32LE() / 1e6; longitude = packet.readInt32LE() / 1e6;
} }
if (hasName && packet.remaining > 0) { if (hasName && packet.remaining > 0) {
name = packet.readString(); name = packet.readCString();
} }
} catch (e) { } catch (e) {
appLogger.warn('Malformed advert frame: $e', tag: 'Connector'); appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
@@ -4965,7 +4995,7 @@ class MeshCoreConnector extends ChangeNotifier {
hasLocation = hasValidLocation(latitude, longitude); hasLocation = hasValidLocation(latitude, longitude);
if (hasName && advert.remaining > 0) { if (hasName && advert.remaining > 0) {
name = advert.readString(); name = advert.readCString();
} }
} catch (e) { } catch (e) {
appLogger.warn('Malformed advert frame: $e', tag: 'Connector'); appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
+31 -53
View File
@@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/widgets.dart';
// Buffer Reader - sequential binary data reader with pointer tracking // Buffer Reader - sequential binary data reader with pointer tracking
class BufferReader { class BufferReader {
int _pointer = 0; int _pointer = 0;
@@ -37,16 +39,6 @@ class BufferReader {
Uint8List readRemainingBytes() => readBytes(remaining); 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) { String readCStringGreedy(int maxLength) {
_lastPointer = _pointer; _lastPointer = _pointer;
final value = <int>[]; final value = <int>[];
@@ -62,11 +54,12 @@ class BufferReader {
} }
} }
String readCString(int maxLength) { String readCString({int maxLength = -1}) {
final backupPointer = _pointer; final backupPointer = _pointer;
final value = <int>[]; final value = <int>[];
int counter = 0; int counter = 0;
while (counter < maxLength) { final maxLen = maxLength >= 0 ? maxLength : remaining;
while (counter < maxLen) {
final byte = readByte(); final byte = readByte();
if (byte == 0) break; if (byte == 0) break;
value.add(byte); value.add(byte);
@@ -220,6 +213,7 @@ const int cmdGetAutoAddConfig = 59;
// Text message types // Text message types
const int txtTypePlain = 0; const int txtTypePlain = 0;
const int txtTypeCliData = 1; const int txtTypeCliData = 1;
const int txtTypeSigned = 2;
// Repeater request types (for server requests) // Repeater request types (for server requests)
const int reqTypeGetStatus = 0x01; const int reqTypeGetStatus = 0x01;
@@ -314,6 +308,7 @@ const int autoAddSensorFlag =
// Sizes // Sizes
const int pubKeySize = 32; const int pubKeySize = 32;
const int signatureSize = 64;
const int maxPathSize = 64; const int maxPathSize = 64;
const int pathHashSize = 1; const int pathHashSize = 1;
const int maxNameSize = 32; const int maxNameSize = 32;
@@ -377,52 +372,44 @@ const int msgTextOffset = 38;
class ParsedContactText { class ParsedContactText {
final Uint8List senderPrefix; final Uint8List senderPrefix;
final String text; final String text;
const ParsedContactText({required this.senderPrefix, required this.text}); const ParsedContactText({required this.senderPrefix, required this.text});
} }
ParsedContactText? parseContactMessageText(Uint8List frame) { ParsedContactText? parseContactMessageText(Uint8List frame) {
if (frame.isEmpty) return null; if (frame.isEmpty) return null;
final code = frame[0];
final message = BufferReader(frame);
try {
final code = message.readByte();
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) { if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null; return null;
} }
// Companion radio layout: // Companion radio layout:
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...] // [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
final isV3 = code == respCodeContactMsgRecvV3; if (code == respCodeContactMsgRecvV3) {
final prefixOffset = isV3 ? 4 : 1; // Skip SNR and reserved bytes in v3 layout
const prefixLen = 6; message.skipBytes(3);
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;
} }
final senderPrefix = message.readBytes(6); // public key
message.skipBytes(1); // path length
final textType = message.readByte();
message.skipBytes(4); // timestamp (4 bytes)
var text = readCString( final shiftedType = textType >> 2;
frame, final isSigned = shiftedType == txtTypeSigned || textType == txtTypeSigned;
baseTextOffset, if (isSigned) {
frame.length - baseTextOffset, // Signed messages have a 4-byte signature after the timestamp, before the text
).trim(); message.skipBytes(4);
if (text.isEmpty && frame.length > baseTextOffset + 4) {
text = readCString(
frame,
baseTextOffset + 4,
frame.length - (baseTextOffset + 4),
).trim();
} }
final text = message.readCString();
if (text.isEmpty) return null; if (text.isEmpty) return null;
final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
return ParsedContactText(senderPrefix: senderPrefix, text: text); return ParsedContactText(senderPrefix: senderPrefix, text: text);
} catch (e) {
debugPrint('Error parsing contact message text: $e');
return null;
}
} }
// Helper to read uint32 little-endian // Helper to read uint32 little-endian
@@ -445,18 +432,9 @@ int readInt32LE(Uint8List data, int offset) {
return val; return val;
} }
// Helper to read null-terminated UTF-8 string // Helper to convert uint32 to hex string
String readCString(Uint8List data, int offset, int maxLen) { String ackHashToHex(int ackHash) {
int end = offset; return ackHash.toRadixString(16).padLeft(8, '0');
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 public key to hex string // Helper to convert public key to hex string
+1 -1
View File
@@ -1892,7 +1892,7 @@
"map_setAsMyLocation": "Задайте като моя местоположение", "map_setAsMyLocation": "Задайте като моя местоположение",
"@path_routeWeight": { "@path_routeWeight": {
"placeholders": { "placeholders": {
"value": { "weight": {
"type": "String" "type": "String"
}, },
"max": { "max": {
File diff suppressed because it is too large Load Diff
+262 -224
View File
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -24,20 +24,23 @@ class Channel {
bool get isPublicChannel => pskHex == publicChannelPsk; bool get isPublicChannel => pskHex == publicChannelPsk;
static Channel? fromFrame(Uint8List data) { static Channel? fromFrame(Uint8List frame) {
// CHANNEL_INFO format: // CHANNEL_INFO format:
// [0] = RESP_CODE_CHANNEL_INFO (18) // [0] = RESP_CODE_CHANNEL_INFO (18)
// [1] = channel_idx // [1] = channel_idx
// [2-33] = name (32 bytes, null-terminated) // [2-33] = name (32 bytes, null-terminated)
// [34-49] = psk (16 bytes) // [34-49] = psk (16 bytes)
if (data.length < 50) return null; if (frame.length < 50) return null;
if (data[0] != respCodeChannelInfo) return null; final reader = BufferReader(frame);
try {
final index = data[1]; if (reader.readByte() != respCodeChannelInfo) return null;
final name = readCString(data, 2, 32); final index = reader.readByte();
final psk = Uint8List.fromList(data.sublist(34, 50)); final name = reader.readCStringGreedy(32);
final psk = reader.readBytes(16);
return Channel(index: index, name: name, psk: psk); return Channel(index: index, name: name, psk: psk);
} catch (e) {
return null;
}
} }
static Channel empty(int index) { static Channel empty(int index) {
+28 -34
View File
@@ -2,6 +2,7 @@ import 'dart:typed_data';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/reaction_helper.dart'; import '../helpers/reaction_helper.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import '../utils/app_logger.dart';
enum ChannelMessageStatus { pending, sent, failed } enum ChannelMessageStatus { pending, sent, failed }
@@ -109,58 +110,46 @@ class ChannelMessage {
); );
} }
static ChannelMessage? fromFrame(Uint8List data) { static ChannelMessage? fromFrame(Uint8List frame) {
// CHANNEL_MSG_RECV format varies by version: // 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...] // 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 // 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 code = data[0]; final reader = BufferReader(frame);
final code = reader.readByte();
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) { if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
return null; return null;
} }
int timestampOffset, textOffset, pathLenOffset, txtTypeOffset; int pathLen;
int txtType;
Uint8List pathBytes = Uint8List(0); Uint8List pathBytes = Uint8List(0);
int channelIdx; int channelIdx;
if (code == respCodeChannelMsgRecvV3) { if (code == respCodeChannelMsgRecvV3) {
channelIdx = data[4]; reader.skipBytes(1); // Skip SNR
pathLenOffset = 5; final flags = reader.readByte();
final pathLen = data[pathLenOffset].toSigned(8); final hasPath = (flags & 0x01) != 0;
var cursor = 6; reader.skipBytes(1); // Skip reserved byte
final hasPathBytesFlag = (data[2] & 0x01) != 0; channelIdx = reader.readByte();
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5; pathLen = reader.readInt8();
final hasValidTxtType = txtType = reader.readByte();
cursor < data.length && if (hasPath && pathLen > 0) {
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData); reader.rewind(); // Rewind to read path length again for pathBytes
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && pathBytes = reader.readBytes(pathLen);
canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen;
} }
txtTypeOffset = cursor;
cursor += 1; // txt_type
timestampOffset = cursor;
textOffset = cursor + 4;
} else { } else {
channelIdx = data[1]; channelIdx = reader.readByte();
pathLenOffset = 2; pathLen = reader.readInt8();
txtTypeOffset = 3; txtType = reader.readByte();
timestampOffset = 4;
textOffset = 8;
} }
final timestampRaw = reader.readUInt32LE();
if (data.length < textOffset + 1) return null;
final txtType = data[txtTypeOffset];
if (txtType != txtTypePlain) { if (txtType != txtTypePlain) {
return null; return null;
} }
final pathLen = data[pathLenOffset].toSigned(8); final text = reader.readCString();
final timestampRaw = readUint32LE(data, timestampOffset);
final text = readCString(data, textOffset, data.length - textOffset);
// Extract sender name and actual message from "name: msg" format // Extract sender name and actual message from "name: msg" format
String senderName = 'Unknown'; String senderName = 'Unknown';
@@ -192,6 +181,11 @@ class ChannelMessage {
pathBytes: pathBytes, pathBytes: pathBytes,
channelIndex: channelIdx, channelIndex: channelIdx,
); );
} catch (e) {
appLogger.error('Error parsing channel message frame: $e');
// If parsing fails, return null to avoid crashes
return null;
}
} }
static ChannelMessage outgoing( static ChannelMessage outgoing(
+2
View File
@@ -18,6 +18,7 @@ class Contact {
final DateTime lastSeen; final DateTime lastSeen;
final DateTime lastMessageAt; final DateTime lastMessageAt;
final bool isActive; final bool isActive;
final bool wasPulled;
final Uint8List? rawPacket; final Uint8List? rawPacket;
Contact({ Contact({
@@ -34,6 +35,7 @@ class Contact {
required this.lastSeen, required this.lastSeen,
DateTime? lastMessageAt, DateTime? lastMessageAt,
this.isActive = true, this.isActive = true,
this.wasPulled = false,
this.rawPacket, this.rawPacket,
}) : lastMessageAt = lastMessageAt ?? lastSeen; }) : lastMessageAt = lastMessageAt ?? lastSeen;
+14 -12
View File
@@ -16,7 +16,7 @@ class Message {
final String? messageId; final String? messageId;
final int retryCount; final int retryCount;
final int? estimatedTimeoutMs; final int? estimatedTimeoutMs;
final Uint8List? expectedAckHash; final int? expectedAckHash;
final DateTime? sentAt; final DateTime? sentAt;
final DateTime? deliveredAt; final DateTime? deliveredAt;
final int? tripTimeMs; final int? tripTimeMs;
@@ -56,7 +56,7 @@ class Message {
MessageStatus? status, MessageStatus? status,
int? retryCount, int? retryCount,
int? estimatedTimeoutMs, int? estimatedTimeoutMs,
Uint8List? expectedAckHash, int? expectedAckHash,
DateTime? sentAt, DateTime? sentAt,
DateTime? deliveredAt, DateTime? deliveredAt,
int? tripTimeMs, int? tripTimeMs,
@@ -90,23 +90,22 @@ class Message {
); );
} }
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) { static Message? fromFrame(Uint8List frame, Uint8List selfPubKey) {
if (data.length < msgTextOffset + 1) return null; if (frame.length < msgTextOffset + 1) return null;
final reader = BufferReader(frame);
final code = data[0]; try {
final code = reader.readByte();
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) { if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null; return null;
} }
final senderKey = Uint8List.fromList( final senderKey = reader.readBytes(pubKeySize);
data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize), final timestampRaw = reader.readInt32LE();
); final flags = reader.readByte();
final timestampRaw = readUint32LE(data, msgTimestampOffset);
final flags = data[msgFlagsOffset];
if ((flags >> 2) != txtTypePlain) { if ((flags >> 2) != txtTypePlain) {
return null; return null;
} }
final text = readCString(data, msgTextOffset, data.length - msgTextOffset); final text = reader.readCString();
return Message( return Message(
senderKey: senderKey, senderKey: senderKey,
@@ -117,6 +116,9 @@ class Message {
status: MessageStatus.delivered, status: MessageStatus.delivered,
pathBytes: Uint8List(0), pathBytes: Uint8List(0),
); );
} catch (e) {
return null;
}
} }
static Message outgoing( static Message outgoing(
+27 -27
View File
@@ -283,16 +283,13 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
if (payload.length < 101) { if (payload.length < 101) {
return 'ADVERT (short)'; return 'ADVERT (short)';
} }
var offset = 0; final reader = BufferReader(payload);
final pubKey = _bytesToHex( try {
payload.sublist(offset, offset + 32), final pubKey = _bytesToHex(reader.readBytes(pubKeySize), spaced: false);
spaced: false,
); final timestamp = reader.readUInt32LE();
offset += 32; reader.skipBytes(signatureSize);
final timestamp = readUint32LE(payload, offset); final flags = reader.readByte();
offset += 4;
offset += 64; // signature
final flags = payload[offset++];
final role = _deviceRoleLabel(flags & 0x0F); final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0; final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0; final hasFeature1 = (flags & 0x20) != 0;
@@ -301,35 +298,35 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
String? name; String? name;
double? lat; double? lat;
double? lon; double? lon;
if (hasLocation && payload.length >= offset + 8) { if (hasLocation) {
lat = readInt32LE(payload, offset) / 1000000.0; lat = reader.readInt32LE() / 1000000.0;
lon = readInt32LE(payload, offset + 4) / 1000000.0; lon = reader.readInt32LE() / 1000000.0;
offset += 8;
} }
if (hasFeature1) offset += 2; if (hasFeature1) reader.skipBytes(2);
if (hasFeature2) offset += 2; if (hasFeature2) reader.skipBytes(2);
if (hasName && payload.length > offset) { if (hasName) {
final rawName = String.fromCharCodes(payload.sublist(offset)); name = reader.readCStringGreedy(maxNameSize);
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 namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null) final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}' ? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: ''; : '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}'; return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
} catch (e) {
return 'ADVERT (invalid)';
}
} }
String _decodeControlSummary(Uint8List payload) { String _decodeControlSummary(Uint8List payload) {
if (payload.isEmpty) return 'CONTROL (empty)'; final reader = BufferReader(payload);
final flags = payload[0]; try {
final flags = reader.readByte();
final subType = flags & 0xF0; final subType = flags & 0xF0;
if (subType == 0x80) { if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)'; if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = payload[1]; final typeFilter = reader.readByte();
final tag = readUint32LE(payload, 2); final tag = reader.readInt32LE();
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0; final since = payload.length >= 10 ? reader.readInt32LE() : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since'; return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
} }
if (subType == 0x90) { if (subType == 0x90) {
@@ -338,11 +335,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final snrRaw = payload[1]; final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw; final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0; final snr = snrSigned / 4.0;
final tag = readUint32LE(payload, 2); final tag = reader.readInt32LE();
final keyLen = payload.length - 6; final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen'; 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')}'; return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
} catch (e) {
return 'CONTROL (invalid)';
}
} }
String _payloadTypeLabel(int payloadType) { String _payloadTypeLabel(int payloadType) {
+93 -43
View File
@@ -40,8 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList()) ? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp; : primaryPathTmp;
final contacts = connector.allContacts; final hops = _buildPathHops(primaryPath, connector, l10n);
final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
primaryPath.length, primaryPath.length,
@@ -303,10 +302,12 @@ class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen> { extends State<ChannelMessagePathMapScreen> {
static const double _labelZoomThreshold = 8.5; static const double _labelZoomThreshold = 8.5;
final MapController _mapController = MapController();
Uint8List? _selectedPath; Uint8List? _selectedPath;
double _pathDistance = 0.0; double _pathDistance = 0.0;
bool _showNodeLabels = true; bool _showNodeLabels = true;
bool _didReceivePositionUpdate = false; bool _didReceivePositionUpdate = false;
int? _focusedHopIndex;
@override @override
void initState() { void initState() {
@@ -337,6 +338,22 @@ class _ChannelMessagePathMapScreenState
return totalDistance; return totalDistance;
} }
void _focusHop(_PathHop hop) {
if (!hop.hasLocation) return;
final targetZoom = _didReceivePositionUpdate
? max(_mapController.camera.zoom, 10.0)
: 12.0;
_mapController.move(hop.position!, targetZoom);
}
void _onHopTapped(_PathHop hop) {
_focusHop(hop);
if (!mounted) return;
setState(() {
_focusedHopIndex = hop.index;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>( return Consumer<MeshCoreConnector>(
@@ -365,8 +382,7 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp; : selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final contacts = connector.allContacts; final hops = _buildPathHops(selectedPath, connector, context.l10n);
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
final points = <LatLng>[]; final points = <LatLng>[];
@@ -421,6 +437,7 @@ class _ChannelMessagePathMapScreenState
children: [ children: [
FlutterMap( FlutterMap(
key: mapKey, key: mapKey,
mapController: _mapController,
options: MapOptions( options: MapOptions(
initialCenter: initialCenter, initialCenter: initialCenter,
initialZoom: initialZoom, initialZoom: initialZoom,
@@ -472,6 +489,7 @@ class _ChannelMessagePathMapScreenState
) { ) {
setState(() { setState(() {
_selectedPath = observedPaths[index].pathBytes; _selectedPath = observedPaths[index].pathBytes;
_focusedHopIndex = null;
}); });
}), }),
if (points.isEmpty) if (points.isEmpty)
@@ -727,8 +745,17 @@ class _ChannelMessagePathMapScreenState
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final hop = hops[index]; final hop = hops[index];
final isFocused = _focusedHopIndex == hop.index;
return ListTile( return ListTile(
dense: true, dense: true,
enabled: hop.hasLocation,
selected: isFocused,
selectedTileColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
onTap: hop.hasLocation
? () => _onHopTapped(hop)
: null,
leading: CircleAvatar( leading: CircleAvatar(
radius: 14, radius: 14,
child: Text( child: Text(
@@ -787,19 +814,71 @@ class _ObservedPath {
List<_PathHop> _buildPathHops( List<_PathHop> _buildPathHops(
Uint8List pathBytes, Uint8List pathBytes,
List<Contact> contacts, MeshCoreConnector connector,
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
if (pathBytes.isEmpty) return const [];
final candidatesByPrefix = <int, List<Contact>>{};
for (final contact in connector.allContacts) {
if (contact.publicKey.isEmpty) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue;
}
final prefix = contact.publicKey.first;
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
}
for (final candidates in candidatesByPrefix.values) {
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
}
final startPoint =
(connector.selfLatitude != null && connector.selfLongitude != null)
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
: null;
var previousPosition = startPoint;
final distance = Distance();
final hops = <_PathHop>[]; final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) { for (var i = 0; i < pathBytes.length; i++) {
final prefix = pathBytes[i]; final searchPoint = i == 0 ? startPoint : previousPosition;
final contact = _matchContactForPrefix(contacts, prefix); final candidates = candidatesByPrefix[pathBytes[i]];
Contact? contact;
if (candidates != null && candidates.isNotEmpty) {
var bestIndex = 0;
if (searchPoint != null) {
var bestDistance = double.infinity;
for (var j = 0; j < candidates.length; j++) {
final candidate = candidates[j];
if (!candidate.hasLocation ||
candidate.latitude == null ||
candidate.longitude == null) {
continue;
}
final currentDistance = distance(
searchPoint,
LatLng(candidate.latitude!, candidate.longitude!),
);
if (currentDistance < bestDistance) {
bestDistance = currentDistance;
bestIndex = j;
}
}
}
contact = candidates.removeAt(bestIndex);
if (candidates.isEmpty) {
candidatesByPrefix.remove(pathBytes[i]);
}
}
final resolvedPosition = _resolvePosition(contact);
if (resolvedPosition != null) {
previousPosition = resolvedPosition;
}
hops.add( hops.add(
_PathHop( _PathHop(
index: i + 1, index: i + 1,
prefix: prefix, prefix: pathBytes[i],
contact: contact, contact: contact,
position: _resolvePosition(contact), position: resolvedPosition,
l10n: l10n, l10n: l10n,
), ),
); );
@@ -807,42 +886,13 @@ List<_PathHop> _buildPathHops(
return hops; return hops;
} }
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
final matches = contacts
.where(
(contact) =>
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix,
)
.toList();
if (matches.isEmpty) return null;
Contact? pickWhere(bool Function(Contact) predicate) {
for (final contact in matches) {
if (predicate(contact)) return contact;
}
return null;
}
return pickWhere((c) => c.type == advTypeRepeater && _hasValidLocation(c)) ??
pickWhere((c) => c.type == advTypeRepeater) ??
pickWhere(_hasValidLocation) ??
matches.first;
}
LatLng? _resolvePosition(Contact? contact) { LatLng? _resolvePosition(Contact? contact) {
if (contact == null) return null; if (contact == null) return null;
if (!_hasValidLocation(contact)) return null; if (!contact.hasLocation) return null;
return LatLng(contact.latitude!, contact.longitude!); final latitude = contact.latitude;
} final longitude = contact.longitude;
if (latitude == null || longitude == null) return null;
bool _hasValidLocation(Contact contact) { return LatLng(latitude, longitude);
final lat = contact.latitude;
final lon = contact.longitude;
if (lat == null || lon == null) return false;
if (lat == 0 && lon == 0) return false;
return true;
} }
String _formatPrefix(int prefix) { String _formatPrefix(int prefix) {
-1
View File
@@ -970,7 +970,6 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
value: advertLocPolicy, value: advertLocPolicy,
onChanged: (value) { onChanged: (value) {
setDialogState(() => advertLocPolicy = value); setDialogState(() => advertLocPolicy = value);
advertLocPolicy = value;
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
+17 -24
View File
@@ -11,7 +11,7 @@ import 'app_debug_log_service.dart';
class _AckHistoryEntry { class _AckHistoryEntry {
final String messageId; final String messageId;
final List<Uint8List> ackHashes; final List<int> ackHashes;
final DateTime timestamp; final DateTime timestamp;
_AckHistoryEntry({ _AckHistoryEntry({
@@ -77,7 +77,7 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, Contact> _pendingContacts = {}; final Map<String, Contact> _pendingContacts = {};
final Map<String, List<PathSelection>> _attemptPathHistory = {}; final Map<String, List<PathSelection>> _attemptPathHistory = {};
final Map<String, AckHashMapping> _ackHashToMessageId = {}; final Map<String, AckHashMapping> _ackHashToMessageId = {};
final Map<String, List<Uint8List>> _expectedAckHashes = {}; final Map<String, List<int>> _expectedAckHashes = {};
final List<_AckHistoryEntry> _ackHistory = []; final List<_AckHistoryEntry> _ackHistory = [];
final Map<String, List<String>> _sendQueue = {}; final Map<String, List<String>> _sendQueue = {};
final Set<String> _activeMessages = {}; final Set<String> _activeMessages = {};
@@ -98,7 +98,7 @@ class MessageRetryService extends ChangeNotifier {
/// Compute expected ACK hash using same algorithm as firmware: /// Compute expected ACK hash using same algorithm as firmware:
/// SHA256([timestamp(4)][attempt(1)][text][sender_pubkey(32)]) -> first 4 bytes /// SHA256([timestamp(4)][attempt(1)][text][sender_pubkey(32)]) -> first 4 bytes
static Uint8List computeExpectedAckHash( static int computeExpectedAckHash(
int timestampSeconds, int timestampSeconds,
int attempt, int attempt,
String text, String text,
@@ -126,7 +126,8 @@ class MessageRetryService extends ChangeNotifier {
// Compute SHA256 and return first 4 bytes // Compute SHA256 and return first 4 bytes
final hash = sha256.convert(buffer); 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<void> sendMessageWithRetry({ Future<void> sendMessageWithRetry({
@@ -324,9 +325,7 @@ class MessageRetryService extends ChangeNotifier {
outboundText, outboundText,
selfPubKey, selfPubKey,
); );
final expectedHashHex = expectedHash final expectedHashHex = expectedHash.toRadixString(16).padLeft(8, '0');
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
_expectedHashToMessageId[expectedHashHex] = messageId; _expectedHashToMessageId[expectedHashHex] = messageId;
final shortText = message.text.length > 20 final shortText = message.text.length > 20
@@ -341,13 +340,11 @@ class MessageRetryService extends ChangeNotifier {
config.sendMessage(contact, message.text, attempt, timestampSeconds); config.sendMessage(contact, message.text, attempt, timestampSeconds);
} }
bool updateMessageFromSent(Uint8List ackHash, int timeoutMs) { bool updateMessageFromSent(int ackHash, int timeoutMs) {
final config = _config; final config = _config;
if (config == null) return false; if (config == null) return false;
final ackHashHex = ackHash final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0');
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// Try hash-based matching (fixes LoRa message drops causing mismatches) // Try hash-based matching (fixes LoRa message drops causing mismatches)
String? messageId = _expectedHashToMessageId.remove(ackHashHex); 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) // Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= []; _expectedAckHashes[messageId] ??= [];
if (!_expectedAckHashes[messageId]!.any( if (!_expectedAckHashes[messageId]!.any((hash) => hash == ackHash)) {
(hash) => listEquals(hash, ackHash), _expectedAckHashes[messageId]!.add(ackHash);
)) {
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
} }
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback // 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 entry in _ackHistory) {
for (final expectedHash in entry.ackHashes) { for (final expectedHash in entry.ackHashes) {
if (listEquals(expectedHash, ackHash)) { if (expectedHash == ackHash) {
return true; return true;
} }
} }
@@ -570,13 +565,11 @@ class MessageRetryService extends ChangeNotifier {
return false; return false;
} }
void handleAckReceived(Uint8List ackHash, int tripTimeMs) { void handleAckReceived(int ackHash, int tripTimeMs) {
final config = _config; final config = _config;
String? matchedMessageId; String? matchedMessageId;
int? matchedAttemptIndex; int? matchedAttemptIndex;
final ackHashHex = ackHash final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0');
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// Clean up old ACK hash mappings (older than 15 minutes) // Clean up old ACK hash mappings (older than 15 minutes)
final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15)); final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15));
@@ -606,7 +599,7 @@ class MessageRetryService extends ChangeNotifier {
final expectedHashes = entry.value; final expectedHashes = entry.value;
for (final expectedHash in expectedHashes) { for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) { if (expectedHash == ackHash) {
matchedMessageId = messageId; matchedMessageId = messageId;
matchedAttemptIndex = expectedHashes.indexOf(expectedHash); matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
break; break;
@@ -685,11 +678,11 @@ class MessageRetryService extends ChangeNotifier {
} }
} }
String? getContactKeyForAckHash(Uint8List ackHash) { String? getContactKeyForAckHash(int ackHash) {
for (var entry in _pendingMessages.entries) { for (var entry in _pendingMessages.entries) {
final message = entry.value; final message = entry.value;
if (message.expectedAckHash != null && if (message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash)) { message.expectedAckHash == ackHash) {
final contact = _pendingContacts[entry.key]; final contact = _pendingContacts[entry.key];
return contact?.publicKeyHex; return contact?.publicKeyHex;
} }
+2 -6
View File
@@ -85,9 +85,7 @@ class MessageStore {
'messageId': msg.messageId, 'messageId': msg.messageId,
'retryCount': msg.retryCount, 'retryCount': msg.retryCount,
'estimatedTimeoutMs': msg.estimatedTimeoutMs, 'estimatedTimeoutMs': msg.estimatedTimeoutMs,
'expectedAckHash': msg.expectedAckHash != null 'expectedAckHash': msg.expectedAckHash,
? base64Encode(msg.expectedAckHash!)
: null,
'sentAt': msg.sentAt?.millisecondsSinceEpoch, 'sentAt': msg.sentAt?.millisecondsSinceEpoch,
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch, 'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
'tripTimeMs': msg.tripTimeMs, 'tripTimeMs': msg.tripTimeMs,
@@ -119,9 +117,7 @@ class MessageStore {
messageId: json['messageId'] as String?, messageId: json['messageId'] as String?,
retryCount: json['retryCount'] as int? ?? 0, retryCount: json['retryCount'] as int? ?? 0,
estimatedTimeoutMs: json['estimatedTimeoutMs'] as int?, estimatedTimeoutMs: json['estimatedTimeoutMs'] as int?,
expectedAckHash: json['expectedAckHash'] != null expectedAckHash: json['expectedAckHash'] as int? ?? 0,
? Uint8List.fromList(base64Decode(json['expectedAckHash'] as String))
: null,
sentAt: json['sentAt'] != null sentAt: json['sentAt'] != null
? DateTime.fromMillisecondsSinceEpoch(json['sentAt'] as int) ? DateTime.fromMillisecondsSinceEpoch(json['sentAt'] as int)
: null, : null,
@@ -9,6 +9,7 @@ import flutter_blue_plus_darwin
import flutter_local_notifications import flutter_local_notifications
import mobile_scanner import mobile_scanner
import package_info_plus import package_info_plus
import path_provider_foundation
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
@@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
+4 -13
View File
@@ -14,7 +14,7 @@ import 'package:meshcore_open/services/message_retry_service.dart';
/// Replicates the SHA-256 computation from [MessageRetryService.computeExpectedAckHash] /// Replicates the SHA-256 computation from [MessageRetryService.computeExpectedAckHash]
/// so tests can cross-check without calling the real implementation twice. /// so tests can cross-check without calling the real implementation twice.
Uint8List _manualAckHash( int _manualAckHash(
int timestampSeconds, int timestampSeconds,
int attemptMasked, // already masked to 0x03 int attemptMasked, // already masked to 0x03
String text, String text,
@@ -35,7 +35,8 @@ Uint8List _manualAckHash(
buffer.setRange(offset, offset + senderPubKey.length, senderPubKey); buffer.setRange(offset, offset + senderPubKey.length, senderPubKey);
final hash = sha256.convert(buffer); 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) { Uint8List _makeKey(int seed) {
@@ -169,16 +170,6 @@ void main() {
expect(first, equals(second)); 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', () { test('hash matches manual SHA-256 computation', () {
for (int attempt = 0; attempt < 4; attempt++) { for (int attempt = 0; attempt < 4; attempt++) {
final actual = MessageRetryService.computeExpectedAckHash( final actual = MessageRetryService.computeExpectedAckHash(
@@ -509,7 +500,7 @@ void main() {
fixedText, fixedText,
fixedKey, fixedKey,
); );
final hex = hash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); final hex = hash.toRadixString(16).padLeft(8, '0');
expect( expect(
hashes.containsKey(hex), hashes.containsKey(hex),
isFalse, isFalse,