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);