diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 8d47f3c..a732963 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 105; + CURRENT_PROJECT_VERSION = 106; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 105; + CURRENT_PROJECT_VERSION = 106; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 105; + CURRENT_PROJECT_VERSION = 106; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 105; + CURRENT_PROJECT_VERSION = 106; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 105; + CURRENT_PROJECT_VERSION = 106; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 105; + CURRENT_PROJECT_VERSION = 106; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index a45b3dd..5f673a1 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 105 + 106 LSRequiresIPhoneOS ITSAppUsesNonExemptEncryption diff --git a/lib/models/message_route_metadata.dart b/lib/models/message_route_metadata.dart new file mode 100644 index 0000000..c20b40c --- /dev/null +++ b/lib/models/message_route_metadata.dart @@ -0,0 +1,79 @@ +import 'path_selection.dart'; + +class MessageRouteMetadata { + final PathSelectionMode mode; + final bool routerFallbackAttempted; + final String? relayName; + final String? relayKey6; + final String? canonicalPath; + final int? hopCount; + + const MessageRouteMetadata({ + required this.mode, + required this.routerFallbackAttempted, + this.relayName, + this.relayKey6, + this.canonicalPath, + this.hopCount, + }); + + factory MessageRouteMetadata.fromSelection( + PathSelection selection, { + required bool routerFallbackAttempted, + }) { + return MessageRouteMetadata( + mode: selection.mode, + routerFallbackAttempted: routerFallbackAttempted, + relayName: selection.relayName, + relayKey6: selection.relayKey6, + canonicalPath: selection.canonicalPath.isEmpty + ? null + : selection.canonicalPath, + hopCount: selection.hopCount > 0 ? selection.hopCount : null, + ); + } + + String get modeLabel { + switch (mode) { + case PathSelectionMode.directCurrent: + return 'Current direct path'; + case PathSelectionMode.directHistorical: + return 'Rotated direct path'; + case PathSelectionMode.flood: + return 'Flood route'; + case PathSelectionMode.nearestRouter: + final suffix = relayName?.trim().isNotEmpty == true + ? ' via $relayName' + : relayKey6?.trim().isNotEmpty == true + ? ' via $relayKey6' + : ''; + return 'Nearest router$suffix'; + } + } + + Map toJson() { + return { + 'mode': mode.name, + 'router_fallback_attempted': routerFallbackAttempted, + 'relay_name': relayName, + 'relay_key6': relayKey6, + 'canonical_path': canonicalPath, + 'hop_count': hopCount, + }; + } + + factory MessageRouteMetadata.fromJson(Map json) { + return MessageRouteMetadata( + mode: PathSelectionMode.values.firstWhere( + (value) => value.name == json['mode'], + orElse: () => PathSelectionMode.directCurrent, + ), + routerFallbackAttempted: + json['router_fallback_attempted'] as bool? ?? false, + relayName: json['relay_name'] as String?, + relayKey6: json['relay_key6'] as String?, + canonicalPath: json['canonical_path'] as String?, + hopCount: json['hop_count'] as int?, + ); + } +} diff --git a/lib/models/path_history.dart b/lib/models/path_history.dart new file mode 100644 index 0000000..83ffbb7 --- /dev/null +++ b/lib/models/path_history.dart @@ -0,0 +1,182 @@ +class PathRecord { + final List pathBytes; + final int hopCount; + final int hashSize; + final int successCount; + final int failureCount; + final int lastRoundTripTimeMs; + final DateTime lastUsedAt; + + const PathRecord({ + required this.pathBytes, + required this.hopCount, + required this.hashSize, + required this.successCount, + required this.failureCount, + required this.lastRoundTripTimeMs, + required this.lastUsedAt, + }); + + 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, + int? successCount, + int? failureCount, + int? lastRoundTripTimeMs, + DateTime? lastUsedAt, + }) { + return PathRecord( + pathBytes: pathBytes ?? this.pathBytes, + hopCount: hopCount ?? this.hopCount, + hashSize: hashSize ?? this.hashSize, + successCount: successCount ?? this.successCount, + failureCount: failureCount ?? this.failureCount, + lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + ); + } + + Map toJson() { + return { + 'path_bytes': pathBytes, + 'hop_count': hopCount, + 'hash_size': hashSize, + 'success_count': successCount, + 'failure_count': failureCount, + 'last_round_trip_time_ms': lastRoundTripTimeMs, + 'last_used_at': lastUsedAt.toIso8601String(), + }; + } + + 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, + 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), + ); + } +} + +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, + }; + } + + 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/models/path_selection.dart b/lib/models/path_selection.dart new file mode 100644 index 0000000..82a4758 --- /dev/null +++ b/lib/models/path_selection.dart @@ -0,0 +1,65 @@ +import 'dart:typed_data'; + +enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter } + +class PathSelection { + final PathSelectionMode mode; + final Uint8List pathBytes; + final int hopCount; + final int hashSize; + final String? relayName; + final String? relayKey6; + + const PathSelection({ + required this.mode, + required this.pathBytes, + required this.hopCount, + required this.hashSize, + this.relayName, + this.relayKey6, + }); + + PathSelection.flood() + : mode = PathSelectionMode.flood, + pathBytes = Uint8List(0), + hopCount = -1, + hashSize = 1, + relayName = null, + relayKey6 = null; + + bool get usesFlood => mode == PathSelectionMode.flood; + bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0; + + String get canonicalPath { + if (!hasDirectPath) return ''; + final hops = []; + for (var index = 0; index < pathBytes.length; index += hashSize) { + hops.add( + pathBytes + .sublist(index, index + hashSize) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(), + ); + } + return hops.join(','); + } + + PathSelection copyWith({ + PathSelectionMode? mode, + Uint8List? pathBytes, + int? hopCount, + int? hashSize, + String? relayName, + String? relayKey6, + }) { + return PathSelection( + mode: mode ?? this.mode, + pathBytes: pathBytes ?? this.pathBytes, + hopCount: hopCount ?? this.hopCount, + hashSize: hashSize ?? this.hashSize, + relayName: relayName ?? this.relayName, + relayKey6: relayKey6 ?? this.relayKey6, + ); + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index f0ac730..9ba377f 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -12,10 +12,15 @@ import 'image_provider.dart' as ip; import 'helpers/fragment_ack_wait_registry.dart'; import 'helpers/session_metadata_restore.dart'; import '../services/location_tracking_service.dart'; +import '../services/messaging_route_preferences.dart'; +import '../services/nearest_router_selector.dart'; import '../services/packet_capture_storage_service.dart'; +import '../services/path_history_service.dart'; +import '../services/route_hash_preferences.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../models/ble_packet_log.dart'; +import '../models/path_selection.dart'; import '../models/message_reception_details.dart'; import '../utils/drawing_message_parser.dart'; import '../utils/raw_route_probe.dart'; @@ -25,6 +30,31 @@ import '../utils/media_swarm_protocol.dart'; import '../utils/message_airtime_estimator.dart'; import '../utils/fast_gps_packet.dart'; +class _DirectMessageRouteSession { + final PathSelection currentSelection; + final ParsedContactRoute? originalRoute; + final bool routerFallbackAttempted; + + const _DirectMessageRouteSession({ + required this.currentSelection, + required this.originalRoute, + required this.routerFallbackAttempted, + }); + + _DirectMessageRouteSession copyWith({ + PathSelection? currentSelection, + ParsedContactRoute? originalRoute, + bool? routerFallbackAttempted, + }) { + return _DirectMessageRouteSession( + currentSelection: currentSelection ?? this.currentSelection, + originalRoute: originalRoute ?? this.originalRoute, + routerFallbackAttempted: + routerFallbackAttempted ?? this.routerFallbackAttempted, + ); + } +} + /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { static const int _maxDirectPayloadHops = 3; @@ -62,6 +92,17 @@ class AppProvider with ChangeNotifier { bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; bool _autoAddDiscoveredContacts = false; bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts; + bool _autoRouteRotationEnabled = + MessagingRoutePreferences.defaultAutoRouteRotationEnabled; + bool get autoRouteRotationEnabled => _autoRouteRotationEnabled; + bool _clearPathOnMaxRetry = + MessagingRoutePreferences.defaultClearPathOnMaxRetry; + bool get clearPathOnMaxRetry => _clearPathOnMaxRetry; + final PathHistoryService _pathHistoryService = PathHistoryService(); + final NearestRouterSelector _nearestRouterSelector = + const NearestRouterSelector(); + final Map _directMessageRouteSessions = + {}; static const Duration _packetRetryDelay = Duration(milliseconds: 1200); static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10); @@ -101,6 +142,8 @@ class AppProvider with ChangeNotifier { _loadVoiceCompressorEnabled(); _loadVoiceLimiterEnabled(); _loadAutoAddDiscoveredContacts(); + _loadMessagingRouteSettings(); + unawaited(_pathHistoryService.initialize()); _startPacketCapturePersistence(); _syncDrawingsOnStartup(); // Sync drawings immediately after providers load _isInitialized = true; @@ -409,6 +452,38 @@ class AppProvider with ChangeNotifier { } } + Future _loadMessagingRouteSettings() async { + try { + _autoRouteRotationEnabled = + await MessagingRoutePreferences.getAutoRouteRotationEnabled(); + _clearPathOnMaxRetry = + await MessagingRoutePreferences.getClearPathOnMaxRetry(); + notifyListeners(); + } catch (e) { + debugPrint('Error loading messaging route settings: $e'); + } + } + + 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; + await MessagingRoutePreferences.setClearPathOnMaxRetry(enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving clear path on max retry setting: $e'); + } + } + /// Initialize location tracking service Future _initializeLocationTracking() async { try { @@ -488,6 +563,7 @@ class AppProvider with ChangeNotifier { contact, devicePublicKey: connectionProvider.deviceInfo.publicKey, ); + unawaited(_pathHistoryService.recordLearnedPath(contact)); // Broadcast to SSE clients if server is running connectionProvider.broadcastContactToSseClients(contact); @@ -500,6 +576,9 @@ class AppProvider with ChangeNotifier { contacts, devicePublicKey: connectionProvider.deviceInfo.publicKey, ); + for (final contact in contacts) { + unawaited(_pathHistoryService.recordLearnedPath(contact)); + } debugPrint('Received ${contacts.length} contacts'); // Broadcast all contacts to SSE clients if server is running @@ -1117,6 +1196,14 @@ class AppProvider with ChangeNotifier { messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm); }; + connectionProvider.prepareDirectMessageSendCallback = + ({required messageId, required contact, required retryAttempt}) async { + return _prepareDirectMessageSend( + messageId: messageId, + contact: contact, + ); + }; + // Wire up MessagesProvider's sendMessageCallback for retry logic messagesProvider.sendMessageCallback = ({ @@ -1134,32 +1221,274 @@ class AppProvider with ChangeNotifier { retryAttempt: retryAttempt, ); }; - - messagesProvider.onDirectPathFailedCallback = - ({required contact, required failureStreak}) async { - debugPrint( - '🧭 [AppProvider] Clearing unhealthy path for ${contact.advName} after $failureStreak failed send chain(s)', + messagesProvider.onFinalRouterFallbackCallback = + ({required messageId, required contact, required message}) async { + return _sendWithFinalNearestRouterFallback( + messageId: messageId, + contact: contact, + message: message, ); - - contactsProvider.markPathUnhealthy(contact.publicKey); - - if (!connectionProvider.deviceInfo.isConnected) { - return; - } - - try { - await connectionProvider.resetPath(contact.publicKey); - Future.delayed(const Duration(milliseconds: 150), () { - if (connectionProvider.deviceInfo.isConnected) { - connectionProvider.getContact(contact.publicKey); - } - }); - } catch (e) { - debugPrint( - '⚠️ [AppProvider] Failed to reset path for ${contact.advName}: $e', - ); - } }; + messagesProvider.onFinalDirectMessageFailureCallback = + ({required messageId, required contact, required message}) async { + await _handleDirectMessageFinalFailure( + messageId: messageId, + contact: contact, + ); + }; + messagesProvider.onDirectMessageDeliveredCallback = + ({ + required messageId, + required contact, + required message, + required roundTripTimeMs, + }) { + _handleDirectMessageDelivered( + messageId: messageId, + contact: contact, + roundTripTimeMs: roundTripTimeMs, + ); + }; + } + + Future _prepareDirectMessageSend({ + required String messageId, + required Contact contact, + }) async { + final latestContact = + contactsProvider.findContactByKey(contact.publicKey) ?? contact; + var session = _directMessageRouteSessions[messageId]; + if (session == null) { + final selection = await _pathHistoryService.getSelectionForContact( + latestContact, + autoRouteRotationEnabled: _autoRouteRotationEnabled, + ); + session = _DirectMessageRouteSession( + currentSelection: selection, + originalRoute: ContactRouteCodec.fromContact(latestContact), + routerFallbackAttempted: false, + ); + _directMessageRouteSessions[messageId] = session; + } + + await _applyPathSelection( + latestContact, + session.currentSelection, + messageId: messageId, + routerFallbackAttempted: session.routerFallbackAttempted, + ); + return contactsProvider.findContactByKey(contact.publicKey) ?? + latestContact; + } + + Future _applyPathSelection( + Contact contact, + PathSelection selection, { + required String messageId, + required bool routerFallbackAttempted, + }) async { + final previousRoute = ContactRouteCodec.fromContact(contact); + + try { + if (selection.usesFlood) { + contactsProvider.resetContactRouteLocal(contact.publicKey); + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.resetPath(contact.publicKey); + } + } else { + final pathDescriptor = + ((selection.hashSize - 1) << 6) | (selection.hopCount & 0x3F); + final signedDescriptor = ContactRouteCodec.toSignedDescriptor( + pathDescriptor, + ); + final paddedPathBytes = Uint8List(ContactRouteCodec.maxPathBytes) + ..setRange(0, selection.pathBytes.length, selection.pathBytes); + + contactsProvider.setContactRouteLocal( + contact.publicKey, + signedEncodedPathLen: signedDescriptor, + paddedPathBytes: paddedPathBytes, + ); + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.setContactRoute( + contact, + signedEncodedPathLen: signedDescriptor, + paddedPathBytes: paddedPathBytes, + ); + } + } + } catch (error) { + _restoreRouteLocal(contact.publicKey, previousRoute); + rethrow; + } + + messagesProvider.updateMessageRouteSelection( + messageId, + selection, + routerFallbackAttempted: routerFallbackAttempted, + ); + } + + void _restoreRouteLocal(Uint8List publicKey, ParsedContactRoute? route) { + if (route == null) { + contactsProvider.resetContactRouteLocal(publicKey); + return; + } + + contactsProvider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + } + + Future _restoreRouteOnDevice( + Contact contact, + ParsedContactRoute? route, + ) async { + _restoreRouteLocal(contact.publicKey, route); + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + if (route == null) { + await connectionProvider.resetPath(contact.publicKey); + return; + } + + await connectionProvider.setContactRoute( + contact, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + } + + PathSelection _buildNearestRouterSelection(Contact repeater, int hashSize) { + return PathSelection( + mode: PathSelectionMode.nearestRouter, + pathBytes: Uint8List.fromList(repeater.publicKey.sublist(0, hashSize)), + hopCount: 1, + hashSize: hashSize, + relayName: repeater.advName, + relayKey6: _key6(repeater.publicKey), + ); + } + + Future _sendWithFinalNearestRouterFallback({ + required String messageId, + required Contact contact, + required Message message, + }) async { + final latestContact = + contactsProvider.findContactByKey(contact.publicKey) ?? contact; + final session = + _directMessageRouteSessions[messageId] ?? + _DirectMessageRouteSession( + currentSelection: latestContact.routeHasPath + ? PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList(latestContact.routePathBytes), + hopCount: latestContact.routeHopCount, + hashSize: latestContact.routeHashSize, + ) + : PathSelection.flood(), + originalRoute: ContactRouteCodec.fromContact(latestContact), + routerFallbackAttempted: false, + ); + + await _pathHistoryService.recordPathResult( + latestContact.publicKeyHex, + session.currentSelection, + success: false, + ); + + final repeater = _nearestRouterSelector.select( + senderPosition: locationTrackingService.currentPosition, + repeaters: contactsProvider.repeaters, + recipient: latestContact, + ); + if (repeater == null) { + return false; + } + + final routeHashSize = await RouteHashPreferences.getHashSize(); + final fallbackSelection = _buildNearestRouterSelection( + repeater, + routeHashSize, + ); + _directMessageRouteSessions[messageId] = session.copyWith( + currentSelection: fallbackSelection, + routerFallbackAttempted: true, + ); + messagesProvider.updateMessageRouteSelection( + messageId, + fallbackSelection, + routerFallbackAttempted: true, + ); + + return connectionProvider.sendTextMessage( + contactPublicKey: latestContact.publicKey, + text: message.text, + messageId: messageId, + contact: latestContact, + retryAttempt: message.retryAttempt + 1, + ); + } + + void _handleDirectMessageDelivered({ + required String messageId, + required Contact contact, + required int roundTripTimeMs, + }) { + final session = _directMessageRouteSessions.remove(messageId); + if (session == null) { + return; + } + + unawaited( + _pathHistoryService.recordPathResult( + contact.publicKeyHex, + session.currentSelection, + success: true, + roundTripTimeMs: roundTripTimeMs, + ), + ); + } + + Future _handleDirectMessageFinalFailure({ + required String messageId, + required Contact contact, + }) async { + final latestContact = + contactsProvider.findContactByKey(contact.publicKey) ?? contact; + final session = _directMessageRouteSessions.remove(messageId); + if (session != null) { + await _pathHistoryService.recordPathResult( + latestContact.publicKeyHex, + session.currentSelection, + success: false, + ); + if (session.routerFallbackAttempted) { + await _restoreRouteOnDevice(latestContact, session.originalRoute); + } + } + + if (_clearPathOnMaxRetry) { + contactsProvider.resetContactRouteLocal(latestContact.publicKey); + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.resetPath(latestContact.publicKey); + Future.delayed(const Duration(milliseconds: 150), () { + if (connectionProvider.deviceInfo.isConnected) { + connectionProvider.getContact(latestContact.publicKey); + } + }); + } + } + } + + String _key6(Uint8List publicKey) { + final bytes = publicKey.sublist(0, math.min(6, publicKey.length)); + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); } /// Initialize the app (load contacts, sync time, etc.) diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 5258eb9..fccdcfc 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -175,6 +175,12 @@ class ConnectionProvider with ChangeNotifier { Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)? onMessageSent; Function(int ackCode, int roundTripTimeMs)? onMessageDelivered; + Future Function({ + required String messageId, + required Contact contact, + required int retryAttempt, + })? + prepareDirectMessageSendCallback; Function(String messageId, int echoCount, int snrRaw, int rssiDbm)? onMessageEchoDetected; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; @@ -1073,7 +1079,7 @@ class ConnectionProvider with ChangeNotifier { /// /// [messageId] - optional message ID to track delivery status /// [contact] - optional contact object for path status logging - /// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries) + /// [retryAttempt] - retry attempt number (0 = first send, >0 = retries) Future sendTextMessage({ required Uint8List contactPublicKey, required String text, @@ -1087,6 +1093,17 @@ class ConnectionProvider with ChangeNotifier { return false; } + var effectiveContact = contact; + if (messageId != null && + effectiveContact != null && + prepareDirectMessageSendCallback != null) { + effectiveContact = await prepareDirectMessageSendCallback!( + messageId: messageId, + contact: effectiveContact, + retryAttempt: retryAttempt, + ); + } + // CRITICAL: Check firmware ACK limit (8 max in circular buffer) // Rate limit at 7 to stay under the limit if (_messageDeliveryTracker.shouldRateLimit) { @@ -1111,33 +1128,33 @@ class ConnectionProvider with ChangeNotifier { try { // Log path status and retry info - if (contact != null) { + if (effectiveContact != null) { if (retryAttempt > 0) { debugPrint( - '🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)', + '🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)', ); } else { debugPrint( - '📤 [ConnectionProvider] Sending message to ${contact.advName}', + '📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}', ); } - debugPrint(' Type: ${contact.type.displayName}'); - debugPrint(' Path status: ${contact.routeSummary}'); - if (contact.routeHasPath) { + debugPrint(' Type: ${effectiveContact.type.displayName}'); + debugPrint(' Path status: ${effectiveContact.routeSummary}'); + if (effectiveContact.routeHasPath) { debugPrint( - ' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)', + ' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)', ); } else { debugPrint(' ⚠️ No path available - will use flood mode'); } } else if (retryAttempt > 0) { debugPrint( - '🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)', + '🔄 [ConnectionProvider] Sending message (retry $retryAttempt)', ); } // Track pending operation for auto-recovery (if contact not found in radio) - if (contact != null) { + if (effectiveContact != null) { final operationId = contactPublicKey .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) @@ -1146,7 +1163,7 @@ class ConnectionProvider with ChangeNotifier { contactPublicKey: contactPublicKey, text: text, messageId: messageId, - contact: contact, + contact: effectiveContact, retryAttempt: retryAttempt, ); debugPrint( @@ -1191,7 +1208,7 @@ class ConnectionProvider with ChangeNotifier { // Clear pending operation after successful send (no error) // If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically - if (contact != null) { + if (effectiveContact != null) { final operationId = contactPublicKey .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) diff --git a/lib/providers/helpers/message_retry_manager.dart b/lib/providers/helpers/message_retry_manager.dart index c46a33d..a536987 100644 --- a/lib/providers/helpers/message_retry_manager.dart +++ b/lib/providers/helpers/message_retry_manager.dart @@ -5,15 +5,13 @@ import '../../models/contact.dart'; /// Manages message retry state and logic /// -/// This helper class centralizes retry logic for direct messages, implementing -/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts -/// with learned routing paths. +/// This helper class centralizes retry logic for direct messages. /// /// IMPORTANT: Based on MeshCore firmware analysis: /// - Firmware calculates timeout based on path length and airtime /// - Direct mode: ~(path_len * airtime * 2) + margin /// - Flood mode: ~10-30 seconds for multi-hop -/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths +/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers /// - Firmware does NOT automatically retry - app must implement class MessageRetryManager { // Track retry state for each message ID @@ -21,10 +19,10 @@ class MessageRetryManager { final Map _lastRetryTimes = {}; final Map _pathFailureStreaks = {}; - // Progressive timeout values in milliseconds - // These are app-level timeouts, separate from firmware's suggested timeout - // Firmware timeout is for ACK arrival, these are for retry attempts - static const List _timeouts = [4000, 8000, 12000]; + static const int maxRetryAttempts = 4; + + // Retry backoff values in milliseconds. + static const List _retryDelays = [1000, 2000, 4000, 8000]; static const int _defaultLoRaSf = 10; static const int _defaultLoRaCr = 5; static const int _defaultLoRaBwHz = 250000; @@ -32,13 +30,12 @@ class MessageRetryManager { static const int _defaultLoRaCrcEnabled = 1; static const int _defaultLoRaExplicitHeader = 1; - /// Get timeout for a specific retry attempt (0-2) - /// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2 - int getTimeoutForAttempt(int attempt) { - if (attempt < 0 || attempt >= _timeouts.length) { - return _timeouts.last; // Default to last timeout if out of range + /// Get backoff delay for the next retry attempt. + int getDelayForAttempt(int attempt) { + if (attempt < 0 || attempt >= _retryDelays.length) { + return _retryDelays.last; } - return _timeouts[attempt]; + return _retryDelays[attempt]; } /// Calculate a conservative delivery-ACK timeout when firmware doesn't @@ -65,43 +62,8 @@ class MessageRetryManager { return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000); } - /// Check if a message is eligible for retry - /// - /// Returns true if: - /// - The message has retryAttempt < 3 - /// - The contact has a learned path (contact.hasPath == true) - /// - The message hasn't used flood fallback yet - /// - /// Messages to contacts without paths should NOT retry (flood mode already broadcasts) bool canRetry(Message message, Contact contact) { - // Never retry if already tried flood mode - if (message.usedFloodFallback) { - return false; - } - - // Never retry beyond 3 attempts - if (message.retryAttempt >= 3) { - return false; - } - - // Only retry if contact has a learned path - // If no path, the device uses flood mode automatically - retrying won't help - return contact.routeHasPath; - } - - /// Check if should fall back to flood mode - /// - /// Returns true if: - /// - Message has exhausted all 3 retry attempts with direct mode - /// - Contact HAS a learned path (so direct mode was used) - /// - Hasn't already used flood fallback - /// - /// IMPORTANT: Only contacts WITH paths need flood fallback. - /// Contacts without paths already use flood mode automatically. - bool shouldUseFloodFallback(Message message, Contact contact) { - return message.retryAttempt >= 3 && - contact.routeHasPath && - !message.usedFloodFallback; + return message.retryAttempt < maxRetryAttempts; } /// Track a retry attempt for a message diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index f4bccea..04d304e 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -5,6 +5,8 @@ import '../models/contact.dart'; import '../models/message_contact_location.dart'; import '../models/message_reception_details.dart'; import '../models/message_transfer_details.dart'; +import '../models/message_route_metadata.dart'; +import '../models/path_selection.dart'; import '../models/sar_marker.dart'; import '../models/map_drawing.dart'; import '../services/message_storage_service.dart'; @@ -27,6 +29,7 @@ class MessagesProvider with ChangeNotifier { final Map _messageContactLocations = {}; final Map _messageReceptionDetails = {}; final Map _messageTransferDetails = {}; + final Map _messageRouteMetadata = {}; // Track pending sent messages by expected ACK/TAG final Map _pendingSentMessages = {}; @@ -81,6 +84,25 @@ class MessagesProvider with ChangeNotifier { Future Function({required Contact contact, required int failureStreak})? onDirectPathFailedCallback; + Future Function({ + required String messageId, + required Contact contact, + required Message message, + })? + onFinalRouterFallbackCallback; + Future Function({ + required String messageId, + required Contact contact, + required Message message, + })? + onFinalDirectMessageFailureCallback; + void Function({ + required String messageId, + required Contact contact, + required Message message, + required int roundTripTimeMs, + })? + onDirectMessageDeliveredCallback; String? Function(Uint8List? publicKey)? resolveContactNameCallback; String Function(int channelIdx)? resolveChannelNameCallback; @@ -126,6 +148,30 @@ class MessagesProvider with ChangeNotifier { MessageTransferDetails? getMessageTransferDetails(String messageId) => _messageTransferDetails[messageId]; + MessageRouteMetadata? getMessageRouteMetadata(String messageId) => + _messageRouteMetadata[messageId]; + + void updateMessageRouteSelection( + String messageId, + PathSelection selection, { + required bool routerFallbackAttempted, + }) { + _messageRouteMetadata[messageId] = MessageRouteMetadata.fromSelection( + selection, + routerFallbackAttempted: routerFallbackAttempted, + ); + + final index = _messages.indexWhere((message) => message.id == messageId); + if (index != -1) { + _messages[index] = _messages[index].copyWith( + usedFloodFallback: selection.usesFlood, + ); + } + + _persistMessages(); + notifyListeners(); + } + /// Set localizations for notifications void setLocalizations(AppLocalizations localizations) { _localizations = localizations; @@ -160,6 +206,8 @@ class MessagesProvider with ChangeNotifier { .loadMessageReceptionDetails(); final storedTransferDetails = await _storageService .loadMessageTransferDetails(); + final storedRouteMetadata = await _storageService + .loadMessageRouteMetadata(); _messageContactLocations ..clear() ..addAll(storedContactLocations); @@ -169,6 +217,9 @@ class MessagesProvider with ChangeNotifier { _messageTransferDetails ..clear() ..addAll(storedTransferDetails); + _messageRouteMetadata + ..clear() + ..addAll(storedRouteMetadata); // Add stored messages with enhancement to ensure SAR detection for (final message in storedMessages) { @@ -688,6 +739,7 @@ class MessagesProvider with ChangeNotifier { messageContactLocations: _messageContactLocations, messageReceptionDetails: _messageReceptionDetails, messageTransferDetails: _messageTransferDetails, + messageRouteMetadata: _messageRouteMetadata, ); } catch (e) { debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); @@ -806,6 +858,7 @@ class MessagesProvider with ChangeNotifier { _messageContactLocations.remove(messageId); _messageReceptionDetails.remove(messageId); _messageTransferDetails.remove(messageId); + _messageRouteMetadata.remove(messageId); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); @@ -837,6 +890,7 @@ class MessagesProvider with ChangeNotifier { _messageContactLocations.clear(); _messageReceptionDetails.clear(); _messageTransferDetails.clear(); + _messageRouteMetadata.clear(); _persistMessages(); notifyListeners(); } @@ -854,6 +908,7 @@ class MessagesProvider with ChangeNotifier { _messageContactLocations.clear(); _messageReceptionDetails.clear(); _messageTransferDetails.clear(); + _messageRouteMetadata.clear(); _persistMessages(); notifyListeners(); } @@ -1549,6 +1604,12 @@ class MessagesProvider with ChangeNotifier { final deliveredContact = _messageContactMap[message.id]; if (deliveredContact != null) { _retryManager.recordDeliverySuccess(deliveredContact); + onDirectMessageDeliveredCallback?.call( + messageId: message.id, + contact: deliveredContact, + message: updatedMessage, + roundTripTimeMs: roundTripTimeMs, + ); } debugPrint( @@ -1592,6 +1653,12 @@ class MessagesProvider with ChangeNotifier { final deliveredContact = _messageContactMap[historicalMessageId]; if (deliveredContact != null) { _retryManager.recordDeliverySuccess(deliveredContact); + onDirectMessageDeliveredCallback?.call( + messageId: historicalMessageId, + contact: deliveredContact, + message: _messages[historicalIndex], + roundTripTimeMs: roundTripTimeMs, + ); } _persistMessages(); notifyListeners(); @@ -1677,29 +1744,29 @@ class MessagesProvider with ChangeNotifier { debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}'); debugPrint(' Used flood fallback: ${message.usedFloodFallback}'); - // Decision tree for retry/flood/fail + final routeMetadata = _messageRouteMetadata[messageId]; + final routerFallbackAttempted = + routeMetadata?.routerFallbackAttempted ?? false; + + // Decision tree for retry/final-router-fallback/fail if (contact != null && _retryManager.canRetry(message, contact)) { - // RETRY: Contact has path and retry attempts < 3 _scheduleRetry(messageId, message, contact); - } else if (contact != null && - _retryManager.shouldUseFloodFallback(message, contact)) { - // FLOOD FALLBACK: After 3 retries failed, try flood once - _sendWithFloodMode(messageId, message, contact); + } else if (contact != null && !routerFallbackAttempted) { + unawaited(_sendWithFinalRouterFallback(messageId, message, contact)); } else { - // PERMANENTLY FAILED: No retry possible _markAsPermanentlyFailed(messageId, message); } } - /// Schedule a retry with progressive timeout + /// Schedule a retry with exponential backoff. void _scheduleRetry(String messageId, Message message, Contact contact) { final nextAttempt = message.retryAttempt + 1; - final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt); + final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt); debugPrint( - '🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId', + '🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId', ); - debugPrint(' Timeout: ${timeout}ms'); + debugPrint(' Delay: ${delayMs}ms'); // Update message with new retry attempt final index = _messages.indexWhere((m) => m.id == messageId); @@ -1720,10 +1787,10 @@ class MessagesProvider with ChangeNotifier { // Track retry _retryManager.trackRetry(messageId, nextAttempt); - notifyListeners(); // Update UI to show "Retrying (X/3)..." + notifyListeners(); // Schedule actual retry after delay - Timer(Duration(milliseconds: timeout), () async { + Timer(Duration(milliseconds: delayMs), () async { debugPrint( '⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId', ); @@ -1759,49 +1826,46 @@ class MessagesProvider with ChangeNotifier { } } - /// Send message with flood mode as last resort - Future _sendWithFloodMode( + Future _sendWithFinalRouterFallback( String messageId, Message message, Contact contact, ) async { debugPrint( - '🌊 [MessagesProvider] Trying flood mode for message $messageId', + '🛟 [MessagesProvider] Trying final router fallback for $messageId', ); final index = _messages.indexWhere((m) => m.id == messageId); if (index != -1) { _messages[index] = message.copyWith( - usedFloodFallback: true, deliveryStatus: MessageDeliveryStatus.sending, + lastRetryAt: DateTime.now(), ); - // Cancel old timeout timer _timeoutTimers[message.id]?.cancel(); _timeoutTimers.remove(message.id); if (message.expectedAckTag != null) { _pendingSentMessages.remove(message.expectedAckTag); } + _clearAckHistoryForMessage(messageId); notifyListeners(); - // Send with flood mode (no retry after this) - if (sendMessageCallback != null) { - final queued = await sendMessageCallback!( - contactPublicKey: contact.publicKey, - text: message.text, - messageId: messageId, - contact: contact, - retryAttempt: 0, // Reset attempt for flood - ); - if (!queued) { - _markAsPermanentlyFailed(messageId, _messages[index]); - } - } else { + if (onFinalRouterFallbackCallback == null) { debugPrint( - '⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood', + '⚠️ [MessagesProvider] onFinalRouterFallbackCallback not set', ); _markAsPermanentlyFailed(messageId, _messages[index]); + return; + } + + final queued = await onFinalRouterFallbackCallback!( + messageId: messageId, + contact: contact, + message: _messages[index], + ); + if (!queued) { + _markAsPermanentlyFailed(messageId, _messages[index]); } _persistMessages(); @@ -1830,19 +1894,15 @@ class MessagesProvider with ChangeNotifier { _retryManager.clearRetry(messageId); final failedContact = _messageContactMap[messageId]; - if (failedContact != null && failedContact.routeHasPath) { - final failureStreak = _retryManager.recordPathFailure(failedContact); - debugPrint( - ' Path failure streak for ${failedContact.advName}: $failureStreak', + if (failedContact != null && + onFinalDirectMessageFailureCallback != null) { + unawaited( + onFinalDirectMessageFailureCallback!( + messageId: messageId, + contact: failedContact, + message: _messages[index], + ), ); - if (failureStreak >= 2 && onDirectPathFailedCallback != null) { - unawaited( - onDirectPathFailedCallback!( - contact: failedContact, - failureStreak: failureStreak, - ), - ); - } } _persistMessages(); @@ -1870,6 +1930,7 @@ class MessagesProvider with ChangeNotifier { } _clearAckHistoryForMessage(messageId); _retryManager.clearRetry(messageId); + _messageRouteMetadata.remove(messageId); _messages[index] = Message( id: message.id, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b438e80..9a2f5b0 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -23,6 +23,7 @@ import '../l10n/app_localizations.dart'; import '../widgets/permission_request_dialog.dart'; import '../widgets/connection_dialog.dart'; import '../utils/battery_display_helper.dart'; +import '../services/developer_mode_service.dart'; enum _HomeTab { messages, contacts, sensors, map } @@ -55,6 +56,7 @@ class _HomeScreenState extends State int _currentIndex = 0; bool _isMapFullscreen = false; bool _showRxTxIndicators = true; + bool _isDeveloperModeEnabled = false; bool _isMapEnabled = true; bool _isContactsEnabled = true; bool _isSensorsEnabled = false; @@ -90,6 +92,7 @@ class _HomeScreenState extends State // Initialize synchronously so first build always has a valid controller. _initTabController(); _loadRxTxPreference(); + _loadDeveloperModePreference(); // Show permission dialog after the first frame if needed if (widget.shouldShowPermissionDialog) { @@ -228,6 +231,14 @@ class _HomeScreenState extends State } } + Future _loadDeveloperModePreference() async { + final isEnabled = await DeveloperModeService.isEnabled(); + if (!mounted) return; + setState(() { + _isDeveloperModeEnabled = isEnabled; + }); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -598,56 +609,67 @@ class _HomeScreenState extends State ), PopupMenuButton( icon: const Icon(Icons.more_vert), - itemBuilder: (context) => [ - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.radar), - const SizedBox(width: 8), - const Text('Spectrum Scan'), - ], - ), - onTap: () { - final navigator = Navigator.of(context); - Future.delayed(Duration.zero, () { - if (!mounted) return; - navigator.push( - MaterialPageRoute( - builder: (context) => const SpectrumScanScreen(), - ), - ); - }); - }, - ), - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.settings), - const SizedBox(width: 8), - Text(AppLocalizations.of(context)!.settings), - ], - ), - onTap: () { - // Capture context-dependent objects before async gap - final navigator = Navigator.of(context); - Future.delayed(Duration.zero, () async { - if (!mounted) return; - await navigator.push( - MaterialPageRoute( - builder: (context) => SettingsScreen( - onThemeChanged: widget.onThemeChanged, - onLocaleChanged: widget.onLocaleChanged, - currentTheme: widget.currentTheme, - currentLocale: widget.currentLocale, + itemBuilder: (context) { + final items = >[]; + + if (_isDeveloperModeEnabled) { + items.add( + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.radar), + const SizedBox(width: 8), + const Text('Spectrum Scan'), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => + const SpectrumScanScreen(), + ), + ); + }); + }, + ), + ); + } + + items.add( + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.settings), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.settings), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () async { + if (!mounted) return; + await navigator.push( + MaterialPageRoute( + builder: (context) => SettingsScreen( + onThemeChanged: widget.onThemeChanged, + onLocaleChanged: widget.onLocaleChanged, + currentTheme: widget.currentTheme, + currentLocale: widget.currentLocale, + ), ), - ), - ); - // Reload preference when returning from settings - _loadRxTxPreference(); - }); - }, - ), - ], + ); + _loadRxTxPreference(); + _loadDeveloperModePreference(); + }); + }, + ), + ); + + return items; + }, ), ], ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9440fde..d7e85ae 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -21,6 +21,7 @@ import '../services/voice_bitrate_preferences.dart'; import '../services/image_preferences.dart'; import '../services/route_hash_preferences.dart'; import '../services/image_codec_service.dart'; +import '../services/developer_mode_service.dart'; import '../utils/sample_data_generator.dart'; import '../utils/image_message_parser.dart'; import '../utils/voice_message_parser.dart'; @@ -70,6 +71,8 @@ class _SettingsScreenState extends State { bool _fastLocationUpdatesEnabled = false; double _fastLocationMovementThresholdMeters = 10.0; int _fastLocationActiveCadenceSeconds = 10; + bool _isDeveloperModeEnabled = false; + int _versionTapCount = 0; final ImagePicker _imagePicker = ImagePicker(); final LocationTrackingService _locationService = LocationTrackingService(); @@ -85,6 +88,7 @@ class _SettingsScreenState extends State { _loadRouteHashSizePreference(); _loadImagePreferences(); _loadFastLocationSettings(); + _loadDeveloperMode(); } @override @@ -114,6 +118,48 @@ class _SettingsScreenState extends State { } } + Future _loadDeveloperMode() async { + final isEnabled = await DeveloperModeService.isEnabled(); + if (!mounted) return; + setState(() { + _isDeveloperModeEnabled = isEnabled; + }); + } + + Future _handleVersionTap() async { + if (_isDeveloperModeEnabled) { + await DeveloperModeService.setEnabled(false); + if (!mounted) return; + setState(() { + _isDeveloperModeEnabled = false; + _versionTapCount = 0; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Developer mode disabled'))); + return; + } + + final nextTapCount = _versionTapCount + 1; + if (nextTapCount >= 3) { + await DeveloperModeService.setEnabled(true); + if (!mounted) return; + setState(() { + _isDeveloperModeEnabled = true; + _versionTapCount = 0; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Developer mode enabled'))); + return; + } + + if (!mounted) return; + setState(() { + _versionTapCount = nextTapCount; + }); + } + Future _saveRxTxPreference(bool value) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('show_rx_tx_indicators', value); @@ -929,6 +975,32 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: _showRouteHashSizeDialog, ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.swap_horiz), + title: const Text('Auto route rotation'), + 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: const Icon(Icons.route), + title: const Text('Clear path on max retry'), + subtitle: const Text( + 'Clear the route only after all retries and final router fallback fail', + ), + value: appProvider.clearPathOnMaxRetry, + onChanged: (value) async { + await appProvider.toggleClearPathOnMaxRetry(value); + }, + ), + ), ListTile( leading: const Icon(Icons.delete_sweep, color: Colors.red), title: const Text( @@ -1197,6 +1269,7 @@ class _SettingsScreenState extends State { ? '${_packageInfo!.version} (${_packageInfo!.buildNumber})' : 'Loading...', ), + onTap: _handleVersionTap, ), ListTile( leading: const Icon(Icons.badge), diff --git a/lib/services/developer_mode_service.dart b/lib/services/developer_mode_service.dart new file mode 100644 index 0000000..9ba5541 --- /dev/null +++ b/lib/services/developer_mode_service.dart @@ -0,0 +1,15 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class DeveloperModeService { + static const String _developerModeKey = 'developer_mode_enabled'; + + static Future isEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_developerModeKey) ?? false; + } + + static Future setEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_developerModeKey, enabled); + } +} diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index 32858ed..4e5136f 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -5,6 +5,7 @@ import '../models/message.dart'; import '../models/message_contact_location.dart'; import '../models/message_reception_details.dart'; import '../models/message_transfer_details.dart'; +import '../models/message_route_metadata.dart'; import 'package:latlong2/latlong.dart'; /// Service for persisting messages to local storage @@ -16,6 +17,8 @@ class MessageStorageService { 'stored_message_reception_details'; static const String _messageTransferDetailsKey = 'stored_message_transfer_details'; + static const String _messageRouteMetadataKey = + 'stored_message_route_metadata'; static const int _maxStoredMessages = 1000; // Store up to 1000 messages /// Save messages to persistent storage @@ -24,6 +27,7 @@ class MessageStorageService { Map messageContactLocations = const {}, Map messageReceptionDetails = const {}, Map messageTransferDetails = const {}, + Map messageRouteMetadata = const {}, }) async { try { final prefs = await SharedPreferences.getInstance(); @@ -44,6 +48,7 @@ class MessageStorageService { final locationJson = {}; final receptionJson = {}; final transferJson = {}; + final routeMetadataJson = {}; for (final entry in messageContactLocations.entries) { if (retainedMessageIds.contains(entry.key)) { locationJson[entry.key] = entry.value.toJson(); @@ -59,6 +64,11 @@ class MessageStorageService { transferJson[entry.key] = entry.value.toJson(); } } + for (final entry in messageRouteMetadata.entries) { + if (retainedMessageIds.contains(entry.key)) { + routeMetadataJson[entry.key] = entry.value.toJson(); + } + } await prefs.setString( _messageContactLocationsKey, jsonEncode(locationJson), @@ -71,6 +81,10 @@ class MessageStorageService { _messageTransferDetailsKey, jsonEncode(transferJson), ); + await prefs.setString( + _messageRouteMetadataKey, + jsonEncode(routeMetadataJson), + ); debugPrint( '✅ [MessageStorage] Saved ${limitedList.length} messages to storage', @@ -170,6 +184,32 @@ class MessageStorageService { } } + Future> loadMessageRouteMetadata() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_messageRouteMetadataKey); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + return const {}; + } + + final result = {}; + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + result[entry.key] = MessageRouteMetadata.fromJson(value); + } + return result; + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading route metadata: $e'); + return const {}; + } + } + /// Load messages from persistent storage Future> loadMessages() async { try { @@ -206,6 +246,7 @@ class MessageStorageService { await prefs.remove(_messageContactLocationsKey); await prefs.remove(_messageReceptionDetailsKey); await prefs.remove(_messageTransferDetailsKey); + await prefs.remove(_messageRouteMetadataKey); debugPrint('✅ [MessageStorage] Cleared all stored messages'); } catch (e) { debugPrint('❌ [MessageStorage] Error clearing messages: $e'); diff --git a/lib/services/messaging_route_preferences.dart b/lib/services/messaging_route_preferences.dart new file mode 100644 index 0000000..9976575 --- /dev/null +++ b/lib/services/messaging_route_preferences.dart @@ -0,0 +1,32 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class MessagingRoutePreferences { + static const bool defaultAutoRouteRotationEnabled = false; + static const bool defaultClearPathOnMaxRetry = false; + + static const String _autoRouteRotationKey = + 'messaging_auto_route_rotation_enabled'; + static const String _clearPathOnMaxRetryKey = + 'messaging_clear_path_on_max_retry'; + + static Future getAutoRouteRotationEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_autoRouteRotationKey) ?? + defaultAutoRouteRotationEnabled; + } + + static Future setAutoRouteRotationEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_autoRouteRotationKey, enabled); + } + + static Future getClearPathOnMaxRetry() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry; + } + + static Future setClearPathOnMaxRetry(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_clearPathOnMaxRetryKey, enabled); + } +} diff --git a/lib/services/nearest_router_selector.dart b/lib/services/nearest_router_selector.dart new file mode 100644 index 0000000..edba1f6 --- /dev/null +++ b/lib/services/nearest_router_selector.dart @@ -0,0 +1,69 @@ +import 'package:geolocator/geolocator.dart'; + +import '../models/contact.dart'; + +class NearestRouterSelector { + const NearestRouterSelector(); + + Contact? select({ + required Position? senderPosition, + required List repeaters, + required Contact recipient, + }) { + if (senderPosition == null) { + return null; + } + + final eligible = repeaters.where((contact) { + if (contact.publicKeyHex == recipient.publicKeyHex) { + return false; + } + if (!contact.isRecentlySeen) { + return false; + } + return contact.displayLocation != null; + }).toList(); + if (eligible.isEmpty) { + return null; + } + + eligible.sort((a, b) { + final locationA = a.displayLocation!; + final locationB = b.displayLocation!; + final distanceA = Geolocator.distanceBetween( + senderPosition.latitude, + senderPosition.longitude, + locationA.latitude, + locationA.longitude, + ); + final distanceB = Geolocator.distanceBetween( + senderPosition.latitude, + senderPosition.longitude, + locationB.latitude, + locationB.longitude, + ); + final distanceCompare = distanceA.compareTo(distanceB); + if (distanceCompare != 0) { + return distanceCompare; + } + + final advertCompare = b.lastAdvert.compareTo(a.lastAdvert); + if (advertCompare != 0) { + return advertCompare; + } + + final hopCompare = a.routeHopCount.compareTo(b.routeHopCount); + if (hopCompare != 0) { + return hopCompare; + } + + final nameCompare = a.advName.compareTo(b.advName); + if (nameCompare != 0) { + return nameCompare; + } + return a.publicKeyHex.compareTo(b.publicKeyHex); + }); + + return eligible.first; + } +} diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart new file mode 100644 index 0000000..0822659 --- /dev/null +++ b/lib/services/path_history_service.dart @@ -0,0 +1,233 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/contact.dart'; +import '../models/path_history.dart'; +import '../models/path_selection.dart'; + +class PathHistoryService { + static const String _storageKey = 'contact_path_history_v1'; + static const int _maxDirectPaths = 20; + static const int _topRotationCount = 3; + + final Map _cache = {}; + bool _isLoaded = false; + + Future initialize() async { + if (_isLoaded) return; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_storageKey); + if (raw == null || raw.isEmpty) { + _isLoaded = true; + return; + } + + try { + 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'); + } + _isLoaded = true; + } + + Future recordLearnedPath(Contact contact) async { + await initialize(); + if (!contact.routeHasPath || contact.routeHopCount <= 0) { + return; + } + + final history = _historyFor(contact.publicKeyHex); + final signature = _signature(contact.routePathBytes); + final existing = _findDirectPath(history.directPaths, signature); + final updated = PathRecord( + pathBytes: contact.routePathBytes.toList(), + hopCount: contact.routeHopCount, + hashSize: contact.routeHashSize, + successCount: existing?.successCount ?? 0, + failureCount: existing?.failureCount ?? 0, + lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, + lastUsedAt: DateTime.now(), + ); + + await _saveHistory( + contact.publicKeyHex, + history.copyWith( + directPaths: _upsertDirectPath(history.directPaths, updated), + ), + ); + } + + Future getSelectionForContact( + Contact contact, { + required bool autoRouteRotationEnabled, + }) async { + await initialize(); + await recordLearnedPath(contact); + + if (!autoRouteRotationEnabled) { + if (contact.routeHasPath && contact.routeHopCount > 0) { + return PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList(contact.routePathBytes), + hopCount: contact.routeHopCount, + hashSize: contact.routeHashSize, + ); + } + 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, + }) 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, + 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(), + ); + await _saveHistory( + contactPublicKeyHex, + history.copyWith( + directPaths: _upsertDirectPath(history.directPaths, updated), + ), + ); + } + + ContactPathHistory historyFor(String contactPublicKeyHex) { + return _cache[contactPublicKeyHex] ?? + ContactPathHistory.empty(contactPublicKeyHex); + } + + ContactPathHistory _historyFor(String contactPublicKeyHex) { + return _cache.putIfAbsent( + contactPublicKeyHex, + () => ContactPathHistory.empty(contactPublicKeyHex), + ); + } + + Future _saveHistory( + String contactPublicKeyHex, + ContactPathHistory history, + ) async { + _cache[contactPublicKeyHex] = history; + final prefs = await SharedPreferences.getInstance(); + final payload = {}; + for (final entry in _cache.entries) { + payload[entry.key] = entry.value.toJson(); + } + await prefs.setString(_storageKey, jsonEncode(payload)); + } + + 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(); +} diff --git a/lib/utils/message_extensions.dart b/lib/utils/message_extensions.dart index e1a9d9f..3d16f1d 100644 --- a/lib/utils/message_extensions.dart +++ b/lib/utils/message_extensions.dart @@ -1,12 +1,17 @@ import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; import '../models/message.dart'; import '../l10n/app_localizations.dart'; +import '../providers/messages_provider.dart'; /// Extension for Message to provide localized delivery status extension MessageLocalization on Message { /// Get localized delivery status text String getLocalizedDeliveryStatus(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final routeMetadata = context + .read() + .getMessageRouteMetadata(id); // For channel messages, show echo count instead of delivery status if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) { @@ -26,29 +31,51 @@ extension MessageLocalization on Message { case MessageDeliveryStatus.sending: if (isContactMessage) { if (retryAttempt > 0) { - return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/3'; + final routeSuffix = routeMetadata != null + ? ' • ${routeMetadata.modeLabel}' + : ''; + return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix'; } - return l10n.pending; + return routeMetadata == null + ? l10n.pending + : '${l10n.pending} • ${routeMetadata.modeLabel}'; } return l10n.sending; case MessageDeliveryStatus.sent: - return l10n.sent; + return routeMetadata == null + ? l10n.sent + : '${l10n.sent} • ${routeMetadata.modeLabel}'; case MessageDeliveryStatus.delivered: if (retryAttempt > 0 && roundTripTimeMs != null) { - return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/3'; + final routeSuffix = routeMetadata != null + ? ' • ${routeMetadata.modeLabel}' + : ''; + return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix'; } if (retryAttempt > 0) { - return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/3'; + final routeSuffix = routeMetadata != null + ? ' • ${routeMetadata.modeLabel}' + : ''; + return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix'; } if (roundTripTimeMs != null) { - return l10n.deliveredWithTime(roundTripTimeMs!); + return routeMetadata == null + ? l10n.deliveredWithTime(roundTripTimeMs!) + : '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${routeMetadata.modeLabel}'; } - return l10n.delivered; + return routeMetadata == null + ? l10n.delivered + : '${l10n.delivered} • ${routeMetadata.modeLabel}'; case MessageDeliveryStatus.failed: if (retryAttempt > 0) { - return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/3'; + final routeSuffix = routeMetadata != null + ? ' • ${routeMetadata.modeLabel}' + : ''; + return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix'; } - return l10n.failed; + return routeMetadata == null + ? l10n.failed + : '${l10n.failed} • ${routeMetadata.modeLabel}'; case MessageDeliveryStatus.received: return ''; } diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index cdb0c0b..2df8926 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../models/contact.dart'; +import '../../providers/app_provider.dart'; import '../../services/route_hash_preferences.dart'; class ContactRouteDialogResult { @@ -133,6 +135,7 @@ class _ContactRouteDialogState extends State { @override Widget build(BuildContext context) { + final appProvider = context.watch(); final routeCandidates = widget.availableContacts .where((contact) => contact.isRepeater || contact.isRoom) @@ -184,6 +187,11 @@ class _ContactRouteDialogState extends State { ), ], const SizedBox(height: 16), + _AutomationRoutingInfo( + autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled, + clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, + ), + const SizedBox(height: 16), Text( 'Pick hops from contacts', style: Theme.of(context).textTheme.labelLarge, @@ -203,10 +211,6 @@ class _ContactRouteDialogState extends State { dense: true, contentPadding: EdgeInsets.zero, title: Text(candidate.displayName), - subtitle: Text( - '1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}', - style: const TextStyle(fontFamily: 'monospace'), - ), trailing: TextButton( onPressed: () => _appendHop(candidate), child: Text( @@ -248,3 +252,97 @@ class _ContactRouteDialogState extends State { ); } } + +class _AutomationRoutingInfo extends StatelessWidget { + final bool autoRouteRotationEnabled; + final bool clearPathOnMaxRetry; + + const _AutomationRoutingInfo({ + required this.autoRouteRotationEnabled, + required this.clearPathOnMaxRetry, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, size: 18, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Automatic direct-send routing', + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + 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.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Text( + 'Public and channel broadcasts are not affected by this automation.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InfoChip( + label: autoRouteRotationEnabled + ? 'Auto route rotation on' + : 'Auto route rotation off', + icon: Icons.swap_horiz, + ), + _InfoChip( + label: clearPathOnMaxRetry + ? 'Clear path on max retry on' + : 'Clear path on max retry off', + icon: Icons.route, + ), + ], + ), + ], + ), + ); + } +} + +class _InfoChip extends StatelessWidget { + final String label; + final IconData icon; + + const _InfoChip({required this.label, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: Theme.of(context).colorScheme.surface, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14), + const SizedBox(width: 6), + Text(label, style: Theme.of(context).textTheme.labelMedium), + ], + ), + ); + } +} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index d985065..db80347 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -96,10 +96,6 @@ class ContactTile extends StatelessWidget { void handleTap() { if (contact.type == ContactType.chat) { _showSetRouteDialog(context, contact); - } else if (contact.type == ContactType.repeater) { - _jumpToMapForRepeater(context, contact); - } else if (contact.type == ContactType.room && !contact.isPublicChannel) { - _showRoomLoginDialog(context, contact); } else { _showContactDetails(context, contact); } @@ -307,23 +303,6 @@ class ContactTile extends StatelessWidget { ); } - void _jumpToMapForRepeater(BuildContext context, Contact contact) { - final location = contact.displayLocation; - if (location != null) { - final mapProvider = context.read(); - - // Navigate to map location - mapProvider.navigateToLocation( - location: LatLng(location.latitude, location.longitude), - zoom: 15.0, - animate: true, - ); - - // Switch to map tab using callback - onNavigateToMap?.call(); - } - } - void _showDeleteConfirmation(BuildContext context, Contact contact) { showDialog( context: context, diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index c0210f1..9d43055 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -469,6 +469,9 @@ class _MessageBubbleState extends State { final retryCause = _retryCauseLabel(widget.message); final retryResult = _retryResultLabel(widget.message); final retryMode = _retryModeLabel(widget.message); + final routeMetadata = context + .read() + .getMessageRouteMetadata(widget.message.id); final rawLines = [ 'Message ID: ${widget.message.id}', @@ -790,7 +793,7 @@ class _MessageBubbleState extends State { _detailRow( context, label: l10n.retryAttempt, - value: '${widget.message.retryAttempt}/3', + value: '${widget.message.retryAttempt}/4', ), if (widget.message.lastRetryAt != null) _detailRow( @@ -809,6 +812,20 @@ class _MessageBubbleState extends State { label: l10n.floodFallback, value: l10n.yes, ), + if (routeMetadata?.relayName case final relayName?) + _detailRow( + context, + label: 'Relay', + value: relayName, + ), + if (routeMetadata?.canonicalPath + case final routePath?) + _detailRow( + context, + label: 'Selected path', + value: routePath, + onCopy: () => copyField(routePath), + ), if (retryResult != null) _detailRow( context, @@ -885,7 +902,9 @@ class _MessageBubbleState extends State { _detailRow( context, label: l10n.envelope, - value: envelope != null ? 'VE3 compact' : l10n.unknown, + value: envelope != null + ? 'VE3 compact' + : l10n.unknown, ), if (voiceSession != null) _detailRow( @@ -1545,8 +1564,15 @@ class _MessageBubbleState extends State { return null; } + final routeMetadata = context + .read() + .getMessageRouteMetadata(message.id); + if (routeMetadata != null) { + return routeMetadata.modeLabel; + } + if (message.usedFloodFallback) { - return 'Flood fallback'; + return 'Flood route'; } if (message.retryAttempt > 0 || message.expectedAckTag != null) { @@ -1561,9 +1587,17 @@ class _MessageBubbleState extends State { return null; } + final routeMetadata = context + .read() + .getMessageRouteMetadata(message.id); + final routeLabel = routeMetadata?.modeLabel.toLowerCase(); + if (message.deliveryStatus == MessageDeliveryStatus.delivered) { + if (routeLabel != null) { + return 'Delivered via $routeLabel'; + } if (message.usedFloodFallback) { - return 'Delivered after flood fallback'; + return 'Delivered after flood route'; } if (message.retryAttempt > 0) { return 'Delivered after retry'; @@ -1574,8 +1608,11 @@ class _MessageBubbleState extends State { } if (message.deliveryStatus == MessageDeliveryStatus.sending) { + if (routeLabel != null) { + return '$routeLabel in progress'; + } if (message.usedFloodFallback) { - return 'Flood fallback in progress'; + return 'Flood route in progress'; } if (message.retryAttempt > 0) { return 'Retry in progress'; @@ -1586,8 +1623,11 @@ class _MessageBubbleState extends State { } if (message.deliveryStatus == MessageDeliveryStatus.failed) { + if (routeLabel != null) { + return 'Failed via $routeLabel'; + } if (message.usedFloodFallback) { - return 'Failed after flood fallback'; + return 'Failed after flood route'; } if (message.retryAttempt > 0) { return 'Failed after retry attempts'; diff --git a/lib/widgets/messages/message_bubble_signal.dart b/lib/widgets/messages/message_bubble_signal.dart index 572742f..3902db0 100644 --- a/lib/widgets/messages/message_bubble_signal.dart +++ b/lib/widgets/messages/message_bubble_signal.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../models/message.dart'; +import '../../models/path_selection.dart'; import '../../models/message_reception_details.dart'; +import '../../providers/messages_provider.dart'; IconData getDeliveryStatusIcon(MessageDeliveryStatus status) { switch (status) { @@ -185,6 +188,9 @@ Widget buildSentDirectSignalStatus( required int roundTripTimeMs, required Duration txEstimate, }) { + final routeMetadata = context + .read() + .getMessageRouteMetadata(message.id); final estimatedTransmitMs = sanitizeEstimatedTransmitMs( estimatedTransmitMs: txEstimate > Duration.zero ? txEstimate.inMilliseconds @@ -230,7 +236,7 @@ Widget buildSentDirectSignalStatus( _techChip( context, icon: Icons.refresh, - label: 'retry ${message.retryAttempt}/3', + label: 'retry ${message.retryAttempt}/4', color: Colors.redAccent, ), if (message.suggestedTimeoutMs != null) @@ -244,7 +250,7 @@ Widget buildSentDirectSignalStatus( _techChip( context, icon: Icons.waves, - label: 'flood fallback', + label: 'flood route', color: Colors.teal, ) else if (message.expectedAckTag != null) @@ -254,6 +260,17 @@ Widget buildSentDirectSignalStatus( label: 'direct ACK', color: Colors.indigo, ), + if (routeMetadata != null) + _techChip( + context, + icon: routeMetadata.mode == PathSelectionMode.nearestRouter + ? Icons.router + : Icons.alt_route, + label: routeMetadata.modeLabel, + color: routeMetadata.mode == PathSelectionMode.nearestRouter + ? Colors.deepPurple + : Colors.indigo, + ), ], ); } diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index 7b490c2..dcb8e28 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -4,7 +4,10 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/models/path_selection.dart'; +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() { return Contact( @@ -39,6 +42,10 @@ Message _buildDirectMessage(String id) { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + group('MessagesProvider retransmission', () { test('direct messages become sent before delivery ACK arrives', () { final provider = MessagesProvider(); @@ -171,7 +178,11 @@ void main() { ); expect(retryCalls, 0); - async.elapse(const Duration(seconds: 4)); + async.elapse(const Duration(milliseconds: 998)); + async.flushMicrotasks(); + expect(retryCalls, 0); + + async.elapse(const Duration(milliseconds: 1)); async.flushMicrotasks(); expect(retryCalls, 1); @@ -237,68 +248,89 @@ void main() { expect(provider.messages.single.retryAttempt, 0); }); - test('repeated max-retry failures request path reset', () async { + test( + 'final router fallback runs after all normal retries are exhausted', + () async { + final provider = MessagesProvider(); + final fallbackCalls = []; + provider.onFinalRouterFallbackCallback = + ({required messageId, required contact, required message}) async { + fallbackCalls.add(messageId); + return true; + }; + + provider.addSentMessage( + _buildDirectMessage( + 'm5', + ).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts), + contact: _buildContact(), + ); + + provider.markMessageFailed('m5'); + await Future.delayed(Duration.zero); + + expect(fallbackCalls, ['m5']); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sending, + ); + }, + ); + + test('final router fallback is not retried twice', () async { final provider = MessagesProvider(); - final contact = _buildContact(); - final resetRequests = <(String, int)>[]; - provider.onDirectPathFailedCallback = - ({required contact, required failureStreak}) async { - resetRequests.add((contact.advName, failureStreak)); - }; - - provider.addSentMessage( - _buildDirectMessage( - 'm5', - ).copyWith(retryAttempt: 3, usedFloodFallback: true), - contact: contact, - ); - provider.markMessageFailed('m5'); - provider.addSentMessage( _buildDirectMessage( 'm6', - ).copyWith(retryAttempt: 3, usedFloodFallback: true), - contact: contact, + ).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts), + contact: _buildContact(), ); + provider.updateMessageRouteSelection( + 'm6', + PathSelection.flood(), + routerFallbackAttempted: true, + ); + provider.markMessageFailed('m6'); - await Future.delayed(Duration.zero); - expect(resetRequests, [('Teammate', 2)]); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.failed, + ); }); - test('successful delivery clears path failure streak', () async { - final provider = MessagesProvider(); - final contact = _buildContact(); - final resetRequests = []; - provider.onDirectPathFailedCallback = - ({required contact, required failureStreak}) async { - resetRequests.add(failureStreak); - }; + test( + 'final permanent failure callback runs after router fallback failure', + () async { + final provider = MessagesProvider(); + final failedMessageIds = []; + provider.onFinalDirectMessageFailureCallback = + ({required messageId, required contact, required message}) async { + failedMessageIds.add(messageId); + }; - provider.addSentMessage( - _buildDirectMessage( + provider.addSentMessage( + _buildDirectMessage( + 'm7', + ).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts), + contact: _buildContact(), + ); + provider.updateMessageRouteSelection( 'm7', - ).copyWith(retryAttempt: 3, usedFloodFallback: true), - contact: contact, - ); - provider.markMessageFailed('m7'); + PathSelection.flood(), + routerFallbackAttempted: true, + ); - provider.addSentMessage(_buildDirectMessage('m8'), contact: contact); - provider.markMessageSent('m8', 123, 10); - provider.markMessageDelivered(123, 150); + provider.markMessageFailed('m7'); + await Future.delayed(Duration.zero); - provider.addSentMessage( - _buildDirectMessage( - 'm9', - ).copyWith(retryAttempt: 3, usedFloodFallback: true), - contact: contact, - ); - provider.markMessageFailed('m9'); - - await Future.delayed(Duration.zero); - - expect(resetRequests, isEmpty); - }); + expect(failedMessageIds, ['m7']); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.failed, + ); + }, + ); }); } diff --git a/test/services/messaging_route_preferences_test.dart b/test/services/messaging_route_preferences_test.dart new file mode 100644 index 0000000..29aa378 --- /dev/null +++ b/test/services/messaging_route_preferences_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:meshcore_sar_app/services/messaging_route_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('route preference defaults are disabled', () async { + expect( + await MessagingRoutePreferences.getAutoRouteRotationEnabled(), + isFalse, + ); + expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse); + }); + + test('route preferences persist changes', () async { + await MessagingRoutePreferences.setAutoRouteRotationEnabled(true); + await MessagingRoutePreferences.setClearPathOnMaxRetry(true); + + expect( + await MessagingRoutePreferences.getAutoRouteRotationEnabled(), + isTrue, + ); + expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue); + }); +} diff --git a/test/services/nearest_router_selector_test.dart b/test/services/nearest_router_selector_test.dart new file mode 100644 index 0000000..ba900a1 --- /dev/null +++ b/test/services/nearest_router_selector_test.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/nearest_router_selector.dart'; + +Contact _buildRepeater({ + required int seed, + required String name, + required double latitude, + required double longitude, + required int lastAdvert, + int outPathLen = -1, +}) { + return Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i + seed)), + type: ContactType.repeater, + flags: 0, + outPathLen: outPathLen, + outPath: Uint8List(0), + advName: name, + lastAdvert: lastAdvert, + advLat: (latitude * 1e6).round(), + advLon: (longitude * 1e6).round(), + lastMod: lastAdvert, + ); +} + +Position _position(double latitude, double longitude) { + return Position( + latitude: latitude, + longitude: longitude, + timestamp: DateTime.now(), + accuracy: 1, + altitude: 0, + altitudeAccuracy: 1, + heading: 0, + headingAccuracy: 1, + speed: 0, + speedAccuracy: 0, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('selector chooses nearest eligible repeater', () { + final selector = NearestRouterSelector(); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final recipient = _buildRepeater( + seed: 90, + name: 'Recipient', + latitude: 46.05, + longitude: 14.50, + lastAdvert: now, + ).copyWith(type: ContactType.chat); + + final selected = selector.select( + senderPosition: _position(46.0569, 14.5058), + repeaters: [ + _buildRepeater( + seed: 1, + name: 'Far', + latitude: 46.10, + longitude: 14.60, + lastAdvert: now, + outPathLen: 1, + ), + _buildRepeater( + seed: 2, + name: 'Near', + latitude: 46.0570, + longitude: 14.5060, + lastAdvert: now - 5, + outPathLen: 1, + ), + ], + recipient: recipient, + ); + + expect(selected?.advName, 'Near'); + }); + + test('selector skips stale repeaters', () { + final selector = NearestRouterSelector(); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final staleAdvert = now - (11 * 60); + final recipient = _buildRepeater( + seed: 91, + name: 'Recipient', + latitude: 46.05, + longitude: 14.50, + lastAdvert: now, + ).copyWith(type: ContactType.chat); + + final selected = selector.select( + senderPosition: _position(46.0569, 14.5058), + repeaters: [ + _buildRepeater( + seed: 3, + name: 'Stale', + latitude: 46.0570, + longitude: 14.5060, + lastAdvert: staleAdvert, + outPathLen: 1, + ), + ], + recipient: recipient, + ); + + expect(selected, isNull); + }); +} diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart new file mode 100644 index 0000000..e25415e --- /dev/null +++ b/test/services/path_history_service_test.dart @@ -0,0 +1,130 @@ +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_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, +}) { + final encoded = ((hashSize - 1) << 6) | (hopCount & 0x3F); + final outPath = Uint8List(ContactRouteCodec.maxPathBytes) + ..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, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('auto rotation ranks best paths before flood', () async { + final service = PathHistoryService(); + final contact = _buildContact( + seed: 0, + pathBytes: [0xAA, 0xBB], + hopCount: 2, + hashSize: 1, + ); + 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.recordLearnedPath(contact); + 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('no history falls back to flood', () async { + final service = PathHistoryService(); + final contact = Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i)), + type: ContactType.chat, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: 'No Route', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + final selection = await service.getSelectionForContact( + contact, + autoRouteRotationEnabled: true, + ); + + expect(selection.mode, PathSelectionMode.flood); + }); +}