Fix echo metadata and bump build number #123

This commit is contained in:
Janez T
2026-04-26 08:02:53 +02:00
parent f9dfdc2340
commit 4181b33da1
15 changed files with 498 additions and 39 deletions

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 140; CURRENT_PROJECT_VERSION = 141;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 140; CURRENT_PROJECT_VERSION = 141;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 140; CURRENT_PROJECT_VERSION = 141;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 140; CURRENT_PROJECT_VERSION = 141;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 140; CURRENT_PROJECT_VERSION = 141;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 140; CURRENT_PROJECT_VERSION = 141;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>140</string> <string>141</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000271"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000252">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.342101"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.48394">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="145.467717"> <testcase classname="fastlane.lanes" name="2: build_app" time="153.436107">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="188.692143"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="6642.682605">
</testcase> </testcase>

View File

@@ -2018,9 +2018,9 @@
}, },
"reply": "Reply", "reply": "Reply",
"@reply": {}, "@reply": {},
"technicalDetails": "Technical details", "technicalDetails": "Details",
"@technicalDetails": {}, "@technicalDetails": {},
"messageTechnicalDetails": "Message technical details", "messageTechnicalDetails": "Message details",
"@messageTechnicalDetails": {}, "@messageTechnicalDetails": {},
"linkQuality": "Link quality", "linkQuality": "Link quality",
"@linkQuality": {}, "@linkQuality": {},

View File

@@ -2769,13 +2769,13 @@ abstract class AppLocalizations {
/// No description provided for @technicalDetails. /// No description provided for @technicalDetails.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Technical details'** /// **'Details'**
String get technicalDetails; String get technicalDetails;
/// No description provided for @messageTechnicalDetails. /// No description provided for @messageTechnicalDetails.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Message technical details'** /// **'Message details'**
String get messageTechnicalDetails; String get messageTechnicalDetails;
/// No description provided for @linkQuality. /// No description provided for @linkQuality.

View File

@@ -1471,10 +1471,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get reply => 'Reply'; String get reply => 'Reply';
@override @override
String get technicalDetails => 'Technical details'; String get technicalDetails => 'Details';
@override @override
String get messageTechnicalDetails => 'Message technical details'; String get messageTechnicalDetails => 'Message details';
@override @override
String get linkQuality => 'Link quality'; String get linkQuality => 'Link quality';

View File

@@ -1773,7 +1773,13 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount', '🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount',
); );
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm); messagesProvider.handleMessageEcho(
messageId,
echoCount,
snrRaw,
rssiDbm,
pathBytes: _latestChannelEchoPathBytes(),
);
}; };
connectionProvider.prepareDirectMessageSendCallback = connectionProvider.prepareDirectMessageSendCallback =
@@ -4056,6 +4062,23 @@ class AppProvider with ChangeNotifier {
return decoded.pathBytes; 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 // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching // The ConnectionProvider's onMessageWaiting callback handles automatic message fetching

View File

@@ -198,6 +198,9 @@ class MessagesProvider with ChangeNotifier {
_messages[index] = _messages[index].copyWith( _messages[index] = _messages[index].copyWith(
usedFloodFallback: selection.usesFlood, usedFloodFallback: selection.usesFlood,
pathLen: nextPathLen, pathLen: nextPathLen,
pathBytes: selection.hasDirectPath
? Uint8List.fromList(selection.pathBytes)
: Uint8List(0),
); );
} }
@@ -803,15 +806,30 @@ class MessagesProvider with ChangeNotifier {
if (contactLocationSnapshot != null) { if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot; _messageContactLocations[existingId] = contactLocationSnapshot;
} }
_messageReceptionDetails[existingId] = final mergedReceptionDetails = MessageReceptionDetails.mergeDuplicate(
MessageReceptionDetails.mergeDuplicate( existing: _messageReceptionDetails[existingId],
existing: _messageReceptionDetails[existingId], incoming: receptionDetailsSnapshot,
incoming: receptionDetailsSnapshot, );
); _messageReceptionDetails[existingId] = mergedReceptionDetails;
final existingMessage = _messages[matchingSentReplayIndex]; final existingMessage = _messages[matchingSentReplayIndex];
final routeMetadata = _messageRouteMetadata[existingId];
_messages[matchingSentReplayIndex] = existingMessage.copyWith( _messages[matchingSentReplayIndex] = existingMessage.copyWith(
pathLen: finalMessage.pathLen > 0 ? finalMessage.pathLen : existingMessage.pathLen, echoCount: _mergeSentReplayEchoCount(
pathBytes: finalMessage.pathBytes ?? existingMessage.pathBytes, existingMessage,
mergedReceptionDetails,
),
pathLen: _mergeSentReplayPathLen(
existingMessage,
finalMessage,
routeMetadata,
),
pathBytes: _mergeSentReplayPathBytes(
existingMessage,
finalMessage,
routeMetadata,
),
firstEchoAt: existingMessage.firstEchoAt ?? DateTime.now(),
lastEchoAt: DateTime.now(),
); );
_persistMessages(); _persistMessages();
return; return;
@@ -1032,6 +1050,51 @@ class MessagesProvider with ChangeNotifier {
return _matchesDuplicateSenderIdentity(existing, message); 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 /// Add multiple messages
void addMessages(List<Message> messages) { void addMessages(List<Message> messages) {
int addedCount = 0; int addedCount = 0;
@@ -2166,7 +2229,9 @@ class MessagesProvider with ChangeNotifier {
int echoCount, int echoCount,
int snrRaw, int snrRaw,
int rssiDbm, int rssiDbm,
) { {
Uint8List? pathBytes,
}) {
debugPrint('🔊 [MessagesProvider] handleMessageEcho called'); debugPrint('🔊 [MessagesProvider] handleMessageEcho called');
debugPrint(' Message ID: $messageId'); debugPrint(' Message ID: $messageId');
debugPrint(' Echo count: $echoCount'); 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)}...', ' ✅ 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 // Update echo count
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
echoCount: echoCount, echoCount: nextEchoCount,
firstEchoAt: message.firstEchoAt ?? DateTime.now(), firstEchoAt: message.firstEchoAt ?? DateTime.now(),
lastEchoSnrRaw: snrRaw.toSigned(8), lastEchoSnrRaw: snrRaw.toSigned(8),
lastEchoRssiDbm: rssiDbm.toSigned(8), lastEchoRssiDbm: rssiDbm.toSigned(8),
lastEchoAt: DateTime.now(), lastEchoAt: DateTime.now(),
); );
_messages[index] = updatedMessage; _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); _clearChannelSendWarning(messageId);
debugPrint(' Updated echo count to: $echoCount'); debugPrint(' Updated echo count to: $nextEchoCount');
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
debugPrint(' ✅ Echo update complete, UI notified'); debugPrint(' ✅ Echo update complete, UI notified');

View File

@@ -79,7 +79,7 @@ class LogRxRouteDecoder {
} }
final pathBytes = rawPacketData.sublist(index, index + pathByteLen); final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
final hashSize = pathMode == 0 final hashSize = pathMode == 0
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize) ? 1
: (descriptorHashSize(pathDescriptor) ?? : (descriptorHashSize(pathDescriptor) ??
inferHashSize(pathBytes, preferredHashSize: preferredHashSize)); inferHashSize(pathBytes, preferredHashSize: preferredHashSize));

View File

@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/message_route_metadata.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
@@ -14,7 +15,7 @@ extension MessageLocalization on Message {
// For channel messages, show echo count instead of delivery status // For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) { if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
final latestMeta = _formatEchoMeta(context); final latestMeta = _formatChannelStatusMeta(context, routeMetadata);
if (echoCount == 0) { if (echoCount == 0) {
if (messagesProvider.hasChannelSendWarning(id)) { if (messagesProvider.hasChannelSendWarning(id)) {
return 'Broadcast may have failed'; return 'Broadcast may have failed';
@@ -127,6 +128,41 @@ extension MessageLocalization on Message {
return parts.join(''); return parts.join('');
} }
String? _formatChannelStatusMeta(
BuildContext context,
MessageRouteMetadata? routeMetadata,
) {
final parts = <String>[];
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) { String _barsForRssi(int rssiDbm) {
// Approximate useful RSSI range: -120..-70 dBm // Approximate useful RSSI range: -120..-70 dBm
final score = ((rssiDbm + 120) / 10).round().clamp(0, 5); final score = ((rssiDbm + 120) / 10).round().clamp(0, 5);

View File

@@ -534,7 +534,7 @@ class _MessageBubbleState extends State<MessageBubble> {
_hideDrawingFromMap(parentContext); _hideDrawingFromMap(parentContext);
}, },
), ),
// Technical details option // Details option
ListTile( ListTile(
leading: Icon(Icons.data_object), leading: Icon(Icons.data_object),
title: Text(l10n.technicalDetails), title: Text(l10n.technicalDetails),
@@ -676,6 +676,15 @@ class _MessageBubbleState extends State<MessageBubble> {
final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes) final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes)
?.map((b) => b.toRadixString(16).padLeft(2, '0')) ?.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':'); .join(':');
final lastEchoRelayHash = receptionDetails?.pathBytes?.isNotEmpty == true
? receptionDetails!.pathBytes!.last
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase()
: null;
final lastEchoBytesReport = _formatPathBytesReport(
receptionDetails?.pathBytes,
);
final snrDb = final snrDb =
receptionDetails?.snrDb ?? receptionDetails?.snrDb ??
matchedRxLog?.logRxDataInfo?.snrDb ?? matchedRxLog?.logRxDataInfo?.snrDb ??
@@ -712,6 +721,9 @@ class _MessageBubbleState extends State<MessageBubble> {
'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}', 'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
'Channel index: ${widget.message.channelIdx ?? '-'}', 'Channel index: ${widget.message.channelIdx ?? '-'}',
'Echo count: ${widget.message.echoCount}', '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 RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}',
'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}', 'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
'Matched RX RSSI: ${rssiDbm ?? '-'}', 'Matched RX RSSI: ${rssiDbm ?? '-'}',
@@ -1044,6 +1056,33 @@ class _MessageBubbleState extends State<MessageBubble> {
label: l10n.receivedCopies, label: l10n.receivedCopies,
value: '${widget.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) if (widget.message.suggestedTimeoutMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
@@ -1624,6 +1663,23 @@ class _MessageBubbleState extends State<MessageBubble> {
return '$durationMs ms'; return '$durationMs ms';
} }
String? _formatPathBytesReport(List<int>? 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( BlePacketLog? _findBestMatchingRxLog(
List<BlePacketLog> logs, List<BlePacketLog> logs,
Message message, Message message,

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.9.2 sdk: ^3.9.2

View File

@@ -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', () { test('channel warning clears when replay arrives after send', () {
fakeAsync((async) { fakeAsync((async) {
final provider = MessagesProvider(); final provider = MessagesProvider();
@@ -302,9 +343,110 @@ void main() {
expect(provider.messages, hasLength(1)); expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-echo')); expect(provider.messages.single.id, equals('c-echo'));
expect(provider.messages.single.echoCount, equals(1));
expect(provider.messages.single.pathLen, 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( test(
'channel replay is not deduped for different sender with same text', 'channel replay is not deduped for different sender with same text',
() { () {

View File

@@ -26,9 +26,30 @@ void main() {
expect(decoded!.payloadType, 0x01); expect(decoded!.payloadType, 0x01);
expect(decoded.pathDescriptor, 0x04); 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, 1);
expect(decoded.hopHashes, ['c2ba', '5fde']); expect(decoded.hopHashes, ['c2', 'ba', '5f', 'de']);
expect(decoded.originalSenderHashHex, 'c2ba'); 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', () { test('parses encoded descriptor with 2-byte hashes', () {
@@ -55,7 +76,7 @@ void main() {
expect(decoded.hopHashes, ['c2ba', '5fde']); 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([ final packet = Uint8List.fromList([
0x88, 0x88,
0x37, 0x37,
@@ -70,7 +91,7 @@ void main() {
0xff, 0xff,
]); ]);
final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 1); final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 2);
expect(decoded, isNotNull); expect(decoded, isNotNull);
expect(decoded!.hashSize, 1); expect(decoded!.hashSize, 1);

View File

@@ -7,6 +7,7 @@ import 'package:geolocator/geolocator.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.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.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/models/path_selection.dart';
import 'package:meshcore_sar_app/providers/app_provider.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/channels_provider.dart';
import 'package:meshcore_sar_app/providers/connection_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/location_tracking_service.dart';
import 'package:meshcore_sar_app/services/voice_codec_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/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:meshcore_sar_app/widgets/messages/message_bubble.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:provider/provider.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', ( testWidgets('channel bubbles refresh to synced channel names', (
tester, tester,
) async { ) async {
@@ -403,7 +502,7 @@ void main() {
await tester.longPress(find.text('Location details')); await tester.longPress(find.text('Location details'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Technical details')); await tester.tap(find.text('Details'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(flutter_map.FlutterMap), findsOneWidget); expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
@@ -451,7 +550,7 @@ void main() {
await tester.longPress(find.text('Channel fallback')); await tester.longPress(find.text('Channel fallback'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Technical details')); await tester.tap(find.text('Details'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(flutter_map.FlutterMap), findsOneWidget); expect(find.byType(flutter_map.FlutterMap), findsOneWidget);