Add retransmission handling

This commit is contained in:
Janez T
2026-03-06 20:12:12 +01:00
parent 7cd5351921
commit 85d0b26c67
14 changed files with 1417 additions and 999 deletions

View File

@@ -249,7 +249,10 @@ class ConnectionProvider with ChangeNotifier {
await Future.delayed(const Duration(milliseconds: 300));
if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!);
_messageDeliveryTracker.trackPendingDirectMessage(
pendingOp.messageId!,
pendingOp.contactPublicKey,
);
}
await _activeService.sendTextMessage(
@@ -352,7 +355,11 @@ class ConnectionProvider with ChangeNotifier {
service.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
final messageId = _messageDeliveryTracker.popPendingMessageId();
final messageId = contactPublicKey != null
? _messageDeliveryTracker.popPendingDirectMessageId(
contactPublicKey,
)
: _messageDeliveryTracker.popPendingMessageId();
if (messageId != null) {
_messageDeliveryTracker.mapAckTagToMessageId(
expectedAckTag,
@@ -1148,7 +1155,10 @@ class ConnectionProvider with ChangeNotifier {
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
// We still track here for the SENT response callback to work.
if (messageId != null) {
_messageDeliveryTracker.trackPendingMessage(messageId);
_messageDeliveryTracker.trackPendingDirectMessage(
messageId,
contactPublicKey,
);
debugPrint(' 📝 Tracked pending message: $messageId');
}

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
/// Message delivery tracking helper
///
/// Manages message delivery tracking for sent messages, including:
@@ -16,6 +18,9 @@ class MessageDeliveryTracker {
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
final List<String> _pendingMessageIds = [];
/// Contact-scoped FIFOs for matching direct-message SENT responses.
final Map<String, List<String>> _pendingMessageIdsByContact = {};
/// Map of ACK tag to message ID for delivery confirmation
final Map<int, String> _ackTagToMessageId = {};
@@ -33,6 +38,15 @@ class MessageDeliveryTracker {
_pendingMessageIds.add(messageId);
}
/// Track a pending direct message ID for a specific contact.
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
trackPendingMessage(messageId);
final contactKey = _contactKey(contactPublicKey);
_pendingMessageIdsByContact
.putIfAbsent(contactKey, () => [])
.add(messageId);
}
/// Pop the next pending message ID from FIFO queue
///
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
@@ -43,6 +57,24 @@ class MessageDeliveryTracker {
return _pendingMessageIds.removeAt(0);
}
/// Pop the next pending direct message ID for a specific contact.
///
/// Falls back to the legacy global FIFO if the contact queue is empty.
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
final contactKey = _contactKey(contactPublicKey);
final queue = _pendingMessageIdsByContact[contactKey];
if (queue == null || queue.isEmpty) {
return popPendingMessageId();
}
final messageId = queue.removeAt(0);
if (queue.isEmpty) {
_pendingMessageIdsByContact.remove(contactKey);
}
_pendingMessageIds.remove(messageId);
return messageId;
}
/// Map ACK tag to message ID after RESP_CODE_SENT received
///
/// Creates bidirectional mapping for efficient cleanup and tracking.
@@ -86,6 +118,17 @@ class MessageDeliveryTracker {
_ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag);
}
_pendingMessageIds.remove(messageId);
final emptyKeys = <String>[];
for (final entry in _pendingMessageIdsByContact.entries) {
entry.value.remove(messageId);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingMessageIdsByContact.remove(key);
}
}
/// Clean up stale ACK mappings
@@ -114,6 +157,7 @@ class MessageDeliveryTracker {
/// Clear all tracking state
void clearTracking() {
_pendingMessageIds.clear();
_pendingMessageIdsByContact.clear();
_ackTagToMessageId.clear();
_messageIdToAckTag.clear();
_ackTagTimestamps.clear();
@@ -133,9 +177,7 @@ class MessageDeliveryTracker {
/// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null;
return _ackTagTimestamps.values.reduce(
(a, b) => a.isBefore(b) ? a : b,
);
return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
}
/// Get diagnostic info for debugging
@@ -145,6 +187,15 @@ class MessageDeliveryTracker {
'shouldRateLimit': shouldRateLimit,
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
'ackTags': _ackTagToMessageId.keys.toList(),
'pendingByContact': _pendingMessageIdsByContact.map(
(key, value) => MapEntry(key, value.length),
),
};
}
String _contactKey(Uint8List contactPublicKey) {
return contactPublicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
}

View File

@@ -28,6 +28,9 @@ class MessagesProvider with ChangeNotifier {
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
final Map<String, Timer> _timeoutTimers = {};
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
final Map<int, DateTime> _completedAckHistory = {};
// Retry management
final MessageRetryManager _retryManager = MessageRetryManager();
@@ -906,11 +909,13 @@ class MessagesProvider with ChangeNotifier {
final (groupId, recipientPublicKey) = groupMapping;
debugPrint(' ✅ This is part of a grouped message: $groupId');
// Update the recipient status to "sent" in the grouped message
// ACK-tracked recipients stay pending until the delivery confirm arrives.
updateGroupedMessageRecipientStatus(
groupId,
recipientPublicKey,
MessageDeliveryStatus.sent,
expectedAckTag > 0
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
);
// Track the ACK for this specific recipient
@@ -1022,7 +1027,9 @@ class MessagesProvider with ChangeNotifier {
);
final updatedMessage = message.copyWith(
deliveryStatus: MessageDeliveryStatus.sent,
deliveryStatus: expectedAckTag > 0
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
);
@@ -1245,6 +1252,7 @@ class MessagesProvider with ChangeNotifier {
/// Update message status to delivered with RTT
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
_cleanupCompletedAckHistory();
debugPrint(
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
);
@@ -1296,6 +1304,7 @@ class MessagesProvider with ChangeNotifier {
);
_ackTagToRecipients.remove(ackCode);
_pendingSentMessages.remove(ackCode);
_rememberCompletedAck(ackCode);
}
debugPrint(
@@ -1339,6 +1348,7 @@ class MessagesProvider with ChangeNotifier {
// Remove from pending
_pendingSentMessages.remove(ackCode);
_rememberCompletedAck(ackCode);
// Clear retry tracking on successful delivery
_retryManager.clearRetry(message.id);
@@ -1362,6 +1372,12 @@ class MessagesProvider with ChangeNotifier {
);
}
} else {
if (_completedAckHistory.containsKey(ackCode)) {
debugPrint(
' [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)',
);
return;
}
debugPrint(
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
);
@@ -1482,18 +1498,31 @@ class MessagesProvider with ChangeNotifier {
debugPrint(
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
);
final currentIndex = _messages.indexWhere((m) => m.id == messageId);
if (currentIndex == -1) {
return;
}
final currentMessage = _messages[currentIndex];
if (currentMessage.deliveryStatus == MessageDeliveryStatus.delivered) {
return;
}
if (sendMessageCallback != null) {
await sendMessageCallback!(
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: nextAttempt,
);
if (!queued) {
_markAsPermanentlyFailed(messageId, currentMessage);
}
} else {
debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
);
_markAsPermanentlyFailed(messageId, currentMessage);
}
});
@@ -1529,17 +1558,21 @@ class MessagesProvider with ChangeNotifier {
// Send with flood mode (no retry after this)
if (sendMessageCallback != null) {
await sendMessageCallback!(
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: 0, // Reset attempt for flood
);
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else {
debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
);
_markAsPermanentlyFailed(messageId, _messages[index]);
}
_persistMessages();
@@ -1608,17 +1641,21 @@ class MessagesProvider with ChangeNotifier {
// Send again
if (sendMessageCallback != null) {
await sendMessageCallback!(
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: 0,
);
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else {
debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
);
_markAsPermanentlyFailed(messageId, _messages[index]);
}
_persistMessages();
@@ -1631,10 +1668,29 @@ class MessagesProvider with ChangeNotifier {
timer.cancel();
}
_timeoutTimers.clear();
_completedAckHistory.clear();
// Clear retry manager
_retryManager.clearAll();
super.dispose();
}
void _rememberCompletedAck(int ackCode) {
_completedAckHistory[ackCode] = DateTime.now();
_cleanupCompletedAckHistory();
}
void _cleanupCompletedAckHistory({
Duration maxAge = const Duration(minutes: 15),
}) {
final cutoff = DateTime.now().subtract(maxAge);
final staleAcks = _completedAckHistory.entries
.where((entry) => entry.value.isBefore(cutoff))
.map((entry) => entry.key)
.toList();
for (final ack in staleAcks) {
_completedAckHistory.remove(ack);
}
}
}