diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index c65495e..d04c739 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -20,6 +20,8 @@ import 'helpers/message_retry_manager.dart'; /// Messages Provider - manages message history and SAR markers class MessagesProvider with ChangeNotifier { + static const Duration _channelEchoWarningDelay = Duration(seconds: 12); + final List _messages = []; final Map _sarMarkers = {}; final Set _removedSarMarkerIds = {}; @@ -38,6 +40,8 @@ class MessagesProvider with ChangeNotifier { // Track timeout timers for pending messages // Key: message ID (not ACK tag, since multiple messages can share same ACK) final Map _timeoutTimers = {}; + final Map _channelEchoWarningTimers = {}; + final Set _channelEchoWarningMessageIds = {}; // Recently completed ACKs are kept briefly to ignore duplicate confirms. final Map _completedAckHistory = {}; @@ -156,6 +160,9 @@ class MessagesProvider with ChangeNotifier { MessageRouteMetadata? getMessageRouteMetadata(String messageId) => _messageRouteMetadata[messageId]; + bool hasChannelSendWarning(String messageId) => + _channelEchoWarningMessageIds.contains(messageId); + void updateMessageRouteSelection( String messageId, PathSelection selection, { @@ -565,6 +572,11 @@ class MessagesProvider with ChangeNotifier { ' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', ); final existingId = _messages[duplicateIndex].id; + if (finalMessage.isChannelMessage && + !finalMessage.isSentMessage && + _messages[duplicateIndex].isSentMessage) { + _clearChannelSendWarning(existingId); + } if (contactLocationSnapshot != null) { _messageContactLocations[existingId] = contactLocationSnapshot; } @@ -787,6 +799,35 @@ class MessagesProvider with ChangeNotifier { 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 Future _triggerSarNotification( Message message, @@ -1167,6 +1208,7 @@ class MessagesProvider with ChangeNotifier { // Cancel timeout timer if it exists _timeoutTimers[message.id]?.cancel(); _timeoutTimers.remove(message.id); + _clearChannelSendWarning(message.id); if (message.expectedAckTag != null) { _pendingSentMessages.remove(message.expectedAckTag); } @@ -1203,6 +1245,11 @@ class MessagesProvider with ChangeNotifier { /// Clear all messages void clearMessages() { + for (final timer in _channelEchoWarningTimers.values) { + timer.cancel(); + } + _channelEchoWarningTimers.clear(); + _channelEchoWarningMessageIds.clear(); _messages.clear(); _sarMarkers.clear(); _removedSarMarkerIds.clear(); @@ -1225,6 +1272,11 @@ class MessagesProvider with ChangeNotifier { /// Clear all data void clearAll() { + for (final timer in _channelEchoWarningTimers.values) { + timer.cancel(); + } + _channelEchoWarningTimers.clear(); + _channelEchoWarningMessageIds.clear(); _messages.clear(); _sarMarkers.clear(); _removedSarMarkerIds.clear(); @@ -1639,6 +1691,9 @@ class MessagesProvider with ChangeNotifier { debugPrint( ' ℹ️ Channel message (no ACK tracking) - marked as sent immediately', ); + if (message.isChannelMessage) { + _scheduleChannelEchoWarning(messageId); + } } debugPrint(' Calling notifyListeners() to update UI with "sent" status'); @@ -1687,6 +1742,7 @@ class MessagesProvider with ChangeNotifier { lastEchoAt: DateTime.now(), ); _messages[index] = updatedMessage; + _clearChannelSendWarning(messageId); debugPrint(' Updated echo count to: $echoCount'); _persistMessages(); @@ -2062,6 +2118,7 @@ class MessagesProvider with ChangeNotifier { } final message = _messages[index]; + _clearChannelSendWarning(messageId); final contact = _messageContactMap[messageId]; debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed'); @@ -2101,6 +2158,7 @@ class MessagesProvider with ChangeNotifier { deliveryStatus: MessageDeliveryStatus.sending, lastRetryAt: DateTime.now(), ); + _clearChannelSendWarning(messageId); // Cancel old timeout timer _timeoutTimers[message.id]?.cancel(); @@ -2166,6 +2224,7 @@ class MessagesProvider with ChangeNotifier { deliveryStatus: MessageDeliveryStatus.sending, lastRetryAt: DateTime.now(), ); + _clearChannelSendWarning(messageId); _timeoutTimers[message.id]?.cancel(); _timeoutTimers.remove(message.id); @@ -2292,6 +2351,7 @@ class MessagesProvider with ChangeNotifier { isVoice: message.isVoice, voiceId: message.voiceId, ); + _clearChannelSendWarning(messageId); _persistMessages(); notifyListeners(); @@ -2355,6 +2415,11 @@ class MessagesProvider with ChangeNotifier { timer.cancel(); } _timeoutTimers.clear(); + for (final timer in _channelEchoWarningTimers.values) { + timer.cancel(); + } + _channelEchoWarningTimers.clear(); + _channelEchoWarningMessageIds.clear(); _completedAckHistory.clear(); _messageAckHistory.clear(); _ackHistoryLookup.clear(); diff --git a/lib/utils/message_extensions.dart b/lib/utils/message_extensions.dart index 3d16f1d..20c3f59 100644 --- a/lib/utils/message_extensions.dart +++ b/lib/utils/message_extensions.dart @@ -9,14 +9,16 @@ extension MessageLocalization on Message { /// Get localized delivery status text String getLocalizedDeliveryStatus(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final routeMetadata = context - .read() - .getMessageRouteMetadata(id); + final messagesProvider = context.read(); + final routeMetadata = messagesProvider.getMessageRouteMetadata(id); // For channel messages, show echo count instead of delivery status if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) { final latestMeta = _formatEchoMeta(context); if (echoCount == 0) { + if (messagesProvider.hasChannelSendWarning(id)) { + return 'Broadcast may have failed'; + } return l10n.broadcast; // "Broadcast (no echoes yet)" } else if (echoCount == 1) { return latestMeta == null ? '1 node' : '1 node • $latestMeta'; diff --git a/pubspec.yaml b/pubspec.yaml index d8d0d25..1f391a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: "0efc820" + ref: "b66e268" # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index d7388ad..2355555 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -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', () { final provider = MessagesProvider(); provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';