mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
1509 lines
53 KiB
Dart
1509 lines
53 KiB
Dart
import 'dart:async';
|
|
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 '../services/tile_cache_service.dart';
|
|
import '../services/location_tracking_service.dart';
|
|
import '../services/packet_capture_storage_service.dart';
|
|
import '../models/contact.dart';
|
|
import '../models/message.dart';
|
|
import '../models/ble_packet_log.dart';
|
|
import '../utils/drawing_message_parser.dart';
|
|
import '../utils/voice_message_parser.dart';
|
|
import '../utils/image_message_parser.dart';
|
|
|
|
/// 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 TileCacheService tileCacheService;
|
|
final LocationTrackingService locationTrackingService =
|
|
LocationTrackingService();
|
|
final PacketCaptureStorageService packetCaptureStorageService =
|
|
PacketCaptureStorageService();
|
|
|
|
bool _isInitialized = false;
|
|
bool get isInitialized => _isInitialized;
|
|
|
|
bool _isSimpleMode = true;
|
|
bool get isSimpleMode => _isSimpleMode;
|
|
|
|
bool _isMapEnabled = true;
|
|
bool get isMapEnabled => _isMapEnabled;
|
|
|
|
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 _autoAddDiscoveredContacts = false;
|
|
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
|
|
|
|
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
|
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, Completer<void>> _voiceFragmentAckWaiters = {};
|
|
final Map<String, Completer<void>> _imageFragmentAckWaiters = {};
|
|
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,
|
|
required this.tileCacheService,
|
|
}) {
|
|
_setupCallbacks();
|
|
_initializeTileCache();
|
|
_initializeLocationTracking();
|
|
_loadSimpleMode();
|
|
_loadMapEnabled();
|
|
_loadVoiceSilenceTrimmingEnabled();
|
|
_loadVoiceBandPassFilterEnabled();
|
|
_loadVoiceCompressorEnabled();
|
|
_loadVoiceLimiterEnabled();
|
|
_loadAutoAddDiscoveredContacts();
|
|
_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 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));
|
|
|
|
debugPrint(
|
|
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
|
|
);
|
|
messagesProvider.syncDrawingsWithProvider(drawingProvider);
|
|
}
|
|
|
|
/// Load simple mode setting from shared preferences
|
|
Future<void> _loadSimpleMode() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
_isSimpleMode = prefs.getBool('simple_mode') ?? true;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
debugPrint('Error loading simple mode setting: $e');
|
|
}
|
|
}
|
|
|
|
/// Toggle simple mode on/off
|
|
Future<void> toggleSimpleMode(bool enabled) async {
|
|
try {
|
|
_isSimpleMode = enabled;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool('simple_mode', enabled);
|
|
notifyListeners();
|
|
} catch (e) {
|
|
debugPrint('Error saving simple mode setting: $e');
|
|
}
|
|
}
|
|
|
|
/// 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 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');
|
|
}
|
|
}
|
|
|
|
/// 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');
|
|
}
|
|
}
|
|
|
|
/// Initialize tile cache service
|
|
Future<void> _initializeTileCache() async {
|
|
try {
|
|
await tileCacheService.initialize();
|
|
debugPrint('Tile cache initialized');
|
|
} catch (e) {
|
|
debugPrint('Error initializing tile cache: $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"}',
|
|
);
|
|
};
|
|
|
|
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);
|
|
|
|
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,
|
|
);
|
|
};
|
|
voiceProvider.waitForFragmentAckCallback = ({
|
|
required sessionId,
|
|
required index,
|
|
timeout = const Duration(seconds: 8),
|
|
}) => _waitForVoiceFragmentAck(
|
|
sessionId: sessionId,
|
|
index: index,
|
|
timeout: timeout,
|
|
);
|
|
imageProvider.waitForFragmentAckCallback = ({
|
|
required sessionId,
|
|
required index,
|
|
timeout = const Duration(seconds: 8),
|
|
}) => _waitForImageFragmentAck(
|
|
sessionId: sessionId,
|
|
index: index,
|
|
timeout: timeout,
|
|
);
|
|
|
|
// 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,
|
|
);
|
|
|
|
// 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,
|
|
);
|
|
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;
|
|
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
|
final contact = contactsProvider.findContactByKey(
|
|
message.senderPublicKeyPrefix!,
|
|
);
|
|
if (contact != null) {
|
|
enrichedMessage = message.copyWith(senderName: contact.advName);
|
|
}
|
|
}
|
|
|
|
// 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) => '',
|
|
);
|
|
|
|
// 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) {
|
|
_voiceSessionSenderKey6[voiceEnvelope.sessionId] = voiceEnvelope
|
|
.senderKey6
|
|
.toLowerCase();
|
|
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 '';
|
|
}
|
|
},
|
|
);
|
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
|
return;
|
|
}
|
|
|
|
// Image envelope (IE1): announce image availability.
|
|
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
|
if (imageEnvelope != null) {
|
|
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope
|
|
.senderKey6
|
|
.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 '';
|
|
}
|
|
},
|
|
);
|
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
|
return;
|
|
}
|
|
|
|
// If it's a text-format voice packet, feed it to VoiceProvider
|
|
if (VoicePacket.isVoiceText(enrichedMessage.text)) {
|
|
final pkt = VoicePacket.tryParseText(enrichedMessage.text);
|
|
if (pkt != null) {
|
|
voiceProvider.addPacket(pkt);
|
|
// Mark the message with voice metadata before adding to chat
|
|
enrichedMessage = enrichedMessage.copyWith(
|
|
isVoice: true,
|
|
voiceId: pkt.sessionId,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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 '';
|
|
}
|
|
},
|
|
);
|
|
|
|
// Broadcast message to SSE clients if server is running
|
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
|
};
|
|
|
|
// 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 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
|
|
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
|
|
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
|
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
|
|
if (voiceFetchRequest != null) {
|
|
final requester = contactsProvider.findContactByPrefixHex(
|
|
voiceFetchRequest.requesterKey6,
|
|
);
|
|
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.outPathLen > _maxDirectPayloadHops) {
|
|
debugPrint(
|
|
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
|
|
);
|
|
messagesProvider.logSystemMessage(
|
|
text:
|
|
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
|
level: 'warning',
|
|
);
|
|
return;
|
|
}
|
|
unawaited(
|
|
voiceProvider.serveSessionTo(
|
|
sessionId: voiceFetchRequest.sessionId,
|
|
requester: requester,
|
|
requestedIndices: voiceFetchRequest.want == 'missing'
|
|
? voiceFetchRequest.missingIndices.toSet()
|
|
: null,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload);
|
|
if (imageFetchRequest != null) {
|
|
final requester = contactsProvider.findContactByPrefixHex(
|
|
imageFetchRequest.requesterKey6,
|
|
);
|
|
if (requester == null) {
|
|
debugPrint(
|
|
'⚠️ [AppProvider] Image fetch requester contact not found (binary)',
|
|
);
|
|
messagesProvider.logSystemMessage(
|
|
text:
|
|
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
|
|
level: 'warning',
|
|
);
|
|
return;
|
|
}
|
|
if (requester.outPathLen > _maxDirectPayloadHops) {
|
|
debugPrint(
|
|
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
|
|
);
|
|
messagesProvider.logSystemMessage(
|
|
text:
|
|
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
|
level: 'warning',
|
|
);
|
|
return;
|
|
}
|
|
unawaited(
|
|
imageProvider.serveSessionTo(
|
|
sessionId: imageFetchRequest.sessionId,
|
|
requester: requester,
|
|
requestedIndices: imageFetchRequest.want == 'missing'
|
|
? imageFetchRequest.missingIndices.toSet()
|
|
: null,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final voiceAck = VoiceFragmentAck.tryParseBinary(payload);
|
|
if (voiceAck != null) {
|
|
_completeVoiceFragmentAck(voiceAck.sessionId, voiceAck.index);
|
|
return;
|
|
}
|
|
|
|
final imageAck = ImageFragmentAck.tryParseBinary(payload);
|
|
if (imageAck != null) {
|
|
_completeImageFragmentAck(imageAck.sessionId, imageAck.index);
|
|
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);
|
|
imageProvider.addFragment(
|
|
frag,
|
|
width: session?.width ?? 0,
|
|
height: session?.height ?? 0,
|
|
);
|
|
_sendImageFragmentAck(frag);
|
|
return;
|
|
}
|
|
|
|
if (!VoicePacket.isVoiceBinary(payload)) return;
|
|
final pkt = VoicePacket.tryParseBinary(payload);
|
|
if (pkt == null) return;
|
|
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
|
final justComplete = voiceProvider.addPacket(pkt);
|
|
_sendVoiceFragmentAck(pkt);
|
|
_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);
|
|
};
|
|
|
|
// 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,
|
|
);
|
|
};
|
|
}
|
|
|
|
/// Initialize the app (load contacts, sync time, etc.)
|
|
Future<void> 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)
|
|
final channelsToSync = _isSimpleMode ? 5 : null;
|
|
debugPrint(
|
|
'📻 [AppProvider] Syncing channels${_isSimpleMode ? ' (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<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 _scheduleVoiceMissingRetry(
|
|
String sessionId, {
|
|
required bool justComplete,
|
|
}) {
|
|
if (justComplete || voiceProvider.isComplete(sessionId)) {
|
|
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
|
|
_voiceMissingRetryAttempts.remove(sessionId);
|
|
return;
|
|
}
|
|
|
|
_voiceMissingRetryAttempts[sessionId] = 0;
|
|
_voiceMissingRetryTimers[sessionId]?.cancel();
|
|
_voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
|
|
unawaited(_requestMissingVoicePackets(sessionId));
|
|
});
|
|
}
|
|
|
|
Future<void> _requestMissingVoicePackets(String sessionId) async {
|
|
if (voiceProvider.isComplete(sessionId)) {
|
|
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
|
|
_voiceMissingRetryAttempts.remove(sessionId);
|
|
return;
|
|
}
|
|
|
|
final attempt = _voiceMissingRetryAttempts[sessionId] ?? 0;
|
|
if (attempt >= _maxPacketRetryAttempts) {
|
|
debugPrint(
|
|
'⚠️ [AppProvider] Voice re-request limit reached for $sessionId',
|
|
);
|
|
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
|
|
return;
|
|
}
|
|
|
|
final senderKey6 = _voiceSessionSenderKey6[sessionId];
|
|
if (senderKey6 == null) return;
|
|
final sender = _resolveContactByPrefixHex(senderKey6);
|
|
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
|
if (sender == null || deviceKey == null || deviceKey.length < 6) return;
|
|
|
|
final missing = voiceProvider.missingPacketIndices(sessionId);
|
|
if (missing.isEmpty) {
|
|
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
|
|
_voiceMissingRetryAttempts.remove(sessionId);
|
|
return;
|
|
}
|
|
|
|
final requesterKey6 = deviceKey
|
|
.sublist(0, 6)
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
.join('');
|
|
|
|
final request = VoiceFetchRequest(
|
|
sessionId: sessionId,
|
|
want: 'missing',
|
|
missingIndices: missing,
|
|
requesterKey6: requesterKey6,
|
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
version: 2,
|
|
);
|
|
|
|
try {
|
|
await connectionProvider.sendRawVoicePacket(
|
|
contactPath: sender.outPath,
|
|
contactPathLen: sender.outPathLen,
|
|
payload: request.encodeBinary(),
|
|
);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
_voiceMissingRetryAttempts[sessionId] = attempt + 1;
|
|
_voiceMissingRetryTimers[sessionId]?.cancel();
|
|
_voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
|
|
unawaited(_requestMissingVoicePackets(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 _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
|
|
|
|
Future<bool> _waitForVoiceFragmentAck({
|
|
required String sessionId,
|
|
required int index,
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
final key = _fragmentAckKey(sessionId, index);
|
|
final completer = Completer<void>();
|
|
_voiceFragmentAckWaiters[key] = completer;
|
|
try {
|
|
await completer.future.timeout(timeout);
|
|
return true;
|
|
} catch (_) {
|
|
if (_voiceFragmentAckWaiters[key] == completer) {
|
|
_voiceFragmentAckWaiters.remove(key);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void _completeVoiceFragmentAck(String sessionId, int index) {
|
|
final key = _fragmentAckKey(sessionId, index);
|
|
final completer = _voiceFragmentAckWaiters.remove(key);
|
|
if (completer != null && !completer.isCompleted) {
|
|
completer.complete();
|
|
}
|
|
}
|
|
|
|
Future<bool> _waitForImageFragmentAck({
|
|
required String sessionId,
|
|
required int index,
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
final key = _fragmentAckKey(sessionId, index);
|
|
final completer = Completer<void>();
|
|
_imageFragmentAckWaiters[key] = completer;
|
|
try {
|
|
await completer.future.timeout(timeout);
|
|
return true;
|
|
} catch (_) {
|
|
if (_imageFragmentAckWaiters[key] == completer) {
|
|
_imageFragmentAckWaiters.remove(key);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void _completeImageFragmentAck(String sessionId, int index) {
|
|
final key = _fragmentAckKey(sessionId, index);
|
|
final completer = _imageFragmentAckWaiters.remove(key);
|
|
if (completer != null && !completer.isCompleted) {
|
|
completer.complete();
|
|
}
|
|
}
|
|
|
|
void _sendVoiceFragmentAck(VoicePacket packet) {
|
|
final senderKey6 = _voiceSessionSenderKey6[packet.sessionId];
|
|
if (senderKey6 == null) return;
|
|
final sender = _resolveContactByPrefixHex(senderKey6);
|
|
if (sender == null) return;
|
|
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
|
|
return;
|
|
}
|
|
unawaited(
|
|
connectionProvider.sendRawVoicePacket(
|
|
contactPath: sender.outPath,
|
|
contactPathLen: sender.outPathLen,
|
|
payload: VoiceFragmentAck(
|
|
sessionId: packet.sessionId,
|
|
index: packet.index,
|
|
).encodeBinary(),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _sendImageFragmentAck(ImagePacket fragment) {
|
|
final senderKey6 = _imageSessionSenderKey6[fragment.sessionId];
|
|
if (senderKey6 == null) return;
|
|
final sender = _resolveContactByPrefixHex(senderKey6);
|
|
if (sender == null) return;
|
|
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
|
|
return;
|
|
}
|
|
unawaited(
|
|
connectionProvider.sendRawVoicePacket(
|
|
contactPath: sender.outPath,
|
|
contactPathLen: sender.outPathLen,
|
|
payload: ImageFragmentAck(
|
|
sessionId: fragment.sessionId,
|
|
index: fragment.index,
|
|
).encodeBinary(),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 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 channels (respect simple mode settings)
|
|
final channelsToSync = _isSimpleMode ? 5 : null;
|
|
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<int> 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<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();
|
|
}
|
|
_voiceMissingRetryTimers.clear();
|
|
_voiceMissingRetryAttempts.clear();
|
|
_voiceSessionSenderKey6.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;
|
|
// Dispose the location tracking service to stop GPS stream and clean up resources
|
|
locationTrackingService.dispose();
|
|
for (final timer in _voiceMissingRetryTimers.values) {
|
|
timer.cancel();
|
|
}
|
|
super.dispose();
|
|
}
|
|
}
|