diff --git a/lib/models/config_profile.dart b/lib/models/config_profile.dart index 837b156..66d0da3 100644 --- a/lib/models/config_profile.dart +++ b/lib/models/config_profile.dart @@ -116,7 +116,6 @@ class AppSettingsProfileSection { final bool? voiceEchoCancellationEnabled; final bool? voiceNoiseSuppressionEnabled; final double? messageFontScale; - final bool? autoRouteRotationEnabled; final bool? clearPathOnMaxRetry; final bool? nearestRelayFallbackEnabled; final int? voiceBitrate; @@ -142,7 +141,6 @@ class AppSettingsProfileSection { this.voiceEchoCancellationEnabled, this.voiceNoiseSuppressionEnabled, this.messageFontScale, - this.autoRouteRotationEnabled, this.clearPathOnMaxRetry, this.nearestRelayFallbackEnabled, this.voiceBitrate, @@ -169,7 +167,6 @@ class AppSettingsProfileSection { voiceEchoCancellationEnabled == null && voiceNoiseSuppressionEnabled == null && messageFontScale == null && - autoRouteRotationEnabled == null && clearPathOnMaxRetry == null && nearestRelayFallbackEnabled == null && voiceBitrate == null && @@ -195,7 +192,6 @@ class AppSettingsProfileSection { 'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled, 'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled, 'messageFontScale': messageFontScale, - 'autoRouteRotationEnabled': autoRouteRotationEnabled, 'clearPathOnMaxRetry': clearPathOnMaxRetry, 'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled, 'voiceBitrate': voiceBitrate, @@ -225,7 +221,6 @@ class AppSettingsProfileSection { voiceNoiseSuppressionEnabled: json['voiceNoiseSuppressionEnabled'] as bool?, messageFontScale: (json['messageFontScale'] as num?)?.toDouble(), - autoRouteRotationEnabled: json['autoRouteRotationEnabled'] as bool?, clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?, nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?, voiceBitrate: json['voiceBitrate'] as int?, diff --git a/lib/models/path_history.dart b/lib/models/path_history.dart deleted file mode 100644 index 6461356..0000000 --- a/lib/models/path_history.dart +++ /dev/null @@ -1,229 +0,0 @@ -enum PathRecordSource { learned, observed } - -class PathRecord { - final List pathBytes; - final int hopCount; - final int hashSize; - final PathRecordSource source; - final int successCount; - final int failureCount; - final int lastRoundTripTimeMs; - final DateTime lastUsedAt; - final DateTime? lastSucceededAt; - final double? senderLatitude; - final double? senderLongitude; - final double? recipientLatitude; - final double? recipientLongitude; - - const PathRecord({ - required this.pathBytes, - required this.hopCount, - required this.hashSize, - required this.source, - required this.successCount, - required this.failureCount, - required this.lastRoundTripTimeMs, - required this.lastUsedAt, - required this.lastSucceededAt, - required this.senderLatitude, - required this.senderLongitude, - required this.recipientLatitude, - required this.recipientLongitude, - }); - - String get signature => - pathBytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); - - double get successRate => - (successCount + 1) / (successCount + failureCount + 2); - - PathRecord copyWith({ - List? pathBytes, - int? hopCount, - int? hashSize, - PathRecordSource? source, - int? successCount, - int? failureCount, - int? lastRoundTripTimeMs, - DateTime? lastUsedAt, - DateTime? lastSucceededAt, - double? senderLatitude, - double? senderLongitude, - double? recipientLatitude, - double? recipientLongitude, - }) { - return PathRecord( - pathBytes: pathBytes ?? this.pathBytes, - hopCount: hopCount ?? this.hopCount, - hashSize: hashSize ?? this.hashSize, - source: source ?? this.source, - successCount: successCount ?? this.successCount, - failureCount: failureCount ?? this.failureCount, - lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs, - lastUsedAt: lastUsedAt ?? this.lastUsedAt, - lastSucceededAt: lastSucceededAt ?? this.lastSucceededAt, - senderLatitude: senderLatitude ?? this.senderLatitude, - senderLongitude: senderLongitude ?? this.senderLongitude, - recipientLatitude: recipientLatitude ?? this.recipientLatitude, - recipientLongitude: recipientLongitude ?? this.recipientLongitude, - ); - } - - Map toJson() { - return { - 'path_bytes': pathBytes, - 'hop_count': hopCount, - 'hash_size': hashSize, - 'source': source.name, - 'success_count': successCount, - 'failure_count': failureCount, - 'last_round_trip_time_ms': lastRoundTripTimeMs, - 'last_used_at': lastUsedAt.toIso8601String(), - 'last_succeeded_at': lastSucceededAt?.toIso8601String(), - 'sender_latitude': senderLatitude, - 'sender_longitude': senderLongitude, - 'recipient_latitude': recipientLatitude, - 'recipient_longitude': recipientLongitude, - }; - } - - factory PathRecord.fromJson(Map json) { - return PathRecord( - pathBytes: (json['path_bytes'] as List? ?? const []) - .map((value) => value as int) - .toList(), - hopCount: json['hop_count'] as int? ?? 0, - hashSize: json['hash_size'] as int? ?? 1, - source: PathRecordSource.values.firstWhere( - (value) => value.name == (json['source'] as String? ?? 'learned'), - orElse: () => PathRecordSource.learned, - ), - successCount: json['success_count'] as int? ?? 0, - failureCount: json['failure_count'] as int? ?? 0, - lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0, - lastUsedAt: - DateTime.tryParse(json['last_used_at'] as String? ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0), - lastSucceededAt: DateTime.tryParse( - json['last_succeeded_at'] as String? ?? '', - ), - senderLatitude: (json['sender_latitude'] as num?)?.toDouble(), - senderLongitude: (json['sender_longitude'] as num?)?.toDouble(), - recipientLatitude: (json['recipient_latitude'] as num?)?.toDouble(), - recipientLongitude: (json['recipient_longitude'] as num?)?.toDouble(), - ); - } -} - -class FloodPathStats { - final int successCount; - final int failureCount; - final int lastRoundTripTimeMs; - final DateTime? lastUsedAt; - - const FloodPathStats({ - required this.successCount, - required this.failureCount, - required this.lastRoundTripTimeMs, - required this.lastUsedAt, - }); - - const FloodPathStats.empty() - : successCount = 0, - failureCount = 0, - lastRoundTripTimeMs = 0, - lastUsedAt = null; - - FloodPathStats copyWith({ - int? successCount, - int? failureCount, - int? lastRoundTripTimeMs, - DateTime? lastUsedAt, - }) { - return FloodPathStats( - successCount: successCount ?? this.successCount, - failureCount: failureCount ?? this.failureCount, - lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs, - lastUsedAt: lastUsedAt ?? this.lastUsedAt, - ); - } - - Map toJson() { - return { - 'success_count': successCount, - 'failure_count': failureCount, - 'last_round_trip_time_ms': lastRoundTripTimeMs, - 'last_used_at': lastUsedAt?.toIso8601String(), - }; - } - - factory FloodPathStats.fromJson(Map json) { - return FloodPathStats( - successCount: json['success_count'] as int? ?? 0, - failureCount: json['failure_count'] as int? ?? 0, - lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0, - lastUsedAt: DateTime.tryParse(json['last_used_at'] as String? ?? ''), - ); - } -} - -class ContactPathHistory { - final String contactPublicKeyHex; - final List directPaths; - final FloodPathStats floodStats; - final int rotationIndex; - - const ContactPathHistory({ - required this.contactPublicKeyHex, - required this.directPaths, - required this.floodStats, - required this.rotationIndex, - }); - - const ContactPathHistory.empty(this.contactPublicKeyHex) - : directPaths = const [], - floodStats = const FloodPathStats.empty(), - rotationIndex = 0; - - ContactPathHistory copyWith({ - List? directPaths, - FloodPathStats? floodStats, - int? rotationIndex, - }) { - return ContactPathHistory( - contactPublicKeyHex: contactPublicKeyHex, - directPaths: directPaths ?? this.directPaths, - floodStats: floodStats ?? this.floodStats, - rotationIndex: rotationIndex ?? this.rotationIndex, - ); - } - - Map toJson() { - return { - 'direct_paths': directPaths.map((record) => record.toJson()).toList(), - 'flood_stats': floodStats.toJson(), - 'rotation_index': rotationIndex, - }; - } - - List get observedPaths => directPaths - .where((record) => record.source == PathRecordSource.observed) - .toList(); - - factory ContactPathHistory.fromJson( - String contactPublicKeyHex, - Map json, - ) { - return ContactPathHistory( - contactPublicKeyHex: contactPublicKeyHex, - directPaths: (json['direct_paths'] as List? ?? const []) - .whereType>() - .map(PathRecord.fromJson) - .toList(), - floodStats: json['flood_stats'] is Map - ? FloodPathStats.fromJson(json['flood_stats'] as Map) - : const FloodPathStats.empty(), - rotationIndex: json['rotation_index'] as int? ?? 0, - ); - } -} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 7ab7d18..30ba659 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -41,26 +41,22 @@ import '../utils/log_rx_route_decoder.dart'; class _DirectMessageRouteSession { final PathSelection currentSelection; final ParsedContactRoute? originalRoute; - final bool usedManualOverride; final bool routerFallbackAttempted; const _DirectMessageRouteSession({ required this.currentSelection, required this.originalRoute, - required this.usedManualOverride, required this.routerFallbackAttempted, }); _DirectMessageRouteSession copyWith({ PathSelection? currentSelection, ParsedContactRoute? originalRoute, - bool? usedManualOverride, bool? routerFallbackAttempted, }) { return _DirectMessageRouteSession( currentSelection: currentSelection ?? this.currentSelection, originalRoute: originalRoute ?? this.originalRoute, - usedManualOverride: usedManualOverride ?? this.usedManualOverride, routerFallbackAttempted: routerFallbackAttempted ?? this.routerFallbackAttempted, ); @@ -199,9 +195,6 @@ class AppProvider with ChangeNotifier { bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled; double _messageFontScale = 1.0; double get messageFontScale => _messageFontScale; - bool _autoRouteRotationEnabled = - MessagingRoutePreferences.defaultAutoRouteRotationEnabled; - bool get autoRouteRotationEnabled => _autoRouteRotationEnabled; bool _clearPathOnMaxRetry = MessagingRoutePreferences.defaultClearPathOnMaxRetry; bool get clearPathOnMaxRetry => _clearPathOnMaxRetry; @@ -213,6 +206,7 @@ class AppProvider with ChangeNotifier { const NearestRouterSelector(); final Map _directMessageRouteSessions = {}; + final Set _pendingDeliveredRouteRefreshContacts = {}; static const Duration _packetRetryDelay = Duration(milliseconds: 1200); static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10); @@ -873,8 +867,7 @@ class AppProvider with ChangeNotifier { Future _loadMessagingRouteSettings() async { try { - _autoRouteRotationEnabled = - await MessagingRoutePreferences.getAutoRouteRotationEnabled(); + await MessagingRoutePreferences.cleanupLegacySettings(); _clearPathOnMaxRetry = await MessagingRoutePreferences.getClearPathOnMaxRetry(); _nearestRelayFallbackEnabled = @@ -885,16 +878,6 @@ class AppProvider with ChangeNotifier { } } - Future toggleAutoRouteRotationEnabled(bool enabled) async { - try { - _autoRouteRotationEnabled = enabled; - await MessagingRoutePreferences.setAutoRouteRotationEnabled(enabled); - notifyListeners(); - } catch (e) { - debugPrint('Error saving auto route rotation setting: $e'); - } - } - Future toggleClearPathOnMaxRetry(bool enabled) async { try { _clearPathOnMaxRetry = enabled; @@ -1048,6 +1031,14 @@ class AppProvider with ChangeNotifier { contact, devicePublicKey: devicePublicKey, ); + + final updatedContact = + contactsProvider.findContactByKey(contact.publicKey) ?? contact; + if (_pendingDeliveredRouteRefreshContacts.remove( + updatedContact.publicKeyHex, + )) { + messagesProvider.applyDeliveredMessageRouteFromContact(updatedContact); + } }; // When all contacts are received @@ -1254,18 +1245,6 @@ class AppProvider with ChangeNotifier { enrichedMessage, ); final receivedPathBytes = receptionDetailsSnapshot?.pathBytes; - if (senderContact != null && - enrichedMessage.isChannelMessage && - receivedPathBytes != null && - receivedPathBytes.isNotEmpty) { - unawaited( - _learnPathFromPublicMessage( - contact: senderContact, - pathBytes: receivedPathBytes, - ), - ); - } - // Estimate location for contacts without GPS using received path if (senderContact != null && senderContact.displayLocation == null && @@ -1685,6 +1664,7 @@ class AppProvider with ChangeNotifier { // When a contact's routing path is updated in the mesh network connectionProvider.onPathUpdated = (publicKey) { + _pendingDeliveredRouteRefreshContacts.add(_publicKeyHex(publicKey)); debugPrint( '🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', ); @@ -1850,28 +1830,18 @@ class AppProvider with ChangeNotifier { .getManualSelectionForContact(latestContact); final selection = manualSelection ?? - await _pathHistoryService.getSelectionForContact( - latestContact, - autoRouteRotationEnabled: _autoRouteRotationEnabled, - ); + await _pathHistoryService.getSelectionForContact(latestContact); session = _DirectMessageRouteSession( currentSelection: selection, originalRoute: ContactRouteCodec.fromContact(latestContact), - usedManualOverride: manualSelection != null, routerFallbackAttempted: false, ); } if (!session.routerFallbackAttempted) { - final currentSignature = session.currentSelection.hasDirectPath - ? session.currentSelection.pathBytes - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join() - : null; final selection = await _resolveDirectMessageSelectionForRetry( latestContact, retryAttempt: retryAttempt, - currentSignature: currentSignature, fallbackSelection: session.currentSelection, ); session = session.copyWith(currentSelection: selection); @@ -1891,26 +1861,17 @@ class AppProvider with ChangeNotifier { Future _resolveDirectMessageSelectionForRetry( Contact contact, { required int retryAttempt, - required String? currentSignature, required PathSelection fallbackSelection, }) async { if (retryAttempt == 2) { return PathSelection.flood(); } - if (retryAttempt >= 3) { - final historicalSelection = await _pathHistoryService - .getLastSuccessfulDirectSelection( - contact, - excludeSignature: currentSignature, - senderLatitude: locationTrackingService.currentPosition?.latitude, - senderLongitude: locationTrackingService.currentPosition?.longitude, - recipientLatitude: contact.displayLocation?.latitude, - recipientLongitude: contact.displayLocation?.longitude, - ); - if (historicalSelection != null) { - return historicalSelection; - } + if (retryAttempt < 2 && + !fallbackSelection.hasDirectPath && + contact.routeHasPath && + contact.routeHopCount > 0) { + return _pathHistoryService.getSelectionForContact(contact); } return fallbackSelection; @@ -2020,32 +1981,19 @@ class AppProvider with ChangeNotifier { final latestContact = contactsProvider.findContactByKey(contact.publicKey) ?? contact; + final manualSelection = await _pathHistoryService.getManualSelectionForContact( + latestContact, + ); final session = _directMessageRouteSessions[messageId] ?? _DirectMessageRouteSession( currentSelection: - await _pathHistoryService.getManualSelectionForContact( - latestContact, - ) ?? - await _pathHistoryService.getSelectionForContact( - latestContact, - autoRouteRotationEnabled: _autoRouteRotationEnabled, - ), + manualSelection ?? + await _pathHistoryService.getSelectionForContact(latestContact), originalRoute: ContactRouteCodec.fromContact(latestContact), - usedManualOverride: - await _pathHistoryService.getManualSelectionForContact( - latestContact, - ) != - null, routerFallbackAttempted: false, ); - await _pathHistoryService.recordPathResult( - latestContact.publicKeyHex, - session.currentSelection, - success: false, - ); - final repeater = _nearestRouterSelector.select( senderPosition: locationTrackingService.currentPosition, repeaters: contactsProvider.repeaters, @@ -2089,33 +2037,10 @@ class AppProvider with ChangeNotifier { return; } - unawaited( - () async { - await _pathHistoryService.recordPathResult( - contact.publicKeyHex, - session.currentSelection, - success: true, - roundTripTimeMs: roundTripTimeMs, - senderLatitude: locationTrackingService.currentPosition?.latitude, - senderLongitude: locationTrackingService.currentPosition?.longitude, - recipientLatitude: contact.displayLocation?.latitude, - recipientLongitude: contact.displayLocation?.longitude, - ); - if (!session.usedManualOverride) { - return; - } - if (session.currentSelection.mode == PathSelectionMode.directCurrent || - session.currentSelection.mode == - PathSelectionMode.directHistorical) { - await _pathHistoryService.setManualSelectionFor( - contact.publicKeyHex, - session.currentSelection, - ); - return; - } - await _pathHistoryService.clearManualRouteFor(contact.publicKeyHex); - }(), - ); + if (session.currentSelection.usesFlood || + session.currentSelection.mode == PathSelectionMode.nearestRouter) { + messagesProvider.queueDeliveredMessageRouteRefresh(messageId, contact); + } } Future _handleDirectMessageFinalFailure({ @@ -2126,16 +2051,6 @@ class AppProvider with ChangeNotifier { contactsProvider.findContactByKey(contact.publicKey) ?? contact; final session = _directMessageRouteSessions.remove(messageId); if (session != null) { - await _pathHistoryService.recordPathResult( - latestContact.publicKeyHex, - session.currentSelection, - success: false, - ); - if (session.usedManualOverride) { - await _pathHistoryService.clearManualRouteFor( - latestContact.publicKeyHex, - ); - } if (session.routerFallbackAttempted) { await _restoreRouteOnDevice(latestContact, session.originalRoute); } @@ -2157,6 +2072,12 @@ class AppProvider with ChangeNotifier { return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); } + String _publicKeyHex(Uint8List publicKey) { + return publicKey + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + } + /// Estimate contact location from the received message path. /// /// When we receive a message, the path bytes describe how it traveled: @@ -2207,22 +2128,6 @@ class AppProvider with ChangeNotifier { ); } - Future _learnPathFromPublicMessage({ - required Contact contact, - required List pathBytes, - }) async { - final preferred = await RouteHashPreferences.getHashSize(); - final inferredHashSize = _inferReceivedPathHashSize( - pathBytes, - preferredHashSize: preferred, - ); - await _pathHistoryService.recordReceivedBytePath( - contact.publicKeyHex, - pathBytes, - inferredHashSize, - ); - } - Future _retainAdvertRxPath(Uint8List publicKey) async { final decoded = _findBestMatchingAdvertRxRoute(publicKey); if (decoded == null || decoded.pathBytes.isEmpty) { @@ -2248,11 +2153,6 @@ class AppProvider with ChangeNotifier { paddedPathBytes: parsedRoute.paddedPathBytes, devicePublicKey: connectionProvider.deviceInfo.publicKey, ); - await _pathHistoryService.recordReceivedBytePath( - publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(), - decoded.pathBytes, - decoded.hashSize, - ); } /// Handle pushAdvert (0x80) — matches the official MeshCore app flow: @@ -2593,22 +2493,6 @@ class AppProvider with ChangeNotifier { } } - int _inferReceivedPathHashSize( - List pathBytes, { - required int preferredHashSize, - }) { - final preferred = preferredHashSize; - final candidates = {preferred, 3, 2, 1}.toList(); - for (final candidate in candidates) { - if (candidate >= 1 && - candidate <= 3 && - pathBytes.length % candidate == 0) { - return candidate; - } - } - return 1; - } - /// Initialize the app (load contacts, sync time, etc.) Future initialize() async { if (!connectionProvider.deviceInfo.isConnected) return; diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 54e74c0..3bb3c16 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -24,6 +24,7 @@ typedef DisplayMessageEntry = ({Message message, int occurrenceCount}); class MessagesProvider with ChangeNotifier { static const Duration _channelEchoWarningDelay = Duration(seconds: 12); static const Duration _receivedDuplicateWindow = Duration(seconds: 5); + static const Duration _deliveredRouteRefreshWindow = Duration(seconds: 30); final List _messages = []; final Map _sarMarkers = {}; @@ -38,6 +39,9 @@ class MessagesProvider with ChangeNotifier { final Map _messageReceptionDetails = {}; final Map _messageTransferDetails = {}; final Map _messageRouteMetadata = {}; + final Map> + _pendingDeliveredRouteRefreshByContact = + >{}; String? _storageNamespace; // Track pending sent messages by expected ACK/TAG @@ -199,6 +203,83 @@ class MessagesProvider with ChangeNotifier { notifyListeners(); } + void queueDeliveredMessageRouteRefresh(String messageId, Contact contact) { + if (messageId.isEmpty || contact.publicKeyHex.isEmpty) { + return; + } + + _prunePendingDeliveredRouteRefresh(); + final queue = _pendingDeliveredRouteRefreshByContact.putIfAbsent( + contact.publicKeyHex, + () => <_PendingDeliveredRouteRefresh>[], + ); + queue.removeWhere((entry) => entry.messageId == messageId); + queue.add( + _PendingDeliveredRouteRefresh( + messageId: messageId, + queuedAt: DateTime.now(), + ), + ); + } + + bool applyDeliveredMessageRouteFromContact(Contact contact) { + if (!contact.routeHasPath || + contact.routeHopCount <= 0 || + contact.publicKeyHex.isEmpty) { + return false; + } + + _prunePendingDeliveredRouteRefresh(); + final queue = _pendingDeliveredRouteRefreshByContact[contact.publicKeyHex]; + if (queue == null || queue.isEmpty) { + return false; + } + + while (queue.isNotEmpty) { + final pending = queue.removeAt(0); + final index = _messages.indexWhere( + (message) => message.id == pending.messageId, + ); + if (index == -1) { + continue; + } + + final message = _messages[index]; + if (!message.isContactMessage || + message.deliveryStatus != MessageDeliveryStatus.delivered) { + continue; + } + + final existingMetadata = _messageRouteMetadata[pending.messageId]; + _messageRouteMetadata[pending.messageId] = MessageRouteMetadata( + mode: PathSelectionMode.directCurrent, + routerFallbackAttempted: + existingMetadata?.routerFallbackAttempted ?? false, + canonicalPath: contact.routeCanonicalText.isEmpty + ? null + : contact.routeCanonicalText, + hopCount: contact.routeHopCount, + ); + _messages[index] = message.copyWith( + pathLen: contact.routeHopCount, + usedFloodFallback: false, + ); + + if (queue.isEmpty) { + _pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex); + } + + _persistMessages(); + notifyListeners(); + return true; + } + + if (queue.isEmpty) { + _pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex); + } + return false; + } + /// Set localizations for notifications void setLocalizations(AppLocalizations localizations) { _localizations = localizations; @@ -2555,6 +2636,7 @@ class MessagesProvider with ChangeNotifier { _pendingSentMessages.remove(message.expectedAckTag); } _clearAckHistoryForMessage(messageId); + _removePendingDeliveredRouteRefresh(messageId); // Clear retry tracking _retryManager.clearRetry(messageId); @@ -2595,6 +2677,7 @@ class MessagesProvider with ChangeNotifier { _pendingSentMessages.remove(message.expectedAckTag); } _clearAckHistoryForMessage(messageId); + _removePendingDeliveredRouteRefresh(messageId); _retryManager.clearRetry(messageId); _messageRouteMetadata.remove(messageId); onManualRetryPreparedCallback?.call(messageId); @@ -2767,4 +2850,51 @@ class MessagesProvider with ChangeNotifier { } } } + + void _prunePendingDeliveredRouteRefresh() { + if (_pendingDeliveredRouteRefreshByContact.isEmpty) { + return; + } + + final cutoff = DateTime.now().subtract(_deliveredRouteRefreshWindow); + final emptyKeys = []; + for (final entry in _pendingDeliveredRouteRefreshByContact.entries) { + entry.value.removeWhere( + (pending) => pending.queuedAt.isBefore(cutoff), + ); + if (entry.value.isEmpty) { + emptyKeys.add(entry.key); + } + } + for (final key in emptyKeys) { + _pendingDeliveredRouteRefreshByContact.remove(key); + } + } + + void _removePendingDeliveredRouteRefresh(String messageId) { + if (_pendingDeliveredRouteRefreshByContact.isEmpty) { + return; + } + + final emptyKeys = []; + for (final entry in _pendingDeliveredRouteRefreshByContact.entries) { + entry.value.removeWhere((pending) => pending.messageId == messageId); + if (entry.value.isEmpty) { + emptyKeys.add(entry.key); + } + } + for (final key in emptyKeys) { + _pendingDeliveredRouteRefreshByContact.remove(key); + } + } +} + +class _PendingDeliveredRouteRefresh { + final String messageId; + final DateTime queuedAt; + + const _PendingDeliveredRouteRefresh({ + required this.messageId, + required this.queuedAt, + }); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1eb33de..6e17ea2 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1486,19 +1486,6 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: _showRouteHashSizeDialog, ), - Consumer( - builder: (context, appProvider, child) => SwitchListTile( - secondary: Icon(Icons.swap_horiz), - title: Text(AppLocalizations.of(context)!.autoRouteRotation), - subtitle: const Text( - 'Rotate between best known direct paths and flood mode for room/contact sends', - ), - value: appProvider.autoRouteRotationEnabled, - onChanged: (value) async { - await appProvider.toggleAutoRouteRotationEnabled(value); - }, - ), - ), Consumer( builder: (context, appProvider, child) => SwitchListTile( secondary: Icon(Icons.route), diff --git a/lib/services/app_config_snapshot_service.dart b/lib/services/app_config_snapshot_service.dart index eda29b0..c9f3f21 100644 --- a/lib/services/app_config_snapshot_service.dart +++ b/lib/services/app_config_snapshot_service.dart @@ -23,7 +23,6 @@ class AppConfigSnapshotService { voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled, voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled, messageFontScale: appProvider.messageFontScale, - autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, voiceBitrate: await VoiceBitratePreferences.getBitrate(), @@ -98,11 +97,6 @@ class AppConfigSnapshotService { if (section.messageFontScale != null) { await appProvider.setMessageFontScale(section.messageFontScale!); } - if (section.autoRouteRotationEnabled != null) { - await appProvider.toggleAutoRouteRotationEnabled( - section.autoRouteRotationEnabled!, - ); - } if (section.clearPathOnMaxRetry != null) { await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!); } diff --git a/lib/services/messaging_route_preferences.dart b/lib/services/messaging_route_preferences.dart index 1b0216e..277b54d 100644 --- a/lib/services/messaging_route_preferences.dart +++ b/lib/services/messaging_route_preferences.dart @@ -2,30 +2,20 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'profiles_feature_service.dart'; class MessagingRoutePreferences { - static const bool defaultAutoRouteRotationEnabled = false; static const bool defaultClearPathOnMaxRetry = false; static const bool defaultNearestRelayFallbackEnabled = true; - static const String _autoRouteRotationKey = + static const String _legacyAutoRouteRotationKey = 'messaging_auto_route_rotation_enabled'; static const String _clearPathOnMaxRetryKey = 'messaging_clear_path_on_max_retry'; static const String _nearestRelayFallbackKey = 'messaging_nearest_relay_fallback_enabled'; - static Future getAutoRouteRotationEnabled() async { + static Future cleanupLegacySettings() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool( - ProfileStorageScope.scopedKey(_autoRouteRotationKey), - ) ?? - defaultAutoRouteRotationEnabled; - } - - static Future setAutoRouteRotationEnabled(bool enabled) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool( - ProfileStorageScope.scopedKey(_autoRouteRotationKey), - enabled, + await prefs.remove( + ProfileStorageScope.scopedKey(_legacyAutoRouteRotationKey), ); } diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index d395c33..3263cbf 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -1,13 +1,10 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; -import '../models/path_history.dart'; import '../models/path_selection.dart'; -import '../utils/log_rx_route_decoder.dart'; class _ManualPathSelectionRecord { final List pathBytes; @@ -46,46 +43,19 @@ class _ManualPathSelectionRecord { } class PathHistoryService { - static const String _storageKey = 'contact_path_history_v2'; + static const String _legacyStorageKey = 'contact_path_history_v2'; static const String _manualRouteStorageKey = 'contact_manual_path_overrides_v1'; - static const int _maxDirectPaths = 20; - static const int _topRotationCount = 3; - final Map _cache = {}; final Map _manualSelections = {}; bool _isLoaded = false; Future initialize() async { if (_isLoaded) return; final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_storageKey); - final manualRaw = prefs.getString(_manualRouteStorageKey); - if (raw == null || raw.isEmpty) { - if (manualRaw == null || manualRaw.isEmpty) { - _isLoaded = true; - return; - } - } + await prefs.remove(_legacyStorageKey); - try { - if (raw != null && raw.isNotEmpty) { - final decoded = jsonDecode(raw); - if (decoded is Map) { - for (final entry in decoded.entries) { - final value = entry.value; - if (value is Map) { - _cache[entry.key] = ContactPathHistory.fromJson( - entry.key, - value, - ); - } - } - } - } - } catch (error) { - debugPrint('⚠️ [PathHistoryService] Failed to load history: $error'); - } + final manualRaw = prefs.getString(_manualRouteStorageKey); try { if (manualRaw != null && manualRaw.isNotEmpty) { final decoded = jsonDecode(manualRaw); @@ -104,220 +74,30 @@ class PathHistoryService { '⚠️ [PathHistoryService] Failed to load manual routes: $error', ); } + _isLoaded = true; } - Future recordReceivedBytePath( - String contactPublicKeyHex, - List pathBytes, - int hashSize, - ) async { - await initialize(); - if (pathBytes.isEmpty) { - return; - } - if (hashSize < 1 || hashSize > 3) { - return; - } - if (pathBytes.length % hashSize != 0) { - return; - } - - final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes( - pathBytes, - hashSize: hashSize, - ); - - final history = _historyFor(contactPublicKeyHex); - final signature = normalizedPathBytes - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join(); - final existing = _findDirectPath(history.directPaths, signature); - final updated = PathRecord( - pathBytes: normalizedPathBytes, - hopCount: normalizedPathBytes.length ~/ hashSize, - hashSize: hashSize, - source: PathRecordSource.observed, - successCount: existing?.successCount ?? 0, - failureCount: existing?.failureCount ?? 0, - lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, - lastUsedAt: DateTime.now(), - lastSucceededAt: existing?.lastSucceededAt, - senderLatitude: existing?.senderLatitude, - senderLongitude: existing?.senderLongitude, - recipientLatitude: existing?.recipientLatitude, - recipientLongitude: existing?.recipientLongitude, - ); - - await _saveHistory( - contactPublicKeyHex, - history.copyWith( - directPaths: _upsertDirectPath(history.directPaths, updated), - ), - ); - } - - Future getSelectionForContact( - Contact contact, { - required bool autoRouteRotationEnabled, - }) async { + Future getSelectionForContact(Contact contact) async { await initialize(); final manualSelection = _manualSelections[contact.publicKeyHex]; if (manualSelection != null) { return manualSelection.toSelection(); } - if (!autoRouteRotationEnabled) { + final route = ContactRouteCodec.fromContact(contact); + if (route == null) { return PathSelection.flood(); } - final history = _historyFor(contact.publicKeyHex); - final ranked = List.from(history.directPaths) - ..sort(_comparePathRecords); - final topPaths = ranked.take(_topRotationCount).toList(); - if (topPaths.isEmpty) { - final nextFloodHistory = history.copyWith( - rotationIndex: history.rotationIndex + 1, - ); - await _saveHistory(contact.publicKeyHex, nextFloodHistory); - return PathSelection.flood(); - } - - final selections = - topPaths - .map( - (record) => PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList(record.pathBytes), - hopCount: record.hopCount, - hashSize: record.hashSize, - ), - ) - .toList() - ..add(PathSelection.flood()); - - final index = history.rotationIndex % selections.length; - final updatedHistory = history.copyWith( - rotationIndex: history.rotationIndex + 1, - ); - await _saveHistory(contact.publicKeyHex, updatedHistory); - return selections[index]; - } - - Future recordPathResult( - String contactPublicKeyHex, - PathSelection selection, { - required bool success, - int? roundTripTimeMs, - double? senderLatitude, - double? senderLongitude, - double? recipientLatitude, - double? recipientLongitude, - }) async { - await initialize(); - final history = _historyFor(contactPublicKeyHex); - if (selection.usesFlood) { - final current = history.floodStats; - await _saveHistory( - contactPublicKeyHex, - history.copyWith( - floodStats: current.copyWith( - successCount: current.successCount + (success ? 1 : 0), - failureCount: current.failureCount + (success ? 0 : 1), - lastRoundTripTimeMs: success - ? (roundTripTimeMs ?? current.lastRoundTripTimeMs) - : current.lastRoundTripTimeMs, - lastUsedAt: DateTime.now(), - ), - ), - ); - return; - } - - final signature = _signature(selection.pathBytes); - final existing = _findDirectPath(history.directPaths, signature); - final updated = PathRecord( - pathBytes: selection.pathBytes.toList(), - hopCount: selection.hopCount, - hashSize: selection.hashSize, - source: success - ? PathRecordSource.learned - : existing?.source ?? PathRecordSource.learned, - successCount: (existing?.successCount ?? 0) + (success ? 1 : 0), - failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1), - lastRoundTripTimeMs: success - ? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0) - : (existing?.lastRoundTripTimeMs ?? 0), - lastUsedAt: DateTime.now(), - lastSucceededAt: success ? DateTime.now() : existing?.lastSucceededAt, - senderLatitude: success ? senderLatitude : existing?.senderLatitude, - senderLongitude: success ? senderLongitude : existing?.senderLongitude, - recipientLatitude: - success ? recipientLatitude : existing?.recipientLatitude, - recipientLongitude: - success ? recipientLongitude : existing?.recipientLongitude, - ); - await _saveHistory( - contactPublicKeyHex, - history.copyWith( - directPaths: _upsertDirectPath(history.directPaths, updated), - ), - ); - } - - Future getLastSuccessfulDirectSelection( - Contact contact, { - String? excludeSignature, - double? senderLatitude, - double? senderLongitude, - double? recipientLatitude, - double? recipientLongitude, - }) async { - await initialize(); - final history = _historyFor(contact.publicKeyHex); - final ranked = history.directPaths - .where( - (record) => - record.successCount > 0 && - record.lastSucceededAt != null && - record.signature != excludeSignature, - ) - .toList() - ..sort((a, b) { - final locationCompare = _compareLocationFit( - a, - b, - senderLatitude: senderLatitude, - senderLongitude: senderLongitude, - recipientLatitude: recipientLatitude, - recipientLongitude: recipientLongitude, - ); - if (locationCompare != 0) return locationCompare; - final succeededCompare = b.lastSucceededAt!.compareTo( - a.lastSucceededAt!, - ); - if (succeededCompare != 0) return succeededCompare; - return _comparePathRecords(a, b); - }); - - if (ranked.isEmpty) { - return null; - } - - final record = ranked.first; return PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList(record.pathBytes), - hopCount: record.hopCount, - hashSize: record.hashSize, + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList(route.pathBytes), + hopCount: route.hopCount, + hashSize: route.hashSize, ); } - ContactPathHistory historyFor(String contactPublicKeyHex) { - return _cache[contactPublicKeyHex] ?? - ContactPathHistory.empty(contactPublicKeyHex); - } - Future setManualRouteForContact( Contact contact, ParsedContactRoute route, @@ -343,7 +123,7 @@ class PathHistoryService { hopCount: selection.hopCount, hashSize: selection.hashSize, ); - await _persistState(); + await _persistManualSelections(); } Future getManualSelectionForContact(Contact contact) async { @@ -354,149 +134,15 @@ class PathHistoryService { Future clearManualRouteFor(String contactPublicKeyHex) async { await initialize(); _manualSelections.remove(contactPublicKeyHex); - await _persistState(); + await _persistManualSelections(); } - Future clearHistoryFor(String contactPublicKeyHex) async { - await initialize(); - _cache.remove(contactPublicKeyHex); - await _persistState(); - } - - Future clearHistoryForContact(Contact contact) async { - await clearHistoryFor(contact.publicKeyHex); - } - - ContactPathHistory _historyFor(String contactPublicKeyHex) { - return _cache.putIfAbsent( - contactPublicKeyHex, - () => ContactPathHistory.empty(contactPublicKeyHex), - ); - } - - Future _saveHistory( - String contactPublicKeyHex, - ContactPathHistory history, - ) async { - _cache[contactPublicKeyHex] = history; - await _persistState(); - } - - Future _persistState() async { + Future _persistManualSelections() async { final prefs = await SharedPreferences.getInstance(); - final payload = {}; - for (final entry in _cache.entries) { - payload[entry.key] = entry.value.toJson(); - } final manualPayload = {}; for (final entry in _manualSelections.entries) { manualPayload[entry.key] = entry.value.toJson(); } - await prefs.setString(_storageKey, jsonEncode(payload)); await prefs.setString(_manualRouteStorageKey, jsonEncode(manualPayload)); } - - List _upsertDirectPath( - List existing, - PathRecord updatedRecord, - ) { - final updated = List.from(existing) - ..removeWhere((record) => record.signature == updatedRecord.signature) - ..insert(0, updatedRecord); - if (updated.length > _maxDirectPaths) { - return updated.take(_maxDirectPaths).toList(); - } - return updated; - } - - int _comparePathRecords(PathRecord a, PathRecord b) { - final successRateCompare = b.successRate.compareTo(a.successRate); - if (successRateCompare != 0) return successRateCompare; - - final successCountCompare = b.successCount.compareTo(a.successCount); - if (successCountCompare != 0) return successCountCompare; - - final aRtt = a.lastRoundTripTimeMs == 0 ? 1 << 30 : a.lastRoundTripTimeMs; - final bRtt = b.lastRoundTripTimeMs == 0 ? 1 << 30 : b.lastRoundTripTimeMs; - final rttCompare = aRtt.compareTo(bRtt); - if (rttCompare != 0) return rttCompare; - - return b.lastUsedAt.compareTo(a.lastUsedAt); - } - - PathRecord? _findDirectPath(List records, String signature) { - for (final record in records) { - if (record.signature == signature) { - return record; - } - } - return null; - } - - String _signature(Uint8List bytes) => - bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); - - int _compareLocationFit( - PathRecord a, - PathRecord b, { - required double? senderLatitude, - required double? senderLongitude, - required double? recipientLatitude, - required double? recipientLongitude, - }) { - final aDistance = _locationDistanceScore( - a, - senderLatitude: senderLatitude, - senderLongitude: senderLongitude, - recipientLatitude: recipientLatitude, - recipientLongitude: recipientLongitude, - ); - final bDistance = _locationDistanceScore( - b, - senderLatitude: senderLatitude, - senderLongitude: senderLongitude, - recipientLatitude: recipientLatitude, - recipientLongitude: recipientLongitude, - ); - return aDistance.compareTo(bDistance); - } - - double _locationDistanceScore( - PathRecord record, { - required double? senderLatitude, - required double? senderLongitude, - required double? recipientLatitude, - required double? recipientLongitude, - }) { - var total = 0.0; - var matched = false; - - if (senderLatitude != null && - senderLongitude != null && - record.senderLatitude != null && - record.senderLongitude != null) { - matched = true; - total += Geolocator.distanceBetween( - senderLatitude, - senderLongitude, - record.senderLatitude!, - record.senderLongitude!, - ); - } - - if (recipientLatitude != null && - recipientLongitude != null && - record.recipientLatitude != null && - record.recipientLongitude != null) { - matched = true; - total += Geolocator.distanceBetween( - recipientLatitude, - recipientLongitude, - record.recipientLatitude!, - record.recipientLongitude!, - ); - } - - return matched ? total : double.infinity; - } } diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index 256eacd..95ef4a0 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -6,11 +6,9 @@ import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../../models/contact.dart'; -import '../../models/path_history.dart'; import '../../l10n/app_localizations.dart'; import '../../providers/app_provider.dart'; import '../../providers/connection_provider.dart'; -import '../../services/path_history_service.dart'; import '../../services/relay_candidate_sorter.dart'; import '../../services/route_hash_preferences.dart'; @@ -72,7 +70,6 @@ class ContactRouteDialog extends StatefulWidget { class _ContactRouteDialogState extends State { late final TextEditingController _controller; late final TextEditingController _relaySearchController; - final PathHistoryService _pathHistoryService = PathHistoryService(); final RelayCandidateSorter _relayCandidateSorter = const RelayCandidateSorter(); int _selectedHashSize = RouteHashPreferences.defaultHashSize; @@ -80,7 +77,6 @@ class _ContactRouteDialogState extends State { String? _errorText; bool _showRoutingInfo = false; List _selectedMapHops = const []; - ContactPathHistory? _pathHistory; @override void initState() { @@ -91,7 +87,6 @@ class _ContactRouteDialogState extends State { _relaySearchController = TextEditingController(); _controller.addListener(_reparse); _loadHashSizePreference(); - _loadPathHistory(); _reparse(); } @@ -182,16 +177,6 @@ class _ContactRouteDialogState extends State { _reparse(); } - Future _loadPathHistory() async { - await _pathHistoryService.initialize(); - if (!mounted) return; - setState(() { - _pathHistory = _pathHistoryService.historyFor( - widget.contact.publicKeyHex, - ); - }); - } - String _tokenFor(Contact contact, int hashSize) { final hex = contact.publicKeyHex.toUpperCase(); final length = hashSize * 2; @@ -237,21 +222,6 @@ class _ContactRouteDialogState extends State { }); } - void _applyHistoryRecord(PathRecord record) { - final canonicalText = _canonicalRouteFromBytes( - record.pathBytes, - hashSize: record.hashSize, - ); - setState(() { - _controller.text = canonicalText; - _controller.selection = TextSelection.fromPosition( - TextPosition(offset: _controller.text.length), - ); - _errorText = null; - }); - _reparse(); - } - LatLng? _resolveLastHopLocation() { if (_selectedMapHops.isNotEmpty) { return _selectedMapHops.last.displayLocation == null @@ -302,86 +272,6 @@ class _ContactRouteDialogState extends State { ); } - String _canonicalRouteFromBytes( - List pathBytes, { - required int hashSize, - }) { - final hops = []; - for (var i = 0; i < pathBytes.length; i += hashSize) { - final hop = pathBytes.sublist(i, i + hashSize); - hops.add( - hop - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join() - .toUpperCase(), - ); - } - return hops.join(','); - } - - String _historySubtitle(PathRecord record) { - final attempts = record.successCount + record.failureCount; - final lastSeen = MaterialLocalizations.of( - context, - ).formatShortDate(record.lastUsedAt); - final sourceLabel = switch (record.source) { - PathRecordSource.observed => 'Observed on mesh', - PathRecordSource.learned => 'Learned route', - }; - final successRate = attempts == 0 - ? 'No send stats yet' - : '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}'; - final latency = record.lastRoundTripTimeMs > 0 - ? ' • ${record.lastRoundTripTimeMs} ms' - : ''; - return '$sourceLabel • $successRate • Last used $lastSeen$latency'; - } - - Widget _buildHistoryRecordTile(PathRecord record, {String? title}) { - final canonicalText = _canonicalRouteFromBytes( - record.pathBytes, - hashSize: record.hashSize, - ); - return Card( - margin: EdgeInsets.zero, - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - leading: title == null ? null : const Icon(Icons.alt_route), - title: title == null - ? Text( - canonicalText, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 6), - Text( - canonicalText, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), - ), - ], - ), - subtitle: Padding( - padding: const EdgeInsets.only(top: 6), - child: Text(_historySubtitle(record)), - ), - trailing: FilledButton.tonal( - onPressed: () => _applyHistoryRecord(record), - child: Text(AppLocalizations.of(context)!.use), - ), - ), - ); - } - Widget _buildPreviewSection() { final previewRoute = _effectiveRoute; if (previewRoute == null) { @@ -676,7 +566,6 @@ class _ContactRouteDialogState extends State { _showRoutingInfo = !_showRoutingInfo; }); }, - autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled, nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, ), @@ -685,79 +574,6 @@ class _ContactRouteDialogState extends State { ); } - Widget _buildHistoryTab() { - final records = List.from(_pathHistory?.directPaths ?? const []) - ..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt)); - if (records.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - 'No historical paths for this contact yet.', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ), - ); - } - - PathRecord? observedRecord; - for (final record in records) { - if (record.source == PathRecordSource.observed) { - observedRecord = record; - break; - } - } - final remainingRecords = observedRecord == null - ? records - : records - .where((record) => !identical(record, observedRecord)) - .toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () async { - await _pathHistoryService.clearHistoryForContact(widget.contact); - if (!mounted) return; - setState(() { - _pathHistory = _pathHistoryService.historyFor( - widget.contact.publicKeyHex, - ); - }); - }, - child: const Text('Clear history'), - ), - ), - const SizedBox(height: 8), - if (observedRecord != null) ...[ - _buildHistoryRecordTile(observedRecord, title: AppLocalizations.of(context)!.observedMeshRoute), - const SizedBox(height: 16), - ], - if (remainingRecords.isEmpty) - Text( - observedRecord == null - ? 'No additional route history yet.' - : 'Observed routes you start using will continue to build history here.', - style: Theme.of(context).textTheme.bodyMedium, - ) - else - ListView.separated( - itemCount: remainingRecords.length, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - return _buildHistoryRecordTile(remainingRecords[index]); - }, - ), - ], - ); - } - @override Widget build(BuildContext context) { final effectiveRoute = _effectiveRoute; @@ -802,14 +618,13 @@ class _ContactRouteDialogState extends State { ]; return DefaultTabController( - length: 3, + length: 2, child: Scaffold( appBar: AppBar( title: Text('Set Path for ${widget.contact.displayName}'), bottom: const TabBar( tabs: [ Tab(text: 'Build'), - Tab(text: 'History'), Tab(text: 'Info'), ], ), @@ -827,12 +642,6 @@ class _ContactRouteDialogState extends State { const SizedBox(height: 24), ], ), - ListView( - children: [ - _buildHistoryTab(), - const SizedBox(height: 24), - ], - ), _buildInfoTab( appProvider: appProvider, routeCandidates: routeCandidates, @@ -912,14 +721,12 @@ class _RouteMarkerDot extends StatelessWidget { class _AutomationRoutingInfo extends StatelessWidget { final bool isExpanded; final VoidCallback onToggle; - final bool autoRouteRotationEnabled; final bool nearestRelayFallbackEnabled; final bool clearPathOnMaxRetry; const _AutomationRoutingInfo({ required this.isExpanded, required this.onToggle, - required this.autoRouteRotationEnabled, required this.nearestRelayFallbackEnabled, required this.clearPathOnMaxRetry, }); @@ -968,7 +775,7 @@ class _AutomationRoutingInfo extends StatelessWidget { if (isExpanded) ...[ const SizedBox(height: 8), Text( - 'Room/contact sends keep one selected path for the whole send chain, retry up to 5 total attempts with 1s, 2s, 4s, and 8s backoff, then try one final nearest repeater if everything else fails.', + 'Room/contact sends use the current direct path when one is known, switch to flood on the last normal retry, then try one final nearest repeater if everything else fails.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), @@ -981,12 +788,6 @@ class _AutomationRoutingInfo extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ - _InfoChip( - label: autoRouteRotationEnabled - ? 'Auto route rotation on' - : 'Auto route rotation off', - icon: Icons.swap_horiz, - ), _InfoChip( label: nearestRelayFallbackEnabled ? 'Nearest repeater fallback on' @@ -1004,7 +805,7 @@ class _AutomationRoutingInfo extends StatelessWidget { ] else ...[ const SizedBox(height: 6), Text( - 'Shows retry, rotation, and final repeater fallback behavior.', + 'Shows retry and final repeater fallback behavior.', style: Theme.of(context).textTheme.bodySmall, ), ], diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 50564ec..21fb74a 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -850,12 +850,6 @@ class ContactTile extends StatelessWidget { signedEncodedPathLen: parsedRoute.signedEncodedPathLen, paddedPathBytes: parsedRoute.paddedPathBytes, ); - await pathHistoryService.clearHistoryForContact( - contact.copyWith( - outPathLen: parsedRoute.signedEncodedPathLen, - outPath: Uint8List.fromList(parsedRoute.paddedPathBytes), - ), - ); await pathHistoryService.setManualRouteForContact(contact, parsedRoute); if (context.mounted) { final routeLabel = parsedRoute.hopCount == 0 diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index 3729ec3..7bec792 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -10,13 +10,16 @@ import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -Contact _buildContact() { +Contact _buildContact({ + int outPathLen = 1, + List outPath = const [1, 2, 3, 4], +}) { return Contact( publicKey: Uint8List.fromList(List.generate(32, (i) => i)), type: ContactType.chat, flags: 0, - outPathLen: 1, - outPath: Uint8List.fromList([1, 2, 3, 4]), + outPathLen: outPathLen, + outPath: Uint8List.fromList(outPath), advName: 'Teammate', lastAdvert: 1700000000, advLat: 0, @@ -162,6 +165,42 @@ void main() { expect(provider.messages.single.roundTripTimeMs, 190); }); + test('delivered flood message upgrades to learned direct route from ACK path', () { + final provider = MessagesProvider(); + final contactWithoutRoute = _buildContact(outPathLen: -1, outPath: []); + provider.addSentMessage( + _buildDirectMessage('m1d'), + contact: contactWithoutRoute, + ); + provider.updateMessageRouteSelection( + 'm1d', + PathSelection.flood(), + routerFallbackAttempted: false, + ); + + provider.markMessageSent('m1d', 80, 250); + provider.markMessageDelivered(80, 200); + provider.queueDeliveredMessageRouteRefresh('m1d', contactWithoutRoute); + + final applied = provider.applyDeliveredMessageRouteFromContact( + _buildContact(outPathLen: 2, outPath: const [0xAA, 0xBB]), + ); + + expect(applied, isTrue); + expect(provider.messages.single.deliveryStatus, MessageDeliveryStatus.delivered); + expect(provider.messages.single.usedFloodFallback, isFalse); + expect(provider.messages.single.pathLen, 2); + expect( + provider.getMessageRouteMetadata('m1d')?.mode, + PathSelectionMode.directCurrent, + ); + expect( + provider.getMessageRouteMetadata('m1d')?.canonicalPath, + 'AA,BB', + ); + expect(provider.getMessageRouteMetadata('m1d')?.hopCount, 2); + }); + test('channel messages are marked sent immediately', () { final provider = MessagesProvider(); provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; diff --git a/test/services/messaging_route_preferences_test.dart b/test/services/messaging_route_preferences_test.dart index 9d19cd9..28a5459 100644 --- a/test/services/messaging_route_preferences_test.dart +++ b/test/services/messaging_route_preferences_test.dart @@ -10,11 +10,7 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - test('route preference defaults are disabled', () async { - expect( - await MessagingRoutePreferences.getAutoRouteRotationEnabled(), - isFalse, - ); + test('route preference defaults are clear-path disabled and fallback enabled', () async { expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse); expect( await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), @@ -23,18 +19,29 @@ void main() { }); test('route preferences persist changes', () async { - await MessagingRoutePreferences.setAutoRouteRotationEnabled(true); await MessagingRoutePreferences.setClearPathOnMaxRetry(true); await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false); - expect( - await MessagingRoutePreferences.getAutoRouteRotationEnabled(), - isTrue, - ); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue); expect( await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), isFalse, ); }); + + test('legacy auto route rotation preference is removed during cleanup', () async { + SharedPreferences.setMockInitialValues({ + 'messaging_auto_route_rotation_enabled': true, + }); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('messaging_auto_route_rotation_enabled'), isTrue); + + await MessagingRoutePreferences.cleanupLegacySettings(); + + expect( + prefs.containsKey('messaging_auto_route_rotation_enabled'), + isFalse, + ); + }); } diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index 1bb8981..12e5b21 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -1,44 +1,31 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:meshcore_sar_app/models/contact.dart'; -import 'package:meshcore_sar_app/models/path_history.dart'; import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/services/path_history_service.dart'; Contact _buildContact({ required int seed, - required List pathBytes, - required int hopCount, - required int hashSize, + List pathBytes = const [], + int hopCount = 0, + int hashSize = 1, }) { - final encoded = ((hashSize - 1) << 6) | (hopCount & 0x3F); - final outPath = Uint8List(ContactRouteCodec.maxPathBytes) - ..setRange(0, pathBytes.length, pathBytes); + final encoded = pathBytes.isEmpty ? -1 : ((hashSize - 1) << 6) | (hopCount & 0x3F); + final outPath = Uint8List(ContactRouteCodec.maxPathBytes); + if (pathBytes.isNotEmpty) { + outPath.setRange(0, pathBytes.length, pathBytes); + } return Contact( publicKey: Uint8List.fromList(List.generate(32, (i) => i + seed)), type: ContactType.chat, flags: 0, - outPathLen: ContactRouteCodec.toSignedDescriptor(encoded), - outPath: outPath, - advName: 'Contact $seed', - lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, - advLat: 0, - advLon: 0, - lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, - ); -} - -Contact _buildContactWithoutRoute({required int seed}) { - return Contact( - publicKey: Uint8List.fromList(List.generate(32, (i) => i + seed)), - type: ContactType.chat, - flags: 0, - outPathLen: -1, - outPath: Uint8List(0), + outPathLen: encoded == -1 ? -1 : ContactRouteCodec.toSignedDescriptor(encoded), + outPath: encoded == -1 ? Uint8List(0) : outPath, advName: 'Contact $seed', lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, advLat: 0, @@ -54,117 +41,10 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - test('auto rotation ranks best paths before flood', () async { + test('manual route override persists across reloads', () async { + final contact = _buildContact(seed: 1); final service = PathHistoryService(); - final contact = _buildContactWithoutRoute(seed: 0); - final best = PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList([0xAA, 0xBB]), - hopCount: 2, - hashSize: 1, - ); - final second = PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList([0xCC, 0xDD]), - hopCount: 2, - hashSize: 1, - ); - await service.initialize(); - await service.recordPathResult( - contact.publicKeyHex, - best, - success: true, - roundTripTimeMs: 120, - ); - await service.recordPathResult( - contact.publicKeyHex, - best, - success: true, - roundTripTimeMs: 110, - ); - await service.recordPathResult( - contact.publicKeyHex, - second, - success: true, - roundTripTimeMs: 200, - ); - await service.recordPathResult( - contact.publicKeyHex, - second, - success: false, - ); - - final first = await service.getSelectionForContact( - contact, - autoRouteRotationEnabled: true, - ); - final third = await service.getSelectionForContact( - contact, - autoRouteRotationEnabled: true, - ); - final secondPick = await service.getSelectionForContact( - contact, - autoRouteRotationEnabled: true, - ); - - expect(first.mode, PathSelectionMode.directHistorical); - expect(first.canonicalPath, 'AA,BB'); - expect(third.mode, PathSelectionMode.directHistorical); - expect(third.canonicalPath, 'CC,DD'); - expect(secondPick.mode, PathSelectionMode.flood); - }); - - test( - 'contact route alone does not override history selection', - () async { - final service = PathHistoryService(); - final contact = _buildContact( - seed: 9, - pathBytes: [0xAA, 0xBB, 0xCC], - hopCount: 1, - hashSize: 3, - ); - - await service.initialize(); - await service.recordPathResult( - contact.publicKeyHex, - PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList([0x11, 0x22, 0x33]), - hopCount: 1, - hashSize: 3, - ), - success: true, - roundTripTimeMs: 90, - ); - - final selection = await service.getSelectionForContact( - contact, - autoRouteRotationEnabled: true, - ); - - expect(selection.mode, PathSelectionMode.directHistorical); - expect(selection.canonicalPath, '112233'); - }, - ); - - test('manual route overrides history selection until cleared', () async { - final service = PathHistoryService(); - final contact = _buildContactWithoutRoute(seed: 10); - - await service.initialize(); - await service.recordPathResult( - contact.publicKeyHex, - PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList([0x11, 0x22]), - hopCount: 2, - hashSize: 1, - ), - success: true, - roundTripTimeMs: 100, - ); await service.setManualSelectionFor( contact.publicKeyHex, PathSelection( @@ -175,237 +55,129 @@ void main() { ), ); - final selection = await service.getSelectionForContact( - contact, - autoRouteRotationEnabled: true, - ); + final reloaded = PathHistoryService(); + final selection = await reloaded.getManualSelectionForContact(contact); - expect(selection.mode, PathSelectionMode.directCurrent); + expect(selection, isNotNull); + expect(selection!.mode, PathSelectionMode.directCurrent); expect(selection.canonicalPath, 'AA,BB'); }); - test('no history falls back to flood', () async { - final service = PathHistoryService(); - final contact = _buildContactWithoutRoute(seed: 0); - - final selection = await service.getSelectionForContact( - contact, - autoRouteRotationEnabled: true, + test('selection uses stored manual route before contact route', () async { + final contact = _buildContact( + seed: 2, + pathBytes: const [0x11, 0x22], + hopCount: 2, + hashSize: 1, ); - - expect(selection.mode, PathSelectionMode.flood); - }); - - test( - 'received public byte path is reversed before adding to history', - () async { - final service = PathHistoryService(); - await service.initialize(); - await service.recordReceivedBytePath('abc123', [ - 0x01, - 0x02, - 0x03, - 0x04, - ], 2); - - final history = service.historyFor('abc123'); - expect(history.directPaths, hasLength(1)); - expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]); - expect(history.directPaths.single.hashSize, 2); - expect(history.directPaths.single.hopCount, 2); - expect(history.directPaths.single.source, PathRecordSource.observed); - }, - ); - - test( - 'observed paths stay marked as observed until delivery succeeds', - () async { - final service = PathHistoryService(); - final contact = _buildContact( - seed: 3, - pathBytes: [0xAA, 0xBB], - hopCount: 2, - hashSize: 1, - ); - - await service.initialize(); - await service.recordReceivedBytePath(contact.publicKeyHex, [ - 0xBB, - 0xAA, - ], 1); - - final history = service.historyFor(contact.publicKeyHex); - expect(history.directPaths, hasLength(1)); - expect(history.directPaths.single.source, PathRecordSource.observed); - }, - ); - - test( - 'confirmed direct delivery promotes an observed path to learned', - () async { - final service = PathHistoryService(); - final contact = _buildContact( - seed: 4, - pathBytes: [0xAA, 0xBB], - hopCount: 2, - hashSize: 1, - ); - - await service.initialize(); - await service.recordReceivedBytePath(contact.publicKeyHex, [ - 0xBB, - 0xAA, - ], 1); - await service.recordPathResult( - contact.publicKeyHex, - PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList([0xAA, 0xBB]), - hopCount: 2, - hashSize: 1, - ), - success: true, - roundTripTimeMs: 150, - ); - - final history = service.historyFor(contact.publicKeyHex); - expect(history.directPaths, hasLength(1)); - expect(history.directPaths.single.source, PathRecordSource.learned); - expect(history.directPaths.single.successCount, 1); - expect(history.directPaths.single.lastRoundTripTimeMs, 150); - }, - ); - - test('clear history removes stored direct paths for one contact', () async { final service = PathHistoryService(); - - await service.initialize(); - await service.recordReceivedBytePath('abc123', [0x01, 0x02], 1); - await service.recordReceivedBytePath('def456', [0x03, 0x04], 1); - - expect(service.historyFor('abc123').directPaths, hasLength(1)); - expect(service.historyFor('def456').directPaths, hasLength(1)); - - await service.clearHistoryFor('abc123'); - - expect(service.historyFor('abc123').directPaths, isEmpty); - expect(service.historyFor('def456').directPaths, hasLength(1)); - }); - - test('clearing manual route falls back to flood without history', () async { - final service = PathHistoryService(); - final contact = _buildContactWithoutRoute(seed: 11); - await service.initialize(); await service.setManualSelectionFor( contact.publicKeyHex, PathSelection( mode: PathSelectionMode.directCurrent, - pathBytes: Uint8List.fromList([0xAA]), - hopCount: 1, + pathBytes: Uint8List.fromList([0xAA, 0xBB]), + hopCount: 2, + hashSize: 1, + ), + ); + + final selection = await service.getSelectionForContact(contact); + + expect(selection.mode, PathSelectionMode.directCurrent); + expect(selection.canonicalPath, 'AA,BB'); + }); + + test('selection falls back to the current contact route', () async { + final contact = _buildContact( + seed: 3, + pathBytes: const [0x10, 0x20, 0x30], + hopCount: 1, + hashSize: 3, + ); + final service = PathHistoryService(); + + final selection = await service.getSelectionForContact(contact); + + expect(selection.mode, PathSelectionMode.directCurrent); + expect(selection.canonicalPath, '102030'); + expect(selection.hashSize, 3); + expect(selection.hopCount, 1); + }); + + test('selection falls back to flood when no route exists', () async { + final contact = _buildContact(seed: 4); + final service = PathHistoryService(); + + final selection = await service.getSelectionForContact(contact); + + expect(selection.mode, PathSelectionMode.flood); + expect(selection.pathBytes, isEmpty); + }); + + test('clearing manual route falls back to the contact route', () async { + final contact = _buildContact( + seed: 5, + pathBytes: const [0x01, 0x02], + hopCount: 2, + hashSize: 1, + ); + final service = PathHistoryService(); + await service.initialize(); + await service.setManualSelectionFor( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList([0xAA, 0xBB]), + hopCount: 2, hashSize: 1, ), ); await service.clearManualRouteFor(contact.publicKeyHex); + final selection = await service.getSelectionForContact(contact); - final selection = await service.getSelectionForContact( - contact, - autoRouteRotationEnabled: true, - ); - - expect(selection.mode, PathSelectionMode.flood); + expect(selection.mode, PathSelectionMode.directCurrent); + expect(selection.canonicalPath, '01,02'); }); - test( - 'clear history for contact leaves the contact route ignored', - () async { - final service = PathHistoryService(); - final contact = _buildContact( - seed: 5, - pathBytes: [0xAA, 0xBB], - hopCount: 2, - hashSize: 1, - ); - - await service.initialize(); - await service.recordPathResult( - contact.publicKeyHex, - PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList([0xAA, 0xBB]), - hopCount: 2, - hashSize: 1, - ), - success: true, - roundTripTimeMs: 120, - ); - expect(service.historyFor(contact.publicKeyHex).directPaths, hasLength(1)); - - await service.clearHistoryForContact(contact); - expect(service.historyFor(contact.publicKeyHex).directPaths, isEmpty); - - final selection = await service.getSelectionForContact( - contact, - autoRouteRotationEnabled: true, - ); - - expect(selection.mode, PathSelectionMode.flood); - }, - ); - - test('last successful direct path is chosen by location fit', () async { - final service = PathHistoryService(); - final contact = _buildContact( - seed: 7, - pathBytes: [0xAA], - hopCount: 1, - hashSize: 1, + test('initialize removes legacy path history storage', () async { + final contact = Contact( + publicKey: Uint8List.fromList([ + 0xAB, + 0xC1, + 0x23, + ...List.filled(29, 0), + ]), + type: ContactType.chat, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: 'Legacy Contact', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); + SharedPreferences.setMockInitialValues({ + 'contact_path_history_v2': '{"abc123":{"direct_paths":[]}}', + 'contact_manual_path_overrides_v1': jsonEncode({ + contact.publicKeyHex: { + 'pathBytes': [0xAA, 0xBB], + 'hopCount': 2, + 'hashSize': 1, + }, + }), + }); + final service = PathHistoryService(); + await service.initialize(); - await service.recordPathResult( - contact.publicKeyHex, - PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList([0x11]), - hopCount: 1, - hashSize: 1, - ), - success: true, - roundTripTimeMs: 120, - senderLatitude: 46.0, - senderLongitude: 14.0, - recipientLatitude: 46.1, - recipientLongitude: 14.1, - ); - await service.recordPathResult( - contact.publicKeyHex, - PathSelection( - mode: PathSelectionMode.directHistorical, - pathBytes: Uint8List.fromList([0x22]), - hopCount: 1, - hashSize: 1, - ), - success: true, - roundTripTimeMs: 90, - senderLatitude: 46.0001, - senderLongitude: 14.0001, - recipientLatitude: 46.1001, - recipientLongitude: 14.1001, - ); - - final selection = await service.getLastSuccessfulDirectSelection( - contact, - excludeSignature: 'aa', - senderLatitude: 46.0002, - senderLongitude: 14.0002, - recipientLatitude: 46.1002, - recipientLongitude: 14.1002, - ); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.containsKey('contact_path_history_v2'), isFalse); + final selection = await service.getManualSelectionForContact(contact); expect(selection, isNotNull); - expect(selection!.mode, PathSelectionMode.directHistorical); - expect(selection.canonicalPath, '22'); + expect(selection!.canonicalPath, 'AA,BB'); }); }