mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
fix: retain voice codec settings now
ref:
This commit is contained in:
@@ -35,6 +35,11 @@ class AppProvider with ChangeNotifier {
|
||||
bool _isMapEnabled = true;
|
||||
bool get isMapEnabled => _isMapEnabled;
|
||||
|
||||
bool _isVoiceSilenceTrimmingEnabled = true;
|
||||
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
||||
bool _isVoiceBandPassFilterEnabled = true;
|
||||
bool get isVoiceBandPassFilterEnabled => _isVoiceBandPassFilterEnabled;
|
||||
|
||||
AppProvider({
|
||||
required this.connectionProvider,
|
||||
required this.contactsProvider,
|
||||
@@ -49,6 +54,8 @@ class AppProvider with ChangeNotifier {
|
||||
_initializeLocationTracking();
|
||||
_loadSimpleMode();
|
||||
_loadMapEnabled();
|
||||
_loadVoiceSilenceTrimmingEnabled();
|
||||
_loadVoiceBandPassFilterEnabled();
|
||||
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
||||
_isInitialized = true;
|
||||
}
|
||||
@@ -118,6 +125,54 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize tile cache service
|
||||
Future<void> _initializeTileCache() async {
|
||||
try {
|
||||
@@ -165,6 +220,20 @@ class AppProvider with ChangeNotifier {
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
// When a contact is received from BLE
|
||||
connectionProvider.onContactReceived = (contact) {
|
||||
// Pass device public key to filter out our own contact
|
||||
@@ -300,6 +369,43 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Voice control plane: request sender to stream raw voice packets.
|
||||
final voiceFetchRequest = VoiceFetchRequest.tryParseText(
|
||||
enrichedMessage.text,
|
||||
);
|
||||
if (voiceFetchRequest != null) {
|
||||
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
|
||||
if (senderPrefix == null) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Voice fetch request without sender prefix',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final senderPrefixHex = senderPrefix
|
||||
.take(6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
if (senderPrefixHex.toLowerCase() !=
|
||||
voiceFetchRequest.requesterKey6.toLowerCase()) {
|
||||
debugPrint('⚠️ [AppProvider] Voice fetch requester key mismatch');
|
||||
return;
|
||||
}
|
||||
final requester = contactsProvider.findContactByPrefix(senderPrefix);
|
||||
if (requester == null) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Voice fetch requester contact not found',
|
||||
);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
voiceProvider.serveSessionTo(
|
||||
sessionId: voiceFetchRequest.sessionId,
|
||||
requester: requester,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if message is a drawing broadcast
|
||||
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
||||
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
|
||||
@@ -339,6 +445,33 @@ class AppProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Voice envelope message (new public/direct on-demand format).
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text);
|
||||
if (voiceEnvelope != null) {
|
||||
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;
|
||||
}
|
||||
|
||||
// If it's a text-format voice packet, feed it to VoiceProvider
|
||||
if (VoicePacket.isVoiceText(enrichedMessage.text)) {
|
||||
final pkt = VoicePacket.tryParseText(enrichedMessage.text);
|
||||
@@ -726,13 +859,16 @@ class AppProvider with ChangeNotifier {
|
||||
///
|
||||
/// 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}) {
|
||||
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;
|
||||
final existing = messagesProvider.messages
|
||||
.where((m) => m.isVoice && m.voiceId == sessionId)
|
||||
.firstOrNull;
|
||||
|
||||
if (existing != null) {
|
||||
// Already have a placeholder — no need to add another
|
||||
@@ -748,7 +884,9 @@ class AppProvider with ChangeNotifier {
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
text: '', // no text — displayed as VoiceMessageBubble
|
||||
// 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,
|
||||
@@ -845,6 +983,7 @@ class AppProvider with ChangeNotifier {
|
||||
void clearAllData() {
|
||||
contactsProvider.clearContacts();
|
||||
messagesProvider.clearAll();
|
||||
unawaited(voiceProvider.clearStoredVoiceData());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// SSE client connection state
|
||||
bool get isSseClientConnecting => _sseClient.isConnecting;
|
||||
int get sseClientReconnectionAttempt => _sseClient.reconnectionAttempts;
|
||||
int get sseClientMaxReconnectionAttempts => _sseClient.maxReconnectionAttempts;
|
||||
int get sseClientMaxReconnectionAttempts =>
|
||||
_sseClient.maxReconnectionAttempts;
|
||||
|
||||
// Message sync state
|
||||
bool _noMoreMessages = false;
|
||||
@@ -151,7 +152,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
Function(List<Contact>)? onContactsComplete;
|
||||
Function(Message)? onMessageReceived;
|
||||
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
||||
Function(int channelIdx, String channelName, Uint8List secret, int? flags)? onChannelInfoReceived;
|
||||
Function(int channelIdx, String channelName, Uint8List secret, int? flags)?
|
||||
onChannelInfoReceived;
|
||||
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
|
||||
onBinaryResponse;
|
||||
Function(Uint8List publicKey)? onContactDeleted;
|
||||
@@ -310,17 +312,22 @@ class ConnectionProvider with ChangeNotifier {
|
||||
};
|
||||
|
||||
_bleService.onContactsComplete = (contacts) {
|
||||
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length} contacts');
|
||||
debugPrint(
|
||||
'📥 [Provider] Contacts sync complete: ${contacts.length} contacts',
|
||||
);
|
||||
debugPrint(' Forwarding to AppProvider via onContactsComplete callback');
|
||||
onContactsComplete?.call(contacts);
|
||||
};
|
||||
|
||||
_bleService.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
|
||||
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
|
||||
};
|
||||
_bleService.onChannelInfoReceived =
|
||||
(int channelIdx, String channelName, Uint8List secret, int? flags) {
|
||||
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
|
||||
};
|
||||
|
||||
_bleService.onContactDeleted = (publicKey) {
|
||||
debugPrint('⚠️ [Provider] Contact deleted by firmware (contacts full overwrite)');
|
||||
debugPrint(
|
||||
'⚠️ [Provider] Contact deleted by firmware (contacts full overwrite)',
|
||||
);
|
||||
onContactDeleted?.call(publicKey);
|
||||
};
|
||||
|
||||
@@ -349,7 +356,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
debugPrint(' LPP data: ${lppData.length} bytes');
|
||||
// Mark ping as successful if this was a ping request
|
||||
_pingTracker.markPingSuccessful(publicKey);
|
||||
debugPrint(' Forwarding to AppProvider via onTelemetryReceived callback');
|
||||
debugPrint(
|
||||
' Forwarding to AppProvider via onTelemetryReceived callback',
|
||||
);
|
||||
onTelemetryReceived?.call(publicKey, lppData);
|
||||
};
|
||||
|
||||
@@ -442,37 +451,42 @@ class ConnectionProvider with ChangeNotifier {
|
||||
onPathUpdated?.call(publicKey);
|
||||
};
|
||||
|
||||
_bleService
|
||||
.onMessageSent = (expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
||||
debugPrint(
|
||||
'📥 [Provider] Message sent - ACK tag: $expectedAckTag, timeout: ${suggestedTimeoutMs}ms',
|
||||
);
|
||||
|
||||
// Pop message ID from FIFO queue (matches send order)
|
||||
final messageId = _messageDeliveryTracker.popPendingMessageId();
|
||||
|
||||
if (messageId != null) {
|
||||
debugPrint(' ✅ Matched with message ID: $messageId');
|
||||
|
||||
// Check if approaching firmware limit (8 pending ACKs max)
|
||||
if (_messageDeliveryTracker.shouldRateLimit) {
|
||||
_bleService.onMessageSent =
|
||||
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
||||
debugPrint(
|
||||
' ⚠️ WARNING: ${_messageDeliveryTracker.pendingCount} pending ACKs (firmware limit: 8)',
|
||||
'📥 [Provider] Message sent - ACK tag: $expectedAckTag, timeout: ${suggestedTimeoutMs}ms',
|
||||
);
|
||||
debugPrint(' ⚠️ Firmware may drop ACK tracking if limit exceeded!');
|
||||
}
|
||||
|
||||
// Store the ACK tag to message ID mapping for delivery confirmation
|
||||
_messageDeliveryTracker.mapAckTagToMessageId(expectedAckTag, messageId);
|
||||
// Pop message ID from FIFO queue (matches send order)
|
||||
final messageId = _messageDeliveryTracker.popPendingMessageId();
|
||||
|
||||
// Notify callback with message ID
|
||||
onMessageSent?.call(messageId, expectedAckTag, suggestedTimeoutMs);
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [Provider] SENT response received but no pending message IDs',
|
||||
);
|
||||
}
|
||||
};
|
||||
if (messageId != null) {
|
||||
debugPrint(' ✅ Matched with message ID: $messageId');
|
||||
|
||||
// Check if approaching firmware limit (8 pending ACKs max)
|
||||
if (_messageDeliveryTracker.shouldRateLimit) {
|
||||
debugPrint(
|
||||
' ⚠️ WARNING: ${_messageDeliveryTracker.pendingCount} pending ACKs (firmware limit: 8)',
|
||||
);
|
||||
debugPrint(
|
||||
' ⚠️ Firmware may drop ACK tracking if limit exceeded!',
|
||||
);
|
||||
}
|
||||
|
||||
// Store the ACK tag to message ID mapping for delivery confirmation
|
||||
_messageDeliveryTracker.mapAckTagToMessageId(
|
||||
expectedAckTag,
|
||||
messageId,
|
||||
);
|
||||
|
||||
// Notify callback with message ID
|
||||
onMessageSent?.call(messageId, expectedAckTag, suggestedTimeoutMs);
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [Provider] SENT response received but no pending message IDs',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_bleService.onMessageDelivered = (ackCode, roundTripTimeMs) {
|
||||
debugPrint(
|
||||
@@ -527,7 +541,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
// Update SSE server with device name if running
|
||||
if (_sseServer.isRunning) {
|
||||
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
|
||||
_sseServer.setDeviceName(
|
||||
_deviceInfo.deviceName ?? _deviceInfo.selfName,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -563,7 +579,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
// Update SSE server with device name if running
|
||||
if (_sseServer.isRunning) {
|
||||
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
|
||||
_sseServer.setDeviceName(
|
||||
_deviceInfo.deviceName ?? _deviceInfo.selfName,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -755,11 +773,15 @@ class ConnectionProvider with ChangeNotifier {
|
||||
void _startAckCleanupTimer() {
|
||||
_stopAckCleanupTimer(); // Cancel any existing timer first
|
||||
|
||||
debugPrint('🧹 [ConnectionProvider] Starting ACK cleanup timer (1 minute interval)');
|
||||
debugPrint(
|
||||
'🧹 [ConnectionProvider] Starting ACK cleanup timer (1 minute interval)',
|
||||
);
|
||||
_ackCleanupTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
final cleanedCount = _messageDeliveryTracker.cleanupStaleAcks();
|
||||
if (cleanedCount > 0) {
|
||||
debugPrint('🧹 [ConnectionProvider] Cleaned up $cleanedCount stale ACK mappings');
|
||||
debugPrint(
|
||||
'🧹 [ConnectionProvider] Cleaned up $cleanedCount stale ACK mappings',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -811,7 +833,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
await _bleService.getContactByKey(publicKey);
|
||||
} catch (e) {
|
||||
_error = 'Failed to get contact: $e';
|
||||
debugPrint('⚠️ [Provider] Failed to get contact by key, falling back to full contact sync');
|
||||
debugPrint(
|
||||
'⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
|
||||
);
|
||||
// Fallback to full contact sync if command not supported
|
||||
await _bleService.getContacts();
|
||||
notifyListeners();
|
||||
@@ -902,7 +926,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// If not cached, query the device
|
||||
await _bleService.getChannel(channelIdx);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
|
||||
// Check again after query
|
||||
if (getChannelInfo != null) {
|
||||
final channel = getChannelInfo!(channelIdx);
|
||||
@@ -911,7 +935,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
return channelName == null || channelName.isEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If still no info, assume it's empty
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -953,7 +977,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use');
|
||||
return null;
|
||||
} catch (e) {
|
||||
@@ -986,7 +1010,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
// Determine channel type
|
||||
final bool isHashChannel = channelName.startsWith('#');
|
||||
|
||||
|
||||
// Check for duplicate channels
|
||||
int? existingSlot;
|
||||
if (getChannelInfo != null) {
|
||||
@@ -998,12 +1022,18 @@ class ConnectionProvider with ChangeNotifier {
|
||||
if (existingName != null && existingName.isNotEmpty) {
|
||||
// For hash channels (#name), check exact match to prevent duplicates
|
||||
if (isHashChannel && existingName == channelName) {
|
||||
debugPrint(' ⚠️ Hash channel "$channelName" already exists in slot $i');
|
||||
throw Exception('Channel "$channelName" already exists. Hash channels cannot be duplicated.');
|
||||
debugPrint(
|
||||
' ⚠️ Hash channel "$channelName" already exists in slot $i',
|
||||
);
|
||||
throw Exception(
|
||||
'Channel "$channelName" already exists. Hash channels cannot be duplicated.',
|
||||
);
|
||||
}
|
||||
// For private channels, check name match to allow overwrite
|
||||
else if (!isHashChannel && existingName == channelName) {
|
||||
debugPrint(' ℹ️ Private channel "$channelName" found in slot $i - will overwrite');
|
||||
debugPrint(
|
||||
' ℹ️ Private channel "$channelName" found in slot $i - will overwrite',
|
||||
);
|
||||
existingSlot = i;
|
||||
break;
|
||||
}
|
||||
@@ -1022,7 +1052,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// Find next empty slot for new channel
|
||||
final emptySlot = await findNextEmptyChannelSlot();
|
||||
if (emptySlot == null) {
|
||||
throw Exception('All channel slots are in use (maximum 39 custom channels)');
|
||||
throw Exception(
|
||||
'All channel slots are in use (maximum 39 custom channels)',
|
||||
);
|
||||
}
|
||||
slotIdx = emptySlot;
|
||||
debugPrint(' Using empty slot: $slotIdx (new channel)');
|
||||
@@ -1049,7 +1081,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
secret: secretBytes,
|
||||
);
|
||||
|
||||
debugPrint('✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx');
|
||||
debugPrint(
|
||||
'✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx',
|
||||
);
|
||||
|
||||
// Small delay to allow the response to propagate
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
@@ -1088,7 +1122,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// Delete channel on device (sets empty name and zeroed secret)
|
||||
await _bleService.deleteChannel(channelIdx);
|
||||
|
||||
debugPrint('✅ [Provider] Channel deleted successfully from slot $channelIdx');
|
||||
debugPrint(
|
||||
'✅ [Provider] Channel deleted successfully from slot $channelIdx',
|
||||
);
|
||||
|
||||
// Small delay to allow the response to propagate
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
@@ -1169,7 +1205,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
|
||||
);
|
||||
debugPrint('⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...');
|
||||
debugPrint(
|
||||
'⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...',
|
||||
);
|
||||
|
||||
// Wait briefly for some ACKs to arrive, then proceed anyway
|
||||
// (User action shouldn't be blocked forever)
|
||||
@@ -1304,7 +1342,11 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// Track for echo detection
|
||||
// The BLE handler will capture the packet via LOG_RX_DATA and associate it
|
||||
debugPrint(' Calling trackSentChannelMessage...');
|
||||
_bleService.trackSentChannelMessage(messageId);
|
||||
_bleService.trackSentChannelMessage(
|
||||
messageId,
|
||||
channelIdx: channelIdx,
|
||||
plainText: text,
|
||||
);
|
||||
debugPrint(' trackSentChannelMessage completed');
|
||||
|
||||
// Small delay to ensure the message is in the MessagesProvider list
|
||||
@@ -2022,7 +2064,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// Convert hex string to Uint8List
|
||||
final bytes = <int>[];
|
||||
for (int i = 0; i < recipientPublicKey.length; i += 2) {
|
||||
bytes.add(int.parse(recipientPublicKey.substring(i, i + 2), radix: 16));
|
||||
bytes.add(
|
||||
int.parse(recipientPublicKey.substring(i, i + 2), radix: 16),
|
||||
);
|
||||
}
|
||||
return await sendTextMessage(
|
||||
contactPublicKey: Uint8List.fromList(bytes),
|
||||
@@ -2107,7 +2151,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🔌 [ConnectionProvider] Connecting to SSE server: $serverUrl');
|
||||
debugPrint(
|
||||
'🔌 [ConnectionProvider] Connecting to SSE server: $serverUrl',
|
||||
);
|
||||
_sseClientServerUrl = serverUrl;
|
||||
|
||||
// Wire up callbacks
|
||||
@@ -2122,11 +2168,17 @@ class ConnectionProvider with ChangeNotifier {
|
||||
};
|
||||
|
||||
_sseClient.onConnectionStateChanged = (isConnected) {
|
||||
debugPrint('🔔 [ConnectionProvider] SSE client connection state changed: $isConnected');
|
||||
debugPrint(
|
||||
'🔔 [ConnectionProvider] SSE client connection state changed: $isConnected',
|
||||
);
|
||||
if (isConnected) {
|
||||
debugPrint('✅ [ConnectionProvider] SSE client connected - updating UI state');
|
||||
debugPrint(
|
||||
'✅ [ConnectionProvider] SSE client connected - updating UI state',
|
||||
);
|
||||
} else {
|
||||
debugPrint('❌ [ConnectionProvider] SSE client disconnected - updating UI state');
|
||||
debugPrint(
|
||||
'❌ [ConnectionProvider] SSE client disconnected - updating UI state',
|
||||
);
|
||||
}
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
connectionState: isConnected
|
||||
@@ -2142,15 +2194,21 @@ class ConnectionProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
debugPrint('📌 [ConnectionProvider] SSE callbacks registered, starting connection...');
|
||||
debugPrint(
|
||||
'📌 [ConnectionProvider] SSE callbacks registered, starting connection...',
|
||||
);
|
||||
await _sseClient.connect(serverUrl: serverUrl, authToken: authToken);
|
||||
|
||||
_connectionMode = ConnectionMode.sseClient;
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('✅ [ConnectionProvider] Connected to SSE server');
|
||||
debugPrint('📊 [ConnectionProvider] SSE client state: isConnected=${_sseClient.isConnected}');
|
||||
debugPrint('📊 [ConnectionProvider] DeviceInfo state: connectionState=${_deviceInfo.connectionState}, isConnected=${_deviceInfo.isConnected}');
|
||||
debugPrint(
|
||||
'📊 [ConnectionProvider] SSE client state: isConnected=${_sseClient.isConnected}',
|
||||
);
|
||||
debugPrint(
|
||||
'📊 [ConnectionProvider] DeviceInfo state: connectionState=${_deviceInfo.connectionState}, isConnected=${_deviceInfo.isConnected}',
|
||||
);
|
||||
} catch (e) {
|
||||
_error = 'Failed to connect to SSE server: $e';
|
||||
debugPrint('❌ [ConnectionProvider] Failed to connect to SSE server: $e');
|
||||
@@ -2206,10 +2264,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
throw Exception('Not connected to SSE server');
|
||||
}
|
||||
|
||||
await _sseClient.sendChannelMessage(
|
||||
channelIdx: channelIdx,
|
||||
text: text,
|
||||
);
|
||||
await _sseClient.sendChannelMessage(channelIdx: channelIdx, text: text);
|
||||
}
|
||||
|
||||
/// Get SSE client connection status
|
||||
|
||||
@@ -446,6 +446,22 @@ class ContactsProvider with ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Find contact by first 6-byte public key prefix.
|
||||
Contact? findContactByPrefix(Uint8List prefix) {
|
||||
return _findContactByPrefix(prefix);
|
||||
}
|
||||
|
||||
/// Find contact by 12-hex-char public key prefix.
|
||||
Contact? findContactByPrefixHex(String prefixHex) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(prefixHex)) return null;
|
||||
final bytes = Uint8List(6);
|
||||
for (var i = 0; i < 6; i++) {
|
||||
final start = i * 2;
|
||||
bytes[i] = int.parse(prefixHex.substring(start, start + 2), radix: 16);
|
||||
}
|
||||
return _findContactByPrefix(bytes);
|
||||
}
|
||||
|
||||
/// Find contact by public key
|
||||
Contact? findContactByKey(Uint8List publicKey) {
|
||||
final keyHex = publicKey
|
||||
|
||||
@@ -156,6 +156,25 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a voice envelope/message and not already marked.
|
||||
if (!enhancedMessage.isVoice) {
|
||||
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
|
||||
if (envelope != null) {
|
||||
enhancedMessage = enhancedMessage.copyWith(
|
||||
isVoice: true,
|
||||
voiceId: envelope.sessionId,
|
||||
);
|
||||
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||
if (pkt != null) {
|
||||
enhancedMessage = enhancedMessage.copyWith(
|
||||
isVoice: true,
|
||||
voiceId: pkt.sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_messages.add(enhancedMessage);
|
||||
|
||||
// Extract SAR markers
|
||||
@@ -182,20 +201,26 @@ class MessagesProvider with ChangeNotifier {
|
||||
/// This restores drawings that may be missing from DrawingProvider storage
|
||||
/// Should be called after both providers are initialized
|
||||
void syncDrawingsWithProvider(dynamic drawingProvider) {
|
||||
debugPrint('🔄 [MessagesProvider] Syncing drawings with DrawingProvider...');
|
||||
debugPrint(
|
||||
'🔄 [MessagesProvider] Syncing drawings with DrawingProvider...',
|
||||
);
|
||||
int restoredCount = 0;
|
||||
|
||||
for (final message in _messages) {
|
||||
if (!message.isDrawing || message.drawingId == null) continue;
|
||||
|
||||
// Check if drawing exists in DrawingProvider
|
||||
final existingDrawing = drawingProvider.getDrawingById(message.drawingId!);
|
||||
final existingDrawing = drawingProvider.getDrawingById(
|
||||
message.drawingId!,
|
||||
);
|
||||
if (existingDrawing != null) {
|
||||
continue; // Drawing already exists
|
||||
}
|
||||
|
||||
// Drawing is missing, reconstruct from message text
|
||||
debugPrint('🔧 [MessagesProvider] Restoring missing drawing: ${message.drawingId}');
|
||||
debugPrint(
|
||||
'🔧 [MessagesProvider] Restoring missing drawing: ${message.drawingId}',
|
||||
);
|
||||
final drawing = DrawingMessageParser.parseDrawingMessage(
|
||||
message.text,
|
||||
senderName: message.senderName,
|
||||
@@ -203,7 +228,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
if (drawing == null) {
|
||||
debugPrint('⚠️ [MessagesProvider] Failed to parse drawing from message ${message.id}');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] Failed to parse drawing from message ${message.id}',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -214,11 +241,15 @@ class MessagesProvider with ChangeNotifier {
|
||||
if (restoredDrawing != null) {
|
||||
drawingProvider.addReceivedDrawing(restoredDrawing);
|
||||
restoredCount++;
|
||||
debugPrint('✅ [MessagesProvider] Restored drawing ${message.drawingId}');
|
||||
debugPrint(
|
||||
'✅ [MessagesProvider] Restored drawing ${message.drawingId}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ [MessagesProvider] Sync complete: restored $restoredCount drawings');
|
||||
debugPrint(
|
||||
'✅ [MessagesProvider] Sync complete: restored $restoredCount drawings',
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of a drawing with a specific ID
|
||||
@@ -278,14 +309,22 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a voice message (V:...) and not already marked
|
||||
if (VoicePacket.isVoiceText(enhancedMessage.text) && !enhancedMessage.isVoice) {
|
||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||
if (pkt != null) {
|
||||
// Check if it's a voice message (VE1:/V:) and not already marked.
|
||||
if (!enhancedMessage.isVoice) {
|
||||
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
|
||||
if (envelope != null) {
|
||||
enhancedMessage = enhancedMessage.copyWith(
|
||||
isVoice: true,
|
||||
voiceId: pkt.sessionId,
|
||||
voiceId: envelope.sessionId,
|
||||
);
|
||||
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||
if (pkt != null) {
|
||||
enhancedMessage = enhancedMessage.copyWith(
|
||||
isVoice: true,
|
||||
voiceId: pkt.sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +811,25 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a voice message (VE1:/V:) and not already marked.
|
||||
if (!enhancedMessage.isVoice) {
|
||||
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
|
||||
if (envelope != null) {
|
||||
enhancedMessage = enhancedMessage.copyWith(
|
||||
isVoice: true,
|
||||
voiceId: envelope.sessionId,
|
||||
);
|
||||
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
||||
if (pkt != null) {
|
||||
enhancedMessage = enhancedMessage.copyWith(
|
||||
isVoice: true,
|
||||
voiceId: pkt.sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
||||
if (_isDuplicate(enhancedMessage)) {
|
||||
debugPrint(
|
||||
@@ -861,7 +919,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
// Clamp at 20 seconds maximum
|
||||
final scaledTimeout = suggestedTimeoutMs * 5;
|
||||
final effectiveTimeout = scaledTimeout > 20000 ? 20000 : scaledTimeout;
|
||||
debugPrint(' ⏱️ Radio suggested ${suggestedTimeoutMs}ms, using ${effectiveTimeout}ms (5x${scaledTimeout > 20000 ? ', clamped at 20s' : ''}) for grouped message');
|
||||
debugPrint(
|
||||
' ⏱️ Radio suggested ${suggestedTimeoutMs}ms, using ${effectiveTimeout}ms (5x${scaledTimeout > 20000 ? ', clamped at 20s' : ''}) for grouped message',
|
||||
);
|
||||
|
||||
// Store ACK tag → List of (groupId, recipientPublicKey)
|
||||
// Multiple recipients can share the same ACK tag
|
||||
@@ -869,8 +929,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
_ackTagToRecipients[expectedAckTag] = [];
|
||||
}
|
||||
_ackTagToRecipients[expectedAckTag]!.add((groupId, recipientPublicKey));
|
||||
debugPrint(' ✅ Added recipient to ACK tag $expectedAckTag → group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
||||
debugPrint(' 📊 Total recipients for ACK $expectedAckTag: ${_ackTagToRecipients[expectedAckTag]!.length}');
|
||||
debugPrint(
|
||||
' ✅ Added recipient to ACK tag $expectedAckTag → group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||
);
|
||||
debugPrint(
|
||||
' 📊 Total recipients for ACK $expectedAckTag: ${_ackTagToRecipients[expectedAckTag]!.length}',
|
||||
);
|
||||
|
||||
// Store the mapping so we can update the right recipient on delivery
|
||||
_pendingSentMessages[expectedAckTag] = Message(
|
||||
@@ -892,7 +956,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
_timeoutTimers[messageId] = Timer(
|
||||
Duration(milliseconds: effectiveTimeout),
|
||||
() {
|
||||
debugPrint('⏱️ [MessagesProvider] Timeout for grouped message recipient (message $messageId)');
|
||||
debugPrint(
|
||||
'⏱️ [MessagesProvider] Timeout for grouped message recipient (message $messageId)',
|
||||
);
|
||||
// Check if this specific recipient is still pending
|
||||
final recipients = _ackTagToRecipients[expectedAckTag];
|
||||
if (recipients != null && recipients.isNotEmpty) {
|
||||
@@ -902,10 +968,13 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
if (recipientIndex >= 0) {
|
||||
final (timeoutGroupId, timeoutRecipientKey) = recipients[recipientIndex];
|
||||
final (timeoutGroupId, timeoutRecipientKey) =
|
||||
recipients[recipientIndex];
|
||||
debugPrint(' ⚠️ Timeout fired - marking recipient as failed');
|
||||
debugPrint(' Group: $timeoutGroupId');
|
||||
debugPrint(' Recipient: ${timeoutRecipientKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
||||
debugPrint(
|
||||
' Recipient: ${timeoutRecipientKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||
);
|
||||
|
||||
// Mark this specific recipient as failed
|
||||
updateGroupedMessageRecipientStatus(
|
||||
@@ -925,7 +994,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
_groupedMessageMapping.remove(messageId);
|
||||
_timeoutTimers.remove(messageId);
|
||||
} else {
|
||||
debugPrint(' ✅ ACK already received for this recipient - ignoring timeout');
|
||||
debugPrint(
|
||||
' ✅ ACK already received for this recipient - ignoring timeout',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugPrint(' ✅ All ACKs already received - ignoring timeout');
|
||||
@@ -1032,6 +1103,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
final updatedMessage = message.copyWith(
|
||||
echoCount: echoCount,
|
||||
firstEchoAt: message.firstEchoAt ?? DateTime.now(),
|
||||
lastEchoSnrRaw: snrRaw.toSigned(8),
|
||||
lastEchoRssiDbm: rssiDbm.toSigned(8),
|
||||
lastEchoAt: DateTime.now(),
|
||||
);
|
||||
_messages[index] = updatedMessage;
|
||||
|
||||
@@ -1052,7 +1126,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
int? roundTripTimeMs,
|
||||
DateTime? deliveredAt,
|
||||
}) {
|
||||
debugPrint('🔄 [MessagesProvider] updateGroupedMessageRecipientStatus called');
|
||||
debugPrint(
|
||||
'🔄 [MessagesProvider] updateGroupedMessageRecipientStatus called',
|
||||
);
|
||||
debugPrint(' Group ID: $groupId');
|
||||
debugPrint(' New status: $newStatus');
|
||||
debugPrint(' RTT: ${roundTripTimeMs}ms');
|
||||
@@ -1060,7 +1136,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
final index = _messages.indexWhere((m) => m.id == groupId);
|
||||
if (index == -1) {
|
||||
debugPrint('⚠️ [MessagesProvider] Grouped message not found: $groupId');
|
||||
debugPrint(' Available message IDs: ${_messages.take(5).map((m) => m.id).join(", ")}');
|
||||
debugPrint(
|
||||
' Available message IDs: ${_messages.take(5).map((m) => m.id).join(", ")}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1068,7 +1146,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
debugPrint(' ✅ Found grouped message at index $index');
|
||||
|
||||
if (!message.isGroupedMessage) {
|
||||
debugPrint('⚠️ [MessagesProvider] Message is not a grouped message: $groupId');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] Message is not a grouped message: $groupId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1094,7 +1174,11 @@ class MessagesProvider with ChangeNotifier {
|
||||
return recipient.copyWith(
|
||||
deliveryStatus: newStatus,
|
||||
roundTripTimeMs: roundTripTimeMs,
|
||||
deliveredAt: deliveredAt ?? (newStatus == MessageDeliveryStatus.delivered ? DateTime.now() : null),
|
||||
deliveredAt:
|
||||
deliveredAt ??
|
||||
(newStatus == MessageDeliveryStatus.delivered
|
||||
? DateTime.now()
|
||||
: null),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1103,10 +1187,14 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
if (!recipientFound) {
|
||||
debugPrint(' ⚠️ Recipient not found in recipients list!');
|
||||
debugPrint(' Looking for key: ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
||||
debugPrint(
|
||||
' Looking for key: ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||
);
|
||||
debugPrint(' Available recipients:');
|
||||
for (final r in message.recipients!) {
|
||||
debugPrint(' - ${r.displayName}: ${r.publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
||||
debugPrint(
|
||||
' - ${r.displayName}: ${r.publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,14 +1203,26 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Update overall message status based on recipients
|
||||
MessageDeliveryStatus overallStatus;
|
||||
final allDelivered = updatedRecipients.every((r) => r.deliveryStatus == MessageDeliveryStatus.delivered);
|
||||
final anyFailed = updatedRecipients.any((r) => r.deliveryStatus == MessageDeliveryStatus.failed);
|
||||
final anySending = updatedRecipients.any((r) => r.deliveryStatus == MessageDeliveryStatus.sending);
|
||||
final allDelivered = updatedRecipients.every(
|
||||
(r) => r.deliveryStatus == MessageDeliveryStatus.delivered,
|
||||
);
|
||||
final anyFailed = updatedRecipients.any(
|
||||
(r) => r.deliveryStatus == MessageDeliveryStatus.failed,
|
||||
);
|
||||
final anySending = updatedRecipients.any(
|
||||
(r) => r.deliveryStatus == MessageDeliveryStatus.sending,
|
||||
);
|
||||
|
||||
debugPrint(' Status counts:');
|
||||
debugPrint(' Delivered: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered).length}');
|
||||
debugPrint(' Sent/Pending: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.sent || r.deliveryStatus == MessageDeliveryStatus.sending).length}');
|
||||
debugPrint(' Failed: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.failed).length}');
|
||||
debugPrint(
|
||||
' Delivered: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered).length}',
|
||||
);
|
||||
debugPrint(
|
||||
' Sent/Pending: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.sent || r.deliveryStatus == MessageDeliveryStatus.sending).length}',
|
||||
);
|
||||
debugPrint(
|
||||
' Failed: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.failed).length}',
|
||||
);
|
||||
|
||||
if (allDelivered) {
|
||||
overallStatus = MessageDeliveryStatus.delivered;
|
||||
@@ -1148,9 +1248,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||||
);
|
||||
debugPrint(
|
||||
' Checking recipient list for ACK $ackCode...',
|
||||
);
|
||||
debugPrint(' Checking recipient list for ACK $ackCode...');
|
||||
|
||||
// Check if this ACK is for grouped message recipient(s)
|
||||
final recipients = _ackTagToRecipients[ackCode];
|
||||
@@ -1158,13 +1256,18 @@ class MessagesProvider with ChangeNotifier {
|
||||
// Pop the first recipient from the list (FIFO order)
|
||||
// This matches the order in which messages were sent
|
||||
final (groupId, recipientPublicKey) = recipients.removeAt(0);
|
||||
debugPrint(' ✅ Found recipient in list: group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
||||
debugPrint(' 📊 Remaining recipients for ACK $ackCode: ${recipients.length}');
|
||||
debugPrint(
|
||||
' ✅ Found recipient in list: group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||
);
|
||||
debugPrint(
|
||||
' 📊 Remaining recipients for ACK $ackCode: ${recipients.length}',
|
||||
);
|
||||
|
||||
// Find the message ID for this recipient to cancel its timeout
|
||||
String? messageIdToCancel;
|
||||
for (final entry in _groupedMessageMapping.entries) {
|
||||
if (entry.value.$1 == groupId && _listEquals(entry.value.$2, recipientPublicKey)) {
|
||||
if (entry.value.$1 == groupId &&
|
||||
_listEquals(entry.value.$2, recipientPublicKey)) {
|
||||
messageIdToCancel = entry.key;
|
||||
break;
|
||||
}
|
||||
@@ -1188,7 +1291,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Clean up if no more recipients for this ACK
|
||||
if (recipients.isEmpty) {
|
||||
debugPrint(' 🧹 All recipients processed for ACK $ackCode, cleaning up');
|
||||
debugPrint(
|
||||
' 🧹 All recipients processed for ACK $ackCode, cleaning up',
|
||||
);
|
||||
_ackTagToRecipients.remove(ackCode);
|
||||
_pendingSentMessages.remove(ackCode);
|
||||
}
|
||||
@@ -1205,9 +1310,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// Not a grouped message, check for single message
|
||||
debugPrint(
|
||||
' Not in simple mapping, checking pending messages...',
|
||||
);
|
||||
debugPrint(' Not in simple mapping, checking pending messages...');
|
||||
debugPrint(
|
||||
' Current pending messages: ${_pendingSentMessages.keys.toList()}',
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../services/voice_codec_service.dart';
|
||||
import '../services/voice_player_service.dart';
|
||||
@@ -32,8 +35,10 @@ class VoiceSession {
|
||||
|
||||
/// Manages incoming voice packet sessions and coordinates playback.
|
||||
class VoiceProvider with ChangeNotifier {
|
||||
static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1';
|
||||
final VoiceCodecService _codec;
|
||||
final VoicePlayerService _player;
|
||||
late final StreamSubscription<void> _playerEventsSub;
|
||||
|
||||
/// Active sessions keyed by sessionId.
|
||||
final Map<String, VoiceSession> _sessions = {};
|
||||
@@ -41,18 +46,52 @@ class VoiceProvider with ChangeNotifier {
|
||||
/// Currently playing session ID, or null.
|
||||
String? _playingSessionId;
|
||||
|
||||
/// Hook for sending a raw voice payload to a destination contact path.
|
||||
Future<void> Function({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
|
||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||
|
||||
VoiceProvider({
|
||||
required VoiceCodecService codec,
|
||||
required VoicePlayerService player,
|
||||
}) : _codec = codec,
|
||||
_player = player;
|
||||
}) : _codec = codec,
|
||||
_player = player {
|
||||
_playerEventsSub = _player.events.listen((_) {
|
||||
if (_playingSessionId != null &&
|
||||
!_player.isPlaying &&
|
||||
_player.duration.inMilliseconds > 0 &&
|
||||
_player.position >= _player.duration) {
|
||||
_playingSessionId = null;
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
_restorePersistedVoiceData();
|
||||
}
|
||||
|
||||
// ── Session accessors ────────────────────────────────────────────────────
|
||||
|
||||
VoiceSession? session(String sessionId) => _sessions[sessionId];
|
||||
bool isComplete(String sessionId) => _sessions[sessionId]?.isComplete ?? false;
|
||||
bool isPlaying(String sessionId) =>
|
||||
_playingSessionId == sessionId && _player.isPlaying;
|
||||
bool isComplete(String sessionId) =>
|
||||
_sessions[sessionId]?.isComplete ?? false;
|
||||
bool isPlaying(String sessionId) => _playingSessionId == sessionId;
|
||||
Duration get playbackPosition => _player.position;
|
||||
Duration get playbackDuration => _player.duration;
|
||||
|
||||
double playbackProgress(String sessionId) {
|
||||
if (_playingSessionId != sessionId) return 0.0;
|
||||
final totalMs = _player.duration.inMilliseconds;
|
||||
if (totalMs <= 0) return 0.0;
|
||||
final posMs = _player.position.inMilliseconds.clamp(0, totalMs);
|
||||
return posMs / totalMs;
|
||||
}
|
||||
|
||||
bool hasOutgoingSession(String sessionId) =>
|
||||
_outgoingSessions.containsKey(sessionId);
|
||||
|
||||
// ── Packet reception ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -74,10 +113,61 @@ class VoiceProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
final justComplete = session.isComplete;
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
return justComplete;
|
||||
}
|
||||
|
||||
/// Cache encoded packets for deferred voice serving.
|
||||
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||
if (packets.isEmpty) return;
|
||||
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
|
||||
sessionId: sessionId,
|
||||
packets: List<VoicePacket>.from(packets),
|
||||
);
|
||||
_persistVoiceData();
|
||||
}
|
||||
|
||||
/// Stream a cached voice session to a requester over raw direct packets.
|
||||
Future<bool> serveSessionTo({
|
||||
required String sessionId,
|
||||
required Contact requester,
|
||||
}) async {
|
||||
final cached = _outgoingSessions[sessionId];
|
||||
if (cached == null) {
|
||||
debugPrint(
|
||||
'⚠️ [VoiceProvider] No cached outgoing session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (sendRawPacketCallback == null) {
|
||||
debugPrint('⚠️ [VoiceProvider] sendRawPacketCallback is not set');
|
||||
return false;
|
||||
}
|
||||
if (requester.outPathLen < 0) {
|
||||
debugPrint(
|
||||
'⚠️ [VoiceProvider] Requester ${requester.advName} has no direct path',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (final packet in cached.packets) {
|
||||
try {
|
||||
await sendRawPacketCallback!(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.outPathLen,
|
||||
payload: packet.encodeBinary(),
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint(
|
||||
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Playback ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode and play the voice session with [sessionId].
|
||||
@@ -85,11 +175,15 @@ class VoiceProvider with ChangeNotifier {
|
||||
Future<void> play(String sessionId) async {
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) {
|
||||
debugPrint('❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}');
|
||||
debugPrint(
|
||||
'❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}');
|
||||
debugPrint(
|
||||
'🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}',
|
||||
);
|
||||
|
||||
try {
|
||||
final pcm = await _codec.decodePackets(session.packets, session.mode);
|
||||
@@ -99,7 +193,6 @@ class VoiceProvider with ChangeNotifier {
|
||||
await _player.play(pcm);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||
} finally {
|
||||
if (_playingSessionId == sessionId) {
|
||||
_playingSessionId = null;
|
||||
notifyListeners();
|
||||
@@ -113,9 +206,127 @@ class VoiceProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> clearStoredVoiceData() async {
|
||||
_sessions.clear();
|
||||
_outgoingSessions.clear();
|
||||
_playingSessionId = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_voiceSessionsStorageKey);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistVoiceData() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final payload = <String, dynamic>{
|
||||
'incoming': _sessions.values
|
||||
.map(
|
||||
(session) => {
|
||||
'sessionId': session.sessionId,
|
||||
'modeId': session.mode.id,
|
||||
'total': session.total,
|
||||
'packets': session.packets
|
||||
.map((p) => p?.encodeText())
|
||||
.toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'outgoing': _outgoingSessions.values
|
||||
.map(
|
||||
(session) => {
|
||||
'sessionId': session.sessionId,
|
||||
'packets': session.packets.map((p) => p.encodeText()).toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload));
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restorePersistedVoiceData() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_voiceSessionsStorageKey);
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
|
||||
final incoming = parsed['incoming'] as List<dynamic>? ?? const [];
|
||||
for (final item in incoming) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
final modeId = map['modeId'] as int?;
|
||||
final total = map['total'] as int?;
|
||||
if (sessionId == null || modeId == null || total == null || total <= 0) {
|
||||
continue;
|
||||
}
|
||||
final mode = VoicePacketMode.fromId(modeId);
|
||||
final session = VoiceSession(
|
||||
sessionId: sessionId,
|
||||
mode: mode,
|
||||
total: total,
|
||||
);
|
||||
final packets = map['packets'] as List<dynamic>? ?? const [];
|
||||
for (var i = 0; i < packets.length && i < session.total; i++) {
|
||||
final encoded = packets[i] as String?;
|
||||
if (encoded == null || encoded.isEmpty) continue;
|
||||
final packet = VoicePacket.tryParseText(encoded);
|
||||
if (packet != null && packet.index < session.total) {
|
||||
session.packets[packet.index] = packet;
|
||||
}
|
||||
}
|
||||
_sessions[sessionId] = session;
|
||||
}
|
||||
|
||||
final outgoing = parsed['outgoing'] as List<dynamic>? ?? const [];
|
||||
for (final item in outgoing) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
if (sessionId == null || sessionId.isEmpty) continue;
|
||||
final packetsRaw = map['packets'] as List<dynamic>? ?? const [];
|
||||
final packets = <VoicePacket>[];
|
||||
for (final encoded in packetsRaw) {
|
||||
final packet = VoicePacket.tryParseText((encoded ?? '') as String);
|
||||
if (packet != null) packets.add(packet);
|
||||
}
|
||||
if (packets.isNotEmpty) {
|
||||
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
|
||||
sessionId: sessionId,
|
||||
packets: packets,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
debugPrint(
|
||||
'🎙️ [VoiceProvider] Restored ${_sessions.length} incoming and ${_outgoingSessions.length} outgoing voice sessions',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceProvider] Failed to restore voice data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playerEventsSub.cancel();
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _OutgoingVoiceSession {
|
||||
final String sessionId;
|
||||
final List<VoicePacket> packets;
|
||||
|
||||
const _OutgoingVoiceSession({
|
||||
required this.sessionId,
|
||||
required this.packets,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user