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 {