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