diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 9ba377f..e538d00 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -98,6 +98,9 @@ class AppProvider with ChangeNotifier { bool _clearPathOnMaxRetry = MessagingRoutePreferences.defaultClearPathOnMaxRetry; bool get clearPathOnMaxRetry => _clearPathOnMaxRetry; + bool _nearestRelayFallbackEnabled = + MessagingRoutePreferences.defaultNearestRelayFallbackEnabled; + bool get nearestRelayFallbackEnabled => _nearestRelayFallbackEnabled; final PathHistoryService _pathHistoryService = PathHistoryService(); final NearestRouterSelector _nearestRouterSelector = const NearestRouterSelector(); @@ -458,6 +461,8 @@ class AppProvider with ChangeNotifier { await MessagingRoutePreferences.getAutoRouteRotationEnabled(); _clearPathOnMaxRetry = await MessagingRoutePreferences.getClearPathOnMaxRetry(); + _nearestRelayFallbackEnabled = + await MessagingRoutePreferences.getNearestRelayFallbackEnabled(); notifyListeners(); } catch (e) { debugPrint('Error loading messaging route settings: $e'); @@ -484,6 +489,16 @@ class AppProvider with ChangeNotifier { } } + Future toggleNearestRelayFallbackEnabled(bool enabled) async { + try { + _nearestRelayFallbackEnabled = enabled; + await MessagingRoutePreferences.setNearestRelayFallbackEnabled(enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving nearest relay fallback setting: $e'); + } + } + /// Initialize location tracking service Future _initializeLocationTracking() async { try { @@ -713,6 +728,19 @@ class AppProvider with ChangeNotifier { final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot( enrichedMessage, ); + final receivedPathBytes = receptionDetailsSnapshot?.pathBytes; + if (senderContact != null && + enrichedMessage.isChannelMessage && + (enrichedMessage.channelIdx ?? 0) == 0 && + receivedPathBytes != null && + receivedPathBytes.isNotEmpty) { + unawaited( + _learnPathFromPublicMessage( + contact: senderContact, + pathBytes: receivedPathBytes, + ), + ); + } // Check if message is a drawing broadcast if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { @@ -1379,6 +1407,10 @@ class AppProvider with ChangeNotifier { required Contact contact, required Message message, }) async { + if (!_nearestRelayFallbackEnabled) { + return false; + } + final latestContact = contactsProvider.findContactByKey(contact.publicKey) ?? contact; final session = @@ -1491,6 +1523,38 @@ class AppProvider with ChangeNotifier { return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); } + 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, + ); + } + + 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/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d7e85ae..0984c9c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -988,6 +988,19 @@ class _SettingsScreenState extends State { }, ), ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.route), + title: const Text('Nearest repeater fallback'), + subtitle: const Text( + 'After normal retries fail, try one final resend through the nearest repeater', + ), + value: appProvider.nearestRelayFallbackEnabled, + onChanged: (value) async { + await appProvider.toggleNearestRelayFallbackEnabled(value); + }, + ), + ), Consumer( builder: (context, appProvider, child) => SwitchListTile( secondary: const Icon(Icons.route), diff --git a/lib/services/messaging_route_preferences.dart b/lib/services/messaging_route_preferences.dart index 9976575..7d00085 100644 --- a/lib/services/messaging_route_preferences.dart +++ b/lib/services/messaging_route_preferences.dart @@ -3,11 +3,14 @@ import 'package:shared_preferences/shared_preferences.dart'; class MessagingRoutePreferences { static const bool defaultAutoRouteRotationEnabled = false; static const bool defaultClearPathOnMaxRetry = false; + static const bool defaultNearestRelayFallbackEnabled = true; static const String _autoRouteRotationKey = '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 { final prefs = await SharedPreferences.getInstance(); @@ -29,4 +32,15 @@ class MessagingRoutePreferences { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_clearPathOnMaxRetryKey, enabled); } + + static Future getNearestRelayFallbackEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_nearestRelayFallbackKey) ?? + defaultNearestRelayFallbackEnabled; + } + + static Future setNearestRelayFallbackEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_nearestRelayFallbackKey, enabled); + } } diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index 0822659..bce4975 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -67,6 +67,45 @@ class PathHistoryService { ); } + 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 history = _historyFor(contactPublicKeyHex); + final signature = pathBytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + final existing = _findDirectPath(history.directPaths, signature); + final updated = PathRecord( + pathBytes: List.from(pathBytes), + hopCount: pathBytes.length ~/ hashSize, + hashSize: hashSize, + successCount: existing?.successCount ?? 0, + failureCount: existing?.failureCount ?? 0, + lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, + lastUsedAt: DateTime.now(), + ); + + await _saveHistory( + contactPublicKeyHex, + history.copyWith( + directPaths: _upsertDirectPath(history.directPaths, updated), + ), + ); + } + Future getSelectionForContact( Contact contact, { required bool autoRouteRotationEnabled, diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index 8671d35..e9b80af 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -196,6 +196,8 @@ class _ContactRouteDialogState extends State { }); }, autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled, + nearestRelayFallbackEnabled: + appProvider.nearestRelayFallbackEnabled, clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, ), const SizedBox(height: 16), @@ -264,12 +266,14 @@ 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, }); @@ -336,6 +340,12 @@ class _AutomationRoutingInfo extends StatelessWidget { : 'Auto route rotation off', icon: Icons.swap_horiz, ), + _InfoChip( + label: nearestRelayFallbackEnabled + ? 'Nearest repeater fallback on' + : 'Nearest repeater fallback off', + icon: Icons.router, + ), _InfoChip( label: clearPathOnMaxRetry ? 'Clear path on max retry on' diff --git a/test/services/messaging_route_preferences_test.dart b/test/services/messaging_route_preferences_test.dart index 29aa378..9d19cd9 100644 --- a/test/services/messaging_route_preferences_test.dart +++ b/test/services/messaging_route_preferences_test.dart @@ -16,16 +16,25 @@ void main() { isFalse, ); expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse); + expect( + await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), + isTrue, + ); }); 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, + ); }); } diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index e25415e..fa8ca9c 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -127,4 +127,16 @@ void main() { expect(selection.mode, PathSelectionMode.flood); }); + + test('received public byte path is added to history', () async { + final service = PathHistoryService(); + await service.initialize(); + await service.recordReceivedBytePath('abc123', [0x01, 0x02, 0x03], 3); + + final history = service.historyFor('abc123'); + expect(history.directPaths, hasLength(1)); + expect(history.directPaths.single.pathBytes, [0x01, 0x02, 0x03]); + expect(history.directPaths.single.hashSize, 3); + expect(history.directPaths.single.hopCount, 1); + }); }