Use LogRxRouteDecoder in AppProvider

This commit is contained in:
Janez T
2026-03-10 08:43:58 +01:00
parent a8e31bb2e8
commit d675ce18dd
9 changed files with 225 additions and 42 deletions

View File

@@ -29,6 +29,7 @@ import '../utils/image_message_parser.dart';
import '../utils/media_swarm_protocol.dart'; import '../utils/media_swarm_protocol.dart';
import '../utils/message_airtime_estimator.dart'; import '../utils/message_airtime_estimator.dart';
import '../utils/fast_gps_packet.dart'; import '../utils/fast_gps_packet.dart';
import '../utils/log_rx_route_decoder.dart';
class _DirectMessageRouteSession { class _DirectMessageRouteSession {
final PathSelection currentSelection; final PathSelection currentSelection;
@@ -2573,12 +2574,10 @@ class AppProvider with ChangeNotifier {
if (log.responseCode != 0x88) continue; if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue; if (log.rawData.length < 6) continue;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
final payloadType = (raw[3] >> 2) & 0x0F; if (decoded == null) continue;
final pathLen = raw[4]; if (decoded.payloadType != expectedPayloadType) continue;
if (payloadType != expectedPayloadType) continue; if (decoded.hopCount != message.pathLen) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -2594,11 +2593,9 @@ class AppProvider with ChangeNotifier {
List<int>? _extractPathBytesFromLog(BlePacketLog? log) { List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null; if (log == null) return null;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
if (raw.length < 6) return null; if (decoded == null || decoded.pathBytes.isEmpty) return null;
final pathLen = raw[4]; return decoded.pathBytes;
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
} }
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events

View File

@@ -885,6 +885,54 @@ class MessagesProvider with ChangeNotifier {
} }
} }
/// Mark unread messages for a specific destination as read.
void markDestinationAsRead({
required String destinationType,
Contact? contact,
}) {
if (destinationType == 'all') {
markAllAsRead();
return;
}
bool hasChanges = false;
for (int i = 0; i < _messages.length; i++) {
final message = _messages[i];
if (message.isRead || message.isSentMessage || message.isSystemMessage) {
continue;
}
final matchesDestination = switch (destinationType) {
'channel' => _isChannelMessageForContact(message, contact),
'contact' || 'room' => contact != null && _isMessageForDestination(message, contact),
_ => false,
};
if (!matchesDestination) {
continue;
}
_messages[i] = message.copyWith(isRead: true);
hasChanges = true;
}
if (hasChanges) {
_persistMessages();
notifyListeners();
}
}
bool _isChannelMessageForContact(Message message, Contact? contact) {
if (!message.isChannelMessage) {
return false;
}
final selectedChannelIdx = contact != null && contact.publicKey.length > 1
? contact.publicKey[1]
: 0;
return (message.channelIdx ?? 0) == selectedChannelIdx;
}
/// Mark a specific message as read /// Mark a specific message as read
void markAsRead(String messageId) { void markAsRead(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId); final index = _messages.indexWhere((m) => m.id == messageId);

View File

@@ -195,7 +195,6 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) { switch (tab) {
case _HomeTab.messages: case _HomeTab.messages:
context.read<MessagesProvider>().markAllAsRead();
break; break;
case _HomeTab.contacts: case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed(); context.read<ContactsProvider>().markAllAsViewed();

View File

@@ -89,9 +89,7 @@ class _MessagesTabState extends State<MessagesTab> {
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceBitrate(); _loadVoiceBitrate();
// Mark all messages as read when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
} }
@@ -309,6 +307,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient; _selectedRecipient = recipient;
}); });
_markCurrentDestinationAsRead();
_enforceMessageByteLimit(); _enforceMessageByteLimit();
// Save to preferences // Save to preferences
@@ -465,6 +465,7 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.clear(); _textController.clear();
_focusNode.unfocus(); _focusNode.unfocus();
_markCurrentDestinationAsRead();
if (!mounted) return; if (!mounted) return;
} catch (e) { } catch (e) {
@@ -1641,6 +1642,13 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
void _markCurrentDestinationAsRead() {
context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType,
contact: _selectedRecipient,
);
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) { List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages // Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100); final allMessages = messagesProvider.getRecentMessages(count: 100);

View File

@@ -4,11 +4,13 @@ import '../models/contact.dart';
class DecodedLogRxRoute { class DecodedLogRxRoute {
final int payloadType; final int payloadType;
final int pathDescriptor;
final List<int> pathBytes; final List<int> pathBytes;
final int hashSize; final int hashSize;
const DecodedLogRxRoute({ const DecodedLogRxRoute({
required this.payloadType, required this.payloadType,
required this.pathDescriptor,
required this.pathBytes, required this.pathBytes,
required this.hashSize, required this.hashSize,
}); });
@@ -63,21 +65,49 @@ class LogRxRouteDecoder {
} }
if (rawPacketData.length <= index) return null; if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++]; final pathDescriptor = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null; final pathMode = (pathDescriptor & 0xFF) >> 6;
final pathBytes = rawPacketData.sublist(index, index + pathLen); final pathByteLen = pathMode == 0
final hashSize = inferHashSize( ? pathDescriptor
pathBytes, : descriptorByteLength(pathDescriptor);
preferredHashSize: preferredHashSize, if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
); return null;
}
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
final hashSize = pathMode == 0
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
: (descriptorHashSize(pathDescriptor) ??
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
return DecodedLogRxRoute( return DecodedLogRxRoute(
payloadType: payloadType, payloadType: payloadType,
pathDescriptor: pathDescriptor,
pathBytes: pathBytes, pathBytes: pathBytes,
hashSize: hashSize, hashSize: hashSize,
); );
} }
static int? descriptorHashSize(int pathDescriptor) {
final normalized = pathDescriptor & 0xFF;
final mode = normalized >> 6;
if (mode == 3) return null;
return mode + 1;
}
static int? descriptorHopCount(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
if (hashSize == null) return null;
return (pathDescriptor & 0xFF) & 0x3F;
}
static int? descriptorByteLength(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
final hopCount = descriptorHopCount(pathDescriptor);
if (hashSize == null || hopCount == null) return null;
final byteLen = hopCount * hashSize;
return byteLen <= 64 ? byteLen : null;
}
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) { static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
if (pathBytes.isEmpty) return 1; if (pathBytes.isEmpty) return 1;

View File

@@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart'; import '../../models/message_transfer_details.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
@@ -1269,12 +1270,10 @@ class _MessageBubbleState extends State<MessageBubble> {
// Logged frame format: // Logged frame format:
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen // [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
final payloadType = (raw[3] >> 2) & 0x0F; if (decoded == null) continue;
final pathLen = raw[4]; if (decoded.payloadType != expectedPayloadType) continue;
if (payloadType != expectedPayloadType) continue; if (decoded.hopCount != message.pathLen) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -1290,11 +1289,9 @@ class _MessageBubbleState extends State<MessageBubble> {
List<int>? _extractPathBytesFromLog(BlePacketLog? log) { List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null; if (log == null) return null;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
if (raw.length < 6) return null; if (decoded == null || decoded.pathBytes.isEmpty) return null;
final pathLen = raw[4]; return decoded.pathBytes;
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
} }
void _showDeleteConfirmation(BuildContext context) { void _showDeleteConfirmation(BuildContext context) {

View File

@@ -516,13 +516,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
for (final log in logs) { for (final log in logs) {
if (log.responseCode != 0x88) continue; // pushLogRxData if (log.responseCode != 0x88) continue; // pushLogRxData
if (log.rawData.length < 6) continue; if (log.rawData.length < 6) continue;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
final header = raw[3]; if (decoded == null) continue;
final payloadType = (header >> 2) & 0x0F; if (decoded.payloadType != expectedPayloadType) continue;
final pathLen = raw[4]; if (decoded.hopCount != message.pathLen) continue;
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -533,9 +530,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
if (bestLog == null || bestDeltaMs > 30000) return null; if (bestLog == null || bestDeltaMs > 30000) return null;
final raw = bestLog.rawData; final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
final pathLen = raw[4]; if (decoded == null || decoded.pathBytes.isEmpty) {
return raw.sublist(5, 5 + pathLen); return null;
}
return decoded.pathBytes;
} }
List<MeshMapNode?> _matchNodesFromPathHashes({ List<MeshMapNode?> _matchNodesFromPathHashes({

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart'; import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
@@ -11,6 +12,30 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
Contact _buildContact({
required List<int> prefix,
required ContactType type,
String name = 'Test Contact',
}) {
final key = Uint8List.fromList([
...prefix,
...List<int>.generate(26, (index) => index),
]);
return Contact(
publicKey: key,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
@@ -190,5 +215,60 @@ void main() {
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566'); expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
expect(restoredImage?.downloaders.single.requesterName, equals('Bob')); expect(restoredImage?.downloaders.single.requesterName, equals('Bob'));
}); });
test('marks only the selected contact destination as read', () {
final provider = MessagesProvider();
final alice = _buildContact(
prefix: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
type: ContactType.chat,
name: 'Alice',
);
final bob = _buildContact(
prefix: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25],
type: ContactType.chat,
name: 'Bob',
);
provider.addMessage(
Message(
id: 'alice-incoming',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Alice unread',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: alice.publicKey.sublist(0, 6),
),
);
provider.addMessage(
Message(
id: 'bob-incoming',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000101,
text: 'Bob unread',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: bob.publicKey.sublist(0, 6),
),
);
provider.markDestinationAsRead(
destinationType: 'contact',
contact: alice,
);
final aliceMessage = provider.messages.firstWhere(
(message) => message.id == 'alice-incoming',
);
final bobMessage = provider.messages.firstWhere(
(message) => message.id == 'bob-incoming',
);
expect(aliceMessage.isRead, isTrue);
expect(bobMessage.isRead, isFalse);
expect(provider.unreadCount, equals(1));
});
}); });
} }

View File

@@ -24,12 +24,37 @@ void main() {
expect(decoded, isNotNull); expect(decoded, isNotNull);
expect(decoded!.payloadType, 0x01); expect(decoded!.payloadType, 0x01);
expect(decoded.pathDescriptor, 0x04);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]); expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2); expect(decoded.hashSize, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']); expect(decoded.hopHashes, ['c2ba', '5fde']);
expect(decoded.originalSenderHashHex, 'c2ba'); expect(decoded.originalSenderHashHex, 'c2ba');
}); });
test('parses encoded descriptor with 2-byte hashes', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x05,
0x42,
0xc2,
0xba,
0x5f,
0xde,
0x5c,
]);
final decoded = LogRxRouteDecoder.decode(packet);
expect(decoded, isNotNull);
expect(decoded!.pathDescriptor, 0x42);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2);
expect(decoded.hopCount, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']);
});
test('uses preferred hash size when packet length is ambiguous', () { test('uses preferred hash size when packet length is ambiguous', () {
final packet = Uint8List.fromList([ final packet = Uint8List.fromList([
0x88, 0x88,