Msg Retry fixes, channel message fixes. Notification fixes. Make more desktop friendly. Enhance retry algo. Fix predicted location clustering add retries to reactions and fix the reactions in private DMS centralize and cleanup code in var areas

This commit is contained in:
zjs81
2026-03-20 01:54:31 -07:00
parent 53caec3e14
commit 4962a48e64
61 changed files with 4509 additions and 900 deletions
+24
View File
@@ -120,6 +120,30 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
}
Future<void> setMaxRouteWeight(double value) async {
await updateSettings(_settings.copyWith(maxRouteWeight: value));
}
Future<void> setInitialRouteWeight(double value) async {
await updateSettings(_settings.copyWith(initialRouteWeight: value));
}
Future<void> setRouteWeightSuccessIncrement(double value) async {
await updateSettings(
_settings.copyWith(routeWeightSuccessIncrement: value),
);
}
Future<void> setRouteWeightFailureDecrement(double value) async {
await updateSettings(
_settings.copyWith(routeWeightFailureDecrement: value),
);
}
Future<void> setMaxMessageRetries(int value) async {
await updateSettings(_settings.copyWith(maxMessageRetries: value));
}
Future<void> setThemeMode(String value) async {
await updateSettings(_settings.copyWith(themeMode: value));
}
+222 -388
View File
@@ -21,86 +21,74 @@ class _AckHistoryEntry {
});
}
class _AckHashMapping {
final String messageId;
final DateTime timestamp;
/// (messageId, timestamp, attemptIndex) — stored per ACK hash for O(1) lookup.
typedef AckHashMapping = ({String messageId, DateTime timestamp, int attemptIndex});
_AckHashMapping({required this.messageId, required this.timestamp});
class RetryServiceConfig {
final void Function(Contact, String, int, int) sendMessage;
final void Function(String, Message) addMessage;
final void Function(Message) updateMessage;
final Function(Contact)? clearContactPath;
final Function(Contact, Uint8List, int)? setContactPath;
final int Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeout;
final Uint8List? Function()? getSelfPublicKey;
final String Function(Contact, String)? prepareContactOutboundText;
final AppSettingsService? appSettingsService;
final AppDebugLogService? debugLogService;
final void Function(String, PathSelection, bool, int?)? recordPathResult;
final void Function(String, int, int, int)? onDeliveryObserved;
final PathSelection? Function(
String contactKey,
int attemptIndex,
int maxRetries,
List<PathSelection> recentSelections,
)? selectRetryPath;
const RetryServiceConfig({
required this.sendMessage,
required this.addMessage,
required this.updateMessage,
this.clearContactPath,
this.setContactPath,
this.calculateTimeout,
this.getSelfPublicKey,
this.prepareContactOutboundText,
this.appSettingsService,
this.debugLogService,
this.recordPathResult,
this.onDeliveryObserved,
this.selectRetryPath,
});
}
class MessageRetryService extends ChangeNotifier {
static const int maxRetries = 5;
static const int maxAckHistorySize = 100;
int _maxRetries = 5;
int get maxRetries => _maxRetries;
final Map<String, Timer> _timeoutTimers = {};
final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {};
final Map<String, _AckHashMapping> _ackHashToMessageId =
{}; // ackHashHex → messageId + timestamp for O(1) lookup
final Map<String, List<Uint8List>> _expectedAckHashes =
{}; // Track all expected ACKs for retries (for history)
final List<_AckHistoryEntry> _ackHistory =
[]; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, List<String>> _sendQueue =
{}; // contactPubKeyHex → ordered list of messageIds awaiting send
final Set<String> _activeMessages =
{}; // messageIds currently in-flight (sent/retrying)
final Set<String> _resolvedMessages =
{}; // messageIds already resolved (prevents double _onMessageResolved)
final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
final Map<String, List<PathSelection>> _attemptPathHistory = {};
final Map<String, AckHashMapping> _ackHashToMessageId = {};
final Map<String, List<Uint8List>> _expectedAckHashes = {};
final List<_AckHistoryEntry> _ackHistory = [];
final Map<String, List<String>> _sendQueue = {};
final Set<String> _activeMessages = {};
final Set<String> _resolvedMessages = {};
final Map<String, String> _expectedHashToMessageId = {};
Function(Contact, String, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback;
Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback;
Function(Contact, Uint8List, int)? _setContactPathCallback;
Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
Uint8List? Function()? _getSelfPublicKeyCallback;
String Function(Contact, String)? _prepareContactOutboundTextCallback;
AppSettingsService? _appSettingsService;
AppDebugLogService? _debugLogService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
Function(String, int, int, int)? _onDeliveryObservedCallback;
RetryServiceConfig? _config;
MessageRetryService();
void initialize({
required Function(Contact, String, int, int) sendMessageCallback,
required Function(String, Message) addMessageCallback,
required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeoutCallback,
Uint8List? Function()? getSelfPublicKeyCallback,
String Function(Contact, String)? prepareContactOutboundTextCallback,
AppSettingsService? appSettingsService,
AppDebugLogService? debugLogService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
Function(
String contactKey,
int pathLength,
int messageBytes,
int tripTimeMs,
)?
onDeliveryObservedCallback,
}) {
_sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback;
_updateMessageCallback = updateMessageCallback;
_clearContactPathCallback = clearContactPathCallback;
_setContactPathCallback = setContactPathCallback;
_calculateTimeoutCallback = calculateTimeoutCallback;
_getSelfPublicKeyCallback = getSelfPublicKeyCallback;
_prepareContactOutboundTextCallback = prepareContactOutboundTextCallback;
_appSettingsService = appSettingsService;
_debugLogService = debugLogService;
_recordPathResultCallback = recordPathResultCallback;
_onDeliveryObservedCallback = onDeliveryObservedCallback;
void initialize(RetryServiceConfig config) {
_config = config;
}
void setMaxRetries(int value) {
_maxRetries = value.clamp(2, 10);
}
/// Compute expected ACK hash using same algorithm as firmware:
@@ -139,17 +127,14 @@ class MessageRetryService extends ChangeNotifier {
Future<void> sendMessageWithRetry({
required Contact contact,
required String text,
PathSelection? pathSelection,
Uint8List? pathBytes,
int? pathLength,
}) async {
final messageId = const Uuid().v4();
final useFlood = pathSelection?.useFlood ?? false;
final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
final resolved = resolvePathSelection(contact);
final messagePathBytes = pathBytes ?? Uint8List.fromList(resolved.pathBytes);
final messagePathLength =
pathLength ??
_resolveMessagePathLength(contact, useFlood, pathSelection);
pathLength ?? (resolved.useFlood ? -1 : resolved.hopCount);
final message = Message(
senderKey: contact.publicKey,
text: text,
@@ -164,13 +149,8 @@ class MessageRetryService extends ChangeNotifier {
_pendingMessages[messageId] = message;
_pendingContacts[messageId] = contact;
if (pathSelection != null) {
_pendingPathSelections[messageId] = pathSelection;
}
if (_addMessageCallback != null) {
_addMessageCallback!(contact.publicKeyHex, message);
}
_config?.addMessage(contact.publicKeyHex, message);
// Queue per contact — only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
@@ -200,13 +180,12 @@ class MessageRetryService extends ChangeNotifier {
if (msg != null) {
final failed = msg.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failed;
_updateMessageCallback?.call(failed);
_config?.updateMessage(failed);
}
_onMessageResolved(messageId, contactKey);
});
return;
}
// Message was cancelled/cleaned up while queued — try next
}
}
@@ -217,33 +196,87 @@ class MessageRetryService extends ChangeNotifier {
_sendNextForContact(contactKey);
}
PathSelection? _selectPathForAttempt(Message message, Contact contact) {
final config = _config;
if (config == null) return null;
final autoRotationEnabled =
config.appSettingsService?.settings.autoRouteRotationEnabled == true;
if (!autoRotationEnabled ||
contact.pathOverride != null ||
config.selectRetryPath == null) {
return null;
}
final recentSelections = List<PathSelection>.from(
_attemptPathHistory[message.messageId] ?? const <PathSelection>[],
);
return config.selectRetryPath!(
contact.publicKeyHex,
message.retryCount,
maxRetries,
recentSelections,
);
}
void _recordAttemptPathHistory(String messageId, PathSelection selection) {
if (selection.useFlood) return;
final history = _attemptPathHistory.putIfAbsent(messageId, () => []);
history.add(selection);
if (history.length > recentAttemptDiversityWindow) {
history.removeAt(0);
}
}
Future<void> _attemptSend(String messageId) async {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
final config = _config;
if (message == null || contact == null) return;
if (message == null || contact == null || config == null) return;
final currentSelection = _selectPathForAttempt(message, contact);
if (currentSelection != null) {
final updatedMessage = message.copyWith(
pathLength: currentSelection.useFlood ? -1 : currentSelection.hopCount,
pathBytes: currentSelection.useFlood
? Uint8List(0)
: Uint8List.fromList(currentSelection.pathBytes),
);
_pendingMessages[messageId] = updatedMessage;
} else if (message.retryCount > 0) {
// No schedule entry for this retry — re-resolve path from current contact
// state so user's path override changes are picked up between retries.
final resolved = resolvePathSelection(contact);
final updatedMessage = message.copyWith(
pathLength: resolved.useFlood ? -1 : resolved.hopCount,
pathBytes: Uint8List.fromList(resolved.pathBytes),
);
_pendingMessages[messageId] = updatedMessage;
}
// Re-read after potential schedule update
final effectiveMessage = _pendingMessages[messageId] ?? message;
// Sync path settings with device before sending
// Use the path that was captured when the message was first sent
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) {
debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}',
);
await _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) {
final pathStr = message.pathBytes.isEmpty
? 'direct'
: message.pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(',');
debugPrint(
'Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}',
);
await _setContactPathCallback!(
if (config.setContactPath != null && config.clearContactPath != null) {
final bool useFlood = currentSelection != null
? currentSelection.useFlood
: (effectiveMessage.pathLength != null && effectiveMessage.pathLength! < 0);
final List<int> pathBytes = currentSelection != null
? currentSelection.pathBytes
: effectiveMessage.pathBytes;
final int hopCount = currentSelection != null
? currentSelection.hopCount
: (effectiveMessage.pathLength ?? 0);
if (useFlood) {
await config.clearContactPath!(contact);
} else if (effectiveMessage.pathLength != null) {
await config.setContactPath!(
contact,
message.pathBytes,
message.pathLength!,
Uint8List.fromList(pathBytes),
hopCount,
);
}
}
@@ -257,8 +290,6 @@ class MessageRetryService extends ChangeNotifier {
);
return;
}
// If the message was retried by a timer during our await, the retryCount
// will have advanced. Only proceed if it still matches the attempt we started.
if (currentMessage.retryCount != message.retryCount) {
debugPrint(
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
@@ -266,15 +297,19 @@ class MessageRetryService extends ChangeNotifier {
return;
}
final attempt = message.retryCount.clamp(0, 3);
if (currentSelection != null) {
_recordAttemptPathHistory(messageId, currentSelection);
}
final attempt = message.retryCount;
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
// Compute expected ACK hash that device will return in RESP_CODE_SENT
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
final selfPubKey = _getSelfPublicKeyCallback?.call();
final selfPubKey = config.getSelfPublicKey?.call();
if (selfPubKey != null) {
final outboundText =
_prepareContactOutboundTextCallback?.call(contact, message.text) ??
config.prepareContactOutboundText?.call(contact, message.text) ??
message.text;
final expectedHash = MessageRetryService.computeExpectedAckHash(
timestampSeconds,
@@ -290,43 +325,24 @@ class MessageRetryService extends ChangeNotifier {
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.info(
config.debugLogService?.info(
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
tag: 'AckHash',
);
debugPrint(
'Computed expected ACK hash $expectedHashHex for message $messageId',
);
}
// DEPRECATED: Old queue-based matching (kept for fallback)
_pendingMessageQueuePerContact[contact.publicKeyHex] ??= [];
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
if (_sendMessageCallback != null) {
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
} else {
// No send callback — message would be stuck forever. Fail it immediately.
debugPrint(
'_attemptSend: no sendMessageCallback, failing message $messageId',
);
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
_updateMessageCallback?.call(failedMessage);
_onMessageResolved(messageId, contact.publicKeyHex);
}
config.sendMessage(contact, message.text, attempt, timestampSeconds);
}
bool updateMessageFromSent(
Uint8List ackHash,
int timeoutMs, {
bool allowQueueFallback = true,
}) {
bool updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
final config = _config;
if (config == null) return false;
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
// Try hash-based matching (fixes LoRa message drops causing mismatches)
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
Contact? contact;
@@ -338,89 +354,31 @@ class MessageRetryService extends ChangeNotifier {
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.info(
config.debugLogService?.info(
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
tag: 'AckHash',
);
debugPrint(
'Hash-based match: ACK hash $ackHashHex → message $messageId',
);
// Remove from old queue since we matched
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
} else {
_debugLogService?.warn(
config.debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex matched but message no longer pending',
tag: 'AckHash',
);
debugPrint('Hash matched $messageId but message no longer pending');
messageId = null;
contact = null;
}
}
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
// Only match within a single contact's queue to avoid cross-contact mismatches.
if (messageId == null && allowQueueFallback) {
_debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
tag: 'AckHash',
);
debugPrint(
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
);
// Search all contact queues so concurrent chats don't miss matches.
final queuesToSearch = _pendingMessageQueuePerContact;
for (var entry in queuesToSearch.entries) {
final contactKey = entry.key;
final queue = entry.value;
// Drain stale entries until we find a valid one or exhaust the queue.
while (queue.isNotEmpty) {
final candidateMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId];
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
);
break;
}
debugPrint('Dequeued stale message $candidateMessageId - skipping');
}
if (messageId != null) break;
}
}
if (messageId == null || contact == null) {
debugPrint('No pending message found for ACK hash: $ackHashHex');
return false;
}
// Store the mapping for future lookups (e.g., when ACK arrives)
// Keep timestamp so we can clean up old mappings later
_ackHashToMessageId[ackHashHex] = _AckHashMapping(
final message = _pendingMessages[messageId]!;
_ackHashToMessageId[ackHashHex] = (
messageId: messageId,
timestamp: DateTime.now(),
attemptIndex: message.retryCount,
);
debugPrint('Mapped ACK hash $ackHashHex to message $messageId');
final message = _pendingMessages[messageId];
final selection = _pendingPathSelections[messageId];
if (message == null) {
debugPrint(
'Message $messageId no longer pending for ACK hash: $ackHashHex',
);
_ackHashToMessageId.remove(ackHashHex);
return false;
}
// Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= [];
@@ -428,37 +386,20 @@ class MessageRetryService extends ChangeNotifier {
(hash) => listEquals(hash, ackHash),
)) {
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
debugPrint(
'Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})',
);
}
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
final pathLengthValue = message.pathLength ?? contact.pathLength;
int actualTimeout = timeoutMs;
if (_calculateTimeoutCallback != null) {
final calculated = _calculateTimeoutCallback!(
if (config.calculateTimeout != null) {
final calculated = config.calculateTimeout!(
pathLengthValue,
message.text.length,
contactKey: contact.publicKeyHex,
);
// calculateTimeout tries ML first, falls back to physics.
// Use calculated value if device didn't provide one, or if ML
// produced a tighter prediction than the device's estimate.
if (timeoutMs <= 0 || calculated < timeoutMs) {
actualTimeout = calculated;
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
}
}
@@ -470,18 +411,26 @@ class MessageRetryService extends ChangeNotifier {
);
_pendingMessages[messageId] = updatedMessage;
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
config.updateMessage(updatedMessage);
_startTimeoutTimer(messageId, actualTimeout);
debugPrint('Updated message $messageId with ACK hash: $ackHashHex');
return true;
}
bool get hasPendingMessages => _pendingMessages.isNotEmpty;
/// Update the stored contact snapshot for all pending messages to this contact.
/// Call this when the contact's pathOverride changes so retries use the new path.
void updatePendingContact(Contact contact) {
final keys = _pendingContacts.entries
.where((e) => e.value.publicKeyHex == contact.publicKeyHex)
.map((e) => e.key)
.toList();
for (final key in keys) {
_pendingContacts[key] = contact;
}
}
void _startTimeoutTimer(String messageId, int timeoutMs) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
@@ -489,10 +438,24 @@ class MessageRetryService extends ChangeNotifier {
});
}
void _cleanupMessage(String messageId) {
_moveAckHashesToHistory(messageId);
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_attemptPathHistory.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
}
void _handleTimeout(String messageId) {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
final selection = _pendingPathSelections[messageId];
final config = _config;
final selection = message != null ? _selectionFromMessage(message) : null;
if (message == null || contact == null) {
debugPrint(
@@ -504,44 +467,40 @@ class MessageRetryService extends ChangeNotifier {
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.warn(
config?.debugLogService?.warn(
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
tag: 'AckHash',
);
debugPrint(
'Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})',
);
if (message.retryCount < maxRetries - 1) {
final backoffMs = 1000 * (1 << message.retryCount);
if (selection != null) {
_recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
false,
null,
);
}
final updatedMessage = message.copyWith(
retryCount: message.retryCount + 1,
status: MessageStatus.pending,
// Keep expectedAckHash - it will be updated when the new attempt is sent
);
_pendingMessages[messageId] = updatedMessage;
config?.updateMessage(updatedMessage);
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
_debugLogService?.info(
config?.debugLogService?.info(
'Scheduling retry for "$shortText" to ${contact.name} after ${backoffMs}ms backoff',
tag: 'AckHash',
);
debugPrint('Scheduling retry after ${backoffMs}ms');
// Store the backoff timer so it can be canceled if new RESP_CODE_SENT arrives
_timeoutTimers[messageId] = Timer(Duration(milliseconds: backoffMs), () {
// Double-check message is still pending before retry
if (_pendingMessages.containsKey(messageId)) {
_attemptSend(messageId);
} else {
debugPrint(
'Retry cancelled: message $messageId was delivered while waiting',
);
}
});
} else {
@@ -549,10 +508,9 @@ class MessageRetryService extends ChangeNotifier {
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
// Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
_clearContactPathCallback != null) {
_clearContactPathCallback!(contact);
if (config?.appSettingsService?.settings.clearPathOnMaxRetry == true &&
config?.clearContactPath != null) {
config!.clearContactPath!(contact);
}
_recordPathResultFromMessage(
@@ -563,34 +521,16 @@ class MessageRetryService extends ChangeNotifier {
null,
);
if (_updateMessageCallback != null) {
_updateMessageCallback!(failedMessage);
}
config?.updateMessage(failedMessage);
notifyListeners();
// Message is done retrying — send next queued message for this contact
_onMessageResolved(messageId, contact.publicKeyHex);
// Keep message in pending maps for 30s grace period so late ACKs
// can still match and update the message to delivered.
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
_moveAckHashesToHistory(messageId);
// Clean up ALL hash mappings for this message
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
final contactKey = contact.publicKeyHex;
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
_pendingMessageQueuePerContact.remove(contactKey);
}
_cleanupMessage(messageId);
});
}
}
@@ -606,14 +546,9 @@ class MessageRetryService extends ChangeNotifier {
),
);
// Trim history to max size (rolling buffer)
while (_ackHistory.length > maxAckHistorySize) {
_ackHistory.removeAt(0);
}
debugPrint(
'Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})',
);
}
}
@@ -621,9 +556,6 @@ class MessageRetryService extends ChangeNotifier {
for (final entry in _ackHistory) {
for (final expectedHash in entry.ackHashes) {
if (listEquals(expectedHash, ackHash)) {
debugPrint(
'Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s',
);
return true;
}
}
@@ -632,14 +564,14 @@ class MessageRetryService extends ChangeNotifier {
}
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
final config = _config;
String? matchedMessageId;
int? matchedAttemptIndex;
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
// First, clean up old ACK hash mappings (older than 15 minutes)
// Clean up old ACK hash mappings (older than 15 minutes)
final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15));
final hashesToRemove = <String>[];
for (var entry in _ackHashToMessageId.entries) {
@@ -650,24 +582,18 @@ class MessageRetryService extends ChangeNotifier {
for (var hash in hashesToRemove) {
_ackHashToMessageId.remove(hash);
}
if (hashesToRemove.isNotEmpty) {
debugPrint('Cleaned up ${hashesToRemove.length} old ACK hash mappings');
}
// Use direct O(1) lookup via ACK hash mapping
final mapping = _ackHashToMessageId[ackHashHex];
if (mapping != null) {
matchedMessageId = mapping.messageId;
debugPrint('Matched ACK to message via direct lookup: $matchedMessageId');
matchedAttemptIndex = mapping.attemptIndex;
} else {
_debugLogService?.warn(
config?.debugLogService?.warn(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex not found in direct mapping, trying fallback',
tag: 'AckHash',
);
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
debugPrint(
'ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)',
);
for (var entry in _expectedAckHashes.entries) {
final messageId = entry.key;
final expectedHashes = entry.value;
@@ -675,9 +601,7 @@ class MessageRetryService extends ChangeNotifier {
for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) {
matchedMessageId = messageId;
debugPrint(
'Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})',
);
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
break;
}
}
@@ -689,27 +613,22 @@ class MessageRetryService extends ChangeNotifier {
if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId];
if (message == null) {
// Message was already cleaned up (e.g. grace period expired)
_ackHashToMessageId.remove(ackHashHex);
debugPrint(
'ACK matched $matchedMessageId but message already cleaned up',
);
return;
}
final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId];
final ackedAttempt = matchedAttemptIndex ?? message.retryCount;
final selection = _selectionFromMessage(message);
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
config?.debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} on retry ${ackedAttempt + 1} in ${tripTimeMs}ms',
tag: 'AckHash',
);
// Cancel any pending timeout or retry
_timeoutTimers[matchedMessageId]?.cancel();
_timeoutTimers.remove(matchedMessageId);
final deliveredMessage = message.copyWith(
status: MessageStatus.delivered,
@@ -717,36 +636,9 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs,
);
// Clean up ALL hash mappings for this message (from all retry attempts)
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == matchedMessageId,
);
_expectedHashToMessageId.removeWhere(
(_, msgId) => msgId == matchedMessageId,
);
_cleanupMessage(matchedMessageId);
// Move ACK hashes to history before removing
_moveAckHashesToHistory(matchedMessageId);
_pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId);
_resolvedMessages.remove(matchedMessageId);
// Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) {
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(
matchedMessageId,
);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
}
if (_updateMessageCallback != null) {
_updateMessageCallback!(deliveredMessage);
}
config?.updateMessage(deliveredMessage);
if (contact != null) {
_recordPathResultFromMessage(
@@ -756,10 +648,10 @@ class MessageRetryService extends ChangeNotifier {
true,
tripTimeMs,
);
if (_onDeliveryObservedCallback != null &&
if (config?.onDeliveryObserved != null &&
tripTimeMs > 0 &&
message.pathLength != null) {
_onDeliveryObservedCallback!(
config!.onDeliveryObserved!(
contact.publicKeyHex,
message.pathLength!,
message.text.length,
@@ -771,15 +663,13 @@ class MessageRetryService extends ChangeNotifier {
notifyListeners();
} else {
// Check ACK history for recently completed messages
if (_checkAckHistory(ackHash)) {
_debugLogService?.info(
config?.debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex matched a recently completed message (duplicate ACK)',
tag: 'AckHash',
);
debugPrint('ACK matched a recently completed message from history');
} else {
_debugLogService?.error(
config?.debugLogService?.error(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex has no matching message!',
tag: 'AckHash',
);
@@ -788,57 +678,6 @@ class MessageRetryService extends ChangeNotifier {
}
}
Uint8List _resolveMessagePathBytes(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
// Priority 1: Check user's path override
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) {
return Uint8List(0); // Force flood
}
return contact.pathOverrideBytes ?? Uint8List(0);
}
// Priority 2: Check forceFlood or device flood mode
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return Uint8List(0);
}
// Priority 3: Check PathSelection (auto-rotation)
if (selection != null && selection.pathBytes.isNotEmpty) {
return Uint8List.fromList(selection.pathBytes);
}
// Priority 4: Use device's discovered path
return contact.path;
}
int? _resolveMessagePathLength(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
// Priority 1: Check user's path override
if (contact.pathOverride != null) {
return contact.pathOverride;
}
// Priority 2: Check forceFlood or device flood mode
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return -1;
}
// Priority 3: Check PathSelection (auto-rotation)
if (selection != null && selection.pathBytes.isNotEmpty) {
return selection.hopCount;
}
// Priority 4: Use device's discovered path
return contact.pathLength;
}
String? getContactKeyForAckHash(Uint8List ackHash) {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
@@ -866,15 +705,11 @@ class MessageRetryService extends ChangeNotifier {
bool success,
int? tripTimeMs,
) {
if (_recordPathResultCallback == null) return;
final callback = _config?.recordPathResult;
if (callback == null) return;
final recordSelection = selection ?? _selectionFromMessage(message);
if (recordSelection == null) return;
_recordPathResultCallback!(
contactKey,
recordSelection,
success,
tripTimeMs,
);
callback(contactKey, recordSelection, success, tripTimeMs);
}
PathSelection? _selectionFromMessage(Message message) {
@@ -899,11 +734,10 @@ class MessageRetryService extends ChangeNotifier {
_timeoutTimers.clear();
_pendingMessages.clear();
_pendingContacts.clear();
_pendingPathSelections.clear();
_attemptPathHistory.clear();
_expectedAckHashes.clear();
_ackHistory.clear();
_ackHashToMessageId.clear();
_pendingMessageQueuePerContact.clear();
_sendQueue.clear();
_activeMessages.clear();
_resolvedMessages.clear();
+18 -3
View File
@@ -4,6 +4,7 @@ import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import '../helpers/reaction_helper.dart';
import '../l10n/app_localizations.dart';
import '../utils/platform_info.dart';
@@ -145,6 +146,19 @@ class NotificationService {
return true;
}
/// Format special message types for human-readable notifications.
static String formatNotificationText(String text) {
final trimmed = text.trim();
final reaction = ReactionHelper.parseReaction(trimmed);
if (reaction != null) {
return 'Reacted ${reaction.emoji}';
}
if (RegExp(r'^g:[A-Za-z0-9_-]+$').hasMatch(trimmed)) {
return 'Sent a GIF';
}
return text;
}
Future<void> _showMessageNotificationImpl({
required String contactName,
required String message,
@@ -187,7 +201,7 @@ class NotificationService {
await _notifications.show(
id: contactId?.hashCode ?? 0,
title: contactName,
body: message,
body: formatNotificationText(message),
notificationDetails: notificationDetails,
payload: 'message:$contactId',
);
@@ -283,7 +297,7 @@ class NotificationService {
macOS: macDetails,
);
final preview = message.trim();
final preview = formatNotificationText(message.trim());
final body = preview.isEmpty
? _l10n.notification_receivedNewMessage
: preview;
@@ -430,6 +444,7 @@ class NotificationService {
Future<void> showChannelMessageNotification({
required String channelName,
required String senderName,
required String message,
int? channelIndex,
int? badgeCount,
@@ -440,7 +455,7 @@ class NotificationService {
_PendingNotification(
type: _NotificationType.channelMessage,
title: channelName,
body: message,
body: '$senderName: $message',
id: channelIndex?.toString(),
badgeCount: badgeCount,
),
+280 -60
View File
@@ -9,6 +9,8 @@ class PathHistoryService extends ChangeNotifier {
final Map<String, ContactPathHistory> _cache = {};
final Map<String, int> _autoRotationIndex = {};
final Map<String, _FloodStats> _floodStats = {};
final Set<String> _pendingLoads = {};
final Map<String, List<_DeferredPathRecord>> _deferredRecords = {};
// LRU cache eviction tracking
static const int _maxCachedContacts = 50;
@@ -18,7 +20,6 @@ class PathHistoryService extends ChangeNotifier {
int _version = 0;
int get version => _version;
static const int _autoRotationTopCount = 3;
PathHistoryService(this._storage);
@@ -26,17 +27,21 @@ class PathHistoryService extends ChangeNotifier {
// Load cached path histories on startup if needed
}
void handlePathUpdated(Contact contact) {
if (contact.pathLength < 0) return;
void handlePathUpdated(Contact contact, {double initialWeight = 1.0}) {
if (contact.pathLength < 0 && contact.path.isEmpty) return;
final hopCount = contact.pathLength < 0
? contact.path.length
: contact.pathLength;
_addPathRecord(
contactPubKeyHex: contact.publicKeyHex,
hopCount: contact.pathLength,
hopCount: hopCount,
tripTimeMs: 0,
wasFloodDiscovery: true,
pathBytes: contact.path,
successCount: 0,
failureCount: 0,
routeWeight: initialWeight,
timestamp: null,
);
}
@@ -54,6 +59,44 @@ class PathHistoryService extends ChangeNotifier {
pathBytes: selection.pathBytes,
successCount: 0,
failureCount: 0,
timestamp: null,
);
}
/// When a flood message is delivered, credit the contact's current device
/// path so that the route the ACK traveled back through gets a weight boost.
void recordFloodPathAttribution({
required String contactPubKeyHex,
required List<int> pathBytes,
required int hopCount,
int? tripTimeMs,
double successIncrement = 0.5,
double maxWeight = 5.0,
}) {
if (pathBytes.isEmpty || hopCount < 0) return;
final existing = _findPathRecord(contactPubKeyHex, pathBytes);
final successCount = (existing?.successCount ?? 0) + 1;
final failureCount = existing?.failureCount ?? 0;
final currentWeight = existing?.routeWeight ?? 1.0;
final newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
debugPrint(
'Flood path attribution: crediting path [${pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',')}] '
'for $contactPubKeyHex (weight $currentWeight$newWeight)',
);
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: hopCount,
tripTimeMs: tripTimeMs ?? existing?.tripTimeMs ?? 0,
wasFloodDiscovery: true,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: newWeight,
timestamp: DateTime.now(),
);
}
@@ -62,6 +105,9 @@ class PathHistoryService extends ChangeNotifier {
PathSelection selection, {
required bool success,
int? tripTimeMs,
double successIncrement = 0.5,
double failureDecrement = 0.5,
double maxWeight = 5.0,
}) {
if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(
@@ -82,6 +128,18 @@ class PathHistoryService extends ChangeNotifier {
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
final currentWeight = existing?.routeWeight ?? 1.0;
double newWeight;
if (success) {
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
} else {
newWeight = currentWeight - failureDecrement;
if (newWeight <= 0) {
removePathRecord(contactPubKeyHex, selection.pathBytes);
return;
}
}
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: selection.hopCount,
@@ -90,37 +148,68 @@ class PathHistoryService extends ChangeNotifier {
pathBytes: selection.pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: newWeight,
timestamp: success ? DateTime.now() : existing?.timestamp,
);
}
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(
contactPubKeyHex,
).take(_autoRotationTopCount).toList();
PathSelection selectPathForAttempt(
String contactPubKeyHex, {
required int attemptIndex,
required int maxRetries,
List<PathSelection> recentSelections = const [],
}) {
if (maxRetries <= 0 || attemptIndex >= maxRetries - 1) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
final ranked = _getRankedPaths(contactPubKeyHex);
if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
_trackAccess(contactPubKeyHex);
final selections =
ranked
.map(
(path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
),
)
.toList()
..add(
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
);
final recentPaths = recentSelections
.where((selection) => !selection.useFlood)
.map((selection) => selection.pathBytes)
.toList();
final candidates = recentPaths.isEmpty
? ranked
: ranked
.where(
(path) => !recentPaths.any(
(recentPath) => _pathsEqual(path.pathBytes, recentPath),
),
)
.toList();
final selected = candidates.isNotEmpty
? (recentPaths.isEmpty
? _selectRotatedCandidate(contactPubKeyHex, candidates)
: candidates.first)
: ranked.first;
return PathSelection(
pathBytes: selected.pathBytes,
hopCount: selected.hopCount,
useFlood: false,
);
}
PathRecord _selectRotatedCandidate(
String contactPubKeyHex,
List<PathRecord> candidates,
) {
if (candidates.length <= 1) {
_autoRotationIndex[contactPubKeyHex] = 0;
return candidates.first;
}
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selection = selections[currentIndex % selections.length];
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
return selection;
final selectedIndex = currentIndex % candidates.length;
_autoRotationIndex[contactPubKeyHex] =
(selectedIndex + 1) % candidates.length;
return candidates[selectedIndex];
}
void _addPathRecord({
@@ -131,37 +220,68 @@ class PathHistoryService extends ChangeNotifier {
required List<int> pathBytes,
required int successCount,
required int failureCount,
double routeWeight = 1.0,
DateTime? timestamp,
}) {
var history = _cache[contactPubKeyHex];
if (history == null) {
// If a load is already in progress, defer this record
if (_pendingLoads.contains(contactPubKeyHex)) {
_deferredRecords.putIfAbsent(contactPubKeyHex, () => []);
_deferredRecords[contactPubKeyHex]!.add(
_DeferredPathRecord(
hopCount: hopCount,
tripTimeMs: tripTimeMs,
wasFloodDiscovery: wasFloodDiscovery,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: routeWeight,
timestamp: timestamp,
),
);
return;
}
_pendingLoads.add(contactPubKeyHex);
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
} else {
_cache[contactPubKeyHex] = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: [],
);
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
_cache[contactPubKeyHex] =
loaded ??
ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: [],
);
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
routeWeight,
timestamp,
);
// Apply any deferred records
final deferred = _deferredRecords.remove(contactPubKeyHex);
if (deferred != null) {
for (final record in deferred) {
_addPathRecordInternal(
contactPubKeyHex,
record.hopCount,
record.tripTimeMs,
record.wasFloodDiscovery,
record.pathBytes,
record.successCount,
record.failureCount,
record.routeWeight,
record.timestamp,
);
}
}
_pendingLoads.remove(contactPubKeyHex);
});
return;
}
@@ -174,6 +294,8 @@ class PathHistoryService extends ChangeNotifier {
pathBytes,
successCount,
failureCount,
routeWeight,
timestamp,
);
}
@@ -185,6 +307,8 @@ class PathHistoryService extends ChangeNotifier {
List<int> pathBytes,
int successCount,
int failureCount,
double routeWeight,
DateTime? timestamp,
) {
var history = _cache[contactPubKeyHex];
if (history == null) return;
@@ -198,16 +322,18 @@ class PathHistoryService extends ChangeNotifier {
tripTimeMs = existing.tripTimeMs;
}
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
timestamp ??= existing.timestamp;
}
final newRecord = PathRecord(
hopCount: hopCount,
tripTimeMs: tripTimeMs,
timestamp: DateTime.now(),
timestamp: timestamp,
wasFloodDiscovery: wasFloodDiscovery,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: routeWeight,
);
final updatedPaths = List<PathRecord>.from(history.recentPaths);
@@ -275,6 +401,23 @@ class PathHistoryService extends ChangeNotifier {
return history?.mostRecent;
}
({
int successCount,
int failureCount,
int lastTripTimeMs,
DateTime? lastUsed,
})?
getFloodStats(String contactPubKeyHex) {
final stats = _floodStats[contactPubKeyHex];
if (stats == null) return null;
return (
successCount: stats.successCount,
failureCount: stats.failureCount,
lastTripTimeMs: stats.lastTripTimeMs,
lastUsed: stats.lastUsed,
);
}
Future<void> clearPathHistory(String contactPubKeyHex) async {
_cache.remove(contactPubKeyHex);
_cacheAccessOrder.remove(contactPubKeyHex);
@@ -322,26 +465,81 @@ class PathHistoryService extends ChangeNotifier {
final ranked = List<PathRecord>.from(history.recentPaths)
..removeWhere((p) => p.pathBytes.isEmpty);
final fastestTripMs = _getFastestKnownTripMs(ranked);
final highestRouteWeight = _getHighestKnownRouteWeight(ranked);
ranked.sort((a, b) {
final aRate =
(a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate =
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
if (aRate != bRate) return bRate.compareTo(aRate);
if (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount);
final scoreCompare = _scorePathRecord(
b,
fastestTripMs: fastestTripMs,
highestRouteWeight: highestRouteWeight,
).compareTo(
_scorePathRecord(
a,
fastestTripMs: fastestTripMs,
highestRouteWeight: highestRouteWeight,
),
);
if (scoreCompare != 0) {
return scoreCompare;
}
if (a.routeWeight != b.routeWeight) {
return b.routeWeight.compareTo(a.routeWeight);
}
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
return b.timestamp.compareTo(a.timestamp);
final aTime = a.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
final bTime = b.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
return bTime.compareTo(aTime);
});
return ranked;
}
int? _getFastestKnownTripMs(List<PathRecord> paths) {
final knownTrips = paths
.where((path) => path.tripTimeMs > 0)
.map((path) => path.tripTimeMs)
.toList();
if (knownTrips.isEmpty) return null;
return knownTrips.reduce((a, b) => a < b ? a : b);
}
double _getHighestKnownRouteWeight(List<PathRecord> paths) {
if (paths.isEmpty) return 1.0;
final highestWeight = paths
.map((path) => path.routeWeight)
.reduce((a, b) => a > b ? a : b);
return highestWeight <= 0 ? 1.0 : highestWeight;
}
double _scorePathRecord(
PathRecord path, {
required int? fastestTripMs,
required double highestRouteWeight,
}) {
final totalAttempts = path.successCount + path.failureCount;
final reliability = (path.successCount + 1) / (totalAttempts + 2);
final latency = fastestTripMs == null || path.tripTimeMs <= 0
? 0.6
: (fastestTripMs / path.tripTimeMs).clamp(0.0, 1.0);
final freshness = path.timestamp == null
? 0.0
: 1.0 /
(1.0 +
(DateTime.now().difference(path.timestamp!).inMinutes /
60.0 /
24.0));
final routeWeight =
(path.routeWeight / highestRouteWeight).clamp(0.0, 1.0);
return (reliability * 0.45) +
(latency * 0.25) +
(freshness * 0.1) +
(routeWeight * 0.2);
}
bool _pathsEqual(List<int> a, List<int> b) {
return listEquals(a, b);
}
@@ -369,6 +567,28 @@ class PathHistoryService extends ChangeNotifier {
}
}
class _DeferredPathRecord {
final int hopCount;
final int tripTimeMs;
final bool wasFloodDiscovery;
final List<int> pathBytes;
final int successCount;
final int failureCount;
final double routeWeight;
final DateTime? timestamp;
_DeferredPathRecord({
required this.hopCount,
required this.tripTimeMs,
required this.wasFloodDiscovery,
required this.pathBytes,
required this.successCount,
required this.failureCount,
this.routeWeight = 1.0,
this.timestamp,
});
}
class _FloodStats {
int successCount = 0;
int failureCount = 0;