From e85dccea35d53bde13052384fdbe822a5d88758e Mon Sep 17 00:00:00 2001 From: Janez T Date: Mon, 23 Mar 2026 15:16:59 +0100 Subject: [PATCH] feat: Add location-based DM retries --- lib/models/path_history.dart | 32 +++++ lib/providers/app_provider.dart | 76 +++++++++- .../helpers/message_retry_manager.dart | 4 +- lib/providers/messages_provider.dart | 11 -- lib/services/path_history_service.dart | 136 +++++++++++++++++- test/services/path_history_service_test.dart | 55 +++++++ 6 files changed, 295 insertions(+), 19 deletions(-) diff --git a/lib/models/path_history.dart b/lib/models/path_history.dart index 64661b9..6461356 100644 --- a/lib/models/path_history.dart +++ b/lib/models/path_history.dart @@ -9,6 +9,11 @@ class PathRecord { 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, @@ -19,6 +24,11 @@ class PathRecord { 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 => @@ -36,6 +46,11 @@ class PathRecord { int? failureCount, int? lastRoundTripTimeMs, DateTime? lastUsedAt, + DateTime? lastSucceededAt, + double? senderLatitude, + double? senderLongitude, + double? recipientLatitude, + double? recipientLongitude, }) { return PathRecord( pathBytes: pathBytes ?? this.pathBytes, @@ -46,6 +61,11 @@ class PathRecord { 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, ); } @@ -59,6 +79,11 @@ class PathRecord { '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, }; } @@ -79,6 +104,13 @@ class PathRecord { 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(), ); } } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 348e548..4579d7f 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1761,6 +1761,7 @@ class AppProvider with ChangeNotifier { return _prepareDirectMessageSend( messageId: messageId, contact: contact, + retryAttempt: retryAttempt, ); }; @@ -1830,23 +1831,47 @@ class AppProvider with ChangeNotifier { Future _prepareDirectMessageSend({ required String messageId, required Contact contact, + required int retryAttempt, }) async { final latestContact = contactsProvider.findContactByKey(contact.publicKey) ?? contact; var session = _directMessageRouteSessions[messageId]; if (session == null) { - final selection = await _pathHistoryService.getSelectionForContact( - latestContact, - autoRouteRotationEnabled: _autoRouteRotationEnabled, - ); + final selection = latestContact.routeHasPath && latestContact.routeHopCount > 0 + ? PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList(latestContact.routePathBytes), + hopCount: latestContact.routeHopCount, + hashSize: latestContact.routeHashSize, + ) + : await _pathHistoryService.getSelectionForContact( + latestContact, + autoRouteRotationEnabled: _autoRouteRotationEnabled, + ); session = _DirectMessageRouteSession( currentSelection: selection, originalRoute: ContactRouteCodec.fromContact(latestContact), routerFallbackAttempted: false, ); - _directMessageRouteSessions[messageId] = session; } + if (!session.routerFallbackAttempted) { + final currentSignature = + latestContact.routeHasPath && latestContact.routeHopCount > 0 + ? latestContact.routePathBytes + .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); + } + _directMessageRouteSessions[messageId] = session; + await _applyPathSelection( latestContact, session.currentSelection, @@ -1857,6 +1882,43 @@ class AppProvider with ChangeNotifier { latestContact; } + Future _resolveDirectMessageSelectionForRetry( + Contact contact, { + required int retryAttempt, + required String? currentSignature, + required PathSelection fallbackSelection, + }) async { + if (contact.routeHasPath && contact.routeHopCount > 0 && retryAttempt <= 1) { + return PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList(contact.routePathBytes), + hopCount: contact.routeHopCount, + hashSize: contact.routeHashSize, + ); + } + + 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; + } + } + + return fallbackSelection; + } + Future _applyPathSelection( Contact contact, PathSelection selection, { @@ -2031,6 +2093,10 @@ class AppProvider with ChangeNotifier { session.currentSelection, success: true, roundTripTimeMs: roundTripTimeMs, + senderLatitude: locationTrackingService.currentPosition?.latitude, + senderLongitude: locationTrackingService.currentPosition?.longitude, + recipientLatitude: contact.displayLocation?.latitude, + recipientLongitude: contact.displayLocation?.longitude, ), ); } diff --git a/lib/providers/helpers/message_retry_manager.dart b/lib/providers/helpers/message_retry_manager.dart index 8a171f0..330ac5e 100644 --- a/lib/providers/helpers/message_retry_manager.dart +++ b/lib/providers/helpers/message_retry_manager.dart @@ -21,8 +21,8 @@ class MessageRetryManager { final Map _pathFailureStreaks = {}; /// Max retry attempts when the contact has a known path. - /// Official MeshCore app uses 5 (with auto-retry) or 3 (without). - static const int maxRetryAttemptsWithPath = 5; + /// Sequence: 2 direct attempts, flood, then last successful route. + static const int maxRetryAttemptsWithPath = 3; /// No retries for flood-only contacts (no known path). /// Value 0 means: don't retry at all, go straight to fallback/fail. diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 64b114b..632c693 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -2354,17 +2354,6 @@ class MessagesProvider with ChangeNotifier { return; } - // On the last attempt, reset the path to force flood mode - // (matches official MeshCore app behaviour) - if (_retryManager.isLastAttempt(currentMessage, contact)) { - debugPrint( - '🔄 [MessagesProvider] Last attempt — resetting path to flood for $messageId', - ); - if (resetPathBeforeLastRetryCallback != null) { - await resetPathBeforeLastRetryCallback!(contact); - } - } - if (sendMessageCallback != null) { final queued = await sendMessageCallback!( contactPublicKey: contact.publicKey, diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index 855e11e..6838f23 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; @@ -9,7 +10,7 @@ import '../models/path_selection.dart'; import '../utils/log_rx_route_decoder.dart'; class PathHistoryService { - static const String _storageKey = 'contact_path_history_v1'; + static const String _storageKey = 'contact_path_history_v2'; static const int _maxDirectPaths = 20; static const int _topRotationCount = 3; @@ -59,6 +60,11 @@ class PathHistoryService { 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( @@ -104,6 +110,11 @@ class PathHistoryService { 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( @@ -172,6 +183,10 @@ class PathHistoryService { PathSelection selection, { required bool success, int? roundTripTimeMs, + double? senderLatitude, + double? senderLongitude, + double? recipientLatitude, + double? recipientLongitude, }) async { await initialize(); final history = _historyFor(contactPublicKeyHex); @@ -206,6 +221,13 @@ class PathHistoryService { ? (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, @@ -215,6 +237,54 @@ class PathHistoryService { ); } + 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, + ); + } + ContactPathHistory historyFor(String contactPublicKeyHex) { return _cache[contactPublicKeyHex] ?? ContactPathHistory.empty(contactPublicKeyHex); @@ -279,4 +349,68 @@ class PathHistoryService { 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/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index 95082ac..0e3e8e7 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -206,4 +206,59 @@ void main() { expect(history.directPaths.single.source, PathRecordSource.observed); }, ); + + 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, + ); + + 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, + ); + + expect(selection, isNotNull); + expect(selection!.mode, PathSelectionMode.directHistorical); + expect(selection.canonicalPath, '22'); + }); }