mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-24 19:32:52 +10:00
Merge branch 'main' into dev-mapOverlap
This commit is contained in:
+2
-1
@@ -58,6 +58,7 @@ secrets.dart
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.AppleDouble
|
.AppleDouble
|
||||||
.LSOverride
|
.LSOverride
|
||||||
|
macos/Flutter/GeneratedPluginRegistrant.swift
|
||||||
|
|
||||||
# iOS
|
# iOS
|
||||||
**/ios/Pods/
|
**/ios/Pods/
|
||||||
@@ -85,4 +86,4 @@ keystore.properties
|
|||||||
.vscode/settings.json
|
.vscode/settings.json
|
||||||
|
|
||||||
# Cloudflare Wrangler
|
# Cloudflare Wrangler
|
||||||
.wrangler
|
.wrangler
|
||||||
|
|||||||
@@ -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,68 +3714,87 @@ 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 packet = _parseRawPacket(raw);
|
final reader = BufferReader(frame);
|
||||||
if (packet == null || packet.payloadType != _payloadTypeGroupText) return;
|
reader.skipBytes(3); // Skip header
|
||||||
|
|
||||||
final payload = packet.payload;
|
final raw = reader.readRemainingBytes();
|
||||||
if (payload.length <= _cipherMacSize) return;
|
final packet = _parseRawPacket(raw);
|
||||||
final channelHash = payload[0];
|
if (packet == null || packet.payloadType != _payloadTypeGroupText) return;
|
||||||
final encrypted = Uint8List.fromList(payload.sublist(1));
|
|
||||||
|
|
||||||
// Use cached channels as fallback if live channels not yet loaded
|
final payload = BufferReader(packet.payload);
|
||||||
final channelsToSearch = _channels.isNotEmpty ? _channels : _cachedChannels;
|
final channelHash = payload.readByte();
|
||||||
for (final channel in channelsToSearch) {
|
final encrypted = Uint8List.fromList(payload.readRemainingBytes());
|
||||||
if (channel.isEmpty) continue;
|
|
||||||
final hash = _computeChannelHash(channel.psk);
|
|
||||||
if (hash != channelHash) continue;
|
|
||||||
|
|
||||||
final decrypted = _decryptPayload(channel.psk, encrypted);
|
// Use cached channels as fallback if live channels not yet loaded
|
||||||
if (decrypted == null || decrypted.length < 6) return;
|
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];
|
final timestampRaw = decrypted.readUInt32LE();
|
||||||
if ((txtType >> 2) != 0) {
|
final txtType = decrypted.readByte();
|
||||||
return;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
final timestampRaw = readUint32LE(decrypted, 0);
|
appLogger.warn('Error handling log RX data frame: $e');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,36 +4315,35 @@ 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 ||
|
||||||
if (hasTransport) {
|
routeType == _routeTransportDirect;
|
||||||
if (raw.length < index + 4) return null;
|
if (hasTransport) {
|
||||||
index += 4;
|
// Skip reserved bytes in transport header made up of two u16 fields
|
||||||
}
|
reader.skipBytes(4);
|
||||||
if (raw.length <= index) return null;
|
}
|
||||||
final pathLenRaw = raw[index++];
|
final pathLenRaw = reader.readByte();
|
||||||
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,
|
||||||
routeType: routeType,
|
routeType: routeType,
|
||||||
payloadType: (header >> _phTypeShift) & _phTypeMask,
|
payloadType: (header >> _phTypeShift) & _phTypeMask,
|
||||||
payloadVer: (header >> _phVerShift) & _phVerMask,
|
payloadVer: (header >> _phVerShift) & _phVerMask,
|
||||||
pathLenRaw: pathLenRaw,
|
pathLenRaw: pathLenRaw,
|
||||||
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');
|
||||||
|
|||||||
@@ -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];
|
|
||||||
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;
|
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
|
// 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
@@ -1892,7 +1892,7 @@
|
|||||||
"map_setAsMyLocation": "Задайте като моя местоположение",
|
"map_setAsMyLocation": "Задайте като моя местоположение",
|
||||||
"@path_routeWeight": {
|
"@path_routeWeight": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"value": {
|
"weight": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
},
|
},
|
||||||
"max": {
|
"max": {
|
||||||
|
|||||||
+252
-239
File diff suppressed because it is too large
Load Diff
+263
-225
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -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) {
|
||||||
|
|||||||
@@ -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,89 +110,82 @@ 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 reader = BufferReader(frame);
|
||||||
|
final code = reader.readByte();
|
||||||
|
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final code = data[0];
|
int pathLen;
|
||||||
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
|
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;
|
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(
|
static ChannelMessage outgoing(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+28
-26
@@ -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,33 +90,35 @@ 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);
|
||||||
|
try {
|
||||||
|
final code = reader.readByte();
|
||||||
|
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final code = data[0];
|
final senderKey = reader.readBytes(pubKeySize);
|
||||||
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
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;
|
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(
|
static Message outgoing(
|
||||||
|
|||||||
@@ -283,66 +283,66 @@ 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;
|
final role = _deviceRoleLabel(flags & 0x0F);
|
||||||
offset += 64; // signature
|
final hasLocation = (flags & 0x10) != 0;
|
||||||
final flags = payload[offset++];
|
final hasFeature1 = (flags & 0x20) != 0;
|
||||||
final role = _deviceRoleLabel(flags & 0x0F);
|
final hasFeature2 = (flags & 0x40) != 0;
|
||||||
final hasLocation = (flags & 0x10) != 0;
|
final hasName = (flags & 0x80) != 0;
|
||||||
final hasFeature1 = (flags & 0x20) != 0;
|
String? name;
|
||||||
final hasFeature2 = (flags & 0x40) != 0;
|
double? lat;
|
||||||
final hasName = (flags & 0x80) != 0;
|
double? lon;
|
||||||
String? name;
|
if (hasLocation) {
|
||||||
double? lat;
|
lat = reader.readInt32LE() / 1000000.0;
|
||||||
double? lon;
|
lon = reader.readInt32LE() / 1000000.0;
|
||||||
if (hasLocation && payload.length >= offset + 8) {
|
}
|
||||||
lat = readInt32LE(payload, offset) / 1000000.0;
|
if (hasFeature1) reader.skipBytes(2);
|
||||||
lon = readInt32LE(payload, offset + 4) / 1000000.0;
|
if (hasFeature2) reader.skipBytes(2);
|
||||||
offset += 8;
|
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) {
|
String _decodeControlSummary(Uint8List payload) {
|
||||||
if (payload.isEmpty) return 'CONTROL (empty)';
|
final reader = BufferReader(payload);
|
||||||
final flags = payload[0];
|
try {
|
||||||
final subType = flags & 0xF0;
|
final flags = reader.readByte();
|
||||||
if (subType == 0x80) {
|
final subType = flags & 0xF0;
|
||||||
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
|
if (subType == 0x80) {
|
||||||
final typeFilter = payload[1];
|
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
|
||||||
final tag = readUint32LE(payload, 2);
|
final typeFilter = reader.readByte();
|
||||||
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
|
final tag = reader.readInt32LE();
|
||||||
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
|
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) {
|
String _payloadTypeLabel(int payloadType) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user