fix: retain voice codec settings now

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

View File

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

View File

@@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict> </dict>
</plist> </plist>

View File

@@ -61,9 +61,6 @@ PODS:
- ObjectBox (= 4.4.1) - ObjectBox (= 4.4.1)
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0): - permission_handler_apple (9.3.0):
- Flutter - Flutter
- record_ios (1.2.0): - record_ios (1.2.0):
@@ -96,7 +93,6 @@ DEPENDENCIES:
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/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`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -139,8 +135,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/objectbox_flutter_libs/ios" :path: ".symlinks/plugins/objectbox_flutter_libs/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple: permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios" :path: ".symlinks/plugins/permission_handler_apple/ios"
record_ios: record_ios:
@@ -165,13 +159,12 @@ SPEC CHECKSUMS:
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1 flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31 objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
@@ -179,7 +172,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: 69774ad57825b11c951ee4c46155f455d7a592ce vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@@ -1,7 +1,7 @@
import UIKit import UIKit
import Flutter import Flutter
@UIApplicationMain @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,

View File

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

View File

@@ -35,6 +35,11 @@ class AppProvider with ChangeNotifier {
bool _isMapEnabled = true; bool _isMapEnabled = true;
bool get isMapEnabled => _isMapEnabled; bool get isMapEnabled => _isMapEnabled;
bool _isVoiceSilenceTrimmingEnabled = true;
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
bool _isVoiceBandPassFilterEnabled = true;
bool get isVoiceBandPassFilterEnabled => _isVoiceBandPassFilterEnabled;
AppProvider({ AppProvider({
required this.connectionProvider, required this.connectionProvider,
required this.contactsProvider, required this.contactsProvider,
@@ -49,6 +54,8 @@ class AppProvider with ChangeNotifier {
_initializeLocationTracking(); _initializeLocationTracking();
_loadSimpleMode(); _loadSimpleMode();
_loadMapEnabled(); _loadMapEnabled();
_loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled();
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load _syncDrawingsOnStartup(); // Sync drawings immediately after providers load
_isInitialized = true; _isInitialized = true;
} }
@@ -118,6 +125,54 @@ class AppProvider with ChangeNotifier {
} }
} }
/// Load voice silence trimming setting from shared preferences.
Future<void> _loadVoiceSilenceTrimmingEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceSilenceTrimmingEnabled =
prefs.getBool('voice_silence_trimming_enabled') ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice silence trimming setting: $e');
}
}
/// Toggle voice silence trimming on/off.
Future<void> toggleVoiceSilenceTrimmingEnabled(bool enabled) async {
try {
_isVoiceSilenceTrimmingEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_silence_trimming_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice silence trimming setting: $e');
}
}
/// Load voice band-pass filter setting from shared preferences.
Future<void> _loadVoiceBandPassFilterEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceBandPassFilterEnabled =
prefs.getBool('voice_band_pass_filter_enabled') ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice band-pass filter setting: $e');
}
}
/// Toggle voice band-pass filter on/off.
Future<void> toggleVoiceBandPassFilterEnabled(bool enabled) async {
try {
_isVoiceBandPassFilterEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_band_pass_filter_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice band-pass filter setting: $e');
}
}
/// Initialize tile cache service /// Initialize tile cache service
Future<void> _initializeTileCache() async { Future<void> _initializeTileCache() async {
try { try {
@@ -165,6 +220,20 @@ class AppProvider with ChangeNotifier {
void _setupCallbacks() { void _setupCallbacks() {
// Monitor connection state changes to start/stop location tracking // Monitor connection state changes to start/stop location tracking
connectionProvider.addListener(_handleConnectionStateChange); 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 // When a contact is received from BLE
connectionProvider.onContactReceived = (contact) { connectionProvider.onContactReceived = (contact) {
// Pass device public key to filter out our own 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 // Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
debugPrint('🎨 [AppProvider] Drawing message received, parsing...'); debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
@@ -339,6 +445,33 @@ class AppProvider with ChangeNotifier {
return; 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 it's a text-format voice packet, feed it to VoiceProvider
if (VoicePacket.isVoiceText(enrichedMessage.text)) { if (VoicePacket.isVoiceText(enrichedMessage.text)) {
final pkt = VoicePacket.tryParseText(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 /// Binary voice packets arrive without a chat message, so we synthesise one
/// to give the user a playable bubble in the message list. /// 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; final sessionId = pkt.sessionId;
// Check if a placeholder for this session already exists // Check if a placeholder for this session already exists
final existing = messagesProvider.messages.where( final existing = messagesProvider.messages
(m) => m.isVoice && m.voiceId == sessionId, .where((m) => m.isVoice && m.voiceId == sessionId)
).firstOrNull; .firstOrNull;
if (existing != null) { if (existing != null) {
// Already have a placeholder — no need to add another // Already have a placeholder — no need to add another
@@ -748,7 +884,9 @@ class AppProvider with ChangeNotifier {
pathLen: 0, pathLen: 0,
textType: MessageTextType.plain, textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, 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(), receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.received, deliveryStatus: MessageDeliveryStatus.received,
isVoice: true, isVoice: true,
@@ -845,6 +983,7 @@ class AppProvider with ChangeNotifier {
void clearAllData() { void clearAllData() {
contactsProvider.clearContacts(); contactsProvider.clearContacts();
messagesProvider.clearAll(); messagesProvider.clearAll();
unawaited(voiceProvider.clearStoredVoiceData());
notifyListeners(); notifyListeners();
} }

View File

@@ -111,7 +111,8 @@ class ConnectionProvider with ChangeNotifier {
// SSE client connection state // SSE client connection state
bool get isSseClientConnecting => _sseClient.isConnecting; bool get isSseClientConnecting => _sseClient.isConnecting;
int get sseClientReconnectionAttempt => _sseClient.reconnectionAttempts; int get sseClientReconnectionAttempt => _sseClient.reconnectionAttempts;
int get sseClientMaxReconnectionAttempts => _sseClient.maxReconnectionAttempts; int get sseClientMaxReconnectionAttempts =>
_sseClient.maxReconnectionAttempts;
// Message sync state // Message sync state
bool _noMoreMessages = false; bool _noMoreMessages = false;
@@ -151,7 +152,8 @@ class ConnectionProvider with ChangeNotifier {
Function(List<Contact>)? onContactsComplete; Function(List<Contact>)? onContactsComplete;
Function(Message)? onMessageReceived; Function(Message)? onMessageReceived;
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived; 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)? Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
onBinaryResponse; onBinaryResponse;
Function(Uint8List publicKey)? onContactDeleted; Function(Uint8List publicKey)? onContactDeleted;
@@ -310,17 +312,22 @@ class ConnectionProvider with ChangeNotifier {
}; };
_bleService.onContactsComplete = (contacts) { _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'); debugPrint(' Forwarding to AppProvider via onContactsComplete callback');
onContactsComplete?.call(contacts); onContactsComplete?.call(contacts);
}; };
_bleService.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) { _bleService.onChannelInfoReceived =
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags); (int channelIdx, String channelName, Uint8List secret, int? flags) {
}; onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
};
_bleService.onContactDeleted = (publicKey) { _bleService.onContactDeleted = (publicKey) {
debugPrint('⚠️ [Provider] Contact deleted by firmware (contacts full overwrite)'); debugPrint(
'⚠️ [Provider] Contact deleted by firmware (contacts full overwrite)',
);
onContactDeleted?.call(publicKey); onContactDeleted?.call(publicKey);
}; };
@@ -349,7 +356,9 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' LPP data: ${lppData.length} bytes'); debugPrint(' LPP data: ${lppData.length} bytes');
// Mark ping as successful if this was a ping request // Mark ping as successful if this was a ping request
_pingTracker.markPingSuccessful(publicKey); _pingTracker.markPingSuccessful(publicKey);
debugPrint(' Forwarding to AppProvider via onTelemetryReceived callback'); debugPrint(
' Forwarding to AppProvider via onTelemetryReceived callback',
);
onTelemetryReceived?.call(publicKey, lppData); onTelemetryReceived?.call(publicKey, lppData);
}; };
@@ -442,37 +451,42 @@ class ConnectionProvider with ChangeNotifier {
onPathUpdated?.call(publicKey); onPathUpdated?.call(publicKey);
}; };
_bleService _bleService.onMessageSent =
.onMessageSent = (expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) { (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) {
debugPrint( 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 // Pop message ID from FIFO queue (matches send order)
_messageDeliveryTracker.mapAckTagToMessageId(expectedAckTag, messageId); final messageId = _messageDeliveryTracker.popPendingMessageId();
// Notify callback with message ID if (messageId != null) {
onMessageSent?.call(messageId, expectedAckTag, suggestedTimeoutMs); debugPrint(' ✅ Matched with message ID: $messageId');
} else {
debugPrint( // Check if approaching firmware limit (8 pending ACKs max)
'⚠️ [Provider] SENT response received but no pending message IDs', 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) { _bleService.onMessageDelivered = (ackCode, roundTripTimeMs) {
debugPrint( debugPrint(
@@ -527,7 +541,9 @@ class ConnectionProvider with ChangeNotifier {
// Update SSE server with device name if running // Update SSE server with device name if running
if (_sseServer.isRunning) { 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 // Update SSE server with device name if running
if (_sseServer.isRunning) { 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() { void _startAckCleanupTimer() {
_stopAckCleanupTimer(); // Cancel any existing timer first _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), (_) { _ackCleanupTimer = Timer.periodic(const Duration(minutes: 1), (_) {
final cleanedCount = _messageDeliveryTracker.cleanupStaleAcks(); final cleanedCount = _messageDeliveryTracker.cleanupStaleAcks();
if (cleanedCount > 0) { 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); await _bleService.getContactByKey(publicKey);
} catch (e) { } catch (e) {
_error = 'Failed to get contact: $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 // Fallback to full contact sync if command not supported
await _bleService.getContacts(); await _bleService.getContacts();
notifyListeners(); notifyListeners();
@@ -902,7 +926,7 @@ class ConnectionProvider with ChangeNotifier {
// If not cached, query the device // If not cached, query the device
await _bleService.getChannel(channelIdx); await _bleService.getChannel(channelIdx);
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
// Check again after query // Check again after query
if (getChannelInfo != null) { if (getChannelInfo != null) {
final channel = getChannelInfo!(channelIdx); final channel = getChannelInfo!(channelIdx);
@@ -911,7 +935,7 @@ class ConnectionProvider with ChangeNotifier {
return channelName == null || channelName.isEmpty; return channelName == null || channelName.isEmpty;
} }
} }
// If still no info, assume it's empty // If still no info, assume it's empty
return true; return true;
} catch (e) { } catch (e) {
@@ -953,7 +977,7 @@ class ConnectionProvider with ChangeNotifier {
return i; return i;
} }
} }
debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use'); debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use');
return null; return null;
} catch (e) { } catch (e) {
@@ -986,7 +1010,7 @@ class ConnectionProvider with ChangeNotifier {
// Determine channel type // Determine channel type
final bool isHashChannel = channelName.startsWith('#'); final bool isHashChannel = channelName.startsWith('#');
// Check for duplicate channels // Check for duplicate channels
int? existingSlot; int? existingSlot;
if (getChannelInfo != null) { if (getChannelInfo != null) {
@@ -998,12 +1022,18 @@ class ConnectionProvider with ChangeNotifier {
if (existingName != null && existingName.isNotEmpty) { if (existingName != null && existingName.isNotEmpty) {
// For hash channels (#name), check exact match to prevent duplicates // For hash channels (#name), check exact match to prevent duplicates
if (isHashChannel && existingName == channelName) { if (isHashChannel && existingName == channelName) {
debugPrint(' ⚠️ Hash channel "$channelName" already exists in slot $i'); debugPrint(
throw Exception('Channel "$channelName" already exists. Hash channels cannot be duplicated.'); ' ⚠️ 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 // For private channels, check name match to allow overwrite
else if (!isHashChannel && existingName == channelName) { 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; existingSlot = i;
break; break;
} }
@@ -1022,7 +1052,9 @@ class ConnectionProvider with ChangeNotifier {
// Find next empty slot for new channel // Find next empty slot for new channel
final emptySlot = await findNextEmptyChannelSlot(); final emptySlot = await findNextEmptyChannelSlot();
if (emptySlot == null) { 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; slotIdx = emptySlot;
debugPrint(' Using empty slot: $slotIdx (new channel)'); debugPrint(' Using empty slot: $slotIdx (new channel)');
@@ -1049,7 +1081,9 @@ class ConnectionProvider with ChangeNotifier {
secret: secretBytes, 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 // Small delay to allow the response to propagate
await Future.delayed(const Duration(milliseconds: 100)); 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) // Delete channel on device (sets empty name and zeroed secret)
await _bleService.deleteChannel(channelIdx); 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 // Small delay to allow the response to propagate
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
@@ -1169,7 +1205,9 @@ class ConnectionProvider with ChangeNotifier {
debugPrint( debugPrint(
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)', '⚠️ [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 // Wait briefly for some ACKs to arrive, then proceed anyway
// (User action shouldn't be blocked forever) // (User action shouldn't be blocked forever)
@@ -1304,7 +1342,11 @@ class ConnectionProvider with ChangeNotifier {
// Track for echo detection // Track for echo detection
// The BLE handler will capture the packet via LOG_RX_DATA and associate it // The BLE handler will capture the packet via LOG_RX_DATA and associate it
debugPrint(' Calling trackSentChannelMessage...'); debugPrint(' Calling trackSentChannelMessage...');
_bleService.trackSentChannelMessage(messageId); _bleService.trackSentChannelMessage(
messageId,
channelIdx: channelIdx,
plainText: text,
);
debugPrint(' trackSentChannelMessage completed'); debugPrint(' trackSentChannelMessage completed');
// Small delay to ensure the message is in the MessagesProvider list // Small delay to ensure the message is in the MessagesProvider list
@@ -2022,7 +2064,9 @@ class ConnectionProvider with ChangeNotifier {
// Convert hex string to Uint8List // Convert hex string to Uint8List
final bytes = <int>[]; final bytes = <int>[];
for (int i = 0; i < recipientPublicKey.length; i += 2) { 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( return await sendTextMessage(
contactPublicKey: Uint8List.fromList(bytes), contactPublicKey: Uint8List.fromList(bytes),
@@ -2107,7 +2151,9 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
debugPrint('🔌 [ConnectionProvider] Connecting to SSE server: $serverUrl'); debugPrint(
'🔌 [ConnectionProvider] Connecting to SSE server: $serverUrl',
);
_sseClientServerUrl = serverUrl; _sseClientServerUrl = serverUrl;
// Wire up callbacks // Wire up callbacks
@@ -2122,11 +2168,17 @@ class ConnectionProvider with ChangeNotifier {
}; };
_sseClient.onConnectionStateChanged = (isConnected) { _sseClient.onConnectionStateChanged = (isConnected) {
debugPrint('🔔 [ConnectionProvider] SSE client connection state changed: $isConnected'); debugPrint(
'🔔 [ConnectionProvider] SSE client connection state changed: $isConnected',
);
if (isConnected) { if (isConnected) {
debugPrint('✅ [ConnectionProvider] SSE client connected - updating UI state'); debugPrint(
'✅ [ConnectionProvider] SSE client connected - updating UI state',
);
} else { } else {
debugPrint('❌ [ConnectionProvider] SSE client disconnected - updating UI state'); debugPrint(
'❌ [ConnectionProvider] SSE client disconnected - updating UI state',
);
} }
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
connectionState: isConnected connectionState: isConnected
@@ -2142,15 +2194,21 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
}; };
debugPrint('📌 [ConnectionProvider] SSE callbacks registered, starting connection...'); debugPrint(
'📌 [ConnectionProvider] SSE callbacks registered, starting connection...',
);
await _sseClient.connect(serverUrl: serverUrl, authToken: authToken); await _sseClient.connect(serverUrl: serverUrl, authToken: authToken);
_connectionMode = ConnectionMode.sseClient; _connectionMode = ConnectionMode.sseClient;
notifyListeners(); notifyListeners();
debugPrint('✅ [ConnectionProvider] Connected to SSE server'); debugPrint('✅ [ConnectionProvider] Connected to SSE server');
debugPrint('📊 [ConnectionProvider] SSE client state: isConnected=${_sseClient.isConnected}'); debugPrint(
debugPrint('📊 [ConnectionProvider] DeviceInfo state: connectionState=${_deviceInfo.connectionState}, isConnected=${_deviceInfo.isConnected}'); '📊 [ConnectionProvider] SSE client state: isConnected=${_sseClient.isConnected}',
);
debugPrint(
'📊 [ConnectionProvider] DeviceInfo state: connectionState=${_deviceInfo.connectionState}, isConnected=${_deviceInfo.isConnected}',
);
} catch (e) { } catch (e) {
_error = 'Failed to connect to SSE server: $e'; _error = 'Failed to connect to SSE server: $e';
debugPrint('❌ [ConnectionProvider] 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'); throw Exception('Not connected to SSE server');
} }
await _sseClient.sendChannelMessage( await _sseClient.sendChannelMessage(channelIdx: channelIdx, text: text);
channelIdx: channelIdx,
text: text,
);
} }
/// Get SSE client connection status /// Get SSE client connection status

View File

@@ -446,6 +446,22 @@ class ContactsProvider with ChangeNotifier {
return null; 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 /// Find contact by public key
Contact? findContactByKey(Uint8List publicKey) { Contact? findContactByKey(Uint8List publicKey) {
final keyHex = publicKey final keyHex = publicKey

View File

@@ -156,6 +156,25 @@ class MessagesProvider with ChangeNotifier {
); );
} }
// Check if it's a voice envelope/message and not already marked.
if (!enhancedMessage.isVoice) {
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
if (envelope != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: envelope.sessionId,
);
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
if (pkt != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
}
_messages.add(enhancedMessage); _messages.add(enhancedMessage);
// Extract SAR markers // Extract SAR markers
@@ -182,20 +201,26 @@ class MessagesProvider with ChangeNotifier {
/// This restores drawings that may be missing from DrawingProvider storage /// This restores drawings that may be missing from DrawingProvider storage
/// Should be called after both providers are initialized /// Should be called after both providers are initialized
void syncDrawingsWithProvider(dynamic drawingProvider) { void syncDrawingsWithProvider(dynamic drawingProvider) {
debugPrint('🔄 [MessagesProvider] Syncing drawings with DrawingProvider...'); debugPrint(
'🔄 [MessagesProvider] Syncing drawings with DrawingProvider...',
);
int restoredCount = 0; int restoredCount = 0;
for (final message in _messages) { for (final message in _messages) {
if (!message.isDrawing || message.drawingId == null) continue; if (!message.isDrawing || message.drawingId == null) continue;
// Check if drawing exists in DrawingProvider // Check if drawing exists in DrawingProvider
final existingDrawing = drawingProvider.getDrawingById(message.drawingId!); final existingDrawing = drawingProvider.getDrawingById(
message.drawingId!,
);
if (existingDrawing != null) { if (existingDrawing != null) {
continue; // Drawing already exists continue; // Drawing already exists
} }
// Drawing is missing, reconstruct from message text // 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( final drawing = DrawingMessageParser.parseDrawingMessage(
message.text, message.text,
senderName: message.senderName, senderName: message.senderName,
@@ -203,7 +228,9 @@ class MessagesProvider with ChangeNotifier {
); );
if (drawing == null) { if (drawing == null) {
debugPrint('⚠️ [MessagesProvider] Failed to parse drawing from message ${message.id}'); debugPrint(
'⚠️ [MessagesProvider] Failed to parse drawing from message ${message.id}',
);
continue; continue;
} }
@@ -214,11 +241,15 @@ class MessagesProvider with ChangeNotifier {
if (restoredDrawing != null) { if (restoredDrawing != null) {
drawingProvider.addReceivedDrawing(restoredDrawing); drawingProvider.addReceivedDrawing(restoredDrawing);
restoredCount++; 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 /// 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 // Check if it's a voice message (VE1:/V:) and not already marked.
if (VoicePacket.isVoiceText(enhancedMessage.text) && !enhancedMessage.isVoice) { if (!enhancedMessage.isVoice) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text); final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
if (pkt != null) { if (envelope != null) {
enhancedMessage = enhancedMessage.copyWith( enhancedMessage = enhancedMessage.copyWith(
isVoice: true, 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) // Check for duplicates (shouldn't happen for sent messages, but be safe)
if (_isDuplicate(enhancedMessage)) { if (_isDuplicate(enhancedMessage)) {
debugPrint( debugPrint(
@@ -861,7 +919,9 @@ class MessagesProvider with ChangeNotifier {
// Clamp at 20 seconds maximum // Clamp at 20 seconds maximum
final scaledTimeout = suggestedTimeoutMs * 5; final scaledTimeout = suggestedTimeoutMs * 5;
final effectiveTimeout = scaledTimeout > 20000 ? 20000 : scaledTimeout; 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) // Store ACK tag → List of (groupId, recipientPublicKey)
// Multiple recipients can share the same ACK tag // Multiple recipients can share the same ACK tag
@@ -869,8 +929,12 @@ class MessagesProvider with ChangeNotifier {
_ackTagToRecipients[expectedAckTag] = []; _ackTagToRecipients[expectedAckTag] = [];
} }
_ackTagToRecipients[expectedAckTag]!.add((groupId, recipientPublicKey)); _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(
debugPrint(' 📊 Total recipients for ACK $expectedAckTag: ${_ackTagToRecipients[expectedAckTag]!.length}'); ' ✅ 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 // Store the mapping so we can update the right recipient on delivery
_pendingSentMessages[expectedAckTag] = Message( _pendingSentMessages[expectedAckTag] = Message(
@@ -892,7 +956,9 @@ class MessagesProvider with ChangeNotifier {
_timeoutTimers[messageId] = Timer( _timeoutTimers[messageId] = Timer(
Duration(milliseconds: effectiveTimeout), 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 // Check if this specific recipient is still pending
final recipients = _ackTagToRecipients[expectedAckTag]; final recipients = _ackTagToRecipients[expectedAckTag];
if (recipients != null && recipients.isNotEmpty) { if (recipients != null && recipients.isNotEmpty) {
@@ -902,10 +968,13 @@ class MessagesProvider with ChangeNotifier {
); );
if (recipientIndex >= 0) { if (recipientIndex >= 0) {
final (timeoutGroupId, timeoutRecipientKey) = recipients[recipientIndex]; final (timeoutGroupId, timeoutRecipientKey) =
recipients[recipientIndex];
debugPrint(' ⚠️ Timeout fired - marking recipient as failed'); debugPrint(' ⚠️ Timeout fired - marking recipient as failed');
debugPrint(' Group: $timeoutGroupId'); 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 // Mark this specific recipient as failed
updateGroupedMessageRecipientStatus( updateGroupedMessageRecipientStatus(
@@ -925,7 +994,9 @@ class MessagesProvider with ChangeNotifier {
_groupedMessageMapping.remove(messageId); _groupedMessageMapping.remove(messageId);
_timeoutTimers.remove(messageId); _timeoutTimers.remove(messageId);
} else { } else {
debugPrint(' ✅ ACK already received for this recipient - ignoring timeout'); debugPrint(
' ✅ ACK already received for this recipient - ignoring timeout',
);
} }
} else { } else {
debugPrint(' ✅ All ACKs already received - ignoring timeout'); debugPrint(' ✅ All ACKs already received - ignoring timeout');
@@ -1032,6 +1103,9 @@ class MessagesProvider with ChangeNotifier {
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
echoCount: echoCount, echoCount: echoCount,
firstEchoAt: message.firstEchoAt ?? DateTime.now(), firstEchoAt: message.firstEchoAt ?? DateTime.now(),
lastEchoSnrRaw: snrRaw.toSigned(8),
lastEchoRssiDbm: rssiDbm.toSigned(8),
lastEchoAt: DateTime.now(),
); );
_messages[index] = updatedMessage; _messages[index] = updatedMessage;
@@ -1052,7 +1126,9 @@ class MessagesProvider with ChangeNotifier {
int? roundTripTimeMs, int? roundTripTimeMs,
DateTime? deliveredAt, DateTime? deliveredAt,
}) { }) {
debugPrint('🔄 [MessagesProvider] updateGroupedMessageRecipientStatus called'); debugPrint(
'🔄 [MessagesProvider] updateGroupedMessageRecipientStatus called',
);
debugPrint(' Group ID: $groupId'); debugPrint(' Group ID: $groupId');
debugPrint(' New status: $newStatus'); debugPrint(' New status: $newStatus');
debugPrint(' RTT: ${roundTripTimeMs}ms'); debugPrint(' RTT: ${roundTripTimeMs}ms');
@@ -1060,7 +1136,9 @@ class MessagesProvider with ChangeNotifier {
final index = _messages.indexWhere((m) => m.id == groupId); final index = _messages.indexWhere((m) => m.id == groupId);
if (index == -1) { if (index == -1) {
debugPrint('⚠️ [MessagesProvider] Grouped message not found: $groupId'); 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; return;
} }
@@ -1068,7 +1146,9 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' ✅ Found grouped message at index $index'); debugPrint(' ✅ Found grouped message at index $index');
if (!message.isGroupedMessage) { if (!message.isGroupedMessage) {
debugPrint('⚠️ [MessagesProvider] Message is not a grouped message: $groupId'); debugPrint(
'⚠️ [MessagesProvider] Message is not a grouped message: $groupId',
);
return; return;
} }
@@ -1094,7 +1174,11 @@ class MessagesProvider with ChangeNotifier {
return recipient.copyWith( return recipient.copyWith(
deliveryStatus: newStatus, deliveryStatus: newStatus,
roundTripTimeMs: roundTripTimeMs, 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) { if (!recipientFound) {
debugPrint(' ⚠️ Recipient not found in recipients list!'); 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:'); debugPrint(' Available recipients:');
for (final r in message.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 // Update overall message status based on recipients
MessageDeliveryStatus overallStatus; MessageDeliveryStatus overallStatus;
final allDelivered = updatedRecipients.every((r) => r.deliveryStatus == MessageDeliveryStatus.delivered); final allDelivered = updatedRecipients.every(
final anyFailed = updatedRecipients.any((r) => r.deliveryStatus == MessageDeliveryStatus.failed); (r) => r.deliveryStatus == MessageDeliveryStatus.delivered,
final anySending = updatedRecipients.any((r) => r.deliveryStatus == MessageDeliveryStatus.sending); );
final anyFailed = updatedRecipients.any(
(r) => r.deliveryStatus == MessageDeliveryStatus.failed,
);
final anySending = updatedRecipients.any(
(r) => r.deliveryStatus == MessageDeliveryStatus.sending,
);
debugPrint(' Status counts:'); debugPrint(' Status counts:');
debugPrint(' Delivered: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered).length}'); debugPrint(
debugPrint(' Sent/Pending: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.sent || r.deliveryStatus == MessageDeliveryStatus.sending).length}'); ' Delivered: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered).length}',
debugPrint(' Failed: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.failed).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) { if (allDelivered) {
overallStatus = MessageDeliveryStatus.delivered; overallStatus = MessageDeliveryStatus.delivered;
@@ -1148,9 +1248,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint( debugPrint(
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms', '🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
); );
debugPrint( debugPrint(' Checking recipient list for ACK $ackCode...');
' Checking recipient list for ACK $ackCode...',
);
// Check if this ACK is for grouped message recipient(s) // Check if this ACK is for grouped message recipient(s)
final recipients = _ackTagToRecipients[ackCode]; final recipients = _ackTagToRecipients[ackCode];
@@ -1158,13 +1256,18 @@ class MessagesProvider with ChangeNotifier {
// Pop the first recipient from the list (FIFO order) // Pop the first recipient from the list (FIFO order)
// This matches the order in which messages were sent // This matches the order in which messages were sent
final (groupId, recipientPublicKey) = recipients.removeAt(0); 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(
debugPrint(' 📊 Remaining recipients for ACK $ackCode: ${recipients.length}'); ' ✅ 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 // Find the message ID for this recipient to cancel its timeout
String? messageIdToCancel; String? messageIdToCancel;
for (final entry in _groupedMessageMapping.entries) { 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; messageIdToCancel = entry.key;
break; break;
} }
@@ -1188,7 +1291,9 @@ class MessagesProvider with ChangeNotifier {
// Clean up if no more recipients for this ACK // Clean up if no more recipients for this ACK
if (recipients.isEmpty) { if (recipients.isEmpty) {
debugPrint(' 🧹 All recipients processed for ACK $ackCode, cleaning up'); debugPrint(
' 🧹 All recipients processed for ACK $ackCode, cleaning up',
);
_ackTagToRecipients.remove(ackCode); _ackTagToRecipients.remove(ackCode);
_pendingSentMessages.remove(ackCode); _pendingSentMessages.remove(ackCode);
} }
@@ -1205,9 +1310,7 @@ class MessagesProvider with ChangeNotifier {
} }
// Not a grouped message, check for single message // Not a grouped message, check for single message
debugPrint( debugPrint(' Not in simple mapping, checking pending messages...');
' Not in simple mapping, checking pending messages...',
);
debugPrint( debugPrint(
' Current pending messages: ${_pendingSentMessages.keys.toList()}', ' Current pending messages: ${_pendingSentMessages.keys.toList()}',
); );

View File

@@ -1,5 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../services/voice_player_service.dart'; import '../services/voice_player_service.dart';
@@ -32,8 +35,10 @@ class VoiceSession {
/// Manages incoming voice packet sessions and coordinates playback. /// Manages incoming voice packet sessions and coordinates playback.
class VoiceProvider with ChangeNotifier { class VoiceProvider with ChangeNotifier {
static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1';
final VoiceCodecService _codec; final VoiceCodecService _codec;
final VoicePlayerService _player; final VoicePlayerService _player;
late final StreamSubscription<void> _playerEventsSub;
/// Active sessions keyed by sessionId. /// Active sessions keyed by sessionId.
final Map<String, VoiceSession> _sessions = {}; final Map<String, VoiceSession> _sessions = {};
@@ -41,18 +46,52 @@ class VoiceProvider with ChangeNotifier {
/// Currently playing session ID, or null. /// Currently playing session ID, or null.
String? _playingSessionId; String? _playingSessionId;
/// Hook for sending a raw voice payload to a destination contact path.
Future<void> Function({
required Uint8List contactPath,
required int contactPathLen,
required Uint8List payload,
})?
sendRawPacketCallback;
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
VoiceProvider({ VoiceProvider({
required VoiceCodecService codec, required VoiceCodecService codec,
required VoicePlayerService player, required VoicePlayerService player,
}) : _codec = codec, }) : _codec = codec,
_player = player; _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 ──────────────────────────────────────────────────── // ── Session accessors ────────────────────────────────────────────────────
VoiceSession? session(String sessionId) => _sessions[sessionId]; VoiceSession? session(String sessionId) => _sessions[sessionId];
bool isComplete(String sessionId) => _sessions[sessionId]?.isComplete ?? false; bool isComplete(String sessionId) =>
bool isPlaying(String sessionId) => _sessions[sessionId]?.isComplete ?? false;
_playingSessionId == sessionId && _player.isPlaying; 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 ───────────────────────────────────────────────────── // ── Packet reception ─────────────────────────────────────────────────────
@@ -74,10 +113,61 @@ class VoiceProvider with ChangeNotifier {
} }
final justComplete = session.isComplete; final justComplete = session.isComplete;
_persistVoiceData();
notifyListeners(); notifyListeners();
return justComplete; return justComplete;
} }
/// Cache encoded packets for deferred voice serving.
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
if (packets.isEmpty) return;
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
sessionId: sessionId,
packets: List<VoicePacket>.from(packets),
);
_persistVoiceData();
}
/// Stream a cached voice session to a requester over raw direct packets.
Future<bool> serveSessionTo({
required String sessionId,
required Contact requester,
}) async {
final cached = _outgoingSessions[sessionId];
if (cached == null) {
debugPrint(
'⚠️ [VoiceProvider] No cached outgoing session for $sessionId',
);
return false;
}
if (sendRawPacketCallback == null) {
debugPrint('⚠️ [VoiceProvider] sendRawPacketCallback is not set');
return false;
}
if (requester.outPathLen < 0) {
debugPrint(
'⚠️ [VoiceProvider] Requester ${requester.advName} has no direct path',
);
return false;
}
for (final packet in cached.packets) {
try {
await sendRawPacketCallback!(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
payload: packet.encodeBinary(),
);
} catch (e, st) {
debugPrint(
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
);
return false;
}
}
return true;
}
// ── Playback ───────────────────────────────────────────────────────────── // ── Playback ─────────────────────────────────────────────────────────────
/// Decode and play the voice session with [sessionId]. /// Decode and play the voice session with [sessionId].
@@ -85,11 +175,15 @@ class VoiceProvider with ChangeNotifier {
Future<void> play(String sessionId) async { Future<void> play(String sessionId) async {
final session = _sessions[sessionId]; final session = _sessions[sessionId];
if (session == null) { 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; 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 { try {
final pcm = await _codec.decodePackets(session.packets, session.mode); final pcm = await _codec.decodePackets(session.packets, session.mode);
@@ -99,7 +193,6 @@ class VoiceProvider with ChangeNotifier {
await _player.play(pcm); await _player.play(pcm);
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st'); debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
} finally {
if (_playingSessionId == sessionId) { if (_playingSessionId == sessionId) {
_playingSessionId = null; _playingSessionId = null;
notifyListeners(); notifyListeners();
@@ -113,9 +206,127 @@ class VoiceProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> clearStoredVoiceData() async {
_sessions.clear();
_outgoingSessions.clear();
_playingSessionId = null;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_voiceSessionsStorageKey);
} catch (e) {
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
}
}
Future<void> _persistVoiceData() async {
try {
final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{
'incoming': _sessions.values
.map(
(session) => {
'sessionId': session.sessionId,
'modeId': session.mode.id,
'total': session.total,
'packets': session.packets
.map((p) => p?.encodeText())
.toList(),
},
)
.toList(),
'outgoing': _outgoingSessions.values
.map(
(session) => {
'sessionId': session.sessionId,
'packets': session.packets.map((p) => p.encodeText()).toList(),
},
)
.toList(),
};
await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload));
} catch (e) {
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
}
}
Future<void> _restorePersistedVoiceData() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_voiceSessionsStorageKey);
if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>;
final incoming = parsed['incoming'] as List<dynamic>? ?? const [];
for (final item in incoming) {
final map = item as Map<String, dynamic>;
final sessionId = map['sessionId'] as String?;
final modeId = map['modeId'] as int?;
final total = map['total'] as int?;
if (sessionId == null || modeId == null || total == null || total <= 0) {
continue;
}
final mode = VoicePacketMode.fromId(modeId);
final session = VoiceSession(
sessionId: sessionId,
mode: mode,
total: total,
);
final packets = map['packets'] as List<dynamic>? ?? const [];
for (var i = 0; i < packets.length && i < session.total; i++) {
final encoded = packets[i] as String?;
if (encoded == null || encoded.isEmpty) continue;
final packet = VoicePacket.tryParseText(encoded);
if (packet != null && packet.index < session.total) {
session.packets[packet.index] = packet;
}
}
_sessions[sessionId] = session;
}
final outgoing = parsed['outgoing'] as List<dynamic>? ?? const [];
for (final item in outgoing) {
final map = item as Map<String, dynamic>;
final sessionId = map['sessionId'] as String?;
if (sessionId == null || sessionId.isEmpty) continue;
final packetsRaw = map['packets'] as List<dynamic>? ?? const [];
final packets = <VoicePacket>[];
for (final encoded in packetsRaw) {
final packet = VoicePacket.tryParseText((encoded ?? '') as String);
if (packet != null) packets.add(packet);
}
if (packets.isNotEmpty) {
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
sessionId: sessionId,
packets: packets,
);
}
}
notifyListeners();
debugPrint(
'🎙️ [VoiceProvider] Restored ${_sessions.length} incoming and ${_outgoingSessions.length} outgoing voice sessions',
);
} catch (e) {
debugPrint('❌ [VoiceProvider] Failed to restore voice data: $e');
}
}
@override @override
void dispose() { void dispose() {
_playerEventsSub.cancel();
_player.dispose(); _player.dispose();
super.dispose(); super.dispose();
} }
} }
class _OutgoingVoiceSession {
final String sessionId;
final List<VoicePacket> packets;
const _OutgoingVoiceSession({
required this.sessionId,
required this.packets,
});
}

View File

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

View File

@@ -9,10 +9,7 @@ import '../l10n/app_localizations.dart';
class PacketLogScreen extends StatefulWidget { class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService; final MeshCoreBleService bleService;
const PacketLogScreen({ const PacketLogScreen({super.key, required this.bleService});
super.key,
required this.bleService,
});
@override @override
State<PacketLogScreen> createState() => _PacketLogScreenState(); State<PacketLogScreen> createState() => _PacketLogScreenState();
@@ -56,16 +53,18 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
final logs = _filteredLogs; final logs = _filteredLogs;
if (logs.isEmpty) { if (logs.isEmpty) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('No logs to export')), context,
); ).showSnackBar(const SnackBar(content: Text('No logs to export')));
} }
return; return;
} }
// Create CSV content // Create CSV content
final buffer = StringBuffer(); 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) { for (final log in logs) {
buffer.writeln(log.toCsvRow()); buffer.writeln(log.toCsvRow());
} }
@@ -73,7 +72,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
// Save to temporary file // Save to temporary file
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
if (!context.mounted) return; 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()); await file.writeAsString(buffer.toString());
// Share the file // Share the file
@@ -87,9 +88,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
); );
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Export failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Export failed: $e')));
} }
} }
} }
@@ -99,9 +100,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
final logs = _filteredLogs; final logs = _filteredLogs;
if (logs.isEmpty) { if (logs.isEmpty) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('No logs to export')), context,
); ).showSnackBar(const SnackBar(content: Text('No logs to export')));
} }
return; return;
} }
@@ -122,7 +123,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
// Save to temporary file // Save to temporary file
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
if (!context.mounted) return; 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()); await file.writeAsString(buffer.toString());
// Share the file // Share the file
@@ -136,9 +139,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
); );
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Export failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Export failed: $e')));
} }
} }
} }
@@ -159,7 +162,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: Text(AppLocalizations.of(dialogContext)!.clearAllData), 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: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
@@ -204,11 +209,13 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
actions: [ actions: [
// Direction filter // Direction filter
PopupMenuButton<PacketDirection?>( PopupMenuButton<PacketDirection?>(
icon: Icon(_filterDirection == null icon: Icon(
? Icons.filter_list _filterDirection == null
: _filterDirection == PacketDirection.rx ? Icons.filter_list
? Icons.arrow_downward : _filterDirection == PacketDirection.rx
: Icons.arrow_upward), ? Icons.arrow_downward
: Icons.arrow_upward,
),
tooltip: 'Filter by direction', tooltip: 'Filter by direction',
onSelected: (direction) { onSelected: (direction) {
setState(() { setState(() {
@@ -220,12 +227,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
value: null, value: null,
child: Row( child: Row(
children: [ children: [
Icon(Icons.filter_list, Icon(
color: _filterDirection == null ? Theme.of(context).colorScheme.primary : null), Icons.filter_list,
color: _filterDirection == null
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('All', Text(
style: TextStyle( 'All',
fontWeight: _filterDirection == null ? FontWeight.bold : FontWeight.normal)), style: TextStyle(
fontWeight: _filterDirection == null
? FontWeight.bold
: FontWeight.normal,
),
),
], ],
), ),
), ),
@@ -233,15 +249,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
value: PacketDirection.rx, value: PacketDirection.rx,
child: Row( child: Row(
children: [ children: [
Icon(Icons.arrow_downward, Icon(
color: _filterDirection == PacketDirection.rx Icons.arrow_downward,
? Theme.of(context).colorScheme.primary color: _filterDirection == PacketDirection.rx
: null), ? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('RX (Received)', Text(
style: TextStyle( 'RX (Received)',
fontWeight: style: TextStyle(
_filterDirection == PacketDirection.rx ? FontWeight.bold : FontWeight.normal)), fontWeight: _filterDirection == PacketDirection.rx
? FontWeight.bold
: FontWeight.normal,
),
),
], ],
), ),
), ),
@@ -249,15 +271,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
value: PacketDirection.tx, value: PacketDirection.tx,
child: Row( child: Row(
children: [ children: [
Icon(Icons.arrow_upward, Icon(
color: _filterDirection == PacketDirection.tx Icons.arrow_upward,
? Theme.of(context).colorScheme.primary color: _filterDirection == PacketDirection.tx
: null), ? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('TX (Sent)', Text(
style: TextStyle( 'TX (Sent)',
fontWeight: style: TextStyle(
_filterDirection == PacketDirection.tx ? FontWeight.bold : FontWeight.normal)), fontWeight: _filterDirection == PacketDirection.tx
? FontWeight.bold
: FontWeight.normal,
),
),
], ],
), ),
), ),
@@ -265,7 +293,11 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
), ),
// Auto-scroll toggle // Auto-scroll toggle
IconButton( 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', tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll',
onPressed: () { onPressed: () {
setState(() { setState(() {
@@ -352,11 +384,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.list_alt, size: 64, color: Colors.grey[400]),
Icons.list_alt,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_searchQuery.isNotEmpty || _filterDirection != null _searchQuery.isNotEmpty || _filterDirection != null
@@ -367,7 +395,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
color: Colors.grey[600], color: Colors.grey[600],
), ),
), ),
if (_searchQuery.isNotEmpty || _filterDirection != null) ...[ if (_searchQuery.isNotEmpty ||
_filterDirection != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: () {
@@ -420,15 +449,13 @@ class _PacketLogCard extends StatelessWidget {
final BlePacketLog log; final BlePacketLog log;
final VoidCallback onCopy; final VoidCallback onCopy;
const _PacketLogCard({ const _PacketLogCard({required this.log, required this.onCopy});
required this.log,
required this.onCopy,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isRx = log.direction == PacketDirection.rx; final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue; final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -480,67 +507,168 @@ class _PacketLogCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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( Wrap(
spacing: 16, spacing: 10,
runSpacing: 8, runSpacing: 10,
children: [ children: [
_InfoChip( _FactCard(
icon: Icons.schedule, icon: isRx ? Icons.call_received : Icons.call_made,
label: log.timestamp.toIso8601String(), label: 'Direction',
value: isRx ? 'RX' : 'TX',
accent: directionColor,
), ),
_InfoChip( _FactCard(
icon: Icons.data_usage, icon: Icons.data_object,
label: '${log.rawData.length} bytes', label: 'Size',
value: '${log.rawData.length} bytes',
),
_FactCard(
icon: Icons.schedule,
label: 'Captured',
value: _formatTimestamp(log.timestamp),
), ),
if (log.responseCode != null) if (log.responseCode != null)
_InfoChip( _FactCard(
icon: Icons.tag, icon: Icons.sell,
label: log.opcodeDescription, label: 'Opcode',
), value: log.opcodeName,
// 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',
), ),
], ],
), ),
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')}'; 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 IconData icon;
final String label; final String label;
final String value;
final Color? accent;
const _InfoChip({ const _FactCard({
required this.icon, required this.icon,
required this.label, required this.label,
required this.value,
this.accent,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Chip( final tileColor = accent ?? Theme.of(context).colorScheme.primary;
avatar: Icon(icon, size: 16), return Container(
label: Text( constraints: const BoxConstraints(minWidth: 108),
label, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
style: const TextStyle(fontSize: 11), decoration: BoxDecoration(
color: tileColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: tileColor),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
);
}
}
class _SignalMeter extends StatelessWidget {
final String label;
final String valueLabel;
final double normalized;
final Color color;
const _SignalMeter({
required this.label,
required this.valueLabel,
required this.normalized,
required this.color,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 42,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: LinearProgressIndicator(
minHeight: 8,
value: normalized,
backgroundColor: color.withValues(alpha: 0.15),
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
),
const SizedBox(width: 10),
SizedBox(
width: 74,
child: Text(
valueLabel,
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
);
}
}
class _HexByteChip extends StatelessWidget {
final int index;
final int value;
final VoidCallback onTap;
const _HexByteChip({
required this.index,
required this.value,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final text = value.toRadixString(16).padLeft(2, '0').toUpperCase();
return Tooltip(
message: 'Byte $index',
waitDuration: const Duration(milliseconds: 250),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(7),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 5),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(7),
),
child: Text(
text,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
), ),
padding: const EdgeInsets.all(4),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
); );
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import geolocator_apple
import nsd_macos import nsd_macos
import objectbox_flutter_libs import objectbox_flutter_libs
import package_info_plus import package_info_plus
import path_provider_foundation
import record_macos import record_macos
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
@@ -30,7 +29,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -5,10 +5,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.7" version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -101,10 +101,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -129,6 +129,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
codec2_flutter: codec2_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -148,10 +156,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: cross_file name: cross_file
sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.5" version: "0.3.5+2"
crypto: crypto:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -188,18 +196,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: dbus name: dbus
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.12"
device_info_plus: device_info_plus:
dependency: transitive dependency: transitive
description: description:
name: device_info_plus name: device_info_plus
sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33 sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.2.0" version: "12.3.0"
device_info_plus_platform_interface: device_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -228,10 +236,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: ffi name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -244,10 +252,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: f8f4ea435f791ab1f817b4e338ed958cb3d04ba43d6736ffc39958d950754967 sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.3.6" version: "10.3.10"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -305,50 +313,58 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_blue_plus name: flutter_blue_plus
sha256: bfcfcd60cd39846d32944f1c1a84f270437ce2d8e7a3e8a1cf8bf9ac9c9a423c sha256: "4fba86c513feab2c5cdb9497da0910ed5b50c0fa8d6cec4a26ffb1a558a24eb8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" version: "2.2.1"
flutter_blue_plus_android: flutter_blue_plus_android:
dependency: transitive dependency: transitive
description: description:
name: flutter_blue_plus_android name: flutter_blue_plus_android
sha256: e62c1cfa4da3594cc8360333bc3f9208a84963bfcbae192fb95a61635caf75fe sha256: "2a73e264685574d1d29dcdd565bad9ecfdf237630237c508ae8b47f5cc791f1d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.1" version: "8.2.1"
flutter_blue_plus_darwin: flutter_blue_plus_darwin:
dependency: transitive dependency: transitive
description: description:
name: flutter_blue_plus_darwin name: flutter_blue_plus_darwin
sha256: d789861c37aee73101515df99f1d6d162b0ea69a13b961e1b3339f27ea06dcb6 sha256: cfef171db550670cf8110f6eb25baf15d9bc8bad2af29550f9bbc0d8fceaf285
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.1" version: "8.2.1"
flutter_blue_plus_linux: flutter_blue_plus_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_blue_plus_linux name: flutter_blue_plus_linux
sha256: "1fd456e7f17f6c9e50a2bdfca8bcfd5dfee83e3c0a9fd7d493dd73d4e60b9755" sha256: "5add6c14d2f90672c5e3ded1455b9ca8e6fe44adf9b53cdc60eb3417d38f34fe"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.1" version: "8.2.1"
flutter_blue_plus_platform_interface: flutter_blue_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_blue_plus_platform_interface name: flutter_blue_plus_platform_interface
sha256: "8d8440360bed1dce921f3140510c9294ec81d21945c30607afd29bda9a8a87d6" sha256: "226fb6753a74a407e3b9975c0fc00de02c490ae655b31c6508cb5790ad30965d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.1" version: "8.2.1"
flutter_blue_plus_web: flutter_blue_plus_web:
dependency: transitive dependency: transitive
description: description:
name: flutter_blue_plus_web name: flutter_blue_plus_web
sha256: "87d4d63cd06e1e3e9c4b4f5774cea6d2a8516b5693dce0e4dbf7c9d4e63dbcfd" sha256: "10a7465ccfc50138280abf32c8ab314f5029aa19039628ad9b4d0ed786e0021f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.1" version: "8.2.1"
flutter_blue_plus_winrt:
dependency: transitive
description:
name: flutter_blue_plus_winrt
sha256: ed894f0ab341f4cece8fa33edc381d46424a7c5bfd0e841d933d0f8c34c86521
url: "https://pub.dev"
source: hosted
version: "0.0.18"
flutter_compass: flutter_compass:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -377,34 +393,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" sha256: cf206c68707773b670340dfeca7fc95f95509f4ca91a544bdd77d03a8106b91e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "19.5.0" version: "21.0.0-dev.2"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 sha256: "223bdd6e389a5ec02597fb494c8b3d6b0a9119534f9f45b7feef69cc0ee838e5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "8.0.0-dev.1"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" sha256: "2adc3c14a650af4e6235817fbf4335ba14bc3d36d5d497d7104d94e1475d5ddc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.1.0" version: "11.0.0-dev.1"
flutter_local_notifications_windows: flutter_local_notifications_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_windows name: flutter_local_notifications_windows
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" sha256: e7136082af98f3459552d2cc423f4b27e3010bbddcd1e575d9a270d47d2384d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "3.0.0-dev.1"
flutter_localizations: flutter_localizations:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -430,10 +446,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_plugin_android_lifecycle name: flutter_plugin_android_lifecycle
sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.32" version: "2.0.33"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -480,10 +496,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: geolocator_linux name: geolocator_linux
sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3 sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.3" version: "0.2.4"
geolocator_platform_interface: geolocator_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -508,6 +524,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.5" version: "0.2.5"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
gsettings: gsettings:
dependency: transitive dependency: transitive
description: description:
@@ -516,6 +540,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.8" version: "0.2.8"
hooks:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -544,18 +576,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: idb_shim name: idb_shim
sha256: "071f3b05032fa62e60ca15db9939f8afbaf403b37e67747ac88f858c3e999228" sha256: "921301da0a735f336a28fc35c3abdbd4498895cc205fa1ea9f7e785e7d854ceb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.7+1" version: "2.8.2+4"
image: image:
dependency: transitive dependency: transitive
description: description:
name: image name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.4" version: "4.8.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -568,10 +600,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: json_annotation name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.9.0" version: "4.11.0"
latlong2: latlong2:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -608,10 +640,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.1.0"
lists: lists:
dependency: transitive dependency: transitive
description: description:
@@ -628,22 +660,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.2" version: "2.6.2"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.18"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
mbtiles: mbtiles:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -656,7 +696,7 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: HEAD ref: main
resolved-ref: "6457853c57b20727f83f5932ee6684e16f20ff8f" resolved-ref: "6457853c57b20727f83f5932ee6684e16f20ff8f"
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
@@ -685,6 +725,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -697,18 +745,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: nsd name: nsd
sha256: cae71ee9c23ea7f75d4610efe7ff335b1f575fb93ef7b7f9a5c6183a091cbb74 sha256: "1611a5c9f61d56ff2973e1488ae04112103e5203b4a7a1fb594b48cfb366fc14"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.3" version: "4.1.0"
nsd_android: nsd_android:
dependency: transitive dependency: transitive
description: description:
name: nsd_android name: nsd_android
sha256: "1309cd47d02c99bd305219f0a226644f9f4a964d341c3d6730cebb906bb1ec78" sha256: "96d2d451c5db0319c37b1b2a38f2d55eb56ae54c0b0d3144c03c19c97436de4a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.2.0"
nsd_ios: nsd_ios:
dependency: transitive dependency: transitive
description: description:
@@ -729,10 +777,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: nsd_platform_interface name: nsd_platform_interface
sha256: "7220c8e0beeacd06c180fefcd6bb708415ed889c76f656e75ac633d09ceaa761" sha256: b1a5ace6f01ea2ce37f373e52c3b7af4fd7c11de2582ddcc89f4fc00615d9dff
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.2.0"
nsd_windows: nsd_windows:
dependency: transitive dependency: transitive
description: description:
@@ -757,14 +805,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.3.1" version: "4.3.1"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_info_plus: package_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.3.1" version: "9.0.0"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -793,18 +849,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.20" version: "2.2.22"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.3" version: "2.6.0"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -881,10 +937,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -905,10 +961,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: posix name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.3" version: "6.5.0"
proj4dart: proj4dart:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -933,6 +989,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record: record:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1009,10 +1073,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sembast name: sembast
sha256: c8063c3146c3c8d5f5b04230de7682c768440a575fbda2634f14d22f263197c3 sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.8.5+2" version: "3.8.6"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1033,26 +1097,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.3" version: "2.5.4"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.15" version: "2.4.21"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_foundation name: shared_preferences_foundation
sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.5" version: "2.5.6"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
@@ -1110,10 +1174,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.2"
sqlite3: sqlite3:
dependency: transitive dependency: transitive
description: description:
@@ -1166,18 +1230,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.9"
timezone: timezone:
dependency: "direct main" dependency: "direct main"
description: description:
name: timezone name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.10.1" version: "0.11.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1206,34 +1270,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.24" version: "6.3.28"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.5" version: "6.4.1"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_linux name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "3.2.2"
url_launcher_macos: url_launcher_macos:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.4" version: "3.2.5"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1246,26 +1310,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.2"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.4" version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
name: uuid name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.2" version: "4.5.3"
vector_map_tiles: vector_map_tiles:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1279,7 +1343,7 @@ packages:
description: description:
path: vector_map_tiles_mbtiles path: vector_map_tiles_mbtiles
ref: HEAD ref: HEAD
resolved-ref: "6d0b7bd077c70c2013704074bd9cd7229a1bf072" resolved-ref: a09543b7590b373f3ac53f4776e343fee41c7dc6
url: "https://github.com/josxha/flutter_map_plugins.git" url: "https://github.com/josxha/flutter_map_plugins.git"
source: git source: git
version: "1.2.1" version: "1.2.1"
@@ -1319,10 +1383,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: vibration name: vibration
sha256: "1fd51cb0f91c6d512734ca0e282dd87fbc7f389b6da5f03c77709ba2cf8fa901" sha256: "9bb06614c69260f8bd11c80fe01ed7988905cf00e3417d656c2647e41f261d87"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.4" version: "3.1.8"
vibration_platform_interface: vibration_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1396,5 +1460,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.9.2 <4.0.0" dart: ">=3.10.3 <4.0.0"
flutter: ">=3.35.0" flutter: ">=3.38.4"

View File

@@ -44,6 +44,7 @@ dependencies:
meshcore_client: meshcore_client:
git: git:
url: https://github.com/dz0ny/meshcore_client.git url: https://github.com/dz0ny/meshcore_client.git
ref: main
# Codec2 ultra-low-bitrate speech codec (FFI plugin) # Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter: codec2_flutter:
@@ -98,14 +99,14 @@ dependencies:
shared_preferences: ^2.3.3 shared_preferences: ^2.3.3
# Package info # Package info
package_info_plus: ^8.1.2 package_info_plus: ^9.0.0
# Background services # Background services
flutter_background_service: ^5.0.13 flutter_background_service: ^5.0.13
# Notifications # Notifications
flutter_local_notifications: ^19.5.0 flutter_local_notifications: ^21.0.0-dev.2
timezone: ^0.10.0 timezone: ^0.11.0
# Vibration # Vibration
vibration: ^3.1.4 vibration: ^3.1.4

View File

@@ -0,0 +1,69 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider voice detection', () {
test('marks VE1 envelope messages as voice', () {
final provider = MessagesProvider();
final envelope = VoiceEnvelope(
sessionId: 'deafbead',
mode: VoicePacketMode.mode1200,
total: 3,
durationMs: 2400,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
);
final message = Message(
id: 'm1',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: envelope.encodeText(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
deliveryStatus: MessageDeliveryStatus.sent,
);
provider.addMessage(message);
final stored = provider.messages.single;
expect(stored.isVoice, isTrue);
expect(stored.voiceId, equals('deafbead'));
});
test('marks legacy V text packets as voice', () {
final provider = MessagesProvider();
final packet = VoicePacket(
sessionId: '00112233',
mode: VoicePacketMode.mode700c,
index: 0,
total: 1,
codec2Data: Uint8List.fromList([1, 2, 3]),
);
final message = Message(
id: 'm2',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000001,
text: packet.encodeText(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
deliveryStatus: MessageDeliveryStatus.sent,
);
provider.addMessage(message);
final stored = provider.messages.single;
expect(stored.isVoice, isTrue);
expect(stored.voiceId, equals('00112233'));
});
});
}

View File

@@ -0,0 +1,152 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
void main() {
group('VoiceEnvelope', () {
test('encodes and parses valid envelope', () {
final env = VoiceEnvelope(
sessionId: 'deadbeef',
mode: VoicePacketMode.mode1200,
total: 4,
durationMs: 3200,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
);
final text = env.encodeText();
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
final parsed = VoiceEnvelope.tryParseText(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('deadbeef'));
expect(parsed.mode, equals(VoicePacketMode.mode1200));
expect(parsed.total, equals(4));
expect(parsed.durationMs, equals(3200));
expect(parsed.senderKey6, equals('aabbccddeeff'));
expect(parsed.version, equals(1));
});
test('rejects invalid envelope payload', () {
final text = 'VE1:nothex:1:2:1000:aabbccddeeff:1700000000:1';
expect(VoiceEnvelope.tryParseText(text), isNull);
});
});
group('VoiceFetchRequest', () {
test('encodes and parses valid request', () {
final req = VoiceFetchRequest(
sessionId: '00112233',
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final text = req.encodeText();
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
final parsed = VoiceFetchRequest.tryParseText(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('00112233'));
expect(parsed.want, equals('all'));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(1));
});
test('rejects invalid request payload', () {
expect(
VoiceFetchRequest.tryParseText(
'VR1:00112233:chunk:ffeeddccbbaa:1700000001:1',
),
isNull,
);
});
});
group('VoicePacket backward compatibility', () {
test('parses legacy V: text format', () {
final pkt = VoicePacket(
sessionId: 'a1b2c3d4',
mode: VoicePacketMode.mode700c,
index: 0,
total: 1,
codec2Data: Uint8List.fromList([1, 2, 3, 4]),
);
final encoded = pkt.encodeText();
final parsed = VoicePacket.tryParseText(encoded);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('a1b2c3d4'));
expect(parsed.total, equals(1));
expect(parsed.codec2Data, equals(Uint8List.fromList([1, 2, 3, 4])));
});
test('constructs binary datagram from actual packet data', () {
final actualCodec2 = Uint8List.fromList([
0xD3,
0x19,
0x7A,
0x00,
0xFE,
0x44,
0xC1,
0x2B,
0x88,
]);
final pkt = VoicePacket(
sessionId: '01020304',
mode: VoicePacketMode.mode1300,
index: 2,
total: 5,
codec2Data: actualCodec2,
);
final datagram = pkt.encodeBinary();
expect(datagram[0], equals(0x56)); // magic 'V'
expect(datagram.sublist(1, 5), equals(Uint8List.fromList([1, 2, 3, 4])));
expect(datagram[5], equals(VoicePacketMode.mode1300.id));
expect(datagram[6], equals(2));
expect(datagram[7], equals(5));
expect(datagram.sublist(8), equals(actualCodec2));
final parsed = VoicePacket.tryParseBinary(datagram);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.mode, equals(VoicePacketMode.mode1300));
expect(parsed.index, equals(2));
expect(parsed.total, equals(5));
expect(parsed.codec2Data, equals(actualCodec2));
});
});
group('VoiceWaveform', () {
test('builds bars from packet bytes', () {
final packet = VoicePacket(
sessionId: '1234abcd',
mode: VoicePacketMode.mode1200,
index: 0,
total: 1,
codec2Data: Uint8List.fromList([
0,
255,
10,
245,
120,
130,
64,
192,
32,
224,
]),
);
final bars = VoiceWaveform.buildBarsFromPackets([packet], bars: 8);
expect(bars.length, equals(8));
expect(bars.every((v) => v >= 0.0 && v <= 1.0), isTrue);
expect(bars.any((v) => v > 0.2), isTrue);
});
test('returns zeros for missing packet data', () {
final bars = VoiceWaveform.buildBarsFromPackets(const [], bars: 6);
expect(bars, equals(List<double>.filled(6, 0.0)));
});
});
}

View File

@@ -9,9 +9,18 @@ import 'package:ffi/ffi.dart';
/// Codec2 operating modes. /// Codec2 operating modes.
/// Numeric values match the C constants in codec2.h. /// Numeric values match the C constants in codec2.h.
enum Codec2Mode { enum Codec2Mode {
/// 3200 bps.
mode3200(0),
/// 2400 bps — higher quality, ~300 bytes/sec output at 8kHz. /// 2400 bps — higher quality, ~300 bytes/sec output at 8kHz.
mode2400(1), mode2400(1),
/// 1600 bps.
mode1600(2),
/// 1400 bps.
mode1400(3),
/// 1300 bps — good quality for LoRa, ~175 bytes/sec at 8kHz (25 fps × 7 B). /// 1300 bps — good quality for LoRa, ~175 bytes/sec at 8kHz (25 fps × 7 B).
mode1300(4), mode1300(4),
@@ -20,7 +29,7 @@ enum Codec2Mode {
/// 700C bps — minimum bandwidth for very narrow LoRa / ham radio channels. /// 700C bps — minimum bandwidth for very narrow LoRa / ham radio channels.
/// ~100 bytes/sec output at 8kHz. /// ~100 bytes/sec output at 8kHz.
mode700c(6); mode700c(8);
const Codec2Mode(this.c2ModeId); const Codec2Mode(this.c2ModeId);
@@ -28,7 +37,8 @@ enum Codec2Mode {
final int c2ModeId; final int c2ModeId;
/// Audio frames per second for this mode (all modes use 8000 Hz sample rate). /// Audio frames per second for this mode (all modes use 8000 Hz sample rate).
int get framesPerSecond => this == mode2400 ? 50 : 25; int get framesPerSecond =>
(this == mode3200 || this == mode2400) ? 50 : 25;
/// Samples per frame (8000 Hz / framesPerSecond). /// Samples per frame (8000 Hz / framesPerSecond).
int get samplesPerFrame => 8000 ~/ framesPerSecond; int get samplesPerFrame => 8000 ~/ framesPerSecond;
@@ -37,12 +47,18 @@ enum Codec2Mode {
/// Used to calculate packet duration for the 172-byte BLE frame limit. /// Used to calculate packet duration for the 172-byte BLE frame limit.
int get bytesPerSecond { int get bytesPerSecond {
switch (this) { switch (this) {
case mode3200:
return 400; // 8 B × 50 fps
case mode700c: case mode700c:
return 100; // ceil(28/8)=4 B × 25 fps return 100; // ceil(28/8)=4 B × 25 fps
case mode1200: case mode1200:
return 150; // 6 B × 25 fps return 150; // 6 B × 25 fps
case mode1300: case mode1300:
return 175; // ceil(52/8)=7 B × 25 fps return 175; // ceil(52/8)=7 B × 25 fps
case mode1400:
return 175; // 7 B × 25 fps
case mode1600:
return 200; // 8 B × 25 fps
case mode2400: case mode2400:
return 300; // 6 B × 50 fps return 300; // 6 B × 50 fps
} }

View File

@@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h> #include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h>
#include <geolocator_windows/geolocator_windows.h> #include <geolocator_windows/geolocator_windows.h>
#include <nsd_windows/nsd_windows_plugin_c_api.h> #include <nsd_windows/nsd_windows_plugin_c_api.h>
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h> #include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
@@ -18,6 +19,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar( AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
FlutterBluePlusPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterBluePlusPlugin"));
GeolocatorWindowsRegisterWithRegistrar( GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows")); registry->GetRegistrarForPlugin("GeolocatorWindows"));
NsdWindowsPluginCApiRegisterWithRegistrar( NsdWindowsPluginCApiRegisterWithRegistrar(

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows audioplayers_windows
flutter_blue_plus_winrt
geolocator_windows geolocator_windows
nsd_windows nsd_windows
objectbox_flutter_libs objectbox_flutter_libs