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

@@ -19,6 +19,8 @@ extension MessageVoiceExtension on Message {
/// Returns null for non-voice messages.
VoicePacketMode? get voicePacketMode {
if (!isVoice || text.isEmpty) return null;
final envelope = VoiceEnvelope.tryParseText(text);
if (envelope != null) return envelope.mode;
final pkt = VoicePacket.tryParseText(text);
return pkt?.mode;
}

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

View File

@@ -19,6 +19,7 @@ import '../widgets/messages/sar_update_sheet.dart';
import '../widgets/messages/recipient_selector_sheet.dart';
import '../widgets/messages/message_bubble.dart';
import '../services/message_destination_preferences.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/voice_recorder_service.dart';
import '../services/voice_codec_service.dart';
import '../utils/toast_logger.dart';
@@ -54,11 +55,15 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isRecording = false;
bool _isSendingVoice = false;
static const int _maxVoicePackets = 10;
static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 1;
bool get _voiceSupported => Platform.isIOS;
StreamSubscription<Int16List>? _voiceStreamSub;
String? _currentVoiceSessionId;
final List<Int16List> _recordedChunks = [];
VoicePacketMode? _activeVoiceMode;
int _selectedVoiceBitrate = VoiceBitratePreferences.defaultBitrate;
@override
void initState() {
@@ -66,6 +71,7 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.addListener(_updateCharacterCount);
// Load saved message destination
_loadSavedDestination();
_loadVoiceBitrate();
// Mark all messages as read when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
@@ -73,6 +79,14 @@ class _MessagesTabState extends State<MessagesTab> {
});
}
Future<void> _loadVoiceBitrate() async {
final bitrate = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
setState(() {
_selectedVoiceBitrate = bitrate;
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -409,6 +423,15 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh bitrate preference so settings changes apply immediately.
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
if (mounted) {
setState(() {
_selectedVoiceBitrate = selectedBitrate;
});
} else {
_selectedVoiceBitrate = selectedBitrate;
}
final hasPermission = await _voiceRecorder.requestPermission();
debugPrint('🎙️ [Voice] hasPermission=$hasPermission');
if (!hasPermission) {
@@ -418,6 +441,7 @@ class _MessagesTabState extends State<MessagesTab> {
}
if (!mounted) return;
final appProvider = context.read<AppProvider>();
final connectionProvider = context.read<ConnectionProvider>();
debugPrint(
'🎙️ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}',
@@ -434,8 +458,9 @@ class _MessagesTabState extends State<MessagesTab> {
(_) => rng.nextInt(256),
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final radioBwKhz = connectionProvider.deviceInfo.radioBw ?? 125;
_activeVoiceMode = voiceModeForBandwidth(radioBwKhz * 1000);
_activeVoiceMode = VoiceBitratePreferences.toVoiceMode(
_selectedVoiceBitrate,
);
final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
);
@@ -448,7 +473,10 @@ class _MessagesTabState extends State<MessagesTab> {
setState(() => _isRecording = true);
try {
final stream = _voiceRecorder.startCapture(chunkDuration: packetDuration);
final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
);
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen(
(pcmChunk) {
@@ -477,6 +505,9 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _stopAndSendVoice() async {
if (!_isRecording) return;
final trimSilenceEnabled = context
.read<AppProvider>()
.isVoiceSilenceTrimmingEnabled;
debugPrint(
'🎙️ [Voice] _stopAndSendVoice: ${_recordedChunks.length} chunks buffered',
);
@@ -485,11 +516,16 @@ class _MessagesTabState extends State<MessagesTab> {
_voiceStreamSub = null;
await _voiceRecorder.stopCapture();
final chunks = List<Int16List>.from(_recordedChunks);
final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode;
_recordedChunks.clear();
debugPrint(
'🎙️ [Voice] silence trim enabled=$trimSilenceEnabled: raw=${rawChunks.length} chunks -> kept=${chunks.length} chunks',
);
if (mounted) {
setState(() {
_isRecording = false;
@@ -498,7 +534,12 @@ class _MessagesTabState extends State<MessagesTab> {
}
if (chunks.isEmpty || sessionId == null || mode == null || !mounted) {
if (mounted) setState(() { _isSendingVoice = false; _currentVoiceSessionId = null; });
if (mounted) {
setState(() {
_isSendingVoice = false;
_currentVoiceSessionId = null;
});
}
return;
}
@@ -511,7 +552,9 @@ class _MessagesTabState extends State<MessagesTab> {
} catch (e, st) {
debugPrint('❌ [Voice] _encodeAndSendAllPackets threw: $e\n$st');
} finally {
debugPrint('🎙️ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice');
debugPrint(
'🎙️ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice',
);
if (mounted) {
setState(() {
_isSendingVoice = false;
@@ -532,10 +575,13 @@ class _MessagesTabState extends State<MessagesTab> {
final messagesProvider = context.read<MessagesProvider>();
final voiceProvider = context.read<VoiceProvider>();
// Insert the chat placeholder before sending (so it appears immediately)
// Insert the chat placeholder before sending (so it appears immediately).
final msgId = 'voice_${sessionId}_sent';
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
final senderPublicKeyPrefix =
devicePublicKey != null && devicePublicKey.length >= 6
? devicePublicKey.sublist(0, 6)
: null;
final isChannel =
_destinationType ==
MessageDestinationPreferences.destinationTypeChannel;
@@ -558,8 +604,9 @@ class _MessagesTabState extends State<MessagesTab> {
);
messagesProvider.addSentMessage(sentMsg);
final encodedPackets = <VoicePacket>[];
debugPrint(
'🎙️ [Voice] encoding+sending $total packets, mode=${mode.label}, session=$sessionId',
'🎙️ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId',
);
for (var i = 0; i < total; i++) {
if (!mounted) return;
@@ -576,44 +623,127 @@ class _MessagesTabState extends State<MessagesTab> {
codec2Data: codec2Data,
);
encodedPackets.add(packet);
voiceProvider.addPacket(packet);
if (!isChannel &&
_selectedRecipient != null &&
_selectedRecipient!.outPathLen >= 0) {
debugPrint(
'🎙️ [Voice] packet $i → binary (raw data), pathLen=${_selectedRecipient!.outPathLen}',
);
await connectionProvider.sendRawVoicePacket(
contactPath: _selectedRecipient!.outPath,
contactPathLen: _selectedRecipient!.outPathLen,
payload: packet.encodeBinary(),
);
} else {
final channelIdx = isChannel
? (_selectedRecipient?.publicKey[1] ?? 0)
: 0;
final text = packet.encodeText();
debugPrint(
'🎙️ [Voice] packet $i → text ch=$channelIdx len=${text.length}: $text',
);
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
);
}
debugPrint('🎙️ [Voice] packet $i sent ok');
} catch (e, st) {
debugPrint('❌ [Voice] packet $i send error: $e\n$st');
debugPrint('❌ [Voice] packet $i encode error: $e\n$st');
}
}
debugPrint('🎙️ [Voice] all packets sent for session $sessionId');
if (encodedPackets.isEmpty) {
debugPrint('❌ [Voice] No packets encoded for session $sessionId');
messagesProvider.markMessageFailed(msgId);
return;
}
voiceProvider.cacheOutgoingSession(sessionId, encodedPackets);
if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) {
debugPrint('❌ [Voice] Missing device public key prefix for envelope');
messagesProvider.markMessageFailed(msgId);
return;
}
final senderKey6 = senderPublicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final durationMs = encodedPackets.fold<int>(
0,
(sum, p) => sum + p.durationMs,
);
final envelope = VoiceEnvelope(
sessionId: sessionId,
mode: mode,
total: encodedPackets.length,
durationMs: durationMs,
senderKey6: senderKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1,
);
final envelopeText = envelope.encodeText();
try {
if (isChannel) {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: envelopeText,
messageId: msgId,
);
} else if (_selectedRecipient != null) {
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey,
text: envelopeText,
messageId: msgId,
contact: _selectedRecipient,
);
if (!sentSuccessfully) {
messagesProvider.markMessageFailed(msgId);
return;
}
} else {
// Fallback to public channel if destination cannot be resolved.
await connectionProvider.sendChannelMessage(
channelIdx: 0,
text: envelopeText,
messageId: msgId,
);
}
} catch (e, st) {
debugPrint('❌ [Voice] envelope send error: $e\n$st');
messagesProvider.markMessageFailed(msgId);
return;
}
debugPrint('🎙️ [Voice] envelope sent for session $sessionId');
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
// For channels this is also set by the onMessageSent callback, but this is harmless.
messagesProvider.markMessageSent(msgId, 0, 0);
}
List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks;
final isSilent = chunks.map(_isSilentChunk).toList();
final firstVoice = isSilent.indexWhere((silent) => !silent);
if (firstVoice == -1) return const [];
final lastVoice = isSilent.lastIndexWhere((silent) => !silent);
if (lastVoice < firstVoice) return const [];
final trimmed = <Int16List>[];
var interiorSilentRun = 0;
for (var i = firstVoice; i <= lastVoice; i++) {
if (isSilent[i]) {
interiorSilentRun++;
if (interiorSilentRun <= _maxInteriorSilentChunks) {
trimmed.add(chunks[i]);
}
} else {
interiorSilentRun = 0;
trimmed.add(chunks[i]);
}
}
return trimmed;
}
bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true;
var sumSquares = 0.0;
var peak = 0;
for (final sample in chunk) {
final absSample = sample.abs();
if (absSample > peak) peak = absSample;
sumSquares += sample * sample;
}
final rms = math.sqrt(sumSquares / chunk.length);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
}
// ── SAR dialog ─────────────────────────────────────────────────────────────
void _showSarDialog() {
@@ -1181,7 +1311,8 @@ class _MessagesTabState extends State<MessagesTab> {
: Theme.of(context).textTheme.bodySmall?.color,
),
suffixIcon: GestureDetector(
onLongPressStart: (_voiceSupported && !_isSendingVoice)
onLongPressStart:
(_voiceSupported && !_isSendingVoice)
? (_) => _startVoiceRecording()
: null,
onLongPressEnd: (_voiceSupported && _isRecording)
@@ -1223,8 +1354,8 @@ class _MessagesTabState extends State<MessagesTab> {
: (_isSendingVoice
? 'Sending voice...'
: _voiceSupported
? 'Send (long press to record voice)'
: 'Send'),
? 'Send (long press to record voice)'
: 'Send'),
),
),
),

View File

@@ -9,10 +9,7 @@ import '../l10n/app_localizations.dart';
class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService;
const PacketLogScreen({
super.key,
required this.bleService,
});
const PacketLogScreen({super.key, required this.bleService});
@override
State<PacketLogScreen> createState() => _PacketLogScreenState();
@@ -56,16 +53,18 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
final logs = _filteredLogs;
if (logs.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No logs to export')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No logs to export')));
}
return;
}
// Create CSV content
final buffer = StringBuffer();
buffer.writeln('Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description');
buffer.writeln(
'Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description',
);
for (final log in logs) {
buffer.writeln(log.toCsvRow());
}
@@ -73,7 +72,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
// Save to temporary file
final tempDir = await getTemporaryDirectory();
if (!context.mounted) return;
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv');
final file = File(
'${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv',
);
await file.writeAsString(buffer.toString());
// Share the file
@@ -87,9 +88,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
}
@@ -99,9 +100,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
final logs = _filteredLogs;
if (logs.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No logs to export')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No logs to export')));
}
return;
}
@@ -122,7 +123,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
// Save to temporary file
final tempDir = await getTemporaryDirectory();
if (!context.mounted) return;
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt');
final file = File(
'${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt',
);
await file.writeAsString(buffer.toString());
// Share the file
@@ -136,9 +139,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
}
@@ -159,7 +162,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(AppLocalizations.of(dialogContext)!.clearAllData),
content: const Text('Are you sure you want to clear all packet logs? This cannot be undone.'),
content: const Text(
'Are you sure you want to clear all packet logs? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
@@ -204,11 +209,13 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
actions: [
// Direction filter
PopupMenuButton<PacketDirection?>(
icon: Icon(_filterDirection == null
? Icons.filter_list
: _filterDirection == PacketDirection.rx
? Icons.arrow_downward
: Icons.arrow_upward),
icon: Icon(
_filterDirection == null
? Icons.filter_list
: _filterDirection == PacketDirection.rx
? Icons.arrow_downward
: Icons.arrow_upward,
),
tooltip: 'Filter by direction',
onSelected: (direction) {
setState(() {
@@ -220,12 +227,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
value: null,
child: Row(
children: [
Icon(Icons.filter_list,
color: _filterDirection == null ? Theme.of(context).colorScheme.primary : null),
Icon(
Icons.filter_list,
color: _filterDirection == null
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text('All',
style: TextStyle(
fontWeight: _filterDirection == null ? FontWeight.bold : FontWeight.normal)),
Text(
'All',
style: TextStyle(
fontWeight: _filterDirection == null
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
@@ -233,15 +249,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
value: PacketDirection.rx,
child: Row(
children: [
Icon(Icons.arrow_downward,
color: _filterDirection == PacketDirection.rx
? Theme.of(context).colorScheme.primary
: null),
Icon(
Icons.arrow_downward,
color: _filterDirection == PacketDirection.rx
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text('RX (Received)',
style: TextStyle(
fontWeight:
_filterDirection == PacketDirection.rx ? FontWeight.bold : FontWeight.normal)),
Text(
'RX (Received)',
style: TextStyle(
fontWeight: _filterDirection == PacketDirection.rx
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
@@ -249,15 +271,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
value: PacketDirection.tx,
child: Row(
children: [
Icon(Icons.arrow_upward,
color: _filterDirection == PacketDirection.tx
? Theme.of(context).colorScheme.primary
: null),
Icon(
Icons.arrow_upward,
color: _filterDirection == PacketDirection.tx
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text('TX (Sent)',
style: TextStyle(
fontWeight:
_filterDirection == PacketDirection.tx ? FontWeight.bold : FontWeight.normal)),
Text(
'TX (Sent)',
style: TextStyle(
fontWeight: _filterDirection == PacketDirection.tx
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
@@ -265,7 +293,11 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
),
// Auto-scroll toggle
IconButton(
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
icon: Icon(
_autoScroll
? Icons.vertical_align_bottom
: Icons.vertical_align_center,
),
tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll',
onPressed: () {
setState(() {
@@ -352,11 +384,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.list_alt,
size: 64,
color: Colors.grey[400],
),
Icon(Icons.list_alt, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
_searchQuery.isNotEmpty || _filterDirection != null
@@ -367,7 +395,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
color: Colors.grey[600],
),
),
if (_searchQuery.isNotEmpty || _filterDirection != null) ...[
if (_searchQuery.isNotEmpty ||
_filterDirection != null) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
@@ -420,15 +449,13 @@ class _PacketLogCard extends StatelessWidget {
final BlePacketLog log;
final VoidCallback onCopy;
const _PacketLogCard({
required this.log,
required this.onCopy,
});
const _PacketLogCard({required this.log, required this.onCopy});
@override
Widget build(BuildContext context) {
final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -480,67 +507,168 @@ class _PacketLogCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Hex data
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Hex: ',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
Expanded(
child: SelectableText(
log.hexData,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy hex data',
onPressed: onCopy,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
// Metadata
Wrap(
spacing: 16,
runSpacing: 8,
spacing: 10,
runSpacing: 10,
children: [
_InfoChip(
icon: Icons.schedule,
label: log.timestamp.toIso8601String(),
_FactCard(
icon: isRx ? Icons.call_received : Icons.call_made,
label: 'Direction',
value: isRx ? 'RX' : 'TX',
accent: directionColor,
),
_InfoChip(
icon: Icons.data_usage,
label: '${log.rawData.length} bytes',
_FactCard(
icon: Icons.data_object,
label: 'Size',
value: '${log.rawData.length} bytes',
),
_FactCard(
icon: Icons.schedule,
label: 'Captured',
value: _formatTimestamp(log.timestamp),
),
if (log.responseCode != null)
_InfoChip(
icon: Icons.tag,
label: log.opcodeDescription,
),
// Show RSSI and SNR for LOG_RX_DATA packets
if (log.logRxDataInfo?.rssiDbm != null)
_InfoChip(
icon: Icons.signal_cellular_alt,
label: 'RSSI: ${log.logRxDataInfo!.rssiDbm} dBm',
),
if (log.logRxDataInfo?.snrDb != null)
_InfoChip(
icon: Icons.waves,
label: 'SNR: ${log.logRxDataInfo!.snrDb!.toStringAsFixed(1)} dB',
_FactCard(
icon: Icons.sell,
label: 'Opcode',
value: log.opcodeName,
),
],
),
if (rxInfo?.rssiDbm != null || rxInfo?.snrDb != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Link Quality',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
const SizedBox(height: 10),
if (rxInfo?.rssiDbm != null)
_SignalMeter(
label: 'RSSI',
valueLabel: '${rxInfo!.rssiDbm} dBm',
normalized: _normalizeRssi(
rxInfo.rssiDbm!.toDouble(),
),
color: _rssiColor(rxInfo.rssiDbm!.toDouble()),
),
if (rxInfo?.snrDb != null) ...[
const SizedBox(height: 8),
_SignalMeter(
label: 'SNR',
valueLabel:
'${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
normalized: _normalizeSnr(rxInfo.snrDb!),
color: _snrColor(rxInfo.snrDb!),
),
],
],
),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.grid_view_rounded, size: 16),
const SizedBox(width: 6),
const Text(
'Hex Explorer',
style: TextStyle(fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
onPressed: onCopy,
tooltip: 'Copy full hex',
icon: const Icon(Icons.copy_all_rounded, size: 18),
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (var i = 0; i < log.rawData.length; i++)
_HexByteChip(
index: i,
value: log.rawData[i],
onTap: () {
_copyText(
context,
log.rawData[i]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase(),
'Byte ${i.toString().padLeft(2, '0')} copied',
);
},
),
],
),
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
title: const Text(
'Raw stream',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
log.hexData,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
],
),
],
),
),
],
),
),
@@ -563,27 +691,176 @@ class _PacketLogCard extends StatelessWidget {
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
}
}
static void _copyText(BuildContext context, String text, String message) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(milliseconds: 900),
),
);
}
static double _normalizeRssi(double rssi) {
return ((rssi + 120.0) / 70.0).clamp(0.0, 1.0);
}
static double _normalizeSnr(double snr) {
return ((snr + 20.0) / 40.0).clamp(0.0, 1.0);
}
static Color _rssiColor(double rssi) {
if (rssi >= -80) return Colors.green;
if (rssi >= -95) return Colors.amber;
return Colors.redAccent;
}
static Color _snrColor(double snr) {
if (snr >= 10) return Colors.green;
if (snr >= 0) return Colors.amber;
return Colors.redAccent;
}
}
class _InfoChip extends StatelessWidget {
class _FactCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Color? accent;
const _InfoChip({
const _FactCard({
required this.icon,
required this.label,
required this.value,
this.accent,
});
@override
Widget build(BuildContext context) {
return Chip(
avatar: Icon(icon, size: 16),
label: Text(
label,
style: const TextStyle(fontSize: 11),
final tileColor = accent ?? Theme.of(context).colorScheme.primary;
return Container(
constraints: const BoxConstraints(minWidth: 108),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: tileColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: tileColor),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
);
}
}
class _SignalMeter extends StatelessWidget {
final String label;
final String valueLabel;
final double normalized;
final Color color;
const _SignalMeter({
required this.label,
required this.valueLabel,
required this.normalized,
required this.color,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 42,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: LinearProgressIndicator(
minHeight: 8,
value: normalized,
backgroundColor: color.withValues(alpha: 0.15),
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
),
const SizedBox(width: 10),
SizedBox(
width: 74,
child: Text(
valueLabel,
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
);
}
}
class _HexByteChip extends StatelessWidget {
final int index;
final int value;
final VoidCallback onTap;
const _HexByteChip({
required this.index,
required this.value,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final text = value.toRadixString(16).padLeft(2, '0').toUpperCase();
return Tooltip(
message: 'Byte $index',
waitDuration: const Duration(milliseconds: 250),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(7),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 5),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(7),
),
child: Text(
text,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
),
padding: const EdgeInsets.all(4),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
}

View File

@@ -12,6 +12,7 @@ import '../providers/app_provider.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/update_checker_service.dart';
import '../services/voice_bitrate_preferences.dart';
import '../utils/sample_data_generator.dart';
import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart';
@@ -45,6 +46,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _isLoadingSampleData = false;
bool _showRxTxIndicators = true;
bool _isCheckingForUpdates = false;
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
final LocationTrackingService _locationService = LocationTrackingService();
@override
@@ -55,6 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo();
_initializeLocationService();
_loadRxTxPreference();
_loadVoiceBitratePreference();
}
@override
@@ -89,6 +92,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
await prefs.setBool('show_rx_tx_indicators', value);
}
Future<void> _loadVoiceBitratePreference() async {
final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
setState(() {
_voiceBitrate = value;
});
}
Future<void> _saveVoiceBitratePreference(int value) async {
await VoiceBitratePreferences.setBitrate(value);
if (!mounted) return;
setState(() {
_voiceBitrate = value;
});
}
String _voiceBitrateSubtitle(int bitrate) {
return '$bitrate bps';
}
Future<void> _initializeLocationService() async {
// Initialize location service with BLE service
WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -550,6 +573,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(),
),
const Divider(),
// Voice Settings Section
_buildSectionHeader('Voice'),
Consumer<AppProvider>(
builder: (context, appProvider, child) => _buildVoiceStatsCard(
bitrate: _voiceBitrate,
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
),
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Voice bitrate'),
subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)),
trailing: const Icon(Icons.chevron_right),
onTap: _showVoiceBitrateDialog,
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.tune),
title: const Text('Band-pass filter voice'),
subtitle: const Text(
'Keeps speech frequencies and cuts low/high noise',
),
value: appProvider.isVoiceBandPassFilterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceBandPassFilterEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
title: const Text('Trim silence in voice messages'),
subtitle: const Text(
'Removes long silent parts before sending voice',
),
value: appProvider.isVoiceSilenceTrimmingEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceSilenceTrimmingEnabled(value);
},
),
),
const Divider(),
// Templates Section
_buildSectionHeader('Templates'),
ListTile(
leading: const Icon(Icons.location_searching),
title: Text(AppLocalizations.of(context)!.sarTemplates),
@@ -708,7 +779,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Text(
AppLocalizations.of(context)!.sampleDataDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
@@ -765,6 +838,102 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildVoiceStatsCard({
required int bitrate,
required bool bandPassEnabled,
required bool silenceTrimEnabled,
}) {
final supported = VoiceBitratePreferences.supportedBitrates;
final minBitrate = supported.reduce((a, b) => a < b ? a : b).toDouble();
final maxBitrate = supported.reduce((a, b) => a > b ? a : b).toDouble();
final normalized = maxBitrate > minBitrate
? ((bitrate - minBitrate) / (maxBitrate - minBitrate)).clamp(0.0, 1.0)
: 1.0;
final enabledCount = (bandPassEnabled ? 1 : 0) + (silenceTrimEnabled ? 1 : 0);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Voice Processing Stats',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
'Bitrate: $bitrate bps',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: normalized,
minHeight: 8,
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _voiceStatChip(
label: 'Band-pass',
enabled: bandPassEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Silence trim',
enabled: silenceTrimEnabled,
),
),
],
),
const SizedBox(height: 8),
Text(
'Processing enabled: $enabledCount/2',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
Widget _voiceStatChip({required String label, required bool enabled}) {
final color = enabled ? Colors.green : Colors.grey;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
enabled ? Icons.check_circle : Icons.radio_button_unchecked,
size: 16,
color: color,
),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
),
],
),
);
}
void _showThemeDialog() {
showDialog(
context: context,
@@ -828,7 +997,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
subtitle: Text(AppLocalizations.of(context)!.safeAllClearMode),
subtitle: Text(
AppLocalizations.of(context)!.safeAllClearMode,
),
value: AppThemeMode.sarGreen,
),
RadioListTile<AppThemeMode>(
@@ -855,7 +1026,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
const Divider(),
RadioListTile<AppThemeMode>(
title: Text(AppLocalizations.of(context)!.autoSystem),
subtitle: Text(AppLocalizations.of(context)!.followSystemTheme),
subtitle: Text(
AppLocalizations.of(context)!.followSystemTheme,
),
value: AppThemeMode.system,
),
],
@@ -913,6 +1086,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _showVoiceBitrateDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Voice bitrate'),
content: SingleChildScrollView(
child: RadioGroup<int>(
groupValue: _voiceBitrate,
onChanged: (value) {
if (value != null) {
_saveVoiceBitratePreference(value);
}
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: VoiceBitratePreferences.supportedBitrates
.map(
(bitrate) => RadioListTile<int>(
value: bitrate,
title: Text('$bitrate bps'),
subtitle: bitrate == VoiceBitratePreferences.defaultBitrate
? const Text('Default')
: null,
),
)
.toList(),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
],
),
);
}
void _showAboutDialog() {
showDialog(
context: context,

View File

@@ -133,20 +133,30 @@ class MessageStorageService {
// Echo detection for channel messages
'echoCount': message.echoCount,
'firstEchoAtMillis': message.firstEchoAt?.millisecondsSinceEpoch,
'lastEchoSnrRaw': message.lastEchoSnrRaw,
'lastEchoRssiDbm': message.lastEchoRssiDbm,
'lastEchoAtMillis': message.lastEchoAt?.millisecondsSinceEpoch,
// Drawing message tracking
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
// Voice message tracking
'isVoice': message.isVoice,
'voiceId': message.voiceId,
// Message grouping (for bulk sends)
'groupId': message.groupId,
'recipients': message.recipients?.map((r) => {
'publicKey': base64Encode(r.publicKey),
'displayName': r.displayName,
'deliveryStatus': r.deliveryStatus.name,
'expectedAckTag': r.expectedAckTag,
'roundTripTimeMs': r.roundTripTimeMs,
'deliveredAtMillis': r.deliveredAt?.millisecondsSinceEpoch,
'sentAtMillis': r.sentAt.millisecondsSinceEpoch,
}).toList(),
'recipients': message.recipients
?.map(
(r) => {
'publicKey': base64Encode(r.publicKey),
'displayName': r.displayName,
'deliveryStatus': r.deliveryStatus.name,
'expectedAckTag': r.expectedAckTag,
'roundTripTimeMs': r.roundTripTimeMs,
'deliveredAtMillis': r.deliveredAt?.millisecondsSinceEpoch,
'sentAtMillis': r.sentAt.millisecondsSinceEpoch,
},
)
.toList(),
};
}
@@ -216,34 +226,46 @@ class MessageStorageService {
json['firstEchoAtMillis'] as int,
)
: null,
lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?,
lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?,
lastEchoAt: json['lastEchoAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
json['lastEchoAtMillis'] as int,
)
: null,
// Drawing message tracking
isDrawing: json['isDrawing'] as bool? ?? false,
drawingId: json['drawingId'] as String?,
// Voice message tracking
isVoice: json['isVoice'] as bool? ?? false,
voiceId: json['voiceId'] as String?,
// Message grouping
groupId: json['groupId'] as String?,
recipients: json['recipients'] != null
? (json['recipients'] as List<dynamic>)
.map((r) => MessageRecipient(
publicKey: Uint8List.fromList(
base64Decode(r['publicKey'] as String),
),
displayName: r['displayName'] as String,
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
(e) => e.name == r['deliveryStatus'],
orElse: () => MessageDeliveryStatus.sending,
),
expectedAckTag: r['expectedAckTag'] as int?,
roundTripTimeMs: r['roundTripTimeMs'] as int?,
deliveredAt: r['deliveredAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
r['deliveredAtMillis'] as int,
)
: null,
sentAt: DateTime.fromMillisecondsSinceEpoch(
r['sentAtMillis'] as int,
),
))
.toList()
.map(
(r) => MessageRecipient(
publicKey: Uint8List.fromList(
base64Decode(r['publicKey'] as String),
),
displayName: r['displayName'] as String,
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
(e) => e.name == r['deliveryStatus'],
orElse: () => MessageDeliveryStatus.sending,
),
expectedAckTag: r['expectedAckTag'] as int?,
roundTripTimeMs: r['roundTripTimeMs'] as int?,
deliveredAt: r['deliveredAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
r['deliveredAtMillis'] as int,
)
: null,
sentAt: DateTime.fromMillisecondsSinceEpoch(
r['sentAtMillis'] as int,
),
),
)
.toList()
: null,
);
} catch (e) {

View File

@@ -69,7 +69,7 @@ class NotificationService {
// Initialize plugin
await _notificationsPlugin.initialize(
initSettings,
settings: initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse,
);
@@ -286,10 +286,10 @@ class NotificationService {
// Show notification
await _notificationsPlugin.show(
notificationId,
title,
body,
notificationDetails,
id: notificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'sar:${type.name}:$coordinates',
);
@@ -457,10 +457,10 @@ class NotificationService {
// Show notification
await _notificationsPlugin.show(
notificationId,
title,
body,
notificationDetails,
id: notificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'message:${isChannelMessage ? "channel" : "contact"}',
);
@@ -487,7 +487,7 @@ class NotificationService {
/// Cancel specific notification
Future<void> cancel(int id) async {
try {
await _notificationsPlugin.cancel(id);
await _notificationsPlugin.cancel(id: id);
debugPrint('✅ [NotificationService] Cancelled notification: $id');
} catch (e) {
debugPrint('❌ [NotificationService] Error canceling notification: $e');
@@ -601,10 +601,10 @@ class NotificationService {
// Show notification
await _notificationsPlugin.show(
_updateNotificationId,
title,
body,
notificationDetails,
id: _updateNotificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'update:$downloadUrl',
);

View File

@@ -21,7 +21,8 @@ class SseClientService {
StreamSubscription? _contactSubscription;
bool _isConnected = false;
bool _isConnecting = false;
bool _hasConnectedBefore = false; // Track if we've ever successfully connected
bool _hasConnectedBefore =
false; // Track if we've ever successfully connected
Timer? _reconnectTimer;
Timer? _heartbeatTimer;
int _reconnectAttempts = 0;
@@ -56,10 +57,7 @@ class SseClientService {
String? get serverUrl => _serverUrl;
/// Connect to SSE server
Future<void> connect({
required String serverUrl,
String? authToken,
}) async {
Future<void> connect({required String serverUrl, String? authToken}) async {
if (_isConnected) {
debugPrint('⚠️ [SseClient] Already connected');
return;
@@ -69,14 +67,18 @@ class SseClientService {
_authToken = authToken;
_isConnecting = true;
debugPrint('🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)');
debugPrint(
'🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)',
);
try {
// Create a new HTTP client with custom configuration for SSE streaming
// Using IOClient with custom HttpClient for better control over connection settings
final ioHttpClient = io.HttpClient();
ioHttpClient.connectionTimeout = const Duration(seconds: 10);
ioHttpClient.idleTimeout = const Duration(hours: 1); // Keep SSE connections alive
ioHttpClient.idleTimeout = const Duration(
hours: 1,
); // Keep SSE connections alive
_httpClient = io_client.IOClient(ioHttpClient);
// Test server availability
@@ -90,7 +92,9 @@ class SseClientService {
// Subscribe to SSE streams
debugPrint('🔗 [SseClient] Subscribing to message stream...');
debugPrint('🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}');
debugPrint(
'🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}',
);
await _subscribeToMessages();
debugPrint('🔗 [SseClient] Subscribing to contact stream...');
await _subscribeToContacts();
@@ -149,9 +153,9 @@ class SseClientService {
final url = Uri.parse('$_serverUrl/api/status');
try {
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 5),
);
final response = await http
.get(url, headers: _getHeaders())
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) {
throw Exception('Server returned ${response.statusCode}');
@@ -179,7 +183,8 @@ class SseClientService {
if (errorStr.contains('Connection refused')) {
return 'Server not available at $host:$port. The server may be offline or not running.';
} else if (errorStr.contains('TimeoutException') || errorStr.contains('timed out')) {
} else if (errorStr.contains('TimeoutException') ||
errorStr.contains('timed out')) {
return 'Connection to $host:$port timed out. Check your network connection.';
} else if (errorStr.contains('SocketException')) {
return 'Network error connecting to $host:$port. Check your network connection.';
@@ -195,18 +200,22 @@ class SseClientService {
Future<void> _fetchMessageHistory() async {
try {
final url = Uri.parse('$_serverUrl/api/messages/history');
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 10),
);
final response = await http
.get(url, headers: _getHeaders())
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Failed to fetch message history: ${response.statusCode}');
throw Exception(
'Failed to fetch message history: ${response.statusCode}',
);
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final messages = data['messages'] as List;
debugPrint('📥 [SseClient] Received ${messages.length} messages from history');
debugPrint(
'📥 [SseClient] Received ${messages.length} messages from history',
);
for (final msgJson in messages) {
try {
@@ -226,9 +235,9 @@ class SseClientService {
Future<void> _fetchContacts() async {
try {
final url = Uri.parse('$_serverUrl/api/contacts');
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 10),
);
final response = await http
.get(url, headers: _getHeaders())
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Failed to fetch contacts: ${response.statusCode}');
@@ -270,45 +279,63 @@ class SseClientService {
debugPrint('📡 [SseClient] Sending message stream request to $url');
debugPrint('📡 [SseClient] Request headers: ${request.headers}');
final streamedResponse = await _httpClient!.send(request).timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('❌ [SseClient] Timeout waiting for response headers');
throw TimeoutException('Message stream connection timed out after 10 seconds');
},
final streamedResponse = await _httpClient!
.send(request)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('❌ [SseClient] Timeout waiting for response headers');
throw TimeoutException(
'Message stream connection timed out after 10 seconds',
);
},
);
debugPrint(
'📡 [SseClient] Received response with status: ${streamedResponse.statusCode}',
);
debugPrint(
'📡 [SseClient] Response headers: ${streamedResponse.headers}',
);
debugPrint(
'📡 [SseClient] Response content length: ${streamedResponse.contentLength}',
);
debugPrint(
'📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}',
);
debugPrint('📡 [SseClient] Received response with status: ${streamedResponse.statusCode}');
debugPrint('📡 [SseClient] Response headers: ${streamedResponse.headers}');
debugPrint('📡 [SseClient] Response content length: ${streamedResponse.contentLength}');
debugPrint('📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}');
if (streamedResponse.statusCode != 200) {
throw Exception('SSE messages subscription failed: ${streamedResponse.statusCode}');
throw Exception(
'SSE messages subscription failed: ${streamedResponse.statusCode}',
);
}
debugPrint('📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}');
debugPrint(
'📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}',
);
debugPrint('📡 [SseClient] Setting up stream listener...');
_messageSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received line: "$line"');
_handleSseLine(line, 'message');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Message stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint('⚠️ [SseClient] Message stream closed (onDone called)');
_handleDisconnect();
},
cancelOnError: false,
);
(line) {
debugPrint('📨 [SseClient] Received line: "$line"');
_handleSseLine(line, 'message');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Message stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint(
'⚠️ [SseClient] Message stream closed (onDone called)',
);
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Message stream listener set up successfully');
} catch (e) {
@@ -332,39 +359,49 @@ class SseClientService {
request.headers['Cache-Control'] = 'no-cache';
debugPrint('📡 [SseClient] Sending contact stream request to $url');
final streamedResponse = await _httpClient!.send(request).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('Contact stream connection timed out after 10 seconds');
},
);
final streamedResponse = await _httpClient!
.send(request)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException(
'Contact stream connection timed out after 10 seconds',
);
},
);
if (streamedResponse.statusCode != 200) {
throw Exception('SSE contacts subscription failed: ${streamedResponse.statusCode}');
throw Exception(
'SSE contacts subscription failed: ${streamedResponse.statusCode}',
);
}
debugPrint('📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}');
debugPrint(
'📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}',
);
debugPrint('📡 [SseClient] Setting up contact stream listener...');
_contactSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received contact line: "$line"');
_handleSseLine(line, 'contact');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Contact stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint('⚠️ [SseClient] Contact stream closed (onDone called)');
_handleDisconnect();
},
cancelOnError: false,
);
(line) {
debugPrint('📨 [SseClient] Received contact line: "$line"');
_handleSseLine(line, 'contact');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Contact stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint(
'⚠️ [SseClient] Contact stream closed (onDone called)',
);
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Contact stream listener set up successfully');
} catch (e) {
@@ -423,7 +460,9 @@ class SseClientService {
_reconnectAttempts++;
final delay = _reconnectDelay * _reconnectAttempts;
debugPrint('🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s');
debugPrint(
'🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s',
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
@@ -436,7 +475,9 @@ class SseClientService {
/// Start heartbeat to detect connection loss
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) async {
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) async {
try {
await _checkServerStatus();
} catch (e) {
@@ -457,17 +498,16 @@ class SseClientService {
try {
final url = Uri.parse('$_serverUrl/api/messages');
final response = await http.post(
url,
headers: {
..._getHeaders(),
'Content-Type': 'application/json',
},
body: jsonEncode({
'recipientPublicKey': recipientPublicKey,
'text': text,
}),
).timeout(const Duration(seconds: 10));
final response = await http
.post(
url,
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
body: jsonEncode({
'recipientPublicKey': recipientPublicKey,
'text': text,
}),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send message failed: ${response.statusCode}');
@@ -492,17 +532,13 @@ class SseClientService {
try {
final url = Uri.parse('$_serverUrl/api/messages/channel');
final response = await http.post(
url,
headers: {
..._getHeaders(),
'Content-Type': 'application/json',
},
body: jsonEncode({
'channelIdx': channelIdx,
'text': text,
}),
).timeout(const Duration(seconds: 10));
final response = await http
.post(
url,
headers: {..._getHeaders(), 'Content-Type': 'application/json'},
body: jsonEncode({'channelIdx': channelIdx, 'text': text}),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send channel message failed: ${response.statusCode}');
@@ -521,10 +557,9 @@ class SseClientService {
try {
final url = Uri.parse('$_serverUrl/api/contacts/sync');
final response = await http.post(
url,
headers: _getHeaders(),
).timeout(const Duration(seconds: 10));
final response = await http
.post(url, headers: _getHeaders())
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Contact sync failed: ${response.statusCode}');
@@ -555,7 +590,9 @@ class SseClientService {
orElse: () => MessageType.contact,
),
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
? Uint8List.fromList((json['senderPublicKeyPrefix'] as List).cast<int>())
? Uint8List.fromList(
(json['senderPublicKeyPrefix'] as List).cast<int>(),
)
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
@@ -597,6 +634,11 @@ class SseClientService {
firstEchoAt: json['firstEchoAt'] != null
? DateTime.parse(json['firstEchoAt'] as String)
: null,
lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?,
lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?,
lastEchoAt: json['lastEchoAt'] != null
? DateTime.parse(json['lastEchoAt'] as String)
: null,
isDrawing: json['isDrawing'] as bool? ?? false,
drawingId: json['drawingId'] as String?,
);

View File

@@ -76,11 +76,14 @@ class SseServerService {
static shelf.Middleware get _corsHeaders {
return shelf.createMiddleware(
responseHandler: (shelf.Response response) {
return response.change(headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Origin, Content-Type, Authorization',
});
return response.change(
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers':
'Origin, Content-Type, Authorization',
},
);
},
);
}
@@ -95,7 +98,9 @@ class SseServerService {
_config = config;
try {
debugPrint('🚀 [SseServer] Starting server on ${config.host}:${config.port}');
debugPrint(
'🚀 [SseServer] Starting server on ${config.host}:${config.port}',
);
// Create shelf handler with CORS support
final handler = const shelf.Pipeline()
@@ -104,11 +109,7 @@ class SseServerService {
.addHandler(_handleRequest);
// Start HTTP server
_server = await io.serve(
handler,
config.host,
config.port,
);
_server = await io.serve(handler, config.host, config.port);
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
@@ -127,7 +128,9 @@ class SseServerService {
/// Register Bonjour/mDNS service for network discovery
Future<void> _registerBonjourService(SseServerConfig config) async {
try {
debugPrint('📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...');
debugPrint(
'📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...',
);
_bonjourRegistration = await register(
const Service(
@@ -148,7 +151,9 @@ class SseServerService {
port: config.port,
),
);
debugPrint('✅ [SseServer] Bonjour service registered on port ${config.port}');
debugPrint(
'✅ [SseServer] Bonjour service registered on port ${config.port}',
);
}
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
@@ -168,20 +173,28 @@ class SseServerService {
/// Clean up dead/closed connections
void _cleanupDeadConnections() {
// Clean up message streams
final deadMessageStreams = _messageStreams.where((s) => s.isClosed).toList();
final deadMessageStreams = _messageStreams
.where((s) => s.isClosed)
.toList();
for (final stream in deadMessageStreams) {
_messageStreams.remove(stream);
}
// Clean up contact streams
final deadContactStreams = _contactStreams.where((s) => s.isClosed).toList();
final deadContactStreams = _contactStreams
.where((s) => s.isClosed)
.toList();
for (final stream in deadContactStreams) {
_contactStreams.remove(stream);
}
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams');
debugPrint(' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients');
debugPrint(
'🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams',
);
debugPrint(
' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients',
);
}
}
@@ -269,7 +282,9 @@ class SseServerService {
/// Handle SSE messages stream
shelf.Response _handleSseMessages(shelf.Request request) {
return request.hijack((channel) async {
debugPrint('📥 [SseServer] New SSE client connected (messages) via hijack');
debugPrint(
'📥 [SseServer] New SSE client connected (messages) via hijack',
);
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
@@ -297,7 +312,9 @@ class SseServerService {
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
@@ -337,7 +354,9 @@ class SseServerService {
/// Handle SSE contacts stream
shelf.Response _handleSseContacts(shelf.Request request) {
return request.hijack((channel) async {
debugPrint('📥 [SseServer] New SSE client connected (contacts) via hijack');
debugPrint(
'📥 [SseServer] New SSE client connected (contacts) via hijack',
);
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
@@ -365,7 +384,9 @@ class SseServerService {
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
@@ -432,7 +453,9 @@ class SseServerService {
}
/// Handle POST channel message request
Future<shelf.Response> _handlePostChannelMessage(shelf.Request request) async {
Future<shelf.Response> _handlePostChannelMessage(
shelf.Request request,
) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
@@ -442,7 +465,9 @@ class SseServerService {
if (onSendChannelMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Send channel message callback not configured'}),
body: jsonEncode({
'error': 'Send channel message callback not configured',
}),
);
}
@@ -574,10 +599,7 @@ class SseServerService {
</body>
</html>
''';
return shelf.Response.ok(
html,
headers: {'content-type': 'text/html'},
);
return shelf.Response.ok(html, headers: {'content-type': 'text/html'});
}
/// Broadcast a new message to all SSE clients
@@ -599,7 +621,9 @@ class SseServerService {
try {
stream.add(event);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
debugPrint(
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
);
deadStreams.add(stream);
}
}
@@ -608,14 +632,20 @@ class SseServerService {
// Remove dead streams
for (final stream in deadStreams) {
_messageStreams.remove(stream);
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
stream.close().catchError(
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
);
}
if (deadStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast');
debugPrint(
'🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast',
);
}
debugPrint('📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients');
debugPrint(
'📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients',
);
}
/// Broadcast a new or updated contact to all SSE clients
@@ -634,7 +664,9 @@ class SseServerService {
try {
stream.add(event);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
debugPrint(
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
);
deadStreams.add(stream);
}
}
@@ -643,14 +675,20 @@ class SseServerService {
// Remove dead streams
for (final stream in deadStreams) {
_contactStreams.remove(stream);
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
stream.close().catchError(
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
);
}
if (deadStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast');
debugPrint(
'🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast',
);
}
debugPrint('📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients');
debugPrint(
'📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients',
);
}
/// Format SSE event
@@ -694,6 +732,9 @@ class SseServerService {
'isRead': message.isRead,
'echoCount': message.echoCount,
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
'lastEchoSnrRaw': message.lastEchoSnrRaw,
'lastEchoRssiDbm': message.lastEchoRssiDbm,
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
};

View File

@@ -0,0 +1,41 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/voice_message_parser.dart';
/// Stores user-selected voice bitrate and maps it to supported codec modes.
class VoiceBitratePreferences {
static const String _bitrateKey = 'voice_bitrate';
static const int defaultBitrate = 1300;
static const List<int> supportedBitrates = [700, 1200, 1300, 1400, 1600, 2400, 3200];
static Future<int> getBitrate() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_bitrateKey) ?? defaultBitrate;
return supportedBitrates.contains(value) ? value : defaultBitrate;
}
static Future<void> setBitrate(int bitrate) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_bitrateKey, bitrate);
}
static VoicePacketMode toVoiceMode(int bitrate) {
switch (bitrate) {
case 1200:
return VoicePacketMode.mode1200;
case 1300:
return VoicePacketMode.mode1300;
case 1400:
return VoicePacketMode.mode1400;
case 1600:
return VoicePacketMode.mode1600;
case 2400:
return VoicePacketMode.mode2400;
case 3200:
return VoicePacketMode.mode3200;
case 700:
return VoicePacketMode.mode700c;
default:
return VoicePacketMode.mode1300;
}
}
}

View File

@@ -8,6 +8,9 @@ export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
@@ -8,36 +9,74 @@ import 'package:path_provider/path_provider.dart';
/// to the system temp directory and using [AudioPlayer].
class VoicePlayerService {
final AudioPlayer _player = AudioPlayer();
final StreamController<void> _events = StreamController<void>.broadcast();
bool _isPlaying = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
Timer? _fallbackTicker;
DateTime? _playbackStartedAt;
bool get isPlaying => _isPlaying;
Duration get position => _position;
Duration get duration => _duration;
Stream<void> get events => _events.stream;
VoicePlayerService() {
_player.onPlayerStateChanged.listen((state) {
debugPrint('🔊 [VoicePlayer] state → $state');
_isPlaying = state == PlayerState.playing;
if (_isPlaying) {
_startFallbackTicker();
} else {
_stopFallbackTicker();
}
_events.add(null);
});
_player.onLog.listen((msg) => debugPrint('🔊 [VoicePlayer] log: $msg'));
_player.onPositionChanged.listen((position) {
_position = position;
_events.add(null);
});
_player.onDurationChanged.listen((duration) {
_duration = duration;
_events.add(null);
});
_player.onPlayerComplete.listen((_) {
_isPlaying = false;
_position = _duration;
_stopFallbackTicker();
_events.add(null);
});
}
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
Future<void> play(Int16List pcmSamples) async {
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
if (_isPlaying) await stop();
_position = Duration.zero;
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
_playbackStartedAt = DateTime.now();
_events.add(null);
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
final tmpDir = await getTemporaryDirectory();
final file = File('${tmpDir.path}/vc_voice.wav');
await file.writeAsBytes(wavBytes);
debugPrint('🔊 [VoicePlayer] WAV written: ${wavBytes.length} bytes → ${file.path}');
debugPrint(
'🔊 [VoicePlayer] WAV written: ${wavBytes.length} bytes → ${file.path}',
);
try {
_isPlaying = true;
_startFallbackTicker();
_events.add(null);
await _player.play(DeviceFileSource(file.path));
debugPrint('🔊 [VoicePlayer] play() returned (audio playing)');
} catch (e, st) {
debugPrint('❌ [VoicePlayer] play() error: $e\n$st');
_isPlaying = false;
_stopFallbackTicker();
_events.add(null);
}
}
@@ -45,40 +84,76 @@ class VoicePlayerService {
debugPrint('🔊 [VoicePlayer] stop()');
await _player.stop();
_isPlaying = false;
_position = Duration.zero;
_playbackStartedAt = null;
_stopFallbackTicker();
_events.add(null);
}
void dispose() {
_stopFallbackTicker();
_events.close();
_player.dispose();
}
void _startFallbackTicker() {
if (_fallbackTicker != null) return;
_fallbackTicker = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (!_isPlaying || _duration.inMilliseconds <= 0) return;
final startedAt = _playbackStartedAt;
if (startedAt == null) return;
final elapsed = DateTime.now().difference(startedAt);
final clamped = elapsed > _duration ? _duration : elapsed;
if (clamped > _position) {
_position = clamped;
_events.add(null);
}
});
}
void _stopFallbackTicker() {
_fallbackTicker?.cancel();
_fallbackTicker = null;
}
// ── WAV file builder ─────────────────────────────────────────────────────
/// Constructs a minimal WAV (RIFF/PCM) file from Int16 mono samples.
static Uint8List _buildWav(Int16List samples, {required int sampleRate}) {
const int numChannels = 1;
const int numChannels = 1;
const int bitsPerSample = 16;
const int audioFormat = 1; // PCM
const int audioFormat = 1; // PCM
final dataSize = samples.length * 2; // 2 bytes per Int16 sample
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
final dataSize = samples.length * 2; // 2 bytes per Int16 sample
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
final blockAlign = numChannels * bitsPerSample ~/ 8;
final totalSize = 36 + dataSize;
final totalSize = 36 + dataSize;
final buf = ByteData(44 + dataSize);
var offset = 0;
void writeStr(String s) {
for (final c in s.codeUnits) { buf.setUint8(offset++, c); }
for (final c in s.codeUnits) {
buf.setUint8(offset++, c);
}
}
void writeU32(int v) {
buf.setUint32(offset, v, Endian.little);
offset += 4;
}
void writeU16(int v) {
buf.setUint16(offset, v, Endian.little);
offset += 2;
}
void writeU32(int v) { buf.setUint32(offset, v, Endian.little); offset += 4; }
void writeU16(int v) { buf.setUint16(offset, v, Endian.little); offset += 2; }
writeStr('RIFF');
writeU32(totalSize);
writeStr('WAVE');
writeStr('fmt ');
writeU32(16); // subchunk1 size
writeU16(audioFormat); // 1 = PCM
writeU32(16); // subchunk1 size
writeU16(audioFormat); // 1 = PCM
writeU16(numChannels);
writeU32(sampleRate);
writeU32(byteRate);

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:record/record.dart';
@@ -23,9 +24,11 @@ class VoiceRecorderService {
/// Start capturing PCM audio.
///
/// [chunkDuration] controls how often samples are emitted (default 1 s).
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1),
bool enableBandPassFilter = true,
}) {
if (_isRecording) {
throw StateError('VoiceRecorderService: already recording');
@@ -36,11 +39,17 @@ class VoiceRecorderService {
);
_isRecording = true;
_startRecording(chunkDuration);
_startRecording(
chunkDuration,
enableBandPassFilter: enableBandPassFilter,
);
return _controller!.stream;
}
Future<void> _startRecording(Duration chunkDuration) async {
Future<void> _startRecording(
Duration chunkDuration, {
required bool enableBandPassFilter,
}) async {
final config = const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 8000,
@@ -50,6 +59,11 @@ class VoiceRecorderService {
try {
final stream = await _recorder.startStream(config);
final voiceFilter = _VoiceBandPassFilter(
sampleRate: 8000,
lowCutHz: 250.0,
highCutHz: 3400.0,
);
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[];
@@ -59,13 +73,19 @@ class VoiceRecorderService {
while (buffer.length >= chunkBytes) {
final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes);
_controller?.add(_bytesToInt16(Uint8List.fromList(chunk)));
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
_controller?.add(
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
);
}
},
onDone: () {
if (buffer.isNotEmpty) {
final padded = _padToEven(buffer);
_controller?.add(_bytesToInt16(Uint8List.fromList(padded)));
final pcm = _bytesToInt16(Uint8List.fromList(padded));
_controller?.add(
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
);
}
_controller?.close();
},
@@ -117,3 +137,121 @@ class VoiceRecorderService {
return buf;
}
}
/// Band-pass filter tuned for human voice at 8 kHz input.
///
/// Uses a cascaded high-pass + low-pass biquad to attenuate very low-frequency
/// rumble and high-frequency noise outside the speech band.
class _VoiceBandPassFilter {
final _BiquadFilter _highPass;
final _BiquadFilter _lowPass;
_VoiceBandPassFilter({
required int sampleRate,
required double lowCutHz,
required double highCutHz,
}) : _highPass = _BiquadFilter.highPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: lowCutHz,
),
_lowPass = _BiquadFilter.lowPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: highCutHz,
);
Int16List process(Int16List input) {
final output = Int16List(input.length);
for (var i = 0; i < input.length; i++) {
var sample = input[i].toDouble();
sample = _highPass.process(sample);
sample = _lowPass.process(sample);
output[i] = sample.clamp(-32768.0, 32767.0).round();
}
return output;
}
}
/// Standard biquad IIR filter (Direct Form I).
class _BiquadFilter {
final double _b0;
final double _b1;
final double _b2;
final double _a1;
final double _a2;
double _x1 = 0.0;
double _x2 = 0.0;
double _y1 = 0.0;
double _y2 = 0.0;
_BiquadFilter._({
required double b0,
required double b1,
required double b2,
required double a1,
required double a2,
}) : _b0 = b0,
_b1 = b1,
_b2 = b2,
_a1 = a1,
_a2 = a2;
factory _BiquadFilter.lowPass({
required double sampleRate,
required double cutoffHz,
}) {
const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2))
final omega = 2.0 * math.pi * cutoffHz / sampleRate;
final cosOmega = math.cos(omega);
final alpha = math.sin(omega) / (2.0 * q);
final b0 = (1.0 - cosOmega) / 2.0;
final b1 = 1.0 - cosOmega;
final b2 = (1.0 - cosOmega) / 2.0;
final a0 = 1.0 + alpha;
final a1 = -2.0 * cosOmega;
final a2 = 1.0 - alpha;
return _BiquadFilter._(
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
);
}
factory _BiquadFilter.highPass({
required double sampleRate,
required double cutoffHz,
}) {
const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2))
final omega = 2.0 * math.pi * cutoffHz / sampleRate;
final cosOmega = math.cos(omega);
final alpha = math.sin(omega) / (2.0 * q);
final b0 = (1.0 + cosOmega) / 2.0;
final b1 = -(1.0 + cosOmega);
final b2 = (1.0 + cosOmega) / 2.0;
final a0 = 1.0 + alpha;
final a1 = -2.0 * cosOmega;
final a2 = 1.0 - alpha;
return _BiquadFilter._(
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
);
}
double process(double x) {
final y = _b0 * x + _b1 * _x1 + _b2 * _x2 - _a1 * _y1 - _a2 * _y2;
_x2 = _x1;
_x1 = x;
_y2 = _y1;
_y1 = y;
return y;
}
}

View File

@@ -10,12 +10,15 @@ extension MessageLocalization on Message {
// For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
final latestMeta = _formatEchoMeta(context);
if (echoCount == 0) {
return l10n.broadcast; // "Broadcast (no echoes yet)"
} else if (echoCount == 1) {
return 'Rebroadcast by 1 node';
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
} else {
return 'Rebroadcast by $echoCount nodes';
return latestMeta == null
? '$echoCount nodes'
: '$echoCount nodes • $latestMeta';
}
}
@@ -46,4 +49,53 @@ extension MessageLocalization on Message {
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
String? _formatEchoMeta(BuildContext context) {
if (lastEchoSnrRaw == null &&
lastEchoRssiDbm == null &&
lastEchoAt == null) {
return null;
}
final parts = <String>[];
if (lastEchoRssiDbm != null) {
parts.add('R ${_barsForRssi(lastEchoRssiDbm!)} $lastEchoRssiDbm dBm');
}
if (lastEchoSnrRaw != null) {
final snrDb = lastEchoSnrRaw!.toSigned(8) / 4.0;
parts.add('S ${_barsForSnr(snrDb)} ${snrDb.toStringAsFixed(1)} dB');
}
if (lastEchoAt != null) {
final diff = DateTime.now().difference(lastEchoAt!);
final l10n = AppLocalizations.of(context)!;
if (diff.inMinutes < 1) {
parts.add(l10n.justNow);
} else if (diff.inMinutes < 60) {
parts.add(l10n.minutesAgo(diff.inMinutes));
} else if (diff.inHours < 24) {
parts.add(l10n.hoursAgo(diff.inHours));
} else {
parts.add(l10n.daysAgo(diff.inDays));
}
}
if (parts.isEmpty) return null;
return parts.join('');
}
String _barsForRssi(int rssiDbm) {
// Approximate useful RSSI range: -120..-70 dBm
final score = ((rssiDbm + 120) / 10).round().clamp(0, 5);
return _asciiBars(score, 5);
}
String _barsForSnr(double snrDb) {
// Approximate useful SNR range: -5..+20 dB
final score = ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
return _asciiBars(score, 5);
}
String _asciiBars(int filled, int total) {
return '[${'#' * filled}${'-' * (total - filled)}]';
}
}

View File

@@ -7,14 +7,19 @@ enum VoicePacketMode {
mode700c(0, '700C'),
mode1200(1, '1200'),
mode2400(2, '2400'),
mode1300(3, '1300');
mode1300(3, '1300'),
mode1400(4, '1400'),
mode1600(5, '1600'),
mode3200(6, '3200');
const VoicePacketMode(this.id, this.label);
final int id;
final String label;
static VoicePacketMode fromId(int id) =>
VoicePacketMode.values.firstWhere((m) => m.id == id, orElse: () => VoicePacketMode.mode700c);
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
(m) => m.id == id,
orElse: () => VoicePacketMode.mode1300,
);
}
/// A single Codec2-encoded chunk belonging to a multi-packet voice session.
@@ -27,8 +32,8 @@ enum VoicePacketMode {
class VoicePacket {
final String sessionId; // 8 hex chars (4 bytes)
final VoicePacketMode mode;
final int index; // 0-based
final int total; // total packet count
final int index; // 0-based
final int total; // total packet count
final Uint8List codec2Data;
const VoicePacket({
@@ -89,7 +94,8 @@ class VoicePacket {
// ── Binary format ────────────────────────────────────────────────────────
static const int _binaryMagic = 0x56; // 'V'
static const int _binaryHeaderLen = 8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
static const int _binaryHeaderLen =
8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
static bool isVoiceBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
@@ -100,10 +106,12 @@ class VoicePacket {
if (payload[0] != _binaryMagic) return null;
try {
final sessionBytes = payload.sublist(1, 5);
final sessionId = sessionBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final sessionId = sessionBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final modeId = payload[5];
final index = payload[6];
final total = payload[7];
final index = payload[6];
final total = payload[7];
if (total < 1) return null;
final codec2Data = payload.sublist(_binaryHeaderLen);
return VoicePacket(
@@ -122,7 +130,10 @@ class VoicePacket {
Uint8List encodeBinary() {
final sessionBytes = Uint8List(4);
for (var i = 0; i < 4; i++) {
sessionBytes[i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
sessionBytes[i] = int.parse(
sessionId.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
out[0] = _binaryMagic;
@@ -143,7 +154,10 @@ class VoicePacket {
VoicePacketMode.mode700c => 100,
VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode1400 => 175,
VoicePacketMode.mode1600 => 200,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode3200 => 400,
};
if (bps == 0) return 0;
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
@@ -153,3 +167,201 @@ class VoicePacket {
String toString() =>
'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)';
}
/// Lightweight public/direct message envelope advertising voice availability.
///
/// Text format:
/// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
/// Example:
/// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1
class VoiceEnvelope {
static const String _prefix = 'VE1:';
final String sessionId;
final VoicePacketMode mode;
final int total;
final int durationMs;
final String senderKey6;
final int timestampSec;
final int version;
const VoiceEnvelope({
required this.sessionId,
required this.mode,
required this.total,
required this.durationMs,
required this.senderKey6,
required this.timestampSec,
this.version = 1,
});
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
static VoiceEnvelope? tryParseText(String text) {
if (!isVoiceEnvelopeText(text)) return null;
final body = text.substring(_prefix.length);
return _tryParseCompact(body);
}
static VoiceEnvelope? _tryParseCompact(String body) {
final parts = body.split(':');
if (parts.length != 7) return null;
try {
final sid = parts[0];
final mode = int.tryParse(parts[1]);
final total = int.tryParse(parts[2]);
final durMs = int.tryParse(parts[3]);
final senderKey6 = parts[4];
final ts = int.tryParse(parts[5]);
final ver = int.tryParse(parts[6]);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
return null;
}
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
return null;
}
if (total == null || total < 1 || total > 255) return null;
if (durMs == null || durMs < 0 || durMs > 10 * 60 * 1000) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
return null;
}
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return VoiceEnvelope(
sessionId: sid.toLowerCase(),
mode: VoicePacketMode.fromId(mode),
total: total,
durationMs: durMs,
senderKey6: senderKey6.toLowerCase(),
timestampSec: ts,
version: ver,
);
} catch (_) {
return null;
}
}
String encodeText() {
return '$_prefix${sessionId.toLowerCase()}:${mode.id}:$total:$durationMs:${senderKey6.toLowerCase()}:$timestampSec:$version';
}
}
/// Direct control-plane request to fetch voice packets for a session.
///
/// Text format:
/// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
/// Example:
/// VR1:00112233:a:aabbccddeeff:1234567890:1
class VoiceFetchRequest {
static const String _prefix = 'VR1:';
final String sessionId;
final String want;
final String requesterKey6;
final int timestampSec;
final int version;
const VoiceFetchRequest({
required this.sessionId,
this.want = 'all',
required this.requesterKey6,
required this.timestampSec,
this.version = 1,
});
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
static VoiceFetchRequest? tryParseText(String text) {
if (!isVoiceFetchRequestText(text)) return null;
final body = text.substring(_prefix.length);
return _tryParseCompact(body);
}
static VoiceFetchRequest? _tryParseCompact(String body) {
final parts = body.split(':');
if (parts.length != 5) return null;
try {
final sid = parts[0];
final wantToken = parts[1];
final requesterKey6 = parts[2];
final ts = int.tryParse(parts[3]);
final ver = int.tryParse(parts[4]);
final normalizedWant = wantToken == 'a' ? 'all' : wantToken;
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
return null;
}
if (normalizedWant != 'all') return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
return null;
}
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return VoiceFetchRequest(
sessionId: sid.toLowerCase(),
want: normalizedWant,
requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts,
version: ver,
);
} catch (_) {
return null;
}
}
String encodeText() {
final wantToken = want == 'all' ? 'a' : want;
return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
}
}
/// Builds a compact visual waveform from real voice packet bytes.
///
/// Note: This uses the encoded Codec2 packet bytes as the source so it works
/// even before full PCM decode/playback is available.
class VoiceWaveform {
static List<double> buildBarsFromPackets(
Iterable<VoicePacket?> packets, {
int bars = 24,
}) {
if (bars <= 0) return const [];
final merged = <int>[];
for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) continue;
merged.addAll(pkt.codec2Data);
}
if (merged.isEmpty) return List<double>.filled(bars, 0.0);
final out = List<double>.filled(bars, 0.0);
for (var i = 0; i < bars; i++) {
final start = (i * merged.length) ~/ bars;
var end = ((i + 1) * merged.length) ~/ bars;
if (end <= start) end = start + 1;
if (end > merged.length) end = merged.length;
var sum = 0.0;
for (var j = start; j < end; j++) {
final centered = (merged[j] - 128).abs();
sum += centered / 127.0;
}
out[i] = (sum / (end - start)).clamp(0.0, 1.0);
}
// Light smoothing to avoid jittery adjacent bars.
if (bars > 2) {
final smoothed = List<double>.from(out);
for (var i = 1; i < bars - 1; i++) {
smoothed[i] = ((out[i - 1] + out[i] + out[i + 1]) / 3.0).clamp(
0.0,
1.0,
);
}
return smoothed;
}
return out;
}
}

View File

@@ -11,12 +11,14 @@ import '../../providers/messages_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart';
import '../../providers/voice_provider.dart';
import '../contacts/direct_message_sheet.dart';
import '../drawing_minimap_preview.dart';
import '../../services/sar_template_service.dart';
import '../../utils/toast_logger.dart';
import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart';
import '../../utils/voice_message_parser.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import 'voice_message_bubble.dart';
@@ -264,6 +266,15 @@ class _MessageBubbleState extends State<MessageBubble> {
_hideDrawingFromMap(context);
},
),
// Technical details option
ListTile(
leading: const Icon(Icons.data_object),
title: const Text('Technical details'),
onTap: () {
Navigator.pop(context);
_showTechnicalDetails(context);
},
),
// Delete message option
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
@@ -282,6 +293,534 @@ class _MessageBubbleState extends State<MessageBubble> {
);
}
void _showTechnicalDetails(BuildContext context) {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final voiceProvider = context.read<VoiceProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage =
widget.message.isSentMessage ||
widget.message.isFromSelf(selfPublicKey);
String? senderName;
if (widget.message.senderPublicKeyPrefix != null) {
final senderKeyHex = widget.message.senderPublicKeyPrefix!
.sublist(
0,
widget.message.senderPublicKeyPrefix!.length < 6
? widget.message.senderPublicKeyPrefix!.length
: 6,
)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final senderContact = contactsProvider.contacts
.where((c) => c.publicKeyHex.startsWith(senderKeyHex))
.firstOrNull;
senderName = senderContact?.advName;
}
String? recipientName;
if (widget.message.recipientPublicKey != null) {
final recipientKeyHex = widget.message.recipientPublicKey!
.sublist(
0,
widget.message.recipientPublicKey!.length < 6
? widget.message.recipientPublicKey!.length
: 6,
)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final recipientContact = contactsProvider.contacts
.where((c) => c.publicKeyHex.startsWith(recipientKeyHex))
.firstOrNull;
recipientName = recipientContact?.advName;
}
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
final voiceSession = widget.message.voiceId != null
? voiceProvider.session(widget.message.voiceId!)
: null;
final senderPrefixHex = widget.message.senderPublicKeyPrefix
?.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final recipientKey = widget.message.recipientPublicKey;
final recipientPrefixHex = recipientKey
?.sublist(0, recipientKey.length < 6 ? recipientKey.length : 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final snrDb = widget.message.lastEchoSnrRaw != null
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0)
: null;
final rawLines = <String>[
'Message ID: ${widget.message.id}',
'Type: ${widget.message.messageType.name}',
'Text type: ${widget.message.textType.name}',
'Own message: $isOwnMessage',
'Sent message: ${widget.message.isSentMessage}',
'Read: ${widget.message.isRead}',
'Status: ${widget.message.deliveryStatus.name}',
'Path length (nodes/hops): ${widget.message.pathLen}',
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
'Received at: ${widget.message.receivedAt.toIso8601String()}',
'Channel index: ${widget.message.channelIdx ?? '-'}',
'Echo count: ${widget.message.echoCount}',
'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}',
'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
'Retry attempt: ${widget.message.retryAttempt}',
'Used flood fallback: ${widget.message.usedFloodFallback}',
'Sender key prefix: ${senderPrefixHex ?? '-'}',
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
'Recipient name: ${recipientName ?? '-'}',
'Drawing flag: ${widget.message.isDrawing}',
'Drawing ID: ${widget.message.drawingId ?? '-'}',
'SAR flag: ${widget.message.isSarMarker}',
'Voice flag: ${widget.message.isVoice}',
'Voice ID: ${widget.message.voiceId ?? '-'}',
'Text length: ${widget.message.text.length}',
];
if (widget.message.isVoice) {
rawLines.add('--- Voice Technical ---');
if (envelope != null) {
rawLines.add('Envelope format: VE1 compact');
rawLines.add(
'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})',
);
rawLines.add('Segments total (envelope): ${envelope.total}');
rawLines.add(
'Estimated duration ms (envelope): ${envelope.durationMs}',
);
rawLines.add('Envelope senderKey6: ${envelope.senderKey6}');
rawLines.add('Envelope ts: ${envelope.timestampSec}');
rawLines.add('Envelope ver: ${envelope.version}');
} else if (legacyVoicePacket != null) {
rawLines.add('Envelope format: legacy V packet');
rawLines.add(
'Legacy segment index/total: ${legacyVoicePacket.index + 1}/${legacyVoicePacket.total}',
);
rawLines.add(
'Legacy codec mode: ${legacyVoicePacket.mode.label} (id=${legacyVoicePacket.mode.id})',
);
} else {
rawLines.add('Envelope format: unknown');
}
if (voiceSession != null) {
rawLines.add('Session present locally: yes');
rawLines.add('Session mode: ${voiceSession.mode.label}');
rawLines.add(
'Session segments received/total: ${voiceSession.receivedCount}/${voiceSession.total}',
);
rawLines.add('Session complete: ${voiceSession.isComplete}');
rawLines.add(
'Session estimated duration s: ${voiceSession.estimatedDurationSeconds.toStringAsFixed(2)}',
);
} else {
rawLines.add('Session present locally: no');
}
}
void copyField(String label, String value) {
Clipboard.setData(ClipboardData(text: value));
ToastLogger.success(context, '$label copied');
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Message technical details'),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_techBadge(
context,
icon: Icons.message,
label: widget.message.messageType.name.toUpperCase(),
),
_techBadge(
context,
icon: Icons.route,
label:
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
),
_techBadge(
context,
icon: Icons.account_tree_outlined,
label:
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
),
if (widget.message.channelIdx != null)
_techBadge(
context,
icon: Icons.group_work,
label: 'CH ${widget.message.channelIdx}',
),
],
),
if (widget.message.lastEchoRssiDbm != null ||
snrDb != null) ...[
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.network_check,
title: 'Link quality',
child: Column(
children: [
if (widget.message.lastEchoRssiDbm != null)
_signalRow(
context,
label: 'RSSI',
valueLabel: '${widget.message.lastEchoRssiDbm} dBm',
normalized:
((widget.message.lastEchoRssiDbm!.toDouble() +
120.0) /
70.0)
.clamp(0.0, 1.0),
color: widget.message.lastEchoRssiDbm! >= -80
? Colors.green
: widget.message.lastEchoRssiDbm! >= -95
? Colors.amber
: Colors.redAccent,
),
if (snrDb != null) ...[
const SizedBox(height: 8),
_signalRow(
context,
label: 'SNR',
valueLabel: '${snrDb.toStringAsFixed(1)} dB',
normalized: ((snrDb + 20.0) / 40.0).clamp(0.0, 1.0),
color: snrDb >= 10
? Colors.green
: snrDb >= 0
? Colors.amber
: Colors.redAccent,
),
],
],
),
),
],
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.tune,
title: 'Delivery',
child: Column(
children: [
_detailRow(
context,
label: 'Status',
value: widget.message.deliveryStatus.name,
),
_detailRow(
context,
label: 'Expected ACK tag',
value: widget.message.expectedAckTag?.toString() ?? '-',
),
_detailRow(
context,
label: 'Round-trip',
value: widget.message.roundTripTimeMs != null
? '${widget.message.roundTripTimeMs} ms'
: '-',
),
_detailRow(
context,
label: 'Retry attempt',
value: widget.message.retryAttempt.toString(),
),
_detailRow(
context,
label: 'Flood fallback',
value: widget.message.usedFloodFallback ? 'Yes' : 'No',
),
],
),
),
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.badge,
title: 'Identity',
child: Column(
children: [
_detailRow(
context,
label: 'Message ID',
value: widget.message.id,
onCopy: () =>
copyField('Message ID', widget.message.id),
),
_detailRow(
context,
label: 'Sender',
value: senderName ?? widget.message.senderName ?? '-',
),
_detailRow(
context,
label: 'Sender key',
value: senderPrefixHex ?? '-',
onCopy: senderPrefixHex != null
? () => copyField('Sender key', senderPrefixHex)
: null,
),
_detailRow(
context,
label: 'Recipient',
value: recipientName ?? '-',
),
_detailRow(
context,
label: 'Recipient key',
value: recipientPrefixHex ?? '-',
onCopy: recipientPrefixHex != null
? () =>
copyField('Recipient key', recipientPrefixHex)
: null,
),
],
),
),
if (widget.message.isVoice) ...[
const SizedBox(height: 12),
_techSection(
context,
icon: Icons.graphic_eq,
title: 'Voice',
child: Column(
children: [
_detailRow(
context,
label: 'Voice ID',
value: widget.message.voiceId ?? '-',
),
_detailRow(
context,
label: 'Envelope',
value: envelope != null
? 'VE1 compact'
: legacyVoicePacket != null
? 'Legacy V packet'
: 'Unknown',
),
if (voiceSession != null)
_detailRow(
context,
label: 'Session progress',
value:
'${voiceSession.receivedCount}/${voiceSession.total} segments',
),
if (voiceSession != null)
_detailRow(
context,
label: 'Complete',
value: voiceSession.isComplete ? 'Yes' : 'No',
),
],
),
),
],
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
title: const Text(
'Raw dump',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
rawLines.join('\n'),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
],
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close),
),
],
),
);
}
Widget _techSection(
BuildContext context, {
required IconData icon,
required String title,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 14),
const SizedBox(width: 6),
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
],
),
const SizedBox(height: 8),
child,
],
),
);
}
Widget _techBadge(
BuildContext context, {
required IconData icon,
required String label,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
),
],
),
);
}
Widget _detailRow(
BuildContext context, {
required String label,
required String value,
VoidCallback? onCopy,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.75),
),
),
),
Expanded(
child: Text(
value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
),
),
if (onCopy != null)
IconButton(
onPressed: onCopy,
icon: const Icon(Icons.copy, size: 14),
visualDensity: VisualDensity.compact,
tooltip: 'Copy $label',
),
],
),
);
}
Widget _signalRow(
BuildContext context, {
required String label,
required String valueLabel,
required double normalized,
required Color color,
}) {
return Row(
children: [
SizedBox(
width: 42,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: LinearProgressIndicator(
minHeight: 8,
value: normalized,
backgroundColor: color.withValues(alpha: 0.15),
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
),
const SizedBox(width: 10),
SizedBox(
width: 74,
child: Text(
valueLabel,
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
);
}
void _showReplySheet(BuildContext context) {
// Find the sender contact by public key prefix
final contactsProvider = context.read<ContactsProvider>();
@@ -595,6 +1134,177 @@ class _MessageBubbleState extends State<MessageBubble> {
}
}
Widget _buildChannelEchoStatus(BuildContext context, Message message) {
final statusColor = _getDeliveryStatusColor(message.deliveryStatus);
final hasEcho = message.echoCount > 0;
if (!hasEcho) {
return Text(
message.getLocalizedDeliveryStatus(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: statusColor,
fontStyle: FontStyle.italic,
),
);
}
final rssi = message.lastEchoRssiDbm;
final snr = message.lastEchoSnrRaw != null
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
: null;
final quality = _linkQualityLabel(rssi, snr);
final qualityColor = _linkQualityColor(quality);
return Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.hub_outlined,
label: 'x${message.echoCount}',
color: statusColor,
),
if (message.expectedAckTag != null)
_techChip(
context,
icon: Icons.tag,
label:
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
color: Colors.indigo,
),
_techChip(
context,
icon: Icons.bolt,
label: quality,
color: qualityColor,
),
if (message.lastEchoRssiDbm != null)
_signalCapsule(
context,
icon: Icons.network_cell,
label: message.lastEchoRssiDbm!.toString(),
filled: _rssiScore(message.lastEchoRssiDbm!),
color: Colors.blueGrey,
),
if (message.lastEchoSnrRaw != null)
_signalCapsule(
context,
icon: Icons.graphic_eq,
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(
1,
),
filled: _snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
color: Colors.teal,
),
],
);
}
Widget _techChip(
BuildContext context, {
required IconData icon,
required String label,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}
Widget _signalCapsule(
BuildContext context, {
required IconData icon,
required String label,
required int filled,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final active = i < filled;
return Container(
width: 3,
height: (4 + i).toDouble(),
margin: const EdgeInsets.symmetric(horizontal: 0.5),
decoration: BoxDecoration(
color: active ? color : color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(1),
),
);
}),
),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}
int _rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5);
int _snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
String _linkQualityLabel(int? rssiDbm, double? snrDb) {
var score = 0;
if (rssiDbm != null) score += _rssiScore(rssiDbm);
if (snrDb != null) score += _snrScore(snrDb);
if (score >= 8) return 'Excellent';
if (score >= 6) return 'Good';
if (score >= 4) return 'Fair';
return 'Weak';
}
Color _linkQualityColor(String quality) {
switch (quality) {
case 'Excellent':
return Colors.green;
case 'Good':
return Colors.lightGreen;
case 'Fair':
return Colors.orange;
default:
return Colors.redAccent;
}
}
@override
Widget build(BuildContext context) {
// Display system messages with minimal styling
@@ -1265,7 +1975,7 @@ class _MessageBubbleState extends State<MessageBubble> {
// Show single message delivery status
else
Row(
mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.max,
children: [
Icon(
_getDeliveryStatusIcon(message.deliveryStatus),
@@ -1273,11 +1983,26 @@ class _MessageBubbleState extends State<MessageBubble> {
color: _getDeliveryStatusColor(message.deliveryStatus),
),
const SizedBox(width: 3),
Text(
message.getLocalizedDeliveryStatus(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: _getDeliveryStatusColor(message.deliveryStatus),
fontStyle: FontStyle.italic,
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child:
message.isChannelMessage &&
message.deliveryStatus ==
MessageDeliveryStatus.sent
? _buildChannelEchoStatus(context, message)
: Text(
message.getLocalizedDeliveryStatus(context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: _getDeliveryStatusColor(
message.deliveryStatus,
),
fontStyle: FontStyle.italic,
),
),
),
),
// Show retry button for failed messages

View File

@@ -1,10 +1,15 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/voice_provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/voice_provider.dart';
import '../../utils/voice_message_parser.dart';
/// A message bubble that shows a voice recording with play/stop controls.
class VoiceMessageBubble extends StatelessWidget {
class VoiceMessageBubble extends StatefulWidget {
final Message message;
final bool isSentByMe;
@@ -14,49 +19,91 @@ class VoiceMessageBubble extends StatelessWidget {
required this.isSentByMe,
});
@override
State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
}
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
bool _isRequesting = false;
bool _autoPlayWhenReady = false;
String? _errorText;
@override
Widget build(BuildContext context) {
final voiceId = message.voiceId;
final voiceId = widget.message.voiceId;
if (voiceId == null) return const SizedBox.shrink();
return Consumer<VoiceProvider>(
builder: (context, voiceProvider, _) {
final session = voiceProvider.session(voiceId);
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId);
if (_isRequesting && isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_isRequesting = false;
});
});
}
if (_autoPlayWhenReady && isComplete && !isPlaying) {
_autoPlayWhenReady = false;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
await voiceProvider.play(voiceId);
});
}
final received = session?.receivedCount ?? 0;
final total = session?.total ?? 0;
final durationSec = session?.estimatedDurationSeconds ?? 0.0;
final total = session?.total ?? envelope?.total ?? 0;
final playbackProgress = voiceProvider.playbackProgress(voiceId);
final requestProgress = total > 0
? (received / total).clamp(0.0, 1.0)
: null;
final durationSec =
session?.estimatedDurationSeconds ??
((envelope?.durationMs ?? 0) / 1000.0);
final durationLabel = _formatDuration(durationSec);
final modeLabel = session?.mode.label ?? '?';
final modeLabel = session?.mode.label ?? envelope?.mode.label ?? '?';
final waveformBars = _resolveWaveformBars(
session: session,
messageText: widget.message.text,
);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Play / Stop button
InkWell(
onTap: () async {
if (isPlaying) {
await voiceProvider.stop();
} else {
await voiceProvider.play(voiceId);
return;
}
if (isComplete) {
await voiceProvider.play(voiceId);
return;
}
await _requestAndPlayVoice(voiceId);
},
borderRadius: BorderRadius.circular(24),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isSentByMe
color: widget.isSentByMe
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
isPlaying ? Icons.stop : Icons.play_arrow,
isPlaying
? Icons.stop
: (_isRequesting ? Icons.downloading : Icons.play_arrow),
size: 28,
color: isSentByMe
color: widget.isSentByMe
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
),
@@ -67,18 +114,20 @@ class VoiceMessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Waveform placeholder / progress indicator
if (isPlaying)
if (isPlaying || _isRequesting)
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: isPlaying ? playbackProgress : requestProgress,
backgroundColor: Colors.grey.withValues(alpha: 0.3),
),
)
else
_WaveformBar(isComplete: isComplete),
_WaveformBar(
isComplete: isComplete,
bars: waveformBars,
),
const SizedBox(height: 4),
// Duration + mode + packet progress
Text(
_buildStatusText(
durationLabel: durationLabel,
@@ -86,10 +135,14 @@ class VoiceMessageBubble extends StatelessWidget {
received: received,
total: total,
isComplete: isComplete,
isRequesting: _isRequesting,
errorText: _errorText,
),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
@@ -100,6 +153,78 @@ class VoiceMessageBubble extends StatelessWidget {
);
}
Future<void> _requestAndPlayVoice(String sessionId) async {
if (_isRequesting) return;
final sender = _resolveSenderContact();
if (sender == null) {
_setUnavailable();
return;
}
final connectionProvider = context.read<ConnectionProvider>();
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
_setUnavailable();
return;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final request = VoiceFetchRequest(
sessionId: sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1,
);
setState(() {
_isRequesting = true;
_autoPlayWhenReady = true;
_errorText = null;
});
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: sender.publicKey,
text: request.encodeText(),
contact: sender,
);
if (!sent) {
_setUnavailable();
}
}
void _setUnavailable() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_autoPlayWhenReady = false;
_errorText = 'Voice unavailable right now';
});
}
Contact? _resolveSenderContact() {
final contactsProvider = context.read<ContactsProvider>();
final senderPrefix = widget.message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
final contact = contactsProvider.findContactByPrefix(
Uint8List.fromList(senderPrefix.sublist(0, 6)),
);
if (contact != null) return contact;
}
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
if (envelope != null) {
final contact = contactsProvider.findContactByPrefixHex(
envelope.senderKey6,
);
if (contact != null) return contact;
}
return null;
}
static String _formatDuration(double seconds) {
final s = seconds.round();
if (s < 60) return '${s}s';
@@ -112,37 +237,69 @@ class VoiceMessageBubble extends StatelessWidget {
required int received,
required int total,
required bool isComplete,
required bool isRequesting,
required String? errorText,
}) {
if (errorText != null) return errorText;
final progress = total > 0 ? ' ($received/$total)' : '';
if (isRequesting) {
return 'Requesting voice$progress';
}
if (!isComplete && total > 0) {
return '🎙️ $durationLabel · $modeLabel$progress';
}
return '🎙️ $durationLabel · $modeLabel';
}
List<double> _resolveWaveformBars({
required VoiceSession? session,
required String messageText,
}) {
if (session != null) {
final fromSession = VoiceWaveform.buildBarsFromPackets(session.packets);
if (fromSession.any((v) => v > 0.0)) return fromSession;
}
final legacyPacket = VoicePacket.tryParseText(messageText);
if (legacyPacket != null) {
final fromLegacy = VoiceWaveform.buildBarsFromPackets([legacyPacket]);
if (fromLegacy.any((v) => v > 0.0)) return fromLegacy;
}
return const [];
}
}
/// Simple static waveform bar using a row of rectangles.
/// Voice waveform rendered as a row of bars.
class _WaveformBar extends StatelessWidget {
final bool isComplete;
const _WaveformBar({required this.isComplete});
final List<double> bars;
const _WaveformBar({
required this.isComplete,
required this.bars,
});
@override
Widget build(BuildContext context) {
const heights = [8.0, 14.0, 10.0, 18.0, 12.0, 16.0, 10.0, 14.0, 8.0, 12.0, 16.0, 10.0];
final heights = bars.isEmpty
? const [8.0, 12.0, 10.0, 14.0, 9.0, 12.0, 8.0, 11.0, 10.0, 13.0]
: bars.map((v) => 6.0 + (v.clamp(0.0, 1.0) * 14.0)).toList();
final color = isComplete
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
: Colors.grey.withValues(alpha: 0.5);
return Row(
children: heights
.map((h) => Container(
width: 3,
height: h,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
))
.map(
(h) => Container(
width: 3,
height: h,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
)
.toList(),
);
}