import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'connection_provider.dart'; import 'contacts_provider.dart'; import 'messages_provider.dart'; import 'drawing_provider.dart'; import 'channels_provider.dart'; import 'voice_provider.dart'; 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'; import '../utils/voice_message_parser.dart'; import '../utils/image_message_parser.dart'; 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; final ConnectionProvider connectionProvider; final ContactsProvider contactsProvider; final MessagesProvider messagesProvider; final DrawingProvider drawingProvider; final ChannelsProvider channelsProvider; final VoiceProvider voiceProvider; final ip.ImageProvider imageProvider; final LocationTrackingService locationTrackingService = LocationTrackingService(); final PacketCaptureStorageService packetCaptureStorageService = PacketCaptureStorageService(); bool _isInitialized = false; bool get isInitialized => _isInitialized; bool get isSimpleMode => true; bool _isMapEnabled = true; bool get isMapEnabled => _isMapEnabled; bool _isContactsEnabled = true; bool get isContactsEnabled => _isContactsEnabled; bool _isSensorsEnabled = true; bool get isSensorsEnabled => _isSensorsEnabled; bool _isVoiceSilenceTrimmingEnabled = true; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; bool _isVoiceBandPassFilterEnabled = true; bool get isVoiceBandPassFilterEnabled => _isVoiceBandPassFilterEnabled; bool _isVoiceCompressorEnabled = true; bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled; bool _isVoiceLimiterEnabled = true; bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; double _messageFontScale = 1.0; double get messageFontScale => _messageFontScale; bool _autoAddDiscoveredContacts = false; bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts; bool _autoRouteRotationEnabled = MessagingRoutePreferences.defaultAutoRouteRotationEnabled; bool get autoRouteRotationEnabled => _autoRouteRotationEnabled; 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(); final Map _directMessageRouteSessions = {}; static const Duration _packetRetryDelay = Duration(milliseconds: 1200); static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10); static const int _maxPacketRetryAttempts = 4; final Map _voiceSessionSenderKey6 = {}; final Map _imageSessionSenderKey6 = {}; final Map _voiceMissingRetryTimers = {}; final Map _voiceMissingRetryAttempts = {}; final Map _imageMissingRetryTimers = {}; final Map _imageMissingRetryAttempts = {}; final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); final Map> _pendingRawRouteProbes = {}; final Map> _pendingMediaSwarmFetches = {}; final Map> _pendingMediaSwarmResponses = {}; bool _fastLocationScreenActive = false; Timer? _packetCaptureFlushTimer; String? _lastPersistedPacketSignature; bool _isPersistingPacketCapture = false; AppProvider({ required this.connectionProvider, required this.contactsProvider, required this.messagesProvider, required this.drawingProvider, required this.channelsProvider, required this.voiceProvider, required this.imageProvider, }) { _setupCallbacks(); _initializeLocationTracking(); _loadMapEnabled(); _loadContactsEnabled(); _loadSensorsEnabled(); _loadVoiceSilenceTrimmingEnabled(); _loadVoiceBandPassFilterEnabled(); _loadVoiceCompressorEnabled(); _loadVoiceLimiterEnabled(); _loadMessageFontScale(); _loadAutoAddDiscoveredContacts(); _loadMessagingRouteSettings(); unawaited(_pathHistoryService.initialize()); _startPacketCapturePersistence(); _syncDrawingsOnStartup(); // Sync drawings immediately after providers load _isInitialized = true; } void _startPacketCapturePersistence() { _packetCaptureFlushTimer?.cancel(); _packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) { unawaited(_flushPacketCaptureLogs()); }); unawaited(_flushPacketCaptureLogs()); } String _packetLogSignature(BlePacketLog log) { final prefix = log.rawData.length <= 12 ? log.rawData : log.rawData.sublist(0, 12); final prefixHex = prefix .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); return '${log.timestamp.microsecondsSinceEpoch}|' '${log.direction.name}|${log.responseCode ?? -1}|' '${log.rawData.length}|$prefixHex'; } Future _flushPacketCaptureLogs() async { if (_isPersistingPacketCapture) return; _isPersistingPacketCapture = true; try { final logs = connectionProvider.bleService.packetLogs; if (logs.isEmpty) return; List toPersist = const []; if (_lastPersistedPacketSignature == null) { toPersist = logs; } else { final lastSig = _lastPersistedPacketSignature!; var lastIndex = -1; for (var i = logs.length - 1; i >= 0; i--) { if (_packetLogSignature(logs[i]) == lastSig) { lastIndex = i; break; } } if (lastIndex == -1) { // In-memory log rotated or cleared; persist current window to avoid gaps. toPersist = logs; } else if (lastIndex < logs.length - 1) { toPersist = logs.sublist(lastIndex + 1); } } if (toPersist.isNotEmpty) { await packetCaptureStorageService.appendLogs(toPersist); } _lastPersistedPacketSignature = _packetLogSignature(logs.last); } catch (e) { debugPrint('❌ [AppProvider] Packet capture flush failed: $e'); } finally { _isPersistingPacketCapture = false; } } /// Sync drawings from messages on app startup (before BLE connection) Future _syncDrawingsOnStartup() async { // Wait for MessagesProvider to finish initializing // DrawingProvider loads around the same time int attempts = 0; while (!messagesProvider.isInitialized && attempts < 20) { await Future.delayed(const Duration(milliseconds: 50)); attempts++; } // Give DrawingProvider a moment to finish loading too await Future.delayed(const Duration(milliseconds: 100)); _restoreSessionMetadataFromMessages(); debugPrint( '🔄 [AppProvider] Early sync: syncing drawings from messages...', ); messagesProvider.syncDrawingsWithProvider(drawingProvider); } void _restoreSessionMetadataFromMessages() { final restored = restoreSessionMetadataFromMessages( messagesProvider.messages, ); _voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession); _imageSessionSenderKey6.addAll(restored.imageSenderKeyBySession); for (final entry in restored.imageEnvelopeBySession.entries) { imageProvider.registerEnvelope(entry.value); } final restoredVoice = restored.voiceSenderKeyBySession.length; final restoredImage = restored.imageEnvelopeBySession.length; if (restoredVoice > 0 || restoredImage > 0) { debugPrint( '🔄 [AppProvider] Restored session metadata from messages: ' '$restoredVoice voice, $restoredImage image', ); } } String? _resolveContactNameForNotification(Uint8List? publicKey) { if (publicKey == null || publicKey.isEmpty) return null; Contact? contact; if (publicKey.length >= 32) { contact = contactsProvider.findContactByKey(publicKey); } contact ??= publicKey.length >= 6 ? contactsProvider.findContactByPrefix( Uint8List.fromList(publicKey.sublist(0, 6)), ) : null; return contact?.advName; } /// Load map enabled setting from shared preferences Future _loadMapEnabled() async { try { final prefs = await SharedPreferences.getInstance(); _isMapEnabled = prefs.getBool('map_enabled') ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading map enabled setting: $e'); } } /// Toggle map on/off Future toggleMapEnabled(bool enabled) async { try { _isMapEnabled = enabled; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('map_enabled', enabled); notifyListeners(); } catch (e) { debugPrint('Error saving map enabled setting: $e'); } } /// Load contacts enabled setting from shared preferences Future _loadContactsEnabled() async { try { final prefs = await SharedPreferences.getInstance(); _isContactsEnabled = prefs.getBool('contacts_enabled') ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading contacts enabled setting: $e'); } } /// Toggle contacts tab on/off Future toggleContactsEnabled(bool enabled) async { try { _isContactsEnabled = enabled; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('contacts_enabled', enabled); notifyListeners(); } catch (e) { debugPrint('Error saving contacts enabled setting: $e'); } } /// Load sensors enabled setting from shared preferences Future _loadSensorsEnabled() async { try { final prefs = await SharedPreferences.getInstance(); _isSensorsEnabled = prefs.getBool('sensors_enabled') ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading sensors enabled setting: $e'); } } /// Toggle sensors tab on/off Future toggleSensorsEnabled(bool enabled) async { try { _isSensorsEnabled = enabled; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('sensors_enabled', enabled); notifyListeners(); } catch (e) { debugPrint('Error saving sensors enabled setting: $e'); } } /// Load voice silence trimming setting from shared preferences. Future _loadVoiceSilenceTrimmingEnabled() async { try { final prefs = await SharedPreferences.getInstance(); _isVoiceSilenceTrimmingEnabled = prefs.getBool('voice_silence_trimming_enabled') ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading voice silence trimming setting: $e'); } } /// Toggle voice silence trimming on/off. Future toggleVoiceSilenceTrimmingEnabled(bool enabled) async { try { _isVoiceSilenceTrimmingEnabled = enabled; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('voice_silence_trimming_enabled', enabled); notifyListeners(); } catch (e) { debugPrint('Error saving voice silence trimming setting: $e'); } } /// Load voice band-pass filter setting from shared preferences. Future _loadVoiceBandPassFilterEnabled() async { try { final prefs = await SharedPreferences.getInstance(); _isVoiceBandPassFilterEnabled = prefs.getBool('voice_band_pass_filter_enabled') ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading voice band-pass filter setting: $e'); } } /// Toggle voice band-pass filter on/off. Future toggleVoiceBandPassFilterEnabled(bool enabled) async { try { _isVoiceBandPassFilterEnabled = enabled; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('voice_band_pass_filter_enabled', enabled); notifyListeners(); } catch (e) { debugPrint('Error saving voice band-pass filter setting: $e'); } } /// Load voice compressor setting from shared preferences. Future _loadVoiceCompressorEnabled() async { try { final prefs = await SharedPreferences.getInstance(); _isVoiceCompressorEnabled = prefs.getBool('voice_compressor_enabled') ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading voice compressor setting: $e'); } } /// Toggle voice compressor on/off. Future toggleVoiceCompressorEnabled(bool enabled) async { try { _isVoiceCompressorEnabled = enabled; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('voice_compressor_enabled', enabled); notifyListeners(); } catch (e) { debugPrint('Error saving voice compressor setting: $e'); } } /// Load voice limiter setting from shared preferences. Future _loadVoiceLimiterEnabled() async { try { final prefs = await SharedPreferences.getInstance(); _isVoiceLimiterEnabled = prefs.getBool('voice_limiter_enabled') ?? true; notifyListeners(); } catch (e) { debugPrint('Error loading voice limiter setting: $e'); } } /// Toggle voice limiter on/off. Future toggleVoiceLimiterEnabled(bool enabled) async { try { _isVoiceLimiterEnabled = enabled; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('voice_limiter_enabled', enabled); notifyListeners(); } catch (e) { debugPrint('Error saving voice limiter setting: $e'); } } Future _loadMessageFontScale() async { try { final prefs = await SharedPreferences.getInstance(); _messageFontScale = (prefs.getDouble('message_font_scale') ?? 1.0).clamp( 0.85, 1.4, ); notifyListeners(); } catch (e) { debugPrint('Error loading message font scale setting: $e'); } } Future setMessageFontScale(double scale) async { try { _messageFontScale = scale.clamp(0.85, 1.4); final prefs = await SharedPreferences.getInstance(); await prefs.setDouble('message_font_scale', _messageFontScale); notifyListeners(); } catch (e) { debugPrint('Error saving message font scale setting: $e'); } } /// Load auto-add discovered contacts setting from shared preferences. Future _loadAutoAddDiscoveredContacts() async { try { final prefs = await SharedPreferences.getInstance(); _autoAddDiscoveredContacts = prefs.getBool('auto_add_discovered_contacts') ?? false; notifyListeners(); } catch (e) { debugPrint('Error loading auto-add discovered contacts setting: $e'); } } /// Toggle auto-add discovered contacts on/off. Future toggleAutoAddDiscoveredContacts(bool enabled) async { try { _autoAddDiscoveredContacts = enabled; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('auto_add_discovered_contacts', enabled); notifyListeners(); } catch (e) { debugPrint('Error saving auto-add discovered contacts setting: $e'); } } Future _loadMessagingRouteSettings() async { try { _autoRouteRotationEnabled = await MessagingRoutePreferences.getAutoRouteRotationEnabled(); _clearPathOnMaxRetry = await MessagingRoutePreferences.getClearPathOnMaxRetry(); _nearestRelayFallbackEnabled = await MessagingRoutePreferences.getNearestRelayFallbackEnabled(); 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'); } } 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 { // Initialize location tracking with BLE service await locationTrackingService.initialize(connectionProvider.bleService); // Setup callbacks locationTrackingService.onPositionUpdate = (position) { debugPrint( '📍 [AppProvider] Position updated: ${position.latitude}, ${position.longitude}', ); }; locationTrackingService.onBroadcastSent = (position) { debugPrint('📡 [AppProvider] Position broadcast to mesh network'); }; locationTrackingService.onError = (error) { debugPrint('❌ [AppProvider] Location tracking error: $error'); }; locationTrackingService.onTrackingStateChanged = (isTracking) { debugPrint( '🔄 [AppProvider] Location tracking state: ${isTracking ? "started" : "stopped"}', ); }; locationTrackingService.onFastLocationUpdate = (position, reason) { unawaited(_sendFastLocationUpdate(position, reason: reason)); }; debugPrint('✅ [AppProvider] Location tracking service initialized'); } catch (e) { debugPrint('❌ [AppProvider] Error initializing location tracking: $e'); } } /// Setup callbacks between providers void _setupCallbacks() { // Monitor connection state changes to start/stop location tracking connectionProvider.addListener(_handleConnectionStateChange); messagesProvider.resolveContactNameCallback = _resolveContactNameForNotification; messagesProvider.resolveChannelNameCallback = channelsProvider.getChannelDisplayName; voiceProvider.sendRawPacketCallback = ({ required Uint8List contactPath, required int contactPathLen, required Uint8List payload, }) async { await connectionProvider.sendRawVoicePacket( contactPath: contactPath, contactPathLen: contactPathLen, payload: payload, ); }; // Image raw-packet serving reuses the same BLE raw-data path as voice. imageProvider.sendRawPacketCallback = ({ required Uint8List contactPath, required int contactPathLen, required Uint8List payload, }) async { await connectionProvider.sendRawVoicePacket( contactPath: contactPath, contactPathLen: contactPathLen, payload: payload, ); }; // When a contact is received from BLE connectionProvider.onContactReceived = (contact) { // Pass device public key to filter out our own contact contactsProvider.addOrUpdateContact( contact, devicePublicKey: connectionProvider.deviceInfo.publicKey, ); unawaited(_pathHistoryService.recordLearnedPath(contact)); // Broadcast to SSE clients if server is running connectionProvider.broadcastContactToSseClients(contact); }; // When all contacts are received connectionProvider.onContactsComplete = (contacts) { // Pass device public key to filter out our own contact contactsProvider.addContacts( 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 for (final contact in contacts) { connectionProvider.broadcastContactToSseClients(contact); } }; // Setup callback for ConnectionProvider to query channel info connectionProvider.getChannelInfo = (int channelIdx) { return channelsProvider.getChannel(channelIdx); }; // When channel info is received connectionProvider.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) { try { debugPrint( '🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"', ); // Check if this is a channel deletion (empty name) if (channelName.isEmpty && channelIdx != 0) { debugPrint( ' 🗑️ Channel $channelIdx deleted - removing from providers', ); // Remove from ChannelsProvider channelsProvider.removeChannel(channelIdx); debugPrint(' ✅ Removed from ChannelsProvider'); // Remove from ContactsProvider using pseudo public key final publicKeyBytes = Uint8List(32); publicKeyBytes[0] = 0xFF; // Special marker for channels publicKeyBytes[1] = channelIdx; // Channel index final publicKeyHex = publicKeyBytes .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); contactsProvider.removeContact(publicKeyHex); debugPrint(' ✅ Removed from ContactsProvider'); return; } // Add/update in ChannelsProvider channelsProvider.addOrUpdateChannel( index: channelIdx, name: channelName, secret: secret, flags: flags, ); debugPrint(' ✅ Added to ChannelsProvider'); // Also add as Contact to ContactsProvider (for UI display) // Skip if it's public channel (already exists) debugPrint( '📻 [AppProvider] Channel $channelIdx: "$channelName" (isEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})', ); if (channelName.isNotEmpty && channelIdx != 0) { debugPrint( ' ✅ Adding channel $channelIdx to ContactsProvider as Contact', ); // Create a pseudo public key for the channel based on its index // Use channel index as a unique identifier (pad to 32 bytes) final publicKeyBytes = Uint8List(32); publicKeyBytes[0] = 0xFF; // Special marker for channels publicKeyBytes[1] = channelIdx; // Channel index final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; contactsProvider.addOrUpdateContact( Contact( publicKey: publicKeyBytes, type: ContactType.channel, flags: flags ?? 0, outPathLen: -1, // Flood mode for channels outPath: Uint8List(0), // Empty path for channels advName: channelName, lastAdvert: now, advLat: 0, // Channels don't have location advLon: 0, lastMod: now, isNew: false, // Don't mark channels as new ), ); debugPrint( ' ✅ Channel contact added. Total channels in ContactsProvider: ${contactsProvider.channels.length}', ); } else { debugPrint( ' ⏭️ Skipping channel $channelIdx (empty: ${channelName.isEmpty}, isPublic: ${channelIdx == 0})', ); } } catch (e, stackTrace) { debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e'); debugPrint(' Stack trace: $stackTrace'); } }; // When a message is received connectionProvider.onMessageReceived = (message) { // Enrich message with sender name from contacts first Message enrichedMessage = message; Contact? senderContact; if (message.senderPublicKeyPrefix != null && message.senderName == null) { final contact = contactsProvider.findContactByKey( message.senderPublicKeyPrefix!, ); if (contact != null) { senderContact = contact; enrichedMessage = message.copyWith(senderName: contact.advName); } } senderContact ??= message.senderPublicKeyPrefix != null ? contactsProvider.findContactByKey(message.senderPublicKeyPrefix!) : null; senderContact ??= enrichedMessage.senderName != null ? contactsProvider.contacts .where((c) => c.advName == enrichedMessage.senderName) .firstOrNull : null; final contactLocationSnapshot = senderContact != null ? contactsProvider.buildMessageContactLocationSnapshot( senderContact, capturedAt: enrichedMessage.receivedAt, ) : null; 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)) { debugPrint('🎨 [AppProvider] Drawing message received, parsing...'); // Extract sender name from message packet metadata final senderName = enrichedMessage.senderName ?? 'unknown'; final drawing = DrawingMessageParser.parseDrawingMessage( enrichedMessage.text, senderName: senderName, messageId: enrichedMessage.id, // Pass message ID for navigation linking ); if (drawing != null) { debugPrint( '🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}', ); debugPrint(' Drawing linked to message ID: ${enrichedMessage.id}'); drawingProvider.addReceivedDrawing(drawing); // Update message to mark as drawing and link to drawing ID final updatedMessage = enrichedMessage.copyWith( isDrawing: true, drawingId: drawing.id, ); // Add the drawing message to chat with drawing metadata // This allows users to click on the drawing message to navigate to it messagesProvider.addMessage( updatedMessage, contactLookup: (name) => '', contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); // Broadcast drawing message to SSE clients if server is running connectionProvider.broadcastMessageToSseClients(updatedMessage); } else { debugPrint('⚠️ [AppProvider] Failed to parse drawing message'); } return; } // Voice envelope message (new public/direct on-demand format). final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text); if (voiceEnvelope != null) { final senderPrefix = enrichedMessage.senderPublicKeyPrefix; if (senderPrefix != null && senderPrefix.length >= 6) { _voiceSessionSenderKey6[voiceEnvelope.sessionId] = senderPrefix .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join() .toLowerCase(); } voiceProvider.registerEnvelope(voiceEnvelope); enrichedMessage = enrichedMessage.copyWith( isVoice: true, voiceId: voiceEnvelope.sessionId, ); messagesProvider.addMessage( enrichedMessage, contactLookup: (name) { try { final contact = contactsProvider.contacts.firstWhere( (c) => c.advName == name, ); return contact.publicKeyHex.isNotEmpty && contact.publicKeyHex.length >= 12 ? contact.publicKeyHex.substring(0, 12) : ''; } catch (_) { return ''; } }, contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; } // Image envelope (IE1): announce image availability. final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text); if (imageEnvelope != null) { final senderPrefix = enrichedMessage.senderPublicKeyPrefix; if (senderPrefix != null && senderPrefix.length >= 6) { _imageSessionSenderKey6[imageEnvelope.sessionId] = senderPrefix .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join() .toLowerCase(); } imageProvider.registerEnvelope(imageEnvelope); messagesProvider.addMessage( enrichedMessage, contactLookup: (name) { try { final contact = contactsProvider.contacts.firstWhere( (c) => c.advName == name, ); return contact.publicKeyHex.isNotEmpty && contact.publicKeyHex.length >= 12 ? contact.publicKeyHex.substring(0, 12) : ''; } catch (_) { return ''; } }, contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; } // Pass contact lookup function to link channel messages with contacts messagesProvider.addMessage( enrichedMessage, contactLookup: (name) { // Find contact by name and return their public key hex (first 12 chars for 6 bytes) try { final contact = contactsProvider.contacts.firstWhere( (c) => c.advName == name, ); return contact.publicKeyHex.isNotEmpty && contact.publicKeyHex.length >= 12 ? contact.publicKeyHex.substring(0, 12) : ''; } catch (e) { // No matching contact found return ''; } }, contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); // Broadcast message to SSE clients if server is running connectionProvider.broadcastMessageToSseClients(enrichedMessage); }; // Keep a compact receive-time snapshot because packet logs roll over. // This lets the UI still show timing/link details after app restarts. // When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B) // Used by older firmware versions for telemetry responses connectionProvider.onTelemetryReceived = (publicKey, lppData) { debugPrint( '📊 [AppProvider] Telemetry response (0x8B) received - updating contact', ); contactsProvider.updateTelemetry(publicKey, lppData); }; // When binary response is received via PUSH_CODE_BINARY_RESPONSE (0x8C) // Used by newer firmware versions for telemetry and other binary data // BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) { debugPrint( '📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry', ); // Binary response tag 0 = telemetry data (Cayenne LPP format) // Other tags may be used for different data types in the future contactsProvider.updateTelemetry(publicKeyPrefix, responseData); }; // When raw binary data is received (PUSH_CODE_RAW_DATA 0x84) // Magic 0x6d 'm' = swarm control; 0x72 'r' = voice fetch request. // Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet. // Magic 0x49 'I' = image packet. connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { final fastGpsPacket = FastGpsPacket.tryParseBinary(payload); if (fastGpsPacket != null) { final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6); if (sender != null) { contactsProvider.updateFastGps( sender.publicKey.sublist(0, 6), fastGpsPacket, ); } return; } final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload); if (rawProbeRequest != null) { debugPrint( '📡 [AppProvider] Incoming raw route probe: nonce=${rawProbeRequest.nonce.toRadixString(16)} requester=${rawProbeRequest.requesterKey6}', ); _handleRawRouteProbeRequest(rawProbeRequest); return; } final rawProbeAck = RawRouteProbeAck.tryParseBinary(payload); if (rawProbeAck != null) { debugPrint( '📡 [AppProvider] Incoming raw route probe ACK: nonce=${rawProbeAck.nonce.toRadixString(16)}', ); _completeRawRouteProbeAck(rawProbeAck.nonce); return; } final mediaSwarmRequest = MediaSwarmRequest.tryParseBinary(payload); if (mediaSwarmRequest != null) { _handleIncomingMediaSwarmRequest(mediaSwarmRequest); return; } final mediaSwarmAvailability = MediaSwarmAvailability.tryParseBinary( payload, ); if (mediaSwarmAvailability != null) { _handleIncomingMediaSwarmAvailability(mediaSwarmAvailability); return; } final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload); if (voiceFetchRequest != null) { debugPrint( '🎙️ [AppProvider] Incoming voice fetch request: session=${voiceFetchRequest.sessionId} want=${voiceFetchRequest.want} requester=${voiceFetchRequest.requesterKey6}', ); final requester = _resolveVoiceFetchRequester(voiceFetchRequest); if (requester == null) { debugPrint( '⚠️ [AppProvider] Voice fetch requester contact not found (binary)', ); messagesProvider.logSystemMessage( text: 'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.', level: 'warning', ); return; } if (requester.routeHopCount > _maxDirectPayloadHops) { debugPrint( '⚠️ [AppProvider] Voice fetch requester too far: ${requester.routeHopCount} hops', ); messagesProvider.logSystemMessage( text: 'Cannot fetch voice for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).', level: 'warning', ); return; } unawaited(() async { final served = await voiceProvider.serveSessionTo( sessionId: voiceFetchRequest.sessionId, requester: requester, requestedIndices: voiceFetchRequest.want == 'missing' ? voiceFetchRequest.missingIndices.toSet() : null, ); if (served) { messagesProvider.recordMediaTransfer( sessionId: voiceFetchRequest.sessionId, mediaType: 'voice', requesterKey6: voiceFetchRequest.requesterKey6, requesterName: requester.advName, ); } }()); return; } final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload); if (imageFetchRequest != null) { final requester = _resolveImageFetchRequester(imageFetchRequest); if (requester == null) { debugPrint( '⚠️ [AppProvider] Image fetch requester contact not found (binary) ' 'for session ${imageFetchRequest.sessionId} / ' '${imageFetchRequest.requesterKey6}', ); messagesProvider.logSystemMessage( text: 'Cannot fetch image: requester contact is unknown. Add/sync contacts first.', level: 'warning', ); return; } if (requester.routeHopCount > _maxDirectPayloadHops) { debugPrint( '⚠️ [AppProvider] Image fetch requester too far: ' '${requester.routeHopCount} hops for session ' '${imageFetchRequest.sessionId}', ); messagesProvider.logSystemMessage( text: 'Cannot fetch image for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).', level: 'warning', ); return; } debugPrint( '📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} ' 'to ${requester.advName} via ${requester.routeHopCount} hop(s)', ); unawaited(() async { final served = await imageProvider.serveSessionTo( sessionId: imageFetchRequest.sessionId, requester: requester, requestedIndices: imageFetchRequest.want == 'missing' ? imageFetchRequest.missingIndices.toSet() : null, ); if (served) { messagesProvider.recordMediaTransfer( sessionId: imageFetchRequest.sessionId, mediaType: 'image', requesterKey6: imageFetchRequest.requesterKey6, requesterName: requester.advName, ); } }()); return; } if (ImagePacket.isImageBinary(payload)) { final frag = ImagePacket.tryParseBinary(payload); if (frag == null) return; debugPrint('📷 [AppProvider] Binary image fragment received: $frag'); final session = imageProvider.session(frag.sessionId); if (session == null && frag.total < 1) { debugPrint( '⚠️ [AppProvider] Dropping compact image fragment without envelope ' 'for session ${frag.sessionId}', ); return; } imageProvider.addFragment( session == null ? frag : ImagePacket( sessionId: frag.sessionId, format: session.format, index: frag.index, total: session.total, data: frag.data, ), width: session?.width ?? 0, height: session?.height ?? 0, ); _scheduleImageMissingRetry( frag.sessionId, justComplete: imageProvider.isComplete(frag.sessionId), ); return; } if (!VoicePacket.isVoiceBinary(payload)) return; final pkt = VoicePacket.tryParseBinary(payload); if (pkt == null) return; debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt'); final session = voiceProvider.session(pkt.sessionId); if (session == null && pkt.total < 1) { debugPrint( '⚠️ [AppProvider] Dropping compact voice packet without envelope ' 'for session ${pkt.sessionId}', ); return; } final justComplete = voiceProvider.addPacket( session == null ? pkt : VoicePacket( sessionId: pkt.sessionId, mode: session.mode, index: pkt.index, total: session.total, codec2Data: pkt.codec2Data, ), ); _scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete); // Insert or update the placeholder message in the chat list _handleIncomingVoicePacket(pkt, justComplete: justComplete); }; // When a contact's routing path is updated in the mesh network connectionProvider.onPathUpdated = (publicKey) { debugPrint( '🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', ); // Trigger a single contact fetch to get the updated path information // This is much more efficient than fetching all contacts // This happens asynchronously to avoid blocking the event handler Future.delayed(const Duration(milliseconds: 100), () { if (connectionProvider.deviceInfo.isConnected) { connectionProvider.getContact(publicKey); } }); }; // When an advertisement is received (PUSH_CODE_ADVERT 0x80) // This may be sent by the radio for existing contacts instead of PUSH_CODE_NEW_ADVERT (0x8A) connectionProvider.onAdvertReceived = (publicKey) { debugPrint( '📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', ); // Check if this is an existing contact that might have updated location final contact = contactsProvider.findContactByKey(publicKey); if (contact != null) { debugPrint( ' Existing contact "${contact.advName}" - fetching updated contact info (optimized)', ); // Trigger a single contact fetch to get the updated contact information // This is much more efficient than fetching all contacts Future.delayed(const Duration(milliseconds: 100), () { if (connectionProvider.deviceInfo.isConnected) { connectionProvider.getContact(publicKey); } }); } else { if (_autoAddDiscoveredContacts) { debugPrint(' Unknown contact - auto-add enabled, fetching details'); Future.delayed(const Duration(milliseconds: 100), () { if (connectionProvider.deviceInfo.isConnected) { connectionProvider.getContact(publicKey); } }); } else { contactsProvider.addPendingAdvert( publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey, ); debugPrint( ' Unknown contact - added to pending adverts list and waiting for details', ); } } }; // When firmware deletes a contact due to contacts table overflow (PUSH_CODE_CONTACT_DELETED 0x8F) connectionProvider.onContactDeleted = (publicKey) { final keyHex = publicKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); final contact = contactsProvider.findContactByKey(publicKey); final name = contact?.advName ?? keyHex.substring(0, 12); debugPrint('⚠️ [AppProvider] Contact deleted by firmware: $name'); contactsProvider.removeContact(keyHex); messagesProvider.logSystemMessage( text: 'Contact "$name" was removed — device contacts table is full', level: 'warning', ); }; // When firmware reports contacts storage is full (PUSH_CODE_CONTACTS_FULL 0x90) connectionProvider.onContactsFull = () { debugPrint('⚠️ [AppProvider] Contacts storage full'); messagesProvider.logSystemMessage( text: 'Device contacts storage is full. New contacts will overwrite old ones.', level: 'warning', ); }; // When a message is sent (RESP_CODE_SENT received) connectionProvider .onMessageSent = (messageId, expectedAckTag, suggestedTimeoutMs) { debugPrint( '📤 [AppProvider] Message sent - Message ID: $messageId, ACK tag: $expectedAckTag', ); messagesProvider.markMessageSent( messageId, expectedAckTag, suggestedTimeoutMs, ); }; // When a message is delivered (PUSH_CODE_SEND_CONFIRMED received) connectionProvider.onMessageDelivered = (ackCode, roundTripTimeMs) { debugPrint( '✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms', ); messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs); }; // When an echo is detected for a public channel message (PUSH_CODE_LOG_RX_DATA matched) connectionProvider .onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) { debugPrint( '🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount', ); 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 = ({ required contactPublicKey, required text, required messageId, required contact, retryAttempt = 0, }) async { return await connectionProvider.sendTextMessage( contactPublicKey: contactPublicKey, text: text, messageId: messageId, contact: contact, retryAttempt: retryAttempt, ); }; messagesProvider.onFinalRouterFallbackCallback = ({required messageId, required contact, required message}) async { return _sendWithFinalNearestRouterFallback( messageId: messageId, contact: contact, message: message, ); }; 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 { if (!_nearestRelayFallbackEnabled) { return false; } 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(); } 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; try { // Initialize contacts provider with device public key to exclude self // If already initialized (from early load), this will just filter out self-contact // This must happen before getContacts to ensure proper filtering await contactsProvider.initialize( devicePublicKey: connectionProvider.deviceInfo.publicKey, ); // Note: Device clock is automatically synced during connection in MeshCoreBleService // No need to sync it again here // Get battery and storage information await connectionProvider.getBatteryAndStorage(); // Load contacts await connectionProvider.getContacts(); // Small delay to ensure contacts are fully loaded await Future.delayed(const Duration(milliseconds: 500)); // Sync channels to get channel names // In simple mode: only sync first 5 channels for faster startup // In normal mode: sync all channels (up to device max) const channelsToSync = 5; debugPrint('📻 [AppProvider] Syncing channels (simple mode: max 5)...'); await connectionProvider.syncChannels(maxChannels: channelsToSync); debugPrint('✅ [AppProvider] Channel sync complete'); // Configure the default public channel (channel 0) // This must be done before sending any channel messages // Note: Some firmware versions may have this pre-configured debugPrint( '📻 [AppProvider] Configuring default public channel (channel 0)...', ); try { await connectionProvider.configureDefaultPublicChannel(); debugPrint('✅ [AppProvider] Public channel configured successfully'); } catch (e) { debugPrint( '⚠️ [AppProvider] Public channel configuration failed (may already be configured): $e', ); // Continue anyway - channel might already be configured in firmware } // Automatically login to all saved rooms await _autoLoginToRooms(); // FALLBACK: Sync messages once after connection to catch any missed push notifications // This handles the case where messages arrived while the app was disconnected debugPrint( '🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)', ); final initialMessageCount = await connectionProvider.syncAllMessages(); debugPrint( '📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)', ); // Note: Future messages are synced automatically via PUSH_CODE_MSG_WAITING events // Start location tracking AFTER all initialization is complete debugPrint( '📍 [AppProvider] Starting location tracking after successful initialization', ); await _startLocationTracking(); // Sync drawing messages with DrawingProvider // This restores any drawings that may be missing from storage debugPrint( '🎨 [AppProvider] Syncing drawing messages with DrawingProvider...', ); messagesProvider.syncDrawingsWithProvider(drawingProvider); notifyListeners(); } catch (e) { debugPrint('Initialization error: $e'); } } /// Automatically login to all rooms with saved passwords on cold connect Future _autoLoginToRooms() async { if (!connectionProvider.deviceInfo.isConnected) return; try { // Get all room contacts (excluding Public Channel) final rooms = contactsProvider.rooms .where((room) => !room.isPublicChannel) .toList(); if (rooms.isEmpty) { debugPrint('📂 [AppProvider] No rooms found to auto-login'); return; } debugPrint( '📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...', ); final prefs = await SharedPreferences.getInstance(); for (final room in rooms) { try { // Load saved password for this room final roomKey = 'room_password_${room.publicKeyHex}'; final savedPassword = prefs.getString(roomKey) ?? 'hello'; debugPrint( '🔑 [AppProvider] Auto-logging into room: ${room.advName}', ); // Set up one-time callbacks for this room login await _loginToRoomWithCallback(room, savedPassword); // Small delay between logins to avoid overwhelming the device await Future.delayed(const Duration(milliseconds: 300)); } catch (e) { debugPrint( '❌ [AppProvider] Failed to auto-login to ${room.advName}: $e', ); } } } catch (e) { debugPrint('❌ [AppProvider] Auto-login error: $e'); } } /// Login to a specific room with callback handling Future _loginToRoomWithCallback(Contact room, String password) async { // Create a completer to wait for login result final completer = Completer(); // Store original callbacks final originalOnSuccess = connectionProvider.onLoginSuccess; final originalOnFail = connectionProvider.onLoginFail; // Set up temporary callbacks connectionProvider .onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { // Restore original callbacks connectionProvider.onLoginSuccess = originalOnSuccess; connectionProvider.onLoginFail = originalOnFail; debugPrint('✅ [AppProvider] Auto-login successful for ${room.advName}'); debugPrint( '📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING', ); completer.complete(true); }; connectionProvider.onLoginFail = (publicKeyPrefix) { // Restore original callbacks connectionProvider.onLoginSuccess = originalOnSuccess; connectionProvider.onLoginFail = originalOnFail; debugPrint( '❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)', ); completer.complete(false); }; try { // Send login request await connectionProvider.loginToRoom( roomPublicKey: room.publicKey, password: password, ); // Wait for login result with timeout await completer.future.timeout( const Duration(seconds: 10), onTimeout: () { // Restore callbacks on timeout connectionProvider.onLoginSuccess = originalOnSuccess; connectionProvider.onLoginFail = originalOnFail; debugPrint('⏱️ [AppProvider] Auto-login timeout for ${room.advName}'); return false; }, ); } catch (e) { // Restore callbacks on error connectionProvider.onLoginSuccess = originalOnSuccess; connectionProvider.onLoginFail = originalOnFail; debugPrint( '❌ [AppProvider] Error during auto-login to ${room.advName}: $e', ); } } Contact? _resolveContactByPrefixHex(String prefixHex) { if (prefixHex.length != 12) return null; return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); } void setFastLocationUiActive(bool isActive) { if (_fastLocationScreenActive == isActive) return; _fastLocationScreenActive = isActive; locationTrackingService.setFastLocationActiveUse(isActive); } Future _sendFastLocationUpdate( dynamic position, { required String reason, }) async { if (!connectionProvider.deviceInfo.isConnected) { return; } final publicKey = connectionProvider.deviceInfo.publicKey; if (publicKey == null || publicKey.length < 6) { return; } final senderKey6 = publicKey .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); final packet = FastGpsPacket( senderKey6: senderKey6, latitude: position.latitude as double, longitude: position.longitude as double, timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); debugPrint( '📍 [AppProvider] Sending fast GPS update ($reason): ' '${position.latitude}, ${position.longitude}', ); try { await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary()); } catch (e) { debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e'); } } Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) { final liveContact = _resolveContactByPrefixHex(request.requesterKey6); if (liveContact != null) { return liveContact; } return _resolveRequesterFromSentMessages( sessionId: request.sessionId, requesterKey6: request.requesterKey6, tryParseEnvelope: VoiceEnvelope.tryParseText, mediaLabel: 'voice', ); } Contact? _resolveImageFetchRequester(ImageFetchRequest request) { final liveContact = _resolveContactByPrefixHex(request.requesterKey6); if (liveContact != null) { return liveContact; } return _resolveRequesterFromSentMessages( sessionId: request.sessionId, requesterKey6: request.requesterKey6, tryParseEnvelope: ImageEnvelope.tryParse, mediaLabel: 'image', ); } Contact? _resolveRequesterFromSentMessages({ required String sessionId, required String requesterKey6, required T? Function(String text) tryParseEnvelope, required String mediaLabel, }) { for (final message in messagesProvider.messages.reversed) { final envelope = tryParseEnvelope(message.text); if (envelope == null) { continue; } final envelopeSessionId = switch (envelope) { VoiceEnvelope voiceEnvelope => voiceEnvelope.sessionId, ImageEnvelope imageEnvelope => imageEnvelope.sessionId, _ => null, }; if (envelopeSessionId != sessionId) { continue; } final recipientKey = message.recipientPublicKey; if (recipientKey == null || recipientKey.isEmpty) { continue; } final recipient = contactsProvider.findContactByKey(recipientKey); if (recipient == null) { continue; } final recipientKey6 = recipient.publicKey .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); if (recipientKey6 != requesterKey6) { continue; } debugPrint( '${mediaLabel == 'voice' ? '🎙️' : '📷'} [AppProvider] Resolved ' '$mediaLabel requester from sent message metadata for session ' '$sessionId: ${recipient.advName}', ); return recipient; } return null; } String _mediaSwarmKey(String mediaType, String sessionId) => '$mediaType:$sessionId'; String? _deviceKey6Hex() { final deviceKey = connectionProvider.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { return null; } return deviceKey .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); } List _availableIndicesForSession(String mediaType, String sessionId) { return switch (mediaType) { 'voice' => voiceProvider.availablePacketIndices(sessionId), 'image' => imageProvider.availableFragmentIndices(sessionId), _ => const [], }; } List _matchingAvailableIndices(MediaSwarmRequest request) { final available = _availableIndicesForSession( request.mediaType, request.sessionId, ); if (available.isEmpty) return const []; if (request.requestsAll) return available; final requested = request.missingIndices.toSet(); return available.where(requested.contains).toList()..sort(); } List _eligibleSwarmPeers({String? excludeKey6}) { final ownKey6 = _deviceKey6Hex(); return contactsProvider.contacts.where((contact) { if (!contact.routeHasPath || contact.routeHopCount > _maxDirectPayloadHops || !contact.routeSupportsLegacyRawTransport || contact.outPath.isEmpty || contact.publicKey.length < 6) { return false; } final key6 = contact.publicKey .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); if (key6 == ownKey6 || key6 == excludeKey6) { return false; } return true; }).toList(); } void _handleIncomingMediaSwarmRequest(MediaSwarmRequest request) { final ownKey6 = _deviceKey6Hex(); if (ownKey6 == null || request.requesterKey6 == ownKey6) { return; } final available = _matchingAvailableIndices(request); if (available.isEmpty) { return; } final availability = MediaSwarmAvailability( mediaType: request.mediaType, sessionId: request.sessionId, requesterKey6: request.requesterKey6, responderKey6: ownKey6, availableIndices: available, ); debugPrint( '🌐 [AppProvider] Media swarm availability for ${request.mediaType} ' '${request.sessionId}: ${available.length} fragment(s)', ); final requester = _resolveContactByPrefixHex(request.requesterKey6); if (requester == null || !requester.routeHasPath || requester.routeHopCount > _maxDirectPayloadHops || !requester.routeSupportsLegacyRawTransport || requester.outPath.isEmpty) { return; } unawaited( connectionProvider.sendRawVoicePacket( contactPath: requester.outPath, contactPathLen: requester.routeSignedPathLen, payload: availability.encodeBinary(), ), ); } void _handleIncomingMediaSwarmAvailability( MediaSwarmAvailability availability, ) { final ownKey6 = _deviceKey6Hex(); if (ownKey6 == null || availability.requesterKey6 != ownKey6) { return; } final key = _mediaSwarmKey(availability.mediaType, availability.sessionId); final responses = _pendingMediaSwarmResponses[key]; if (responses == null) { return; } responses[availability.responderKey6] = availability; debugPrint( '🌐 [AppProvider] Media swarm response for ${availability.mediaType} ' '${availability.sessionId} from ${availability.responderKey6} ' '(${availability.servesAll ? 'all' : availability.availableIndices.length})', ); } Future _requestMissingMediaViaSwarm({ required String mediaType, required String sessionId, required List missingIndices, required String? originalSenderKey6, }) async { if (!connectionProvider.deviceInfo.isConnected || missingIndices.isEmpty) { return false; } final key = _mediaSwarmKey(mediaType, sessionId); final pending = _pendingMediaSwarmFetches[key]; if (pending != null) { return pending; } final requesterKey6 = _deviceKey6Hex(); if (requesterKey6 == null) { return false; } final future = () async { final responses = {}; _pendingMediaSwarmResponses[key] = responses; try { final request = MediaSwarmRequest( mediaType: mediaType, sessionId: sessionId, requesterKey6: requesterKey6, missingIndices: missingIndices, ); final peers = _eligibleSwarmPeers(excludeKey6: originalSenderKey6); if (peers.isEmpty) { return false; } debugPrint( '🌐 [AppProvider] Media swarm request for $mediaType $sessionId ' '(${missingIndices.length} needed fragment(s), ${peers.length} peer(s))', ); for (final peer in peers) { await connectionProvider.sendRawVoicePacket( contactPath: peer.outPath, contactPathLen: peer.routeSignedPathLen, payload: request.encodeBinary(), ); } await Future.delayed(_mediaSwarmResponseWindow); final orderedResponses = responses.values .where( (response) => response.responderKey6 != originalSenderKey6, ) .toList() ..sort((a, b) { final aScore = _swarmResponseScore(a, missingIndices); final bScore = _swarmResponseScore(b, missingIndices); return bScore.compareTo(aScore); }); for (final response in orderedResponses) { final responder = _resolveContactByPrefixHex(response.responderKey6); if (responder == null || !responder.routeHasPath || responder.routeHopCount > _maxDirectPayloadHops || !responder.routeSupportsLegacyRawTransport || responder.outPath.isEmpty) { continue; } final requestedSubset = response.servesAll ? missingIndices : missingIndices .where(response.availableIndices.toSet().contains) .toList(); if (requestedSubset.isEmpty) { continue; } final requestedSet = requestedSubset.toSet(); final sent = await _sendDirectMediaFetchRequest( mediaType: mediaType, sessionId: sessionId, target: responder, requesterKey6: requesterKey6, missingIndices: requestedSet, ); if (sent) { debugPrint( '🌐 [AppProvider] Requested $mediaType $sessionId ' 'from swarm peer ${responder.advName} ' '(${requestedSubset.length} fragment(s))', ); return true; } } return false; } catch (e) { debugPrint( '⚠️ [AppProvider] Media swarm request failed for $mediaType ' '$sessionId: $e', ); return false; } finally { _pendingMediaSwarmResponses.remove(key); } }(); _pendingMediaSwarmFetches[key] = future; try { return await future; } finally { _pendingMediaSwarmFetches.remove(key); } } int _swarmResponseScore( MediaSwarmAvailability response, List missingIndices, ) { if (response.servesAll) { return missingIndices.length; } final needed = missingIndices.toSet(); return response.availableIndices.where(needed.contains).length; } Future _sendDirectMediaFetchRequest({ required String mediaType, required String sessionId, required Contact target, required String requesterKey6, required Set missingIndices, }) async { try { final payload = switch (mediaType) { 'voice' => VoiceFetchRequest( sessionId: sessionId, want: missingIndices.isEmpty ? 'all' : 'missing', missingIndices: missingIndices.toList()..sort(), requesterKey6: requesterKey6, ).encodeBinary(), 'image' => ImageFetchRequest( sessionId: sessionId, want: missingIndices.isEmpty ? 'all' : 'missing', missingIndices: missingIndices.toList()..sort(), requesterKey6: requesterKey6, ).encodeBinary(), _ => null, }; if (payload == null) { return false; } await connectionProvider.sendRawVoicePacket( contactPath: target.outPath, contactPathLen: target.routeSignedPathLen, payload: payload, ); return true; } catch (e) { debugPrint( '⚠️ [AppProvider] Direct $mediaType fetch via ${target.advName} failed: $e', ); return false; } } void _scheduleVoiceMissingRetry( String sessionId, { required bool justComplete, }) { if (voiceProvider.isReceiveCanceled(sessionId)) { _clearVoiceMissingRetry(sessionId); return; } if (justComplete || voiceProvider.isComplete(sessionId)) { _clearVoiceMissingRetry(sessionId); return; } _voiceMissingRetryAttempts[sessionId] = 0; _voiceMissingRetryTimers[sessionId]?.cancel(); _voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { unawaited(_requestMissingVoicePackets(sessionId)); }); } void _scheduleImageMissingRetry( String sessionId, { required bool justComplete, }) { if (imageProvider.isReceiveCanceled(sessionId)) { _clearImageMissingRetry(sessionId); return; } if (justComplete || imageProvider.isComplete(sessionId)) { _clearImageMissingRetry(sessionId); return; } _imageMissingRetryAttempts[sessionId] = 0; _imageMissingRetryTimers[sessionId]?.cancel(); _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { unawaited(_requestMissingImageFragments(sessionId)); }); } void _clearVoiceMissingRetry(String sessionId) { _voiceMissingRetryTimers.remove(sessionId)?.cancel(); _voiceMissingRetryAttempts.remove(sessionId); } void _clearImageMissingRetry(String sessionId) { _imageMissingRetryTimers.remove(sessionId)?.cancel(); _imageMissingRetryAttempts.remove(sessionId); } Future _requestMissingVoicePackets(String sessionId) async { if (voiceProvider.isReceiveCanceled(sessionId)) { _clearVoiceMissingRetry(sessionId); return; } if (voiceProvider.isComplete(sessionId)) { _clearVoiceMissingRetry(sessionId); return; } final attempt = _voiceMissingRetryAttempts[sessionId] ?? 0; if (attempt >= _maxPacketRetryAttempts) { debugPrint( '⚠️ [AppProvider] Voice re-request limit reached for $sessionId', ); _clearVoiceMissingRetry(sessionId); return; } final senderKey6 = _voiceSessionSenderKey6[sessionId]; if (senderKey6 == null) return; final sender = _resolveContactByPrefixHex(senderKey6); final requesterKey6 = _deviceKey6Hex(); if (requesterKey6 == null) return; final missing = voiceProvider.missingPacketIndices(sessionId); if (missing.isEmpty) { _clearVoiceMissingRetry(sessionId); return; } var sent = false; if (sender != null) { final routeOk = await verifyRawTransportRoute(sender); if (routeOk) { sent = await _sendDirectMediaFetchRequest( mediaType: 'voice', sessionId: sessionId, target: sender, requesterKey6: requesterKey6, missingIndices: missing.toSet(), ); } } if (!sent) { sent = await _requestMissingMediaViaSwarm( mediaType: 'voice', sessionId: sessionId, missingIndices: missing, originalSenderKey6: senderKey6, ); } if (!sent) { return; } _voiceMissingRetryAttempts[sessionId] = attempt + 1; _voiceMissingRetryTimers[sessionId]?.cancel(); _voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { unawaited(_requestMissingVoicePackets(sessionId)); }); } Future _requestMissingImageFragments(String sessionId) async { if (imageProvider.isReceiveCanceled(sessionId)) { _clearImageMissingRetry(sessionId); return; } if (imageProvider.isComplete(sessionId)) { _clearImageMissingRetry(sessionId); return; } final attempt = _imageMissingRetryAttempts[sessionId] ?? 0; if (attempt >= _maxPacketRetryAttempts) { debugPrint( '⚠️ [AppProvider] Image re-request limit reached for $sessionId', ); _clearImageMissingRetry(sessionId); return; } final senderKey6 = _imageSessionSenderKey6[sessionId]; if (senderKey6 == null) return; final sender = _resolveContactByPrefixHex(senderKey6); final requesterKey6 = _deviceKey6Hex(); if (requesterKey6 == null) return; final missing = imageProvider.missingFragmentIndices(sessionId); if (missing.isEmpty) { _clearImageMissingRetry(sessionId); return; } var sent = false; if (sender != null) { final routeOk = await verifyRawTransportRoute(sender); if (routeOk) { sent = await _sendDirectMediaFetchRequest( mediaType: 'image', sessionId: sessionId, target: sender, requesterKey6: requesterKey6, missingIndices: missing.toSet(), ); } } if (!sent) { sent = await _requestMissingMediaViaSwarm( mediaType: 'image', sessionId: sessionId, missingIndices: missing, originalSenderKey6: senderKey6, ); } if (!sent) { return; } _imageMissingRetryAttempts[sessionId] = attempt + 1; _imageMissingRetryTimers[sessionId]?.cancel(); _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { unawaited(_requestMissingImageFragments(sessionId)); }); } /// Insert or update a voice placeholder message for binary raw-data packets. /// /// Binary voice packets arrive without a chat message, so we synthesise one /// to give the user a playable bubble in the message list. void _handleIncomingVoicePacket( VoicePacket pkt, { required bool justComplete, }) { final sessionId = pkt.sessionId; // Check if a placeholder for this session already exists final existing = messagesProvider.messages .where((m) => m.isVoice && m.voiceId == sessionId) .firstOrNull; if (existing != null) { // Already have a placeholder — no need to add another return; } // First packet of a new session: insert placeholder message final msgId = 'voice_$sessionId'; final placeholder = Message( id: msgId, messageType: MessageType.contact, // direct contact (binary only) senderPublicKeyPrefix: null, pathLen: 0, textType: MessageTextType.plain, senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, // Persist the first real packet in legacy V: text form so UI/debug paths // can reconstruct packet metadata from actual data. text: pkt.encodeText(), receivedAt: DateTime.now(), deliveryStatus: MessageDeliveryStatus.received, isVoice: true, voiceId: sessionId, ); messagesProvider.addMessage(placeholder, contactLookup: (_) => ''); } String _rawProbeKey(int nonce) => nonce.toRadixString(16).padLeft(8, '0').toLowerCase(); Future verifyRawTransportRoute( Contact target, { Duration timeout = const Duration(seconds: 8), }) async { if (!connectionProvider.deviceInfo.isConnected) { return false; } if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) { return false; } if (!target.routeSupportsLegacyRawTransport) { return false; } if (target.outPath.isEmpty) { return false; } final probeKey = _routeProbeTargetKey(target); final pendingProbe = _pendingRawRouteProbes[probeKey]; if (pendingProbe != null) { return pendingProbe; } final deviceKey = connectionProvider.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { return false; } final requesterKey6 = deviceKey .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); final future = () async { final nonce = math.Random.secure().nextInt(0x100000000); final ackFuture = _rawProbeWaiters.waitFor( _rawProbeKey(nonce), timeout: timeout, ); try { debugPrint( '📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.routeHopCount} nonce=${nonce.toRadixString(16)}', ); await connectionProvider.sendRawVoicePacket( contactPath: target.outPath, contactPathLen: target.routeSignedPathLen, payload: RawRouteProbeRequest( nonce: nonce, requesterKey6: requesterKey6, ).encodeBinary(), ); return await ackFuture; } catch (e) { debugPrint( '⚠️ [AppProvider] Raw route probe failed for ${target.advName}: $e', ); _rawProbeWaiters.complete(_rawProbeKey(nonce)); return false; } }(); _pendingRawRouteProbes[probeKey] = future; try { return await future; } finally { _pendingRawRouteProbes.remove(probeKey); } } String _routeProbeTargetKey(Contact target) { if (target.publicKeyHex.isNotEmpty) { return 'pk:${target.publicKeyHex}'; } return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; } void _handleRawRouteProbeRequest(RawRouteProbeRequest request) { final requester = _resolveContactByPrefixHex(request.requesterKey6); if (requester == null) { debugPrint( '⚠️ [AppProvider] Raw route probe requester not found: ${request.requesterKey6}', ); return; } if (!requester.routeHasPath || requester.routeHopCount > _maxDirectPayloadHops) { debugPrint( '⚠️ [AppProvider] Raw route probe requester out of range: ${requester.routeHopCount}', ); return; } if (!requester.routeSupportsLegacyRawTransport) { return; } if (requester.outPath.isEmpty) { return; } debugPrint( '📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.routeHopCount} nonce=${request.nonce.toRadixString(16)}', ); unawaited( connectionProvider.sendRawVoicePacket( contactPath: requester.outPath, contactPathLen: requester.routeSignedPathLen, payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), ), ); } void _completeRawRouteProbeAck(int nonce) { _rawProbeWaiters.complete(_rawProbeKey(nonce)); } MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) { final matchedRxLog = _findBestMatchingRxLog(message); final estimatedTx = estimateMessageTransmitDuration( message, radioBw: connectionProvider.deviceInfo.radioBw, radioSf: connectionProvider.deviceInfo.radioSf, radioCr: connectionProvider.deviceInfo.radioCr, ); final senderToReceiptMs = _senderToReceiptMs(message); final estimatedTransmitMs = sanitizeEstimatedTransmitMs( estimatedTransmitMs: estimatedTx > Duration.zero ? estimatedTx.inMilliseconds : null, senderToReceiptMs: senderToReceiptMs, ); final postTransmitDelayMs = senderToReceiptMs != null && estimatedTransmitMs != null ? (senderToReceiptMs - estimatedTransmitMs).clamp(0, 86400000).toInt() : null; if (matchedRxLog == null && senderToReceiptMs == null && estimatedTransmitMs == null) { return null; } return MessageReceptionDetails( capturedAt: DateTime.now(), packetLoggedAt: matchedRxLog?.timestamp, rssiDbm: matchedRxLog?.logRxDataInfo?.rssiDbm, snrDb: matchedRxLog?.logRxDataInfo?.snrDb, pathBytes: _extractPathBytesFromLog(matchedRxLog), senderToReceiptMs: senderToReceiptMs, estimatedTransmitMs: estimatedTransmitMs, postTransmitDelayMs: postTransmitDelayMs, ); } int? _senderToReceiptMs(Message message) { if (message.senderTimestamp <= 0) return null; final senderAt = DateTime.fromMillisecondsSinceEpoch( message.senderTimestamp * 1000, isUtc: true, ); final deltaMs = message.receivedAt .toUtc() .difference(senderAt) .inMilliseconds; if (deltaMs < 0 || deltaMs > 86400000) return null; return deltaMs; } BlePacketLog? _findBestMatchingRxLog(Message message) { if (message.pathLen < 0 || message.pathLen >= 255) return null; final expectedPayloadType = message.messageType == MessageType.channel ? 0x05 : 0x02; BlePacketLog? bestLog; var bestDeltaMs = 999999999; for (final log in connectionProvider.bleService.packetLogs) { if (log.responseCode != 0x88) continue; if (log.rawData.length < 6) continue; final raw = log.rawData; final payloadType = (raw[3] >> 2) & 0x0F; final pathLen = raw[4]; if (payloadType != expectedPayloadType) continue; if (pathLen != message.pathLen) continue; if (raw.length < 5 + pathLen) continue; final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); if (deltaMs < bestDeltaMs) { bestDeltaMs = deltaMs; bestLog = log; } } if (bestDeltaMs > 30000) return null; return bestLog; } List? _extractPathBytesFromLog(BlePacketLog? log) { if (log == null) return null; final raw = log.rawData; if (raw.length < 6) return null; final pathLen = raw[4]; if (pathLen <= 0 || raw.length < 5 + pathLen) return null; return raw.sublist(5, 5 + pathLen); } // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // The ConnectionProvider's onMessageWaiting callback handles automatic message fetching /// Refresh data (contacts and channels - messages are handled via events) Future refresh() async { if (!connectionProvider.deviceInfo.isConnected) return; try { // Sync contacts await connectionProvider.getContacts(); // Sync channels (respect simple mode settings) const channelsToSync = 5; await connectionProvider.syncChannels(maxChannels: channelsToSync); // Messages are automatically synced via PUSH_CODE_MSG_WAITING events notifyListeners(); } catch (e) { debugPrint('Refresh error: $e'); } } /// Manually sync messages (only for explicit user pull-to-refresh) /// Note: Messages are automatically synced via PUSH_CODE_MSG_WAITING events /// This method should ONLY be called when the user explicitly pulls to refresh Future syncMessages() async { if (!connectionProvider.deviceInfo.isConnected) return 0; try { debugPrint( '🔄 [AppProvider] Manual message sync requested (user initiated)', ); final messageCount = await connectionProvider.syncAllMessages(); debugPrint( '✅ [AppProvider] Manual sync completed: $messageCount messages', ); notifyListeners(); return messageCount; } catch (e) { debugPrint('❌ [AppProvider] Message sync error: $e'); return 0; } } /// Handle connection state changes to manage location tracking void _handleConnectionStateChange() { final isConnected = connectionProvider.deviceInfo.isConnected; final wasTracking = locationTrackingService.isTracking; // Only stop tracking on disconnect - DON'T start on connect // Location tracking will be started AFTER initialization completes if (!isConnected && wasTracking) { // Connection lost - stop location tracking debugPrint( '🔴 [AppProvider] BLE disconnected - stopping location tracking', ); _stopLocationTracking(); } } /// Start location tracking Future _startLocationTracking() async { try { final started = await locationTrackingService.startTracking(); if (started) { debugPrint('✅ [AppProvider] Location tracking started successfully'); } else { debugPrint('⚠️ [AppProvider] Failed to start location tracking'); } } catch (e) { debugPrint('❌ [AppProvider] Error starting location tracking: $e'); } } /// Stop location tracking Future _stopLocationTracking() async { try { await locationTrackingService.stopTracking(); debugPrint('✅ [AppProvider] Location tracking stopped'); } catch (e) { debugPrint('❌ [AppProvider] Error stopping location tracking: $e'); } } /// Clear all data void clearAllData() { contactsProvider.clearContacts(); messagesProvider.clearAll(); unawaited(voiceProvider.clearStoredVoiceData()); unawaited(imageProvider.clearAll()); for (final timer in _voiceMissingRetryTimers.values) { timer.cancel(); } for (final timer in _imageMissingRetryTimers.values) { timer.cancel(); } _voiceMissingRetryTimers.clear(); _voiceMissingRetryAttempts.clear(); _imageMissingRetryTimers.clear(); _imageMissingRetryAttempts.clear(); _voiceSessionSenderKey6.clear(); _imageSessionSenderKey6.clear(); notifyListeners(); } /// Get app statistics Map get statistics { return { 'connection': { 'isConnected': connectionProvider.deviceInfo.isConnected, 'deviceName': connectionProvider.deviceInfo.deviceName, 'battery': connectionProvider.deviceInfo.batteryPercent, }, 'contacts': contactsProvider.contactCounts, 'messages': messagesProvider.messageStats, 'sarMarkers': messagesProvider.sarMarkerStats, }; } @override void dispose() { _packetCaptureFlushTimer?.cancel(); unawaited(_flushPacketCaptureLogs()); // Remove connection state listener connectionProvider.removeListener(_handleConnectionStateChange); // Clear location service callbacks locationTrackingService.onPositionUpdate = null; locationTrackingService.onBroadcastSent = null; locationTrackingService.onError = null; locationTrackingService.onTrackingStateChanged = null; locationTrackingService.onFastLocationUpdate = null; // Dispose the location tracking service to stop GPS stream and clean up resources locationTrackingService.dispose(); for (final timer in _voiceMissingRetryTimers.values) { timer.cancel(); } for (final timer in _imageMissingRetryTimers.values) { timer.cancel(); } super.dispose(); } }