diff --git a/android/.kotlin/sessions/kotlin-compiler-419839215230614760.salive b/android/.kotlin/sessions/kotlin-compiler-419839215230614760.salive deleted file mode 100644 index e69de29..0000000 diff --git a/docs/voice-mode-technical.md b/docs/voice-mode-technical.md new file mode 100644 index 0000000..c3a0eb8 --- /dev/null +++ b/docs/voice-mode-technical.md @@ -0,0 +1,207 @@ +# Voice Mode Technical Design + +## 1. Overview + +Voice mode uses a **two-plane architecture**: + +- **Control plane (text messages):** + - `VE1:` voice envelope announces voice availability in chat. + - `VR1:` direct fetch request asks sender to stream voice payload. +- **Data plane (raw binary packets):** + - `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`. + +This design avoids broadcasting full voice payloads to channels/rooms. Chat carries only metadata; audio is fetched on demand when user presses play. + +## 2. Key Modules + +- `lib/utils/voice_message_parser.dart` + - `VoicePacket` (legacy text + binary packet format) + - `VoiceEnvelope` (`VE1`) + - `VoiceFetchRequest` (`VR1`) +- `lib/screens/messages_tab.dart` + - Capture/encode voice, cache encoded packets, send envelope only +- `lib/providers/voice_provider.dart` + - Reassembly/playback sessions + - Outgoing session cache + deferred serving +- `lib/providers/app_provider.dart` + - Incoming routing for `VE1` and `VR1` + - Handles raw packet ingestion +- `lib/widgets/messages/voice_message_bubble.dart` + - Play behavior (immediate play if complete, otherwise fetch + auto-play) +- `lib/providers/messages_provider.dart` + - Message-level voice detection (`VE1` + legacy `V:`) +- `lib/services/message_storage_service.dart` + - Persists `isVoice` and `voiceId` + +## 3. Wire Formats + +### 3.1 Voice Envelope (`VE1`) + +Prefix: `VE1:` + colon-delimited compact payload + +Fields: + +- `sid` (string, 8 hex chars): session ID +- `mode` (int): codec mode ID (`VoicePacketMode.id`) +- `total` (int): packet count (1..255) +- `durMs` (int): estimated duration in ms +- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes) +- `ts` (int): unix timestamp seconds +- `ver` (int): protocol version (currently `1`) + +Compact format: + +```text +VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver} +``` + +Example: + +```text +VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1 +``` + +### 3.2 Voice Fetch Request (`VR1`) + +Prefix: `VR1:` + colon-delimited compact payload + +Fields: + +- `sid` (string, 8 hex chars): requested session +- `want` (string): currently `a` (compact token for `all`) +- `requesterKey6` (string, 12 hex chars): requester key prefix +- `ts` (int): unix timestamp seconds +- `ver` (int): protocol version (`1`) + +Compact format: + +```text +VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} +``` + +Example: + +```text +VR1:deadbeef:a:112233445566:1700000010:1 +``` + +### 3.3 Raw Voice Packet (data plane) + +Binary payload structure: + +- Byte 0: magic `0x56` (`'V'`) +- Bytes 1..4: session ID (4 bytes) +- Byte 5: mode ID +- Byte 6: packet index +- Byte 7: total packets +- Bytes 8..N: codec2 data + +## 4. Outgoing Flow (Send) + +1. Recorder captures PCM chunks. +2. Each chunk is codec2-encoded into `VoicePacket` objects. +3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min). +4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`). +5. Sender sends one envelope (`VE1`) through normal message path: + - channel/room: `sendChannelMessage` + - direct: `sendTextMessage` +6. **No raw audio packets are sent during initial send.** + +## 5. Incoming Routing + +### 5.1 `VE1` envelope received + +`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat. + +### 5.2 `VR1` request received + +`AppProvider` treats it as control-plane only: + +- request is not added to chat +- validates requester prefix match against sender metadata +- resolves requester contact via key prefix +- calls `voiceProvider.serveSessionTo(...)` + +### 5.3 Raw packet received (`pushRawData`) + +`AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`. + +## 6. Play / Fetch Behavior + +In `VoiceMessageBubble`: + +- If session already complete: play immediately. +- If incomplete/missing: + 1. Resolve sender contact (message sender prefix or `VE1.senderKey6` fallback) + 2. Send direct `VR1` fetch request + 3. Show requesting state in UI + 4. Auto-play when session becomes complete + +If sender cannot be resolved or request cannot be sent, bubble remains and shows: **"Voice unavailable right now"**. + +## 7. Outgoing Cache Details + +`VoiceProvider` outgoing cache: + +- key: `sessionId` +- value: encoded packet list + cached timestamp +- TTL: 15 minutes (`_outgoingSessionTtl`) +- eviction: lazy (on cache access/add/serve) + +Serving prerequisites: + +- session exists in cache +- `sendRawPacketCallback` configured +- requester has direct path (`outPathLen >= 0`) + +## 8. Persistence + +`MessageStorageService` now stores and restores: + +- `isVoice` +- `voiceId` + +This ensures envelope messages remain voice bubbles across app restart. + +## 9. Validation and Safety + +Parser validation enforces: + +- strict hex lengths for IDs and key prefixes +- valid mode range +- valid packet counts and duration bounds +- fixed protocol version (`ver == 1`) +- `VR1.want` token `a` (internally normalized to `all`) + +`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk. + +## 10. Operational Constraints + +- No firmware changes required. +- On-demand fetch works only if sender app is online and has cached session. +- Raw return path needs a currently valid direct route to requester. +- Voice capture in UI is currently iOS-only (`MessagesTab._voiceSupported`). + +## 11. Backward Compatibility + +- Legacy `V:` text packet parsing is still supported. +- Message voice detection accepts both new `VE1` and legacy `V:` formats. + +## 12. High-Level Sequence + +```mermaid +sequenceDiagram + participant A as Sender App + participant M as Mesh Chat + participant B as Receiver App + + A->>A: Record + encode voice packets + A->>A: Cache session packets (TTL 15m) + A->>M: Send VE1 envelope + M->>B: Deliver VE1 + B->>B: Render voice bubble (metadata only) + B->>A: Send VR1 request on Play + A->>B: Stream raw VoicePacket packets + B->>B: Reassemble session + B->>B: Auto-play when complete +``` diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8fc96d9..0c7ee26 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -61,9 +61,6 @@ PODS: - ObjectBox (= 4.4.1) - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - record_ios (1.2.0): @@ -96,7 +93,6 @@ DEPENDENCIES: - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) @@ -139,8 +135,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/objectbox_flutter_libs/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" record_ios: @@ -165,13 +159,12 @@ SPEC CHECKSUMS: flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf @@ -179,7 +172,7 @@ SPEC CHECKSUMS: shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - vibration: 69774ad57825b11c951ee4c46155f455d7a592ce + vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 70693e4..b636303 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/lib/models/message.dart b/lib/models/message.dart index faa442b..da271e9 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -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; } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 889a475..ef22b22 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -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 _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 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 _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 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 _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(); } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 3e01e12..9553016 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -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)? 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 = []; 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 diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index ce52b79..3618f8f 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -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 diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index feb3c19..84d167e 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -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()}', ); diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 787aa6d..5483b8c 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -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 _playerEventsSub; /// Active sessions keyed by sessionId. final Map _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 Function({ + required Uint8List contactPath, + required int contactPathLen, + required Uint8List payload, + })? + sendRawPacketCallback; + + final Map _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 packets) { + if (packets.isEmpty) return; + _outgoingSessions[sessionId] = _OutgoingVoiceSession( + sessionId: sessionId, + packets: List.from(packets), + ); + _persistVoiceData(); + } + + /// Stream a cached voice session to a requester over raw direct packets. + Future 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 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 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 _persistVoiceData() async { + try { + final prefs = await SharedPreferences.getInstance(); + final payload = { + '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 _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; + + final incoming = parsed['incoming'] as List? ?? const []; + for (final item in incoming) { + final map = item as Map; + 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? ?? 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? ?? const []; + for (final item in outgoing) { + final map = item as Map; + final sessionId = map['sessionId'] as String?; + if (sessionId == null || sessionId.isEmpty) continue; + final packetsRaw = map['packets'] as List? ?? const []; + final packets = []; + 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 packets; + + const _OutgoingVoiceSession({ + required this.sessionId, + required this.packets, + }); +} diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 50f4c54..22a7d45 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -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 { 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? _voiceStreamSub; String? _currentVoiceSessionId; final List _recordedChunks = []; VoicePacketMode? _activeVoiceMode; + int _selectedVoiceBitrate = VoiceBitratePreferences.defaultBitrate; @override void initState() { @@ -66,6 +71,7 @@ class _MessagesTabState extends State { _textController.addListener(_updateCharacterCount); // Load saved message destination _loadSavedDestination(); + _loadVoiceBitrate(); // Mark all messages as read when tab is opened WidgetsBinding.instance.addPostFrameCallback((_) { context.read().markAllAsRead(); @@ -73,6 +79,14 @@ class _MessagesTabState extends State { }); } + Future _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 { Future _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 { } if (!mounted) return; + final appProvider = context.read(); final connectionProvider = context.read(); debugPrint( '🎙️ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}', @@ -434,8 +458,9 @@ class _MessagesTabState extends State { (_) => 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 { 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 { Future _stopAndSendVoice() async { if (!_isRecording) return; + final trimSilenceEnabled = context + .read() + .isVoiceSilenceTrimmingEnabled; debugPrint( '🎙️ [Voice] _stopAndSendVoice: ${_recordedChunks.length} chunks buffered', ); @@ -485,11 +516,16 @@ class _MessagesTabState extends State { _voiceStreamSub = null; await _voiceRecorder.stopCapture(); - final chunks = List.from(_recordedChunks); + final rawChunks = List.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 { } 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 { } 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 { final messagesProvider = context.read(); final voiceProvider = context.read(); - // 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 { ); messagesProvider.addSentMessage(sentMsg); + final encodedPackets = []; 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 { 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( + 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 _trimSilence(List 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 = []; + 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 { : 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 { : (_isSendingVoice ? 'Sending voice...' : _voiceSupported - ? 'Send (long press to record voice)' - : 'Send'), + ? 'Send (long press to record voice)' + : 'Send'), ), ), ), diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart index 4b7c318..ec05782 100644 --- a/lib/screens/packet_log_screen.dart +++ b/lib/screens/packet_log_screen.dart @@ -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 createState() => _PacketLogScreenState(); @@ -56,16 +53,18 @@ class _PacketLogScreenState extends State { 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 { // 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 { ); } 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 { 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 { // 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 { ); } 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 { 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 { actions: [ // Direction filter PopupMenuButton( - 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 { 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 { 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 { 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 { ), // 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 { 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 { 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), + ), + ), + ), + 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, ); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 47aa201..9d805db 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 { 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 { _loadPackageInfo(); _initializeLocationService(); _loadRxTxPreference(); + _loadVoiceBitratePreference(); } @override @@ -89,6 +92,26 @@ class _SettingsScreenState extends State { await prefs.setBool('show_rx_tx_indicators', value); } + Future _loadVoiceBitratePreference() async { + final value = await VoiceBitratePreferences.getBitrate(); + if (!mounted) return; + setState(() { + _voiceBitrate = value; + }); + } + + Future _saveVoiceBitratePreference(int value) async { + await VoiceBitratePreferences.setBitrate(value); + if (!mounted) return; + setState(() { + _voiceBitrate = value; + }); + } + + String _voiceBitrateSubtitle(int bitrate) { + return '$bitrate bps'; + } + Future _initializeLocationService() async { // Initialize location service with BLE service WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -550,6 +573,54 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showLanguageDialog(), ), + const Divider(), + + // Voice Settings Section + _buildSectionHeader('Voice'), + Consumer( + 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( + 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( + 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 { 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 { ); } + 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 { ), ], ), - subtitle: Text(AppLocalizations.of(context)!.safeAllClearMode), + subtitle: Text( + AppLocalizations.of(context)!.safeAllClearMode, + ), value: AppThemeMode.sarGreen, ), RadioListTile( @@ -855,7 +1026,9 @@ class _SettingsScreenState extends State { const Divider(), RadioListTile( 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 { ); } + void _showVoiceBitrateDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Voice bitrate'), + content: SingleChildScrollView( + child: RadioGroup( + groupValue: _voiceBitrate, + onChanged: (value) { + if (value != null) { + _saveVoiceBitratePreference(value); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: VoiceBitratePreferences.supportedBitrates + .map( + (bitrate) => RadioListTile( + 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, diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index 5936008..5f02dcc 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -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) - .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) { diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 7934eca..ac681cc 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -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 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', ); diff --git a/lib/services/sse_client_service.dart b/lib/services/sse_client_service.dart index d60accd..2c68d34 100644 --- a/lib/services/sse_client_service.dart +++ b/lib/services/sse_client_service.dart @@ -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 connect({ - required String serverUrl, - String? authToken, - }) async { + Future 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 _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; 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 _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()) + ? Uint8List.fromList( + (json['senderPublicKeyPrefix'] as List).cast(), + ) : 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?, ); diff --git a/lib/services/sse_server_service.dart b/lib/services/sse_server_service.dart index 132cc01..6837d46 100644 --- a/lib/services/sse_server_service.dart +++ b/lib/services/sse_server_service.dart @@ -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 _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 _handlePostChannelMessage(shelf.Request request) async { + Future _handlePostChannelMessage( + shelf.Request request, + ) async { try { final body = await request.readAsString(); final json = jsonDecode(body) as Map; @@ -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 { '''; - 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, }; diff --git a/lib/services/voice_bitrate_preferences.dart b/lib/services/voice_bitrate_preferences.dart new file mode 100644 index 0000000..225de5c --- /dev/null +++ b/lib/services/voice_bitrate_preferences.dart @@ -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 supportedBitrates = [700, 1200, 1300, 1400, 1600, 2400, 3200]; + + static Future getBitrate() async { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getInt(_bitrateKey) ?? defaultBitrate; + return supportedBitrates.contains(value) ? value : defaultBitrate; + } + + static Future 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; + } + } +} diff --git a/lib/services/voice_codec_service.dart b/lib/services/voice_codec_service.dart index 49ad0f0..9435dfa 100644 --- a/lib/services/voice_codec_service.dart +++ b/lib/services/voice_codec_service.dart @@ -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; diff --git a/lib/services/voice_player_service.dart b/lib/services/voice_player_service.dart index 3347921..a3d3374 100644 --- a/lib/services/voice_player_service.dart +++ b/lib/services/voice_player_service.dart @@ -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 _events = StreamController.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 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 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); diff --git a/lib/services/voice_recorder_service.dart b/lib/services/voice_recorder_service.dart index 8039a86..eb94ef4 100644 --- a/lib/services/voice_recorder_service.dart +++ b/lib/services/voice_recorder_service.dart @@ -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 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 _startRecording(Duration chunkDuration) async { + Future _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 = []; @@ -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; + } +} diff --git a/lib/utils/message_extensions.dart b/lib/utils/message_extensions.dart index b8a7f8c..1b3c8d4 100644 --- a/lib/utils/message_extensions.dart +++ b/lib/utils/message_extensions.dart @@ -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 = []; + 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)}]'; + } } diff --git a/lib/utils/voice_message_parser.dart b/lib/utils/voice_message_parser.dart index 5e34043..5e91170 100644 --- a/lib/utils/voice_message_parser.dart +++ b/lib/utils/voice_message_parser.dart @@ -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 buildBarsFromPackets( + Iterable packets, { + int bars = 24, + }) { + if (bars <= 0) return const []; + + final merged = []; + for (final pkt in packets) { + if (pkt == null || pkt.codec2Data.isEmpty) continue; + merged.addAll(pkt.codec2Data); + } + if (merged.isEmpty) return List.filled(bars, 0.0); + + final out = List.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.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; + } +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 2add029..2ee14df 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -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 { _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 { ); } + void _showTechnicalDetails(BuildContext context) { + final connectionProvider = context.read(); + final contactsProvider = context.read(); + final voiceProvider = context.read(); + 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 = [ + '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), + ), + ), + ), + 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(); @@ -595,6 +1134,177 @@ class _MessageBubbleState extends State { } } + 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 { // 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 { 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 diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index 3f807bb..fd02398 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -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 createState() => _VoiceMessageBubbleState(); +} + +class _VoiceMessageBubbleState extends State { + 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( 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 _requestAndPlayVoice(String sessionId) async { + if (_isRequesting) return; + final sender = _resolveSenderContact(); + if (sender == null) { + _setUnavailable(); + return; + } + + final connectionProvider = context.read(); + 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(); + 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 _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 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(), ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a909dff..d6e480b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,7 +14,6 @@ import geolocator_apple import nsd_macos import objectbox_flutter_libs import package_info_plus -import path_provider_foundation import record_macos import share_plus import shared_preferences_foundation @@ -30,7 +29,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index c7df784..2fe84e7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" codec2_flutter: dependency: "direct main" description: @@ -148,10 +156,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.3.5+2" crypto: dependency: "direct main" description: @@ -188,18 +196,18 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" device_info_plus: dependency: transitive description: name: device_info_plus - sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33 + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" url: "https://pub.dev" source: hosted - version: "12.2.0" + version: "12.3.0" device_info_plus_platform_interface: dependency: transitive description: @@ -228,10 +236,10 @@ packages: dependency: "direct main" description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -244,10 +252,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f8f4ea435f791ab1f817b4e338ed958cb3d04ba43d6736ffc39958d950754967 + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" url: "https://pub.dev" source: hosted - version: "10.3.6" + version: "10.3.10" fixnum: dependency: transitive description: @@ -305,50 +313,58 @@ packages: dependency: "direct main" description: name: flutter_blue_plus - sha256: bfcfcd60cd39846d32944f1c1a84f270437ce2d8e7a3e8a1cf8bf9ac9c9a423c + sha256: "4fba86c513feab2c5cdb9497da0910ed5b50c0fa8d6cec4a26ffb1a558a24eb8" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.1" flutter_blue_plus_android: dependency: transitive description: name: flutter_blue_plus_android - sha256: e62c1cfa4da3594cc8360333bc3f9208a84963bfcbae192fb95a61635caf75fe + sha256: "2a73e264685574d1d29dcdd565bad9ecfdf237630237c508ae8b47f5cc791f1d" url: "https://pub.dev" source: hosted - version: "8.0.1" + version: "8.2.1" flutter_blue_plus_darwin: dependency: transitive description: name: flutter_blue_plus_darwin - sha256: d789861c37aee73101515df99f1d6d162b0ea69a13b961e1b3339f27ea06dcb6 + sha256: cfef171db550670cf8110f6eb25baf15d9bc8bad2af29550f9bbc0d8fceaf285 url: "https://pub.dev" source: hosted - version: "8.0.1" + version: "8.2.1" flutter_blue_plus_linux: dependency: transitive description: name: flutter_blue_plus_linux - sha256: "1fd456e7f17f6c9e50a2bdfca8bcfd5dfee83e3c0a9fd7d493dd73d4e60b9755" + sha256: "5add6c14d2f90672c5e3ded1455b9ca8e6fe44adf9b53cdc60eb3417d38f34fe" url: "https://pub.dev" source: hosted - version: "8.0.1" + version: "8.2.1" flutter_blue_plus_platform_interface: dependency: transitive description: name: flutter_blue_plus_platform_interface - sha256: "8d8440360bed1dce921f3140510c9294ec81d21945c30607afd29bda9a8a87d6" + sha256: "226fb6753a74a407e3b9975c0fc00de02c490ae655b31c6508cb5790ad30965d" url: "https://pub.dev" source: hosted - version: "8.0.1" + version: "8.2.1" flutter_blue_plus_web: dependency: transitive description: name: flutter_blue_plus_web - sha256: "87d4d63cd06e1e3e9c4b4f5774cea6d2a8516b5693dce0e4dbf7c9d4e63dbcfd" + sha256: "10a7465ccfc50138280abf32c8ab314f5029aa19039628ad9b4d0ed786e0021f" url: "https://pub.dev" source: hosted - version: "8.0.1" + version: "8.2.1" + flutter_blue_plus_winrt: + dependency: transitive + description: + name: flutter_blue_plus_winrt + sha256: ed894f0ab341f4cece8fa33edc381d46424a7c5bfd0e841d933d0f8c34c86521 + url: "https://pub.dev" + source: hosted + version: "0.0.18" flutter_compass: dependency: "direct main" description: @@ -377,34 +393,34 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + sha256: cf206c68707773b670340dfeca7fc95f95509f4ca91a544bdd77d03a8106b91e url: "https://pub.dev" source: hosted - version: "19.5.0" + version: "21.0.0-dev.2" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + sha256: "223bdd6e389a5ec02597fb494c8b3d6b0a9119534f9f45b7feef69cc0ee838e5" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "8.0.0-dev.1" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + sha256: "2adc3c14a650af4e6235817fbf4335ba14bc3d36d5d497d7104d94e1475d5ddc" url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "11.0.0-dev.1" flutter_local_notifications_windows: dependency: transitive description: name: flutter_local_notifications_windows - sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + sha256: e7136082af98f3459552d2cc423f4b27e3010bbddcd1e575d9a270d47d2384d4 url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "3.0.0-dev.1" flutter_localizations: dependency: "direct main" description: flutter @@ -430,10 +446,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 url: "https://pub.dev" source: hosted - version: "2.0.32" + version: "2.0.33" flutter_test: dependency: "direct dev" description: flutter @@ -480,10 +496,10 @@ packages: dependency: transitive description: name: geolocator_linux - sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3 + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 url: "https://pub.dev" source: hosted - version: "0.2.3" + version: "0.2.4" geolocator_platform_interface: dependency: transitive description: @@ -508,6 +524,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" gsettings: dependency: transitive description: @@ -516,6 +540,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" http: dependency: "direct main" description: @@ -544,18 +576,18 @@ packages: dependency: transitive description: name: idb_shim - sha256: "071f3b05032fa62e60ca15db9939f8afbaf403b37e67747ac88f858c3e999228" + sha256: "921301da0a735f336a28fc35c3abdbd4498895cc205fa1ea9f7e785e7d854ceb" url: "https://pub.dev" source: hosted - version: "2.6.7+1" + version: "2.8.2+4" image: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.8.0" intl: dependency: "direct main" description: @@ -568,10 +600,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.11.0" latlong2: dependency: "direct main" description: @@ -608,10 +640,10 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" lists: dependency: transitive description: @@ -628,22 +660,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" mbtiles: dependency: "direct main" description: @@ -656,7 +696,7 @@ packages: dependency: "direct main" description: path: "." - ref: HEAD + ref: main resolved-ref: "6457853c57b20727f83f5932ee6684e16f20ff8f" url: "https://github.com/dz0ny/meshcore_client.git" source: git @@ -685,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -697,18 +745,18 @@ packages: dependency: "direct main" description: name: nsd - sha256: cae71ee9c23ea7f75d4610efe7ff335b1f575fb93ef7b7f9a5c6183a091cbb74 + sha256: "1611a5c9f61d56ff2973e1488ae04112103e5203b4a7a1fb594b48cfb366fc14" url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "4.1.0" nsd_android: dependency: transitive description: name: nsd_android - sha256: "1309cd47d02c99bd305219f0a226644f9f4a964d341c3d6730cebb906bb1ec78" + sha256: "96d2d451c5db0319c37b1b2a38f2d55eb56ae54c0b0d3144c03c19c97436de4a" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.2.0" nsd_ios: dependency: transitive description: @@ -729,10 +777,10 @@ packages: dependency: transitive description: name: nsd_platform_interface - sha256: "7220c8e0beeacd06c180fefcd6bb708415ed889c76f656e75ac633d09ceaa761" + sha256: b1a5ace6f01ea2ce37f373e52c3b7af4fd7c11de2582ddcc89f4fc00615d9dff url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" nsd_windows: dependency: transitive description: @@ -757,14 +805,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.1" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "9.0.0" package_info_plus_platform_interface: dependency: transitive description: @@ -793,18 +849,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.20" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -881,10 +937,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -905,10 +961,10 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" proj4dart: dependency: "direct main" description: @@ -933,6 +989,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" record: dependency: "direct main" description: @@ -1009,10 +1073,10 @@ packages: dependency: transitive description: name: sembast - sha256: c8063c3146c3c8d5f5b04230de7682c768440a575fbda2634f14d22f263197c3 + sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505" url: "https://pub.dev" source: hosted - version: "3.8.5+2" + version: "3.8.6" share_plus: dependency: "direct main" description: @@ -1033,26 +1097,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.4.21" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -1110,10 +1174,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqlite3: dependency: transitive description: @@ -1166,18 +1230,18 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" timezone: dependency: "direct main" description: name: timezone - sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" url: "https://pub.dev" source: hosted - version: "0.10.1" + version: "0.11.0" typed_data: dependency: transitive description: @@ -1206,34 +1270,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.24" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.5" + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1246,26 +1310,26 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: transitive description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" vector_map_tiles: dependency: "direct main" description: @@ -1279,7 +1343,7 @@ packages: description: path: vector_map_tiles_mbtiles ref: HEAD - resolved-ref: "6d0b7bd077c70c2013704074bd9cd7229a1bf072" + resolved-ref: a09543b7590b373f3ac53f4776e343fee41c7dc6 url: "https://github.com/josxha/flutter_map_plugins.git" source: git version: "1.2.1" @@ -1319,10 +1383,10 @@ packages: dependency: "direct main" description: name: vibration - sha256: "1fd51cb0f91c6d512734ca0e282dd87fbc7f389b6da5f03c77709ba2cf8fa901" + sha256: "9bb06614c69260f8bd11c80fe01ed7988905cf00e3417d656c2647e41f261d87" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.8" vibration_platform_interface: dependency: transitive description: @@ -1396,5 +1460,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 579b0ef..aaf16e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git + ref: main # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: @@ -98,14 +99,14 @@ dependencies: shared_preferences: ^2.3.3 # Package info - package_info_plus: ^8.1.2 + package_info_plus: ^9.0.0 # Background services flutter_background_service: ^5.0.13 # Notifications - flutter_local_notifications: ^19.5.0 - timezone: ^0.10.0 + flutter_local_notifications: ^21.0.0-dev.2 + timezone: ^0.11.0 # Vibration vibration: ^3.1.4 diff --git a/test/providers/messages_provider_voice_test.dart b/test/providers/messages_provider_voice_test.dart new file mode 100644 index 0000000..d2841a3 --- /dev/null +++ b/test/providers/messages_provider_voice_test.dart @@ -0,0 +1,69 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MessagesProvider voice detection', () { + test('marks VE1 envelope messages as voice', () { + final provider = MessagesProvider(); + final envelope = VoiceEnvelope( + sessionId: 'deafbead', + mode: VoicePacketMode.mode1200, + total: 3, + durationMs: 2400, + senderKey6: 'aabbccddeeff', + timestampSec: 1700000000, + ); + + final message = Message( + id: 'm1', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: envelope.encodeText(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + deliveryStatus: MessageDeliveryStatus.sent, + ); + + provider.addMessage(message); + final stored = provider.messages.single; + expect(stored.isVoice, isTrue); + expect(stored.voiceId, equals('deafbead')); + }); + + test('marks legacy V text packets as voice', () { + final provider = MessagesProvider(); + final packet = VoicePacket( + sessionId: '00112233', + mode: VoicePacketMode.mode700c, + index: 0, + total: 1, + codec2Data: Uint8List.fromList([1, 2, 3]), + ); + final message = Message( + id: 'm2', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000001, + text: packet.encodeText(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + deliveryStatus: MessageDeliveryStatus.sent, + ); + + provider.addMessage(message); + final stored = provider.messages.single; + expect(stored.isVoice, isTrue); + expect(stored.voiceId, equals('00112233')); + }); + }); +} diff --git a/test/utils/voice_message_parser_test.dart b/test/utils/voice_message_parser_test.dart new file mode 100644 index 0000000..796a112 --- /dev/null +++ b/test/utils/voice_message_parser_test.dart @@ -0,0 +1,152 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; + +void main() { + group('VoiceEnvelope', () { + test('encodes and parses valid envelope', () { + final env = VoiceEnvelope( + sessionId: 'deadbeef', + mode: VoicePacketMode.mode1200, + total: 4, + durationMs: 3200, + senderKey6: 'aabbccddeeff', + timestampSec: 1700000000, + ); + + final text = env.encodeText(); + expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue); + + final parsed = VoiceEnvelope.tryParseText(text); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('deadbeef')); + expect(parsed.mode, equals(VoicePacketMode.mode1200)); + expect(parsed.total, equals(4)); + expect(parsed.durationMs, equals(3200)); + expect(parsed.senderKey6, equals('aabbccddeeff')); + expect(parsed.version, equals(1)); + }); + + test('rejects invalid envelope payload', () { + final text = 'VE1:nothex:1:2:1000:aabbccddeeff:1700000000:1'; + expect(VoiceEnvelope.tryParseText(text), isNull); + }); + }); + + group('VoiceFetchRequest', () { + test('encodes and parses valid request', () { + final req = VoiceFetchRequest( + sessionId: '00112233', + requesterKey6: 'ffeeddccbbaa', + timestampSec: 1700000001, + ); + final text = req.encodeText(); + expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue); + + final parsed = VoiceFetchRequest.tryParseText(text); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('00112233')); + expect(parsed.want, equals('all')); + expect(parsed.requesterKey6, equals('ffeeddccbbaa')); + expect(parsed.version, equals(1)); + }); + + test('rejects invalid request payload', () { + expect( + VoiceFetchRequest.tryParseText( + 'VR1:00112233:chunk:ffeeddccbbaa:1700000001:1', + ), + isNull, + ); + }); + }); + + group('VoicePacket backward compatibility', () { + test('parses legacy V: text format', () { + final pkt = VoicePacket( + sessionId: 'a1b2c3d4', + mode: VoicePacketMode.mode700c, + index: 0, + total: 1, + codec2Data: Uint8List.fromList([1, 2, 3, 4]), + ); + final encoded = pkt.encodeText(); + final parsed = VoicePacket.tryParseText(encoded); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('a1b2c3d4')); + expect(parsed.total, equals(1)); + expect(parsed.codec2Data, equals(Uint8List.fromList([1, 2, 3, 4]))); + }); + + test('constructs binary datagram from actual packet data', () { + final actualCodec2 = Uint8List.fromList([ + 0xD3, + 0x19, + 0x7A, + 0x00, + 0xFE, + 0x44, + 0xC1, + 0x2B, + 0x88, + ]); + + final pkt = VoicePacket( + sessionId: '01020304', + mode: VoicePacketMode.mode1300, + index: 2, + total: 5, + codec2Data: actualCodec2, + ); + + final datagram = pkt.encodeBinary(); + expect(datagram[0], equals(0x56)); // magic 'V' + expect(datagram.sublist(1, 5), equals(Uint8List.fromList([1, 2, 3, 4]))); + expect(datagram[5], equals(VoicePacketMode.mode1300.id)); + expect(datagram[6], equals(2)); + expect(datagram[7], equals(5)); + expect(datagram.sublist(8), equals(actualCodec2)); + + final parsed = VoicePacket.tryParseBinary(datagram); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.mode, equals(VoicePacketMode.mode1300)); + expect(parsed.index, equals(2)); + expect(parsed.total, equals(5)); + expect(parsed.codec2Data, equals(actualCodec2)); + }); + }); + + group('VoiceWaveform', () { + test('builds bars from packet bytes', () { + final packet = VoicePacket( + sessionId: '1234abcd', + mode: VoicePacketMode.mode1200, + index: 0, + total: 1, + codec2Data: Uint8List.fromList([ + 0, + 255, + 10, + 245, + 120, + 130, + 64, + 192, + 32, + 224, + ]), + ); + + final bars = VoiceWaveform.buildBarsFromPackets([packet], bars: 8); + expect(bars.length, equals(8)); + expect(bars.every((v) => v >= 0.0 && v <= 1.0), isTrue); + expect(bars.any((v) => v > 0.2), isTrue); + }); + + test('returns zeros for missing packet data', () { + final bars = VoiceWaveform.buildBarsFromPackets(const [], bars: 6); + expect(bars, equals(List.filled(6, 0.0))); + }); + }); +} diff --git a/vendor/codec2_flutter_ios_only/lib/codec2_flutter.dart b/vendor/codec2_flutter_ios_only/lib/codec2_flutter.dart index fdba6c6..4824ce8 100644 --- a/vendor/codec2_flutter_ios_only/lib/codec2_flutter.dart +++ b/vendor/codec2_flutter_ios_only/lib/codec2_flutter.dart @@ -9,9 +9,18 @@ import 'package:ffi/ffi.dart'; /// Codec2 operating modes. /// Numeric values match the C constants in codec2.h. enum Codec2Mode { + /// 3200 bps. + mode3200(0), + /// 2400 bps — higher quality, ~300 bytes/sec output at 8kHz. mode2400(1), + /// 1600 bps. + mode1600(2), + + /// 1400 bps. + mode1400(3), + /// 1300 bps — good quality for LoRa, ~175 bytes/sec at 8kHz (25 fps × 7 B). mode1300(4), @@ -20,7 +29,7 @@ enum Codec2Mode { /// 700C bps — minimum bandwidth for very narrow LoRa / ham radio channels. /// ~100 bytes/sec output at 8kHz. - mode700c(6); + mode700c(8); const Codec2Mode(this.c2ModeId); @@ -28,7 +37,8 @@ enum Codec2Mode { final int c2ModeId; /// Audio frames per second for this mode (all modes use 8000 Hz sample rate). - int get framesPerSecond => this == mode2400 ? 50 : 25; + int get framesPerSecond => + (this == mode3200 || this == mode2400) ? 50 : 25; /// Samples per frame (8000 Hz / framesPerSecond). int get samplesPerFrame => 8000 ~/ framesPerSecond; @@ -37,12 +47,18 @@ enum Codec2Mode { /// Used to calculate packet duration for the 172-byte BLE frame limit. int get bytesPerSecond { switch (this) { + case mode3200: + return 400; // 8 B × 50 fps case mode700c: return 100; // ceil(28/8)=4 B × 25 fps case mode1200: return 150; // 6 B × 25 fps case mode1300: return 175; // ceil(52/8)=7 B × 25 fps + case mode1400: + return 175; // 7 B × 25 fps + case mode1600: + return 200; // 8 B × 25 fps case mode2400: return 300; // 6 B × 50 fps } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 3b4555b..7b1bb5b 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AudioplayersWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + FlutterBluePlusPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); NsdWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b3f2b49..672c58c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows + flutter_blue_plus_winrt geolocator_windows nsd_windows objectbox_flutter_libs