Files
meshcore-sar_android/lib/providers/app_provider.dart
2026-03-14 10:24:14 +01:00

3117 lines
104 KiB
Dart

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';
import '../utils/log_rx_route_decoder.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;
@visibleForTesting
static bool isDeletedChannelInfo(
int channelIdx,
String channelName,
Uint8List secret,
) {
return channelIdx != 0 &&
channelName.isEmpty &&
secret.every((byte) => byte == 0);
}
@visibleForTesting
static String channelContactName(int channelIdx, String channelName) {
return channelName.isEmpty && channelIdx != 0
? 'Channel $channelIdx'
: channelName;
}
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;
bool _isVoiceAutoGainEnabled = false;
bool get isVoiceAutoGainEnabled => _isVoiceAutoGainEnabled;
bool _isVoiceEchoCancellationEnabled = false;
bool get isVoiceEchoCancellationEnabled => _isVoiceEchoCancellationEnabled;
bool _isVoiceNoiseSuppressionEnabled = false;
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
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<String, _DirectMessageRouteSession> _directMessageRouteSessions =
{};
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
static const int _maxPacketRetryAttempts = 4;
final Map<String, String> _voiceSessionSenderKey6 = {};
final Map<String, String> _imageSessionSenderKey6 = {};
final Map<String, Timer> _voiceMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _imageMissingRetryAttempts = {};
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
final Map<String, Map<String, MediaSwarmAvailability>>
_pendingMediaSwarmResponses = {};
bool _fastLocationScreenActive = false;
Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
bool _wasDeviceConnected = false;
bool _hasCompletedConnectionBootstrap = false;
bool _isReconnectSyncInProgress = false;
AppProvider({
required this.connectionProvider,
required this.contactsProvider,
required this.messagesProvider,
required this.drawingProvider,
required this.channelsProvider,
required this.voiceProvider,
required this.imageProvider,
}) {
_setupCallbacks();
connectionProvider.canStartAutomaticMessageSyncCallback =
_canStartAutomaticMessageSync;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
_initializeLocationTracking();
_loadMapEnabled();
_loadContactsEnabled();
_loadSensorsEnabled();
_loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled();
_loadVoiceLimiterEnabled();
_loadVoiceAutoGainEnabled();
_loadVoiceEchoCancellationEnabled();
_loadVoiceNoiseSuppressionEnabled();
_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<void> _flushPacketCaptureLogs() async {
if (_isPersistingPacketCapture) return;
_isPersistingPacketCapture = true;
try {
final logs = connectionProvider.bleService.packetLogs;
if (logs.isEmpty) return;
List<BlePacketLog> 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<void> _syncDrawingsOnStartup() async {
// Wait for both MessagesProvider and DrawingProvider to finish initializing
int attempts = 0;
while ((!messagesProvider.isInitialized || !drawingProvider.isInitialized) &&
attempts < 40) {
await Future.delayed(const Duration(milliseconds: 50));
attempts++;
}
_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;
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName = _preferredSelfDisplayName(
deviceName: connectionProvider.deviceInfo.deviceName,
selfName: connectionProvider.deviceInfo.selfName,
);
if (_matchesPublicKeyPrefix(publicKey, ownPublicKey) &&
ownName != null &&
ownName.trim().isNotEmpty) {
return ownName;
}
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;
}
static bool shouldIgnoreSelfReplay({
required Message message,
required Uint8List? ownPublicKey,
required String? ownName,
}) {
if ((!message.isContactMessage && !message.isChannelMessage) ||
message.pathLen > 0) {
return false;
}
if (_matchesPublicKeyPrefix(message.senderPublicKeyPrefix, ownPublicKey)) {
return true;
}
final trimmedSenderName = message.senderName?.trim();
final trimmedOwnName = ownName?.trim();
if (trimmedSenderName == null ||
trimmedSenderName.isEmpty ||
trimmedOwnName == null ||
trimmedOwnName.isEmpty) {
return false;
}
return trimmedSenderName == trimmedOwnName;
}
static String? preferredSelfDisplayName({
required String? deviceName,
required String? selfName,
}) => _preferredSelfDisplayName(deviceName: deviceName, selfName: selfName);
static String? _preferredSelfDisplayName({
required String? deviceName,
required String? selfName,
}) {
final trimmedSelfName = selfName?.trim();
if (trimmedSelfName != null && trimmedSelfName.isNotEmpty) {
return trimmedSelfName;
}
final trimmedDeviceName = deviceName?.trim();
if (trimmedDeviceName == null || trimmedDeviceName.isEmpty) {
return null;
}
if (trimmedDeviceName.toLowerCase().startsWith('meshcore-')) {
final stripped = trimmedDeviceName.substring('meshcore-'.length).trim();
if (stripped.isNotEmpty) {
return stripped;
}
}
return trimmedDeviceName;
}
static bool _matchesPublicKeyPrefix(
Uint8List? candidateKey,
Uint8List? ownPublicKey,
) {
if (candidateKey == null ||
candidateKey.isEmpty ||
ownPublicKey == null ||
ownPublicKey.isEmpty) {
return false;
}
final compareLength = candidateKey.length >= 6 && ownPublicKey.length >= 6
? 6
: (candidateKey.length < ownPublicKey.length
? candidateKey.length
: ownPublicKey.length);
if (compareLength <= 0) {
return false;
}
for (var index = 0; index < compareLength; index++) {
if (candidateKey[index] != ownPublicKey[index]) {
return false;
}
}
return true;
}
/// Load map enabled setting from shared preferences
Future<void> _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<void> 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<void> _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<void> 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<void> _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<void> 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<void> _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<void> 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<void> _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<void> 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<void> _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<void> 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<void> _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<void> 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<void> _loadVoiceAutoGainEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceAutoGainEnabled =
prefs.getBool('voice_auto_gain_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice auto gain setting: $e');
}
}
Future<void> toggleVoiceAutoGainEnabled(bool enabled) async {
try {
_isVoiceAutoGainEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_auto_gain_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice auto gain setting: $e');
}
}
Future<void> _loadVoiceEchoCancellationEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceEchoCancellationEnabled =
prefs.getBool('voice_echo_cancellation_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice echo cancellation setting: $e');
}
}
Future<void> toggleVoiceEchoCancellationEnabled(bool enabled) async {
try {
_isVoiceEchoCancellationEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_echo_cancellation_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice echo cancellation setting: $e');
}
}
Future<void> _loadVoiceNoiseSuppressionEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceNoiseSuppressionEnabled =
prefs.getBool('voice_noise_suppression_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice noise suppression setting: $e');
}
}
Future<void> toggleVoiceNoiseSuppressionEnabled(bool enabled) async {
try {
_isVoiceNoiseSuppressionEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_noise_suppression_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice noise suppression setting: $e');
}
}
Future<void> _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<void> 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<void> _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<void> 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<void> _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<void> toggleAutoRouteRotationEnabled(bool enabled) async {
try {
_autoRouteRotationEnabled = enabled;
await MessagingRoutePreferences.setAutoRouteRotationEnabled(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving auto route rotation setting: $e');
}
}
Future<void> 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<void> 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<void> _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 = (channelIdx) {
if (channelIdx == 0) {
return 'Public';
}
for (final channel in contactsProvider.channels) {
if (channel.publicKey.length > 1 &&
channel.publicKey[1] == channelIdx) {
return channel.displayName;
}
}
return channelsProvider.getChannelDisplayName(channelIdx);
};
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"',
);
final isDeletedChannel = connectionProvider
.shouldTreatChannelInfoAsDeleted(
channelIdx,
channelName,
secret,
);
final isEmptyChannelInfo = AppProvider.isDeletedChannelInfo(
channelIdx,
channelName,
secret,
);
final contactChannelName = AppProvider.channelContactName(
channelIdx,
channelName,
);
// Only treat the slot as deleted when firmware returns an empty
// name and a zeroed secret. Protected channels may still have an
// empty display name but remain fully configured.
if (isDeletedChannel) {
debugPrint(
' 🗑️ Channel $channelIdx deleted - removing from providers',
);
messagesProvider.clearChannelMessages(channelIdx);
debugPrint(' ✅ Cleared channel messages');
// 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;
}
if (isEmptyChannelInfo) {
debugPrint(
' ⏭️ Ignoring empty channel slot $channelIdx during sync',
);
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: "$contactChannelName" (rawNameEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})',
);
if (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: contactChannelName,
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 (isPublic: ${channelIdx == 0})',
);
}
} catch (e, stackTrace) {
debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e');
debugPrint(' Stack trace: $stackTrace');
}
};
// When a message is received
connectionProvider.onMessageReceived = (message) {
if (message.isChannelMessage) {
debugPrint(
'📥 [AppProvider] Channel message received: '
'id=${message.id}, '
'senderName=${message.senderName ?? "-"}, '
'senderKey=${message.senderKeyShort ?? "-"}, '
'pathLen=${message.pathLen}, '
'ts=${message.senderTimestamp}, '
'text=${message.text}',
);
}
if (AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: connectionProvider.deviceInfo.publicKey,
ownName: _preferredSelfDisplayName(
deviceName: connectionProvider.deviceInfo.deviceName,
selfName: connectionProvider.deviceInfo.selfName,
),
)) {
debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}');
return;
}
// 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 &&
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(':')}...',
);
unawaited(_retainAdvertRxPath(publicKey));
// 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,
);
};
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
return contactsProvider.findContactByKey(contactPublicKey);
};
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,
);
};
messagesProvider.onManualRetryPreparedCallback = (messageId) {
_directMessageRouteSessions.remove(messageId);
};
}
Future<Contact> _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<void> _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<void> _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<bool> _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<void> _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<void> _learnPathFromPublicMessage({
required Contact contact,
required List<int> pathBytes,
}) async {
final preferred = await RouteHashPreferences.getHashSize();
final inferredHashSize = _inferReceivedPathHashSize(
pathBytes,
preferredHashSize: preferred,
);
await _pathHistoryService.recordReceivedBytePath(
contact.publicKeyHex,
pathBytes,
inferredHashSize,
);
}
Future<void> _retainAdvertRxPath(Uint8List publicKey) async {
final decoded = _findBestMatchingAdvertRxRoute(publicKey);
if (decoded == null || decoded.pathBytes.isEmpty) {
return;
}
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
decoded.pathBytes,
hashSize: decoded.hashSize,
);
final reversedHopHashes = LogRxRouteDecoder.splitHopHashes(
reversedPathBytes,
hashSize: decoded.hashSize,
);
final parsedRoute = ContactRouteCodec.parse(
reversedHopHashes.join(','),
expectedHashSize: decoded.hashSize,
);
contactsProvider.retainReceivedRoute(
publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await _pathHistoryService.recordReceivedBytePath(
publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(),
decoded.pathBytes,
decoded.hashSize,
);
}
int _inferReceivedPathHashSize(
List<int> 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<void> initialize() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
_isReconnectSyncInProgress = true;
_hasCompletedConnectionBootstrap = false;
// 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 (waits for device to finish sending all contacts)
await connectionProvider.getContacts();
// Sync all channels so slot assignment and channel state mirror the device.
final channelsToSync = connectionProvider.deviceInfo.maxChannels;
debugPrint(
'📻 [AppProvider] Syncing channels (max: ${channelsToSync ?? 40})...',
);
await connectionProvider.syncChannels(maxChannels: channelsToSync);
debugPrint('✅ [AppProvider] Channel sync complete');
// 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(
force: true,
);
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);
_hasCompletedConnectionBootstrap = true;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
await _flushDeferredAutomaticMessageSync();
notifyListeners();
} catch (e) {
debugPrint('Initialization error: $e');
} finally {
_isReconnectSyncInProgress = false;
}
}
Future<void> _syncAfterReconnect({bool started = false}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
if (!started) {
if (_isReconnectSyncInProgress) {
return;
}
_isReconnectSyncInProgress = true;
}
try {
debugPrint(
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
);
await contactsProvider.initialize(
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await connectionProvider.getContacts();
final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint(
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
);
} catch (e) {
debugPrint('❌ [AppProvider] Reconnect sync error: $e');
} finally {
_hasCompletedConnectionBootstrap =
connectionProvider.deviceInfo.isConnected;
_isReconnectSyncInProgress = false;
await _flushDeferredAutomaticMessageSync();
}
}
/// Automatically login to all rooms with saved passwords on cold connect
Future<void> _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<void> _loginToRoomWithCallback(Contact room, String password) async {
// Create a completer to wait for login result
final completer = Completer<bool>();
// 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<void> _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<T>({
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<int> _availableIndicesForSession(String mediaType, String sessionId) {
return switch (mediaType) {
'voice' => voiceProvider.availablePacketIndices(sessionId),
'image' => imageProvider.availableFragmentIndices(sessionId),
_ => const <int>[],
};
}
List<int> _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<Contact> _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.outPath.isEmpty) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.routeEncodedPathLen,
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<bool> _requestMissingMediaViaSwarm({
required String mediaType,
required String sessionId,
required List<int> 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 = <String, MediaSwarmAvailability>{};
_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.routeEncodedPathLen,
payload: request.encodeBinary(),
);
}
await Future<void>.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.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<int> missingIndices,
) {
if (response.servesAll) {
return missingIndices.length;
}
final needed = missingIndices.toSet();
return response.availableIndices.where(needed.contains).length;
}
Future<bool> _sendDirectMediaFetchRequest({
required String mediaType,
required String sessionId,
required Contact target,
required String requesterKey6,
required Set<int> 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.routeEncodedPathLen,
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<void> _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<void> _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<bool> 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.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.routeEncodedPathLen,
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.routeEncodedPathLen}:${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.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.routeEncodedPathLen,
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 decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.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;
}
DecodedLogRxRoute? _findBestMatchingAdvertRxRoute(Uint8List publicKey) {
final publicKeyHex = publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
DecodedLogRxRoute? bestRoute;
var bestDeltaMs = 999999999;
final now = DateTime.now();
for (final log in connectionProvider.bleService.packetLogs) {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != 0x04) continue;
final senderHash = decoded.originalSenderHashHex;
if (senderHash == null || !publicKeyHex.startsWith(senderHash)) continue;
final deltaMs = (log.timestamp.difference(now).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestRoute = decoded;
}
}
if (bestDeltaMs > 30000) return null;
return bestRoute;
}
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) return null;
return decoded.pathBytes;
}
// 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<void> refresh() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
// Sync contacts
await connectionProvider.getContacts();
// Sync all channels so refresh reflects the full device state.
await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels,
);
// 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<int> syncMessages() async {
if (!connectionProvider.deviceInfo.isConnected) return 0;
try {
debugPrint(
'🔄 [AppProvider] Manual message sync requested (user initiated)',
);
final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
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 wasConnected = _wasDeviceConnected;
_wasDeviceConnected = 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();
}
if (!isConnected) {
connectionProvider.clearPendingAutomaticMessageSync();
}
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
_isReconnectSyncInProgress = true;
unawaited(_syncAfterReconnect(started: true));
}
}
bool _canStartAutomaticMessageSync() {
return connectionProvider.deviceInfo.isConnected &&
_hasCompletedConnectionBootstrap &&
!_isReconnectSyncInProgress;
}
Future<void> _flushDeferredAutomaticMessageSync() async {
if (!connectionProvider.hasPendingAutomaticMessageSync ||
!_canStartAutomaticMessageSync()) {
return;
}
debugPrint(
'🔄 [AppProvider] Running deferred automatic message sync after bootstrap',
);
await connectionProvider.syncAllMessages(force: true);
}
/// Start location tracking
Future<void> _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<void> _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<String, dynamic> 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();
}
}