mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +00:00
Add channel echo warning tracking
This commit is contained in:
@@ -20,6 +20,8 @@ import 'helpers/message_retry_manager.dart';
|
|||||||
|
|
||||||
/// Messages Provider - manages message history and SAR markers
|
/// Messages Provider - manages message history and SAR markers
|
||||||
class MessagesProvider with ChangeNotifier {
|
class MessagesProvider with ChangeNotifier {
|
||||||
|
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
|
||||||
|
|
||||||
final List<Message> _messages = [];
|
final List<Message> _messages = [];
|
||||||
final Map<String, SarMarker> _sarMarkers = {};
|
final Map<String, SarMarker> _sarMarkers = {};
|
||||||
final Set<String> _removedSarMarkerIds = <String>{};
|
final Set<String> _removedSarMarkerIds = <String>{};
|
||||||
@@ -38,6 +40,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Track timeout timers for pending messages
|
// Track timeout timers for pending messages
|
||||||
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
||||||
final Map<String, Timer> _timeoutTimers = {};
|
final Map<String, Timer> _timeoutTimers = {};
|
||||||
|
final Map<String, Timer> _channelEchoWarningTimers = {};
|
||||||
|
final Set<String> _channelEchoWarningMessageIds = <String>{};
|
||||||
|
|
||||||
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
|
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
|
||||||
final Map<int, DateTime> _completedAckHistory = {};
|
final Map<int, DateTime> _completedAckHistory = {};
|
||||||
@@ -156,6 +160,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
|
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
|
||||||
_messageRouteMetadata[messageId];
|
_messageRouteMetadata[messageId];
|
||||||
|
|
||||||
|
bool hasChannelSendWarning(String messageId) =>
|
||||||
|
_channelEchoWarningMessageIds.contains(messageId);
|
||||||
|
|
||||||
void updateMessageRouteSelection(
|
void updateMessageRouteSelection(
|
||||||
String messageId,
|
String messageId,
|
||||||
PathSelection selection, {
|
PathSelection selection, {
|
||||||
@@ -565,6 +572,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
|
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
|
||||||
);
|
);
|
||||||
final existingId = _messages[duplicateIndex].id;
|
final existingId = _messages[duplicateIndex].id;
|
||||||
|
if (finalMessage.isChannelMessage &&
|
||||||
|
!finalMessage.isSentMessage &&
|
||||||
|
_messages[duplicateIndex].isSentMessage) {
|
||||||
|
_clearChannelSendWarning(existingId);
|
||||||
|
}
|
||||||
if (contactLocationSnapshot != null) {
|
if (contactLocationSnapshot != null) {
|
||||||
_messageContactLocations[existingId] = contactLocationSnapshot;
|
_messageContactLocations[existingId] = contactLocationSnapshot;
|
||||||
}
|
}
|
||||||
@@ -787,6 +799,35 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return _normalizeSenderName(resolved);
|
return _normalizeSenderName(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scheduleChannelEchoWarning(String messageId) {
|
||||||
|
_channelEchoWarningTimers[messageId]?.cancel();
|
||||||
|
_channelEchoWarningMessageIds.remove(messageId);
|
||||||
|
_channelEchoWarningTimers[messageId] = Timer(_channelEchoWarningDelay, () {
|
||||||
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
|
_channelEchoWarningTimers.remove(messageId);
|
||||||
|
if (index == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = _messages[index];
|
||||||
|
if (!message.isChannelMessage ||
|
||||||
|
!message.isSentMessage ||
|
||||||
|
message.deliveryStatus != MessageDeliveryStatus.sent ||
|
||||||
|
message.echoCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_channelEchoWarningMessageIds.add(messageId);
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearChannelSendWarning(String messageId) {
|
||||||
|
_channelEchoWarningTimers[messageId]?.cancel();
|
||||||
|
_channelEchoWarningTimers.remove(messageId);
|
||||||
|
_channelEchoWarningMessageIds.remove(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Trigger urgent notification for SAR marker
|
/// Trigger urgent notification for SAR marker
|
||||||
Future<void> _triggerSarNotification(
|
Future<void> _triggerSarNotification(
|
||||||
Message message,
|
Message message,
|
||||||
@@ -1167,6 +1208,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Cancel timeout timer if it exists
|
// Cancel timeout timer if it exists
|
||||||
_timeoutTimers[message.id]?.cancel();
|
_timeoutTimers[message.id]?.cancel();
|
||||||
_timeoutTimers.remove(message.id);
|
_timeoutTimers.remove(message.id);
|
||||||
|
_clearChannelSendWarning(message.id);
|
||||||
if (message.expectedAckTag != null) {
|
if (message.expectedAckTag != null) {
|
||||||
_pendingSentMessages.remove(message.expectedAckTag);
|
_pendingSentMessages.remove(message.expectedAckTag);
|
||||||
}
|
}
|
||||||
@@ -1203,6 +1245,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Clear all messages
|
/// Clear all messages
|
||||||
void clearMessages() {
|
void clearMessages() {
|
||||||
|
for (final timer in _channelEchoWarningTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_channelEchoWarningTimers.clear();
|
||||||
|
_channelEchoWarningMessageIds.clear();
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
_removedSarMarkerIds.clear();
|
_removedSarMarkerIds.clear();
|
||||||
@@ -1225,6 +1272,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Clear all data
|
/// Clear all data
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
|
for (final timer in _channelEchoWarningTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_channelEchoWarningTimers.clear();
|
||||||
|
_channelEchoWarningMessageIds.clear();
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
_removedSarMarkerIds.clear();
|
_removedSarMarkerIds.clear();
|
||||||
@@ -1639,6 +1691,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
' ℹ️ Channel message (no ACK tracking) - marked as sent immediately',
|
' ℹ️ Channel message (no ACK tracking) - marked as sent immediately',
|
||||||
);
|
);
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
_scheduleChannelEchoWarning(messageId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(' Calling notifyListeners() to update UI with "sent" status');
|
debugPrint(' Calling notifyListeners() to update UI with "sent" status');
|
||||||
@@ -1687,6 +1742,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
lastEchoAt: DateTime.now(),
|
lastEchoAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
_messages[index] = updatedMessage;
|
_messages[index] = updatedMessage;
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
|
|
||||||
debugPrint(' Updated echo count to: $echoCount');
|
debugPrint(' Updated echo count to: $echoCount');
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -2062,6 +2118,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final message = _messages[index];
|
final message = _messages[index];
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
final contact = _messageContactMap[messageId];
|
final contact = _messageContactMap[messageId];
|
||||||
|
|
||||||
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
|
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
|
||||||
@@ -2101,6 +2158,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
lastRetryAt: DateTime.now(),
|
lastRetryAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
|
|
||||||
// Cancel old timeout timer
|
// Cancel old timeout timer
|
||||||
_timeoutTimers[message.id]?.cancel();
|
_timeoutTimers[message.id]?.cancel();
|
||||||
@@ -2166,6 +2224,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
lastRetryAt: DateTime.now(),
|
lastRetryAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
|
|
||||||
_timeoutTimers[message.id]?.cancel();
|
_timeoutTimers[message.id]?.cancel();
|
||||||
_timeoutTimers.remove(message.id);
|
_timeoutTimers.remove(message.id);
|
||||||
@@ -2292,6 +2351,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
isVoice: message.isVoice,
|
isVoice: message.isVoice,
|
||||||
voiceId: message.voiceId,
|
voiceId: message.voiceId,
|
||||||
);
|
);
|
||||||
|
_clearChannelSendWarning(messageId);
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -2355,6 +2415,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
_timeoutTimers.clear();
|
_timeoutTimers.clear();
|
||||||
|
for (final timer in _channelEchoWarningTimers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_channelEchoWarningTimers.clear();
|
||||||
|
_channelEchoWarningMessageIds.clear();
|
||||||
_completedAckHistory.clear();
|
_completedAckHistory.clear();
|
||||||
_messageAckHistory.clear();
|
_messageAckHistory.clear();
|
||||||
_ackHistoryLookup.clear();
|
_ackHistoryLookup.clear();
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ extension MessageLocalization on Message {
|
|||||||
/// Get localized delivery status text
|
/// Get localized delivery status text
|
||||||
String getLocalizedDeliveryStatus(BuildContext context) {
|
String getLocalizedDeliveryStatus(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final routeMetadata = context
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
.read<MessagesProvider>()
|
final routeMetadata = messagesProvider.getMessageRouteMetadata(id);
|
||||||
.getMessageRouteMetadata(id);
|
|
||||||
|
|
||||||
// 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 = _formatEchoMeta(context);
|
||||||
if (echoCount == 0) {
|
if (echoCount == 0) {
|
||||||
|
if (messagesProvider.hasChannelSendWarning(id)) {
|
||||||
|
return 'Broadcast may have failed';
|
||||||
|
}
|
||||||
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
||||||
} else if (echoCount == 1) {
|
} else if (echoCount == 1) {
|
||||||
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
|
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ dependencies:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/dz0ny/meshcore_client.git
|
url: https://github.com/dz0ny/meshcore_client.git
|
||||||
ref: "0efc820"
|
ref: "b66e268"
|
||||||
|
|
||||||
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
|
|||||||
@@ -176,6 +176,72 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('channel warning appears when no echo arrives in time', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(id: 'c-warn', senderTimestamp: 1700000000),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('c-warn', 0, 0);
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn'), isFalse);
|
||||||
|
|
||||||
|
async.elapse(const Duration(seconds: 12));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn'), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel warning clears when echo arrives before timeout', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(
|
||||||
|
id: 'c-warn-echo',
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('c-warn-echo', 0, 0);
|
||||||
|
async.elapse(const Duration(seconds: 6));
|
||||||
|
provider.handleMessageEcho('c-warn-echo', 1, 4, -90);
|
||||||
|
async.elapse(const Duration(seconds: 6));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn-echo'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel warning clears when replay is deduped into sent bubble', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildSentChannelMessage(
|
||||||
|
id: 'c-warn-replay',
|
||||||
|
senderTimestamp: 1700000100,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('c-warn-replay', 0, 0);
|
||||||
|
async.elapse(const Duration(seconds: 12));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn-replay'), isTrue);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
_buildReceivedChannelReplay(
|
||||||
|
id: 'c-warn-replay-incoming',
|
||||||
|
senderTimestamp: 1700000101,
|
||||||
|
senderName: 'dz0ny (SI)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse);
|
||||||
|
expect(provider.messages, hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('channel replay is deduped for self sender within repeat window', () {
|
test('channel replay is deduped for self sender within repeat window', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
|
||||||
|
|||||||
Reference in New Issue
Block a user