From d675ce18dd70fb4ac5c9fac7b34eb648151ccc3b Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 10 Mar 2026 08:43:58 +0100 Subject: [PATCH] Use LogRxRouteDecoder in AppProvider --- lib/providers/app_provider.dart | 19 ++--- lib/providers/messages_provider.dart | 48 +++++++++++ lib/screens/home_screen.dart | 1 - lib/screens/messages_tab.dart | 12 ++- lib/utils/log_rx_route_decoder.dart | 44 ++++++++-- lib/widgets/messages/message_bubble.dart | 19 ++--- lib/widgets/messages/message_trace_sheet.dart | 19 +++-- .../messages_provider_voice_test.dart | 80 +++++++++++++++++++ test/utils/log_rx_route_decoder_test.dart | 25 ++++++ 9 files changed, 225 insertions(+), 42 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 07dd84e..7fe28df 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -29,6 +29,7 @@ import '../utils/image_message_parser.dart'; import '../utils/media_swarm_protocol.dart'; import '../utils/message_airtime_estimator.dart'; import '../utils/fast_gps_packet.dart'; +import '../utils/log_rx_route_decoder.dart'; class _DirectMessageRouteSession { final PathSelection currentSelection; @@ -2573,12 +2574,10 @@ class AppProvider with ChangeNotifier { if (log.responseCode != 0x88) continue; if (log.rawData.length < 6) continue; - final raw = log.rawData; - final payloadType = (raw[3] >> 2) & 0x0F; - final pathLen = raw[4]; - if (payloadType != expectedPayloadType) continue; - if (pathLen != message.pathLen) continue; - if (raw.length < 5 + pathLen) continue; + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null) continue; + if (decoded.payloadType != expectedPayloadType) continue; + if (decoded.hopCount != message.pathLen) continue; final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); @@ -2594,11 +2593,9 @@ class AppProvider with ChangeNotifier { List? _extractPathBytesFromLog(BlePacketLog? log) { if (log == null) return null; - final raw = log.rawData; - if (raw.length < 6) return null; - final pathLen = raw[4]; - if (pathLen <= 0 || raw.length < 5 + pathLen) return null; - return raw.sublist(5, 5 + pathLen); + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null || decoded.pathBytes.isEmpty) return null; + return decoded.pathBytes; } // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index ffa6422..3922bfa 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -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 void markAsRead(String messageId) { final index = _messages.indexWhere((m) => m.id == messageId); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5bf9d93..aea34a3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -195,7 +195,6 @@ class _HomeScreenState extends State switch (tab) { case _HomeTab.messages: - context.read().markAllAsRead(); break; case _HomeTab.contacts: context.read().markAllAsViewed(); diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 7a710f0..68c5184 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -89,9 +89,7 @@ class _MessagesTabState extends State { // Load saved message destination _loadSavedDestination(); _loadVoiceBitrate(); - // Mark all messages as read when tab is opened WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().markAllAsRead(); _checkForNavigationRequest(); }); } @@ -309,6 +307,8 @@ class _MessagesTabState extends State { _selectedRecipient = recipient; }); + _markCurrentDestinationAsRead(); + _enforceMessageByteLimit(); // Save to preferences @@ -465,6 +465,7 @@ class _MessagesTabState extends State { _textController.clear(); _focusNode.unfocus(); + _markCurrentDestinationAsRead(); if (!mounted) return; } catch (e) { @@ -1641,6 +1642,13 @@ class _MessagesTabState extends State { } } + void _markCurrentDestinationAsRead() { + context.read().markDestinationAsRead( + destinationType: _destinationType, + contact: _selectedRecipient, + ); + } + List _getFilteredMessages(MessagesProvider messagesProvider) { // Get all recent messages final allMessages = messagesProvider.getRecentMessages(count: 100); diff --git a/lib/utils/log_rx_route_decoder.dart b/lib/utils/log_rx_route_decoder.dart index 2e35aac..6742bdf 100644 --- a/lib/utils/log_rx_route_decoder.dart +++ b/lib/utils/log_rx_route_decoder.dart @@ -4,11 +4,13 @@ import '../models/contact.dart'; class DecodedLogRxRoute { final int payloadType; + final int pathDescriptor; final List pathBytes; final int hashSize; const DecodedLogRxRoute({ required this.payloadType, + required this.pathDescriptor, required this.pathBytes, required this.hashSize, }); @@ -63,21 +65,49 @@ class LogRxRouteDecoder { } if (rawPacketData.length <= index) return null; - final pathLen = rawPacketData[index++]; - if (rawPacketData.length < index + pathLen) return null; - final pathBytes = rawPacketData.sublist(index, index + pathLen); - final hashSize = inferHashSize( - pathBytes, - preferredHashSize: preferredHashSize, - ); + final pathDescriptor = rawPacketData[index++]; + final pathMode = (pathDescriptor & 0xFF) >> 6; + final pathByteLen = pathMode == 0 + ? pathDescriptor + : descriptorByteLength(pathDescriptor); + 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( payloadType: payloadType, + pathDescriptor: pathDescriptor, pathBytes: pathBytes, 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 pathBytes, {int? preferredHashSize}) { if (pathBytes.isEmpty) return 1; diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 639b898..5c4ea58 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart'; import '../../utils/location_formats.dart'; import '../../l10n/app_localizations.dart'; import '../../utils/message_extensions.dart'; +import '../../utils/log_rx_route_decoder.dart'; import '../../models/message_transfer_details.dart'; import 'voice_message_bubble.dart'; import 'image_message_bubble.dart'; @@ -1269,12 +1270,10 @@ class _MessageBubbleState extends State { // Logged frame format: // [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen - final raw = log.rawData; - final payloadType = (raw[3] >> 2) & 0x0F; - final pathLen = raw[4]; - if (payloadType != expectedPayloadType) continue; - if (pathLen != message.pathLen) continue; - if (raw.length < 5 + pathLen) continue; + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null) continue; + if (decoded.payloadType != expectedPayloadType) continue; + if (decoded.hopCount != message.pathLen) continue; final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); @@ -1290,11 +1289,9 @@ class _MessageBubbleState extends State { List? _extractPathBytesFromLog(BlePacketLog? log) { if (log == null) return null; - final raw = log.rawData; - if (raw.length < 6) return null; - final pathLen = raw[4]; - if (pathLen <= 0 || raw.length < 5 + pathLen) return null; - return raw.sublist(5, 5 + pathLen); + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null || decoded.pathBytes.isEmpty) return null; + return decoded.pathBytes; } void _showDeleteConfirmation(BuildContext context) { diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index 138740f..f685a46 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -516,13 +516,10 @@ class _MessageTraceSheetState extends State { for (final log in logs) { if (log.responseCode != 0x88) continue; // pushLogRxData if (log.rawData.length < 6) continue; - final raw = log.rawData; - final header = raw[3]; - final payloadType = (header >> 2) & 0x0F; - final pathLen = raw[4]; - if (payloadType != expectedPayloadType) continue; - if (pathLen != message.pathLen) continue; - if (raw.length < 5 + pathLen) continue; + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null) continue; + if (decoded.payloadType != expectedPayloadType) continue; + if (decoded.hopCount != message.pathLen) continue; final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); @@ -533,9 +530,11 @@ class _MessageTraceSheetState extends State { } if (bestLog == null || bestDeltaMs > 30000) return null; - final raw = bestLog.rawData; - final pathLen = raw[4]; - return raw.sublist(5, 5 + pathLen); + final decoded = LogRxRouteDecoder.decode(bestLog.rawData); + if (decoded == null || decoded.pathBytes.isEmpty) { + return null; + } + return decoded.pathBytes; } List _matchNodesFromPathHashes({ diff --git a/test/providers/messages_provider_voice_test.dart b/test/providers/messages_provider_voice_test.dart index 0a20b74..9d28520 100644 --- a/test/providers/messages_provider_voice_test.dart +++ b/test/providers/messages_provider_voice_test.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; 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_contact_location.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart'; @@ -11,6 +12,30 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + Contact _buildContact({ + required List prefix, + required ContactType type, + String name = 'Test Contact', + }) { + final key = Uint8List.fromList([ + ...prefix, + ...List.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', () { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -190,5 +215,60 @@ void main() { expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566'); 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)); + }); }); } diff --git a/test/utils/log_rx_route_decoder_test.dart b/test/utils/log_rx_route_decoder_test.dart index fb5a775..6c92ecf 100644 --- a/test/utils/log_rx_route_decoder_test.dart +++ b/test/utils/log_rx_route_decoder_test.dart @@ -24,12 +24,37 @@ void main() { expect(decoded, isNotNull); expect(decoded!.payloadType, 0x01); + expect(decoded.pathDescriptor, 0x04); expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]); expect(decoded.hashSize, 2); expect(decoded.hopHashes, ['c2ba', '5fde']); 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', () { final packet = Uint8List.fromList([ 0x88,