fix: retain voice codec settings now

ref:
This commit is contained in:
Janez T
2026-03-01 11:00:30 +01:00
parent daeb8aeb9c
commit 474fd02635
34 changed files with 3796 additions and 639 deletions

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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

View File

@@ -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()}',
);

View File

@@ -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,
});
}