diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6d3757c..4b67c69 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 140; + CURRENT_PROJECT_VERSION = 141; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 140; + CURRENT_PROJECT_VERSION = 141; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 140; + CURRENT_PROJECT_VERSION = 141; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 140; + CURRENT_PROJECT_VERSION = 141; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 140; + CURRENT_PROJECT_VERSION = 141; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 140; + CURRENT_PROJECT_VERSION = 141; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 06a43f6..6b0fb21 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 140 + 141 LSRequiresIPhoneOS ITSAppUsesNonExemptEncryption diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 2d4b632..a64cc8d 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 45027bd..7c4aabe 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2018,9 +2018,9 @@ }, "reply": "Reply", "@reply": {}, - "technicalDetails": "Technical details", + "technicalDetails": "Details", "@technicalDetails": {}, - "messageTechnicalDetails": "Message technical details", + "messageTechnicalDetails": "Message details", "@messageTechnicalDetails": {}, "linkQuality": "Link quality", "@linkQuality": {}, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f64b762..8a8fdce 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2769,13 +2769,13 @@ abstract class AppLocalizations { /// No description provided for @technicalDetails. /// /// In en, this message translates to: - /// **'Technical details'** + /// **'Details'** String get technicalDetails; /// No description provided for @messageTechnicalDetails. /// /// In en, this message translates to: - /// **'Message technical details'** + /// **'Message details'** String get messageTechnicalDetails; /// No description provided for @linkQuality. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 95a8647..dfd9a90 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1471,10 +1471,10 @@ class AppLocalizationsEn extends AppLocalizations { String get reply => 'Reply'; @override - String get technicalDetails => 'Technical details'; + String get technicalDetails => 'Details'; @override - String get messageTechnicalDetails => 'Message technical details'; + String get messageTechnicalDetails => 'Message details'; @override String get linkQuality => 'Link quality'; diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index e7f3ebf..916b39f 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1773,7 +1773,13 @@ class AppProvider with ChangeNotifier { debugPrint( '🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount', ); - messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm); + messagesProvider.handleMessageEcho( + messageId, + echoCount, + snrRaw, + rssiDbm, + pathBytes: _latestChannelEchoPathBytes(), + ); }; connectionProvider.prepareDirectMessageSendCallback = @@ -4056,6 +4062,23 @@ class AppProvider with ChangeNotifier { return decoded.pathBytes; } + Uint8List? _latestChannelEchoPathBytes() { + for (final log in connectionProvider.bleService.packetLogs.reversed) { + if (log.responseCode != 0x88) continue; + + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null || + decoded.payloadType != 0x05 || + decoded.pathBytes.isEmpty) { + continue; + } + + return Uint8List.fromList(decoded.pathBytes); + } + + return null; + } + // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // The ConnectionProvider's onMessageWaiting callback handles automatic message fetching diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index b0566c5..3c0f48e 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -198,6 +198,9 @@ class MessagesProvider with ChangeNotifier { _messages[index] = _messages[index].copyWith( usedFloodFallback: selection.usesFlood, pathLen: nextPathLen, + pathBytes: selection.hasDirectPath + ? Uint8List.fromList(selection.pathBytes) + : Uint8List(0), ); } @@ -803,15 +806,30 @@ class MessagesProvider with ChangeNotifier { if (contactLocationSnapshot != null) { _messageContactLocations[existingId] = contactLocationSnapshot; } - _messageReceptionDetails[existingId] = - MessageReceptionDetails.mergeDuplicate( - existing: _messageReceptionDetails[existingId], - incoming: receptionDetailsSnapshot, - ); + final mergedReceptionDetails = MessageReceptionDetails.mergeDuplicate( + existing: _messageReceptionDetails[existingId], + incoming: receptionDetailsSnapshot, + ); + _messageReceptionDetails[existingId] = mergedReceptionDetails; final existingMessage = _messages[matchingSentReplayIndex]; + final routeMetadata = _messageRouteMetadata[existingId]; _messages[matchingSentReplayIndex] = existingMessage.copyWith( - pathLen: finalMessage.pathLen > 0 ? finalMessage.pathLen : existingMessage.pathLen, - pathBytes: finalMessage.pathBytes ?? existingMessage.pathBytes, + echoCount: _mergeSentReplayEchoCount( + existingMessage, + mergedReceptionDetails, + ), + pathLen: _mergeSentReplayPathLen( + existingMessage, + finalMessage, + routeMetadata, + ), + pathBytes: _mergeSentReplayPathBytes( + existingMessage, + finalMessage, + routeMetadata, + ), + firstEchoAt: existingMessage.firstEchoAt ?? DateTime.now(), + lastEchoAt: DateTime.now(), ); _persistMessages(); return; @@ -1032,6 +1050,51 @@ class MessagesProvider with ChangeNotifier { return _matchesDuplicateSenderIdentity(existing, message); } + int _mergeSentReplayEchoCount( + Message existing, + MessageReceptionDetails mergedReceptionDetails, + ) { + final replayCount = mergedReceptionDetails.receivedCopies > 0 + ? mergedReceptionDetails.receivedCopies - 1 + : 0; + return replayCount > existing.echoCount ? replayCount : existing.echoCount; + } + + int _mergeSentReplayPathLen( + Message existing, + Message incoming, + MessageRouteMetadata? routeMetadata, + ) { + if (routeMetadata?.mode == PathSelectionMode.flood || + existing.usedFloodFallback) { + return existing.pathLen; + } + + final routeHopCount = routeMetadata?.hopCount; + if (routeHopCount != null && routeHopCount > 0) { + return routeHopCount; + } + + if (existing.pathLen > 0) { + return existing.pathLen; + } + + return incoming.pathLen > 0 ? incoming.pathLen : existing.pathLen; + } + + Uint8List? _mergeSentReplayPathBytes( + Message existing, + Message incoming, + MessageRouteMetadata? routeMetadata, + ) { + if (routeMetadata?.mode == PathSelectionMode.flood || + existing.usedFloodFallback) { + return existing.pathBytes; + } + + return existing.pathBytes ?? incoming.pathBytes; + } + /// Add multiple messages void addMessages(List messages) { int addedCount = 0; @@ -2166,7 +2229,9 @@ class MessagesProvider with ChangeNotifier { int echoCount, int snrRaw, int rssiDbm, - ) { + { + Uint8List? pathBytes, + }) { debugPrint('🔊 [MessagesProvider] handleMessageEcho called'); debugPrint(' Message ID: $messageId'); debugPrint(' Echo count: $echoCount'); @@ -2181,18 +2246,35 @@ class MessagesProvider with ChangeNotifier { ' ✅ Found message: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', ); + final nextEchoCount = echoCount > message.echoCount + ? echoCount + : message.echoCount + 1; + // Update echo count final updatedMessage = message.copyWith( - echoCount: echoCount, + echoCount: nextEchoCount, firstEchoAt: message.firstEchoAt ?? DateTime.now(), lastEchoSnrRaw: snrRaw.toSigned(8), lastEchoRssiDbm: rssiDbm.toSigned(8), lastEchoAt: DateTime.now(), ); _messages[index] = updatedMessage; + _messageReceptionDetails[messageId] = _messageReceptionDetails[messageId] + ?.copyWith( + capturedAt: DateTime.now(), + rssiDbm: rssiDbm.toSigned(8), + snrDb: snrRaw.toSigned(8) / 4.0, + pathBytes: pathBytes?.toList(), + ) ?? + MessageReceptionDetails( + capturedAt: DateTime.now(), + rssiDbm: rssiDbm.toSigned(8), + snrDb: snrRaw.toSigned(8) / 4.0, + pathBytes: pathBytes?.toList(), + ); _clearChannelSendWarning(messageId); - debugPrint(' Updated echo count to: $echoCount'); + debugPrint(' Updated echo count to: $nextEchoCount'); _persistMessages(); notifyListeners(); debugPrint(' ✅ Echo update complete, UI notified'); diff --git a/lib/utils/log_rx_route_decoder.dart b/lib/utils/log_rx_route_decoder.dart index a5deec4..fa17485 100644 --- a/lib/utils/log_rx_route_decoder.dart +++ b/lib/utils/log_rx_route_decoder.dart @@ -79,7 +79,7 @@ class LogRxRouteDecoder { } final pathBytes = rawPacketData.sublist(index, index + pathByteLen); final hashSize = pathMode == 0 - ? inferHashSize(pathBytes, preferredHashSize: preferredHashSize) + ? 1 : (descriptorHashSize(pathDescriptor) ?? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)); diff --git a/lib/utils/message_extensions.dart b/lib/utils/message_extensions.dart index 20c3f59..a516a3f 100644 --- a/lib/utils/message_extensions.dart +++ b/lib/utils/message_extensions.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import '../models/message.dart'; +import '../models/message_route_metadata.dart'; import '../l10n/app_localizations.dart'; import '../providers/messages_provider.dart'; @@ -14,7 +15,7 @@ extension MessageLocalization on Message { // For channel messages, show echo count instead of delivery status if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) { - final latestMeta = _formatEchoMeta(context); + final latestMeta = _formatChannelStatusMeta(context, routeMetadata); if (echoCount == 0) { if (messagesProvider.hasChannelSendWarning(id)) { return 'Broadcast may have failed'; @@ -127,6 +128,41 @@ extension MessageLocalization on Message { return parts.join(' • '); } + String? _formatChannelStatusMeta( + BuildContext context, + MessageRouteMetadata? routeMetadata, + ) { + final parts = []; + final hopLabel = _formatChannelHopMeta(routeMetadata); + if (hopLabel != null) { + parts.add(hopLabel); + } + + final echoMeta = _formatEchoMeta(context); + if (echoMeta != null) { + parts.add(echoMeta); + } + + if (parts.isEmpty) { + return null; + } + + return parts.join(' • '); + } + + String? _formatChannelHopMeta(MessageRouteMetadata? routeMetadata) { + if (routeMetadata?.mode.name == 'flood') { + return routeMetadata!.modeLabel; + } + + final effectivePathLen = routeMetadata?.hopCount ?? pathLen; + if (effectivePathLen <= 0 || effectivePathLen >= 255) { + return null; + } + + return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}'; + } + String _barsForRssi(int rssiDbm) { // Approximate useful RSSI range: -120..-70 dBm final score = ((rssiDbm + 120) / 10).round().clamp(0, 5); diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index b3b20f9..3aeb696 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -534,7 +534,7 @@ class _MessageBubbleState extends State { _hideDrawingFromMap(parentContext); }, ), - // Technical details option + // Details option ListTile( leading: Icon(Icons.data_object), title: Text(l10n.technicalDetails), @@ -676,6 +676,15 @@ class _MessageBubbleState extends State { final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes) ?.map((b) => b.toRadixString(16).padLeft(2, '0')) .join(':'); + final lastEchoRelayHash = receptionDetails?.pathBytes?.isNotEmpty == true + ? receptionDetails!.pathBytes!.last + .toRadixString(16) + .padLeft(2, '0') + .toUpperCase() + : null; + final lastEchoBytesReport = _formatPathBytesReport( + receptionDetails?.pathBytes, + ); final snrDb = receptionDetails?.snrDb ?? matchedRxLog?.logRxDataInfo?.snrDb ?? @@ -712,6 +721,9 @@ class _MessageBubbleState extends State { 'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}', 'Channel index: ${widget.message.channelIdx ?? '-'}', 'Echo count: ${widget.message.echoCount}', + 'Last echo relay hash: ${lastEchoRelayHash ?? '-'}', + 'Last echo path bytes: ${receptionDetails?.pathBytesHex ?? '-'}', + 'Last echo bytes report: ${lastEchoBytesReport ?? '-'}', 'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}', 'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}', 'Matched RX RSSI: ${rssiDbm ?? '-'}', @@ -1044,6 +1056,33 @@ class _MessageBubbleState extends State { label: l10n.receivedCopies, value: '${widget.receivedCopies}', ), + if (lastEchoRelayHash != null) + _detailRow( + sheetContext, + label: 'Last echo relay', + value: lastEchoRelayHash, + onCopy: () => + copyField(sheetContext, lastEchoRelayHash), + ), + if (receptionDetails?.pathBytesHex + case final echoPath?) + _detailRow( + sheetContext, + label: 'Last echo path', + value: echoPath, + onCopy: () => + copyField(sheetContext, echoPath), + ), + if (lastEchoBytesReport != null) + _detailRow( + sheetContext, + label: 'Last echo bytes report', + value: lastEchoBytesReport, + onCopy: () => copyField( + sheetContext, + lastEchoBytesReport, + ), + ), if (widget.message.suggestedTimeoutMs != null) _detailRow( sheetContext, @@ -1624,6 +1663,23 @@ class _MessageBubbleState extends State { return '$durationMs ms'; } + String? _formatPathBytesReport(List? pathBytes) { + if (pathBytes == null || pathBytes.isEmpty) { + return null; + } + + final byteHex = pathBytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase()) + .toList(); + final indexedHops = byteHex + .asMap() + .entries + .map((entry) => '#${entry.key + 1}=${entry.value}') + .join(', '); + final byteLabel = pathBytes.length == 1 ? 'byte' : 'bytes'; + return '${pathBytes.length} $byteLabel [${byteHex.join(' ')}] • hops $indexedHops'; + } + BlePacketLog? _findBestMatchingRxLog( List logs, Message message, diff --git a/pubspec.yaml b/pubspec.yaml index ff51855..94126d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.0414.1+54 +version: 2026.0420.1+55 environment: sdk: ^3.9.2 diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index ad7ca6b..d5f60ad 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -254,6 +254,47 @@ void main() { }); }); + test('repeated echo callbacks accumulate when radio reports one each time', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-echo-callbacks', + senderTimestamp: 1700000002, + ), + ); + provider.markMessageSent('c-echo-callbacks', 0, 0); + + provider.handleMessageEcho( + 'c-echo-callbacks', + 1, + 4, + -90, + pathBytes: Uint8List.fromList([0xAA]), + ); + provider.handleMessageEcho( + 'c-echo-callbacks', + 1, + 5, + -89, + pathBytes: Uint8List.fromList([0xAA, 0xBB]), + ); + provider.handleMessageEcho( + 'c-echo-callbacks', + 1, + 6, + -88, + pathBytes: Uint8List.fromList([0xAA, 0xBB, 0xCC]), + ); + + expect(provider.messages.single.echoCount, equals(3)); + expect(provider.messages.single.lastEchoSnrRaw, equals(6)); + expect(provider.messages.single.lastEchoRssiDbm, equals(-88)); + expect( + provider.getMessageReceptionDetails('c-echo-callbacks')?.pathBytes, + [0xAA, 0xBB, 0xCC], + ); + }); + test('channel warning clears when replay arrives after send', () { fakeAsync((async) { final provider = MessagesProvider(); @@ -302,9 +343,110 @@ void main() { expect(provider.messages, hasLength(1)); expect(provider.messages.single.id, equals('c-echo')); + expect(provider.messages.single.echoCount, equals(1)); expect(provider.messages.single.pathLen, equals(1)); }); + test('channel replay count reflects how many times a sent message was heard', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-repeat-count', + senderTimestamp: 1700000110, + ), + ); + provider.markMessageSent('c-repeat-count', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-repeat-count-1', + senderTimestamp: 1700000111, + senderName: 'dz0ny (SI)', + ), + ); + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-repeat-count-2', + senderTimestamp: 1700000112, + senderName: 'dz0ny (SI)', + ), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.echoCount, equals(2)); + expect(provider.getMessageReceptionDetails('c-repeat-count')?.receivedCopies, equals(3)); + }); + + test('channel replay keeps flood mode hop count untouched', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-flood-path', + senderTimestamp: 1700000120, + ), + ); + provider.updateMessageRouteSelection( + 'c-flood-path', + PathSelection.flood(), + routerFallbackAttempted: false, + ); + provider.markMessageSent('c-flood-path', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-flood-path-incoming', + senderTimestamp: 1700000121, + senderName: 'dz0ny (SI)', + ).copyWith(pathBytes: Uint8List.fromList([0xAA, 0xBB])), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.echoCount, equals(1)); + expect(provider.messages.single.pathLen, equals(0)); + expect(provider.messages.single.pathBytes, isEmpty); + expect( + provider.getMessageRouteMetadata('c-flood-path')?.mode, + PathSelectionMode.flood, + ); + }); + + test('channel replay preserves sent direct path bytes', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-direct-path', + senderTimestamp: 1700000130, + ), + ); + provider.updateMessageRouteSelection( + 'c-direct-path', + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList([0x01, 0x02]), + hopCount: 2, + hashSize: 1, + ), + routerFallbackAttempted: false, + ); + provider.markMessageSent('c-direct-path', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-direct-path-incoming', + senderTimestamp: 1700000131, + senderName: 'dz0ny (SI)', + ).copyWith(pathBytes: Uint8List.fromList([0xAA, 0xBB])), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.echoCount, equals(1)); + expect(provider.messages.single.pathLen, equals(2)); + expect(provider.messages.single.pathBytes, [0x01, 0x02]); + }); + test( 'channel replay is not deduped for different sender with same text', () { diff --git a/test/utils/log_rx_route_decoder_test.dart b/test/utils/log_rx_route_decoder_test.dart index 0317789..1117507 100644 --- a/test/utils/log_rx_route_decoder_test.dart +++ b/test/utils/log_rx_route_decoder_test.dart @@ -26,9 +26,30 @@ void main() { 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'); + expect(decoded.hashSize, 1); + expect(decoded.hopHashes, ['c2', 'ba', '5f', 'de']); + expect(decoded.originalSenderHashHex, 'c2'); + }); + + test('parses legacy two byte paths as two one-byte hops', () { + final packet = Uint8List.fromList([ + 0x88, + 0x37, + 0xae, + 0x05, + 0x02, + 0xc2, + 0xba, + ]); + + final decoded = LogRxRouteDecoder.decode(packet); + + expect(decoded, isNotNull); + expect(decoded!.pathDescriptor, 0x02); + expect(decoded.pathBytes, [0xc2, 0xba]); + expect(decoded.hashSize, 1); + expect(decoded.hopCount, 2); + expect(decoded.hopHashes, ['c2', 'ba']); }); test('parses encoded descriptor with 2-byte hashes', () { @@ -55,7 +76,7 @@ void main() { expect(decoded.hopHashes, ['c2ba', '5fde']); }); - test('uses preferred hash size when packet length is ambiguous', () { + test('uses one byte hashes for legacy packet lengths', () { final packet = Uint8List.fromList([ 0x88, 0x37, @@ -70,7 +91,7 @@ void main() { 0xff, ]); - final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 1); + final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 2); expect(decoded, isNotNull); expect(decoded!.hashSize, 1); diff --git a/test/widgets/message_bubble_test.dart b/test/widgets/message_bubble_test.dart index 087d4e8..a2351cc 100644 --- a/test/widgets/message_bubble_test.dart +++ b/test/widgets/message_bubble_test.dart @@ -7,6 +7,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.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/path_selection.dart'; import 'package:meshcore_sar_app/providers/app_provider.dart'; import 'package:meshcore_sar_app/providers/channels_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart'; @@ -18,6 +19,7 @@ import 'package:meshcore_sar_app/providers/voice_provider.dart'; import 'package:meshcore_sar_app/services/location_tracking_service.dart'; import 'package:meshcore_sar_app/services/voice_codec_service.dart'; import 'package:meshcore_sar_app/services/voice_player_service.dart'; +import 'package:meshcore_sar_app/utils/message_extensions.dart'; import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; @@ -187,6 +189,103 @@ void main() { } }); + testWidgets('sent channel status shows heard count and flood route', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'sent-channel-status', + messageType: MessageType.channel, + senderPublicKeyPrefix: _prefix(22), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000001, + text: 'Flood status', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000001500), + deliveryStatus: MessageDeliveryStatus.sent, + echoCount: 2, + ); + harness.messagesProvider.updateMessageRouteSelection( + 'sent-channel-status', + PathSelection.flood(), + routerFallbackAttempted: false, + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: harness.messagesProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => + Text(message.getLocalizedDeliveryStatus(context)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('2 nodes • Flood route'), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('details show sent channel echo relay path', (tester) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'sent-channel-echo-details', + messageType: MessageType.channel, + senderPublicKeyPrefix: _prefix(23), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000002, + text: 'Echo detail path', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000002500), + deliveryStatus: MessageDeliveryStatus.sent, + echoCount: 1, + lastEchoRssiDbm: -88, + lastEchoSnrRaw: 6, + ); + harness.messagesProvider.addSentMessage(message); + harness.messagesProvider.handleMessageEcho( + 'sent-channel-echo-details', + 1, + 6, + -88, + pathBytes: Uint8List.fromList([0x10, 0x20, 0xAA]), + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Echo detail path')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + + expect(find.text('Last echo relay'), findsOneWidget); + expect(find.text('AA'), findsOneWidget); + expect(find.text('Last echo path'), findsOneWidget); + expect(find.text('10:20:aa'), findsWidgets); + expect(find.text('Last echo bytes report'), findsOneWidget); + expect( + find.text('3 bytes [10 20 AA] • hops #1=10, #2=20, #3=AA'), + findsOneWidget, + ); + } finally { + await _disposeHarness(tester, harness); + } + }); + testWidgets('channel bubbles refresh to synced channel names', ( tester, ) async { @@ -403,7 +502,7 @@ void main() { await tester.longPress(find.text('Location details')); await tester.pumpAndSettle(); - await tester.tap(find.text('Technical details')); + await tester.tap(find.text('Details')); await tester.pumpAndSettle(); expect(find.byType(flutter_map.FlutterMap), findsOneWidget); @@ -451,7 +550,7 @@ void main() { await tester.longPress(find.text('Channel fallback')); await tester.pumpAndSettle(); - await tester.tap(find.text('Technical details')); + await tester.tap(find.text('Details')); await tester.pumpAndSettle(); expect(find.byType(flutter_map.FlutterMap), findsOneWidget);