From 234edf5bb02e22ef61349ca70ecc77be2b8a5883 Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 5 Mar 2026 11:04:41 +0100 Subject: [PATCH] Add message clear option --- docs/image-mode-technical.md | 66 ++-- docs/voice-mode-technical.md | 84 ++--- lib/providers/app_provider.dart | 338 ++++++++++++------ lib/providers/image_provider.dart | 20 ++ lib/providers/voice_provider.dart | 20 ++ lib/screens/settings_screen.dart | 49 +++ lib/utils/image_message_parser.dart | 284 ++++++++++++--- lib/utils/voice_message_parser.dart | 287 ++++++++++++--- .../messages/image_message_bubble.dart | 29 +- lib/widgets/messages/message_bubble.dart | 10 +- .../messages/voice_message_bubble.dart | 15 +- .../messages_provider_voice_test.dart | 2 +- test/utils/image_message_parser_test.dart | 116 ++++++ test/utils/voice_message_parser_test.dart | 74 +++- 14 files changed, 1075 insertions(+), 319 deletions(-) create mode 100644 test/utils/image_message_parser_test.dart diff --git a/docs/image-mode-technical.md b/docs/image-mode-technical.md index bd1a44b..451766a 100644 --- a/docs/image-mode-technical.md +++ b/docs/image-mode-technical.md @@ -5,8 +5,9 @@ Image mode mirrors the voice on-demand architecture exactly: - **Control plane (text messages):** - - `IE1:` image envelope announces image availability in chat. - - `IR1:` direct fetch request asks sender to stream image fragments. + - `IE2:` image envelope announces image availability in chat. +- **Control plane (raw binary request):** + - Binary image fetch request (same raw route as image fragments). - **Data plane (raw binary packets):** - `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`. @@ -17,8 +18,8 @@ pixels are fetched on demand when the user taps the image bubble. - `lib/utils/image_message_parser.dart` - `ImagePacket` (binary fragment format) - - `ImageEnvelope` (`IE1`) - - `ImageFetchRequest` (`IR1`) + - `ImageEnvelope` (`IE2`) + - `ImageFetchRequest` (binary) - `fragmentImage()` — split compressed bytes into packets - `reassembleImage()` — join received fragments into bytes - `lib/screens/messages_tab.dart` @@ -27,7 +28,7 @@ pixels are fetched on demand when the user taps the image bubble. - Reassembly sessions, outgoing cache, deferred serving - Outgoing sessions also registered as complete incoming sessions for immediate local display - `lib/providers/app_provider.dart` - - Incoming routing for `IE1`, `IR1`, binary `0x49` packets + - Incoming routing for `IE2`, binary image fetch requests, binary `0x49` packets - `lib/widgets/messages/image_message_bubble.dart` - Square cover thumbnail (up to 256 px); tap-to-load for received images; progress ring during fetch; full-screen `InteractiveViewer` on tap @@ -39,52 +40,52 @@ pixels are fetched on demand when the user taps the image bubble. ## 3. Wire Formats -### 3.1 Image Envelope (`IE1`) +### 3.1 Image Envelope (`IE2`) -Prefix: `IE1:` + colon-delimited payload +Prefix: `IE2:` + colon-delimited compact payload (base36 numeric fields) Fields: | Field | Type | Description | |--------------|--------|------------------------------------------------| -| `sid` | string | 8 hex chars (4 bytes), session ID | -| `fmt` | int | `ImageFormat.id` (0 = AVIF, 1 = JPEG) | -| `total` | int | Fragment count (1..255) | -| `w` | int | Actual image width after compression (pixels) | -| `h` | int | Actual image height after compression (pixels) | -| `bytes` | int | Total compressed size in bytes | +| `sid` | string | base36 token for 32-bit session ID | +| `fmt` | base36 | `ImageFormat.id` (0 = AVIF, 1 = JPEG) | +| `total` | base36 | Fragment count (1..255) | +| `w` | base36 | Actual image width after compression (pixels) | +| `h` | base36 | Actual image height after compression (pixels) | +| `bytes` | base36 | Total compressed size in bytes | | `senderKey6` | string | 12 hex chars (6 bytes sender prefix) | -| `ts` | int | Unix timestamp (seconds) | -| `ver` | int | Protocol version (currently `1`) | +| `ts` | base36 | Unix timestamp (seconds) | Compact format: ```text -IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver} +IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts} ``` Example (256×171 landscape image, 14 fragments): ```text -IE1:deadbeef:0:14:256:171:2100:aabbccddeeff:1700000000:1 +IE2:a:0:e:74:4r:1mc:aabbccddeeff:s44we8 ``` -Note: `w` and `h` reflect the actual post-compression dimensions, which preserve +Note: `sid` is base36 on wire and expands to 8-hex internally. +`w` and `h` reflect the actual post-compression dimensions, which preserve the source aspect ratio (contain within the configured max size). -### 3.2 Image Fetch Request (`IR1`) +### 3.2 Image Fetch Request (binary) -Same structure as `VR1`: +Binary payload format: ```text -IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} +[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...] ``` | Field | Value | |------------------|--------------------------| -| `want` | `a` (= "all fragments") | -| `requesterKey6` | 12 hex chars | -| `ver` | `1` | +| `flags` | bit0=1 => request missing indices, else all | +| `requesterKey6` | 6-byte requester key prefix | +| `ts` | unix timestamp seconds (u32) | ### 3.3 Raw Image Packet (data plane) @@ -151,16 +152,16 @@ only the shorter axis is padded — no cropping occurs. 7. Envelope sent via normal message path: - Channel: `sendChannelMessage` - Direct: `sendTextMessage` -8. Local placeholder message added (`IE1:` text, `deliveryStatus.sending`). +8. Local placeholder message added (`IE2:` text, `deliveryStatus.sending`). ## 6. Incoming Flow (Receive) -### 6.1 `IE1` envelope received +### 6.1 `IE2` envelope received `AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to chat. The bubble shows a grey square placeholder with a download icon. -### 6.2 `IR1` request received +### 6.2 Binary image fetch request received `AppProvider` treats it as control-plane only (not added to chat): @@ -194,7 +195,7 @@ When `cacheOutgoingSession()` is called it also writes all fragments into - **Complete session**: `AspectRatio(1.0)` → `AvifImage.memory(fit: cover)` square thumbnail; tap → full-screen `InteractiveViewer` with fade transition. - **Incomplete/missing**: grey square placeholder with download icon; - tap → sends `IR1` fetch request. + tap → sends binary fetch request. - **Loading**: circular progress indicator showing `received/total` count. - **Error**: broken-image icon. @@ -212,7 +213,8 @@ Image bubbles and Message Technical Details show an **estimated transmit time** The estimate is airtime-based (LoRa packet model), not just compressed image size: - Source inputs: - - `total` fragments and `bytes` from `IE1` envelope + - `total` fragments and `bytes` from `IE2` envelope + - all numeric envelope values are decoded from base36 - `pathLen` from message metadata - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` - Per-fragment payload model: @@ -270,10 +272,10 @@ sequenceDiagram A->>A: Compress: contain resize → grayscale → PNG → AVIF A->>A: Fragment into ≤152B packets A->>A: Cache outgoing + populate local session (immediate display) - A->>M: Send IE1 envelope (actual w×h, fragment count) - M->>B: Deliver IE1 + A->>M: Send IE2 envelope (actual w×h, fragment count) + M->>B: Deliver IE2 B->>B: Render grey placeholder bubble - B->>A: Tap → send IR1 fetch request + B->>A: Tap → send binary fetch request A->>B: Stream binary ImagePackets B->>B: Reassemble fragments B->>B: Display AVIF image (cover thumbnail) diff --git a/docs/voice-mode-technical.md b/docs/voice-mode-technical.md index 243c8e2..cce25cb 100644 --- a/docs/voice-mode-technical.md +++ b/docs/voice-mode-technical.md @@ -5,8 +5,9 @@ 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. + - `VE2:` voice envelope announces voice availability in chat. +- **Control plane (raw binary request):** + - Binary voice fetch request (same raw route as voice packets). - **Data plane (raw binary packets):** - `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`. @@ -16,73 +17,58 @@ This design avoids broadcasting full voice payloads to channels/rooms. Chat carr - `lib/utils/voice_message_parser.dart` - `VoicePacket` (legacy text + binary packet format) - - `VoiceEnvelope` (`VE1`) - - `VoiceFetchRequest` (`VR1`) + - `VoiceEnvelope` (`VE2`) + - `VoiceFetchRequest` (binary) - `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` + - Incoming routing for `VE2` and binary voice fetch requests - 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:`) + - Message-level voice detection (`VE2` + legacy `V:`) - `lib/services/message_storage_service.dart` - Persists `isVoice` and `voiceId` ## 3. Wire Formats -### 3.1 Voice Envelope (`VE1`) +### 3.1 Voice Envelope (`VE2`) -Prefix: `VE1:` + colon-delimited compact payload +Prefix: `VE2:` + colon-delimited compact payload (base36 numeric fields) 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 +- `sid` (string): base36 token for 32-bit session ID +- `mode` (base36): codec mode ID (`VoicePacketMode.id`) +- `total` (base36): packet count (1..255) +- `durS` (base36): estimated duration in seconds - `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes) -- `ts` (int): unix timestamp seconds -- `ver` (int): protocol version (currently `1`) +- `ts` (base36): unix timestamp seconds + +`sid` is base36 on wire and expands to 8-hex internally. Compact format: ```text -VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver} +VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts} ``` Example: ```text -VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1 +VE2:a:1:4:4:aabbccddeeff:s44we8 ``` -### 3.2 Voice Fetch Request (`VR1`) +### 3.2 Voice Fetch Request (binary) -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: +Binary payload format: ```text -VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} -``` - -Example: - -```text -VR1:deadbeef:a:112233445566:1700000010:1 +[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...] ``` ### 3.3 Raw Voice Packet (data plane) @@ -102,18 +88,18 @@ Binary payload structure: 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: +5. Sender sends one envelope (`VE2`) 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 +### 5.1 `VE2` envelope received `AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat. -### 5.2 `VR1` request received +### 5.2 Binary voice fetch request received `AppProvider` treats it as control-plane only: @@ -132,8 +118,8 @@ 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 + 1. Resolve sender contact (message sender prefix or `VE2.senderKey6` fallback) + 2. Send direct binary fetch request 3. Show requesting state in UI 4. Auto-play when session becomes complete @@ -170,10 +156,9 @@ 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. +- compact base36 numeric fields in envelope/request +- Binary request flags specify `all` or `missing` indices. +- Request payload includes `requesterKey6` to resolve return route. ## 10. Transmit Time Estimate (UI) @@ -182,7 +167,8 @@ Voice bubbles and Message Technical Details show an **estimated transmit time** The estimate is airtime-based (LoRa packet model), not file-duration-only: - Source inputs: - - `packetCount` and `durationMs` from `VE1` envelope, or + - `packetCount` and `durationMs` from `VE2` envelope, or + - numeric envelope values decoded from base36 - actual received `VoicePacket.codec2Data.length` bytes when local session packets exist - `pathLen` from message metadata - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` @@ -223,7 +209,7 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz` ## 12. Backward Compatibility - Legacy `V:` text packet parsing is still supported. -- Message voice detection accepts both new `VE1` and legacy `V:` formats. +- Message voice detection accepts `VE2` and legacy `V:` formats. ## 13. High-Level Sequence @@ -235,10 +221,10 @@ sequenceDiagram A->>A: Record + encode voice packets A->>A: Cache session packets (TTL 15m) - A->>M: Send VE1 envelope - M->>B: Deliver VE1 + A->>M: Send VE2 envelope + M->>B: Deliver VE2 B->>B: Render voice bubble (metadata only) - B->>A: Send VR1 request on Play + B->>A: Send binary fetch request on Play A->>B: Stream raw VoicePacket packets B->>B: Reassemble session B->>B: Auto-play when complete diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 9153d1b..ed50a99 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -57,8 +57,11 @@ class AppProvider with ChangeNotifier { static const Duration _packetRetryDelay = Duration(milliseconds: 1200); static const int _maxPacketRetryAttempts = 4; final Map _voiceSessionSenderKey6 = {}; + final Map _imageSessionSenderKey6 = {}; final Map _voiceMissingRetryTimers = {}; final Map _voiceMissingRetryAttempts = {}; + final Map> _voiceFragmentAckWaiters = {}; + final Map> _imageFragmentAckWaiters = {}; Timer? _packetCaptureFlushTimer; String? _lastPersistedPacketSignature; bool _isPersistingPacketCapture = false; @@ -404,6 +407,24 @@ class AppProvider with ChangeNotifier { payload: payload, ); }; + voiceProvider.waitForFragmentAckCallback = ({ + required sessionId, + required index, + timeout = const Duration(seconds: 8), + }) => _waitForVoiceFragmentAck( + sessionId: sessionId, + index: index, + timeout: timeout, + ); + imageProvider.waitForFragmentAckCallback = ({ + required sessionId, + required index, + timeout = const Duration(seconds: 8), + }) => _waitForImageFragmentAck( + sessionId: sessionId, + index: index, + timeout: timeout, + ); // When a contact is received from BLE connectionProvider.onContactReceived = (contact) { @@ -540,62 +561,6 @@ 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', - ); - messagesProvider.logSystemMessage( - text: - 'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.', - level: 'warning', - ); - return; - } - if (requester.outPathLen > _maxDirectPayloadHops) { - debugPrint( - '⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops', - ); - messagesProvider.logSystemMessage( - text: - 'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', - level: 'warning', - ); - return; - } - unawaited( - voiceProvider.serveSessionTo( - sessionId: voiceFetchRequest.sessionId, - requester: requester, - requestedIndices: voiceFetchRequest.want == 'missing' - ? voiceFetchRequest.missingIndices.toSet() - : null, - ), - ); - return; - } - // Check if message is a drawing broadcast if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { debugPrint('🎨 [AppProvider] Drawing message received, parsing...'); @@ -665,61 +630,12 @@ class AppProvider with ChangeNotifier { return; } - // Image fetch request (IR1): requester asks us to stream image fragments. - final imageFetchRequest = ImageFetchRequest.tryParse( - enrichedMessage.text, - ); - if (imageFetchRequest != null) { - final senderPrefix = enrichedMessage.senderPublicKeyPrefix; - if (senderPrefix != null) { - final senderPrefixHex = senderPrefix - .take(6) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(''); - if (senderPrefixHex.toLowerCase() == - imageFetchRequest.requesterKey6.toLowerCase()) { - final requester = contactsProvider.findContactByPrefix( - senderPrefix, - ); - if (requester != null) { - if (requester.outPathLen > _maxDirectPayloadHops) { - debugPrint( - '⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops', - ); - messagesProvider.logSystemMessage( - text: - 'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', - level: 'warning', - ); - return; - } - unawaited( - imageProvider.serveSessionTo( - sessionId: imageFetchRequest.sessionId, - requester: requester, - requestedIndices: imageFetchRequest.want == 'missing' - ? imageFetchRequest.missingIndices.toSet() - : null, - ), - ); - } else { - debugPrint( - '⚠️ [AppProvider] Image fetch requester contact not found', - ); - messagesProvider.logSystemMessage( - text: - 'Cannot fetch image: requester contact is unknown. Add/sync contacts first.', - level: 'warning', - ); - } - } - } - return; // IR1 is control-plane only; not displayed in chat - } - // Image envelope (IE1): announce image availability. final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text); if (imageEnvelope != null) { + _imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope + .senderKey6 + .toLowerCase(); imageProvider.registerEnvelope(imageEnvelope); messagesProvider.addMessage( enrichedMessage, @@ -800,8 +716,99 @@ class AppProvider with ChangeNotifier { }; // When raw binary data is received (PUSH_CODE_RAW_DATA 0x84) + // Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request. // Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet. connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { + final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload); + if (voiceFetchRequest != null) { + final requester = contactsProvider.findContactByPrefixHex( + voiceFetchRequest.requesterKey6, + ); + if (requester == null) { + debugPrint( + '⚠️ [AppProvider] Voice fetch requester contact not found (binary)', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.', + level: 'warning', + ); + return; + } + if (requester.outPathLen > _maxDirectPayloadHops) { + debugPrint( + '⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', + level: 'warning', + ); + return; + } + unawaited( + voiceProvider.serveSessionTo( + sessionId: voiceFetchRequest.sessionId, + requester: requester, + requestedIndices: voiceFetchRequest.want == 'missing' + ? voiceFetchRequest.missingIndices.toSet() + : null, + ), + ); + return; + } + + final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload); + if (imageFetchRequest != null) { + final requester = contactsProvider.findContactByPrefixHex( + imageFetchRequest.requesterKey6, + ); + if (requester == null) { + debugPrint( + '⚠️ [AppProvider] Image fetch requester contact not found (binary)', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch image: requester contact is unknown. Add/sync contacts first.', + level: 'warning', + ); + return; + } + if (requester.outPathLen > _maxDirectPayloadHops) { + debugPrint( + '⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', + level: 'warning', + ); + return; + } + unawaited( + imageProvider.serveSessionTo( + sessionId: imageFetchRequest.sessionId, + requester: requester, + requestedIndices: imageFetchRequest.want == 'missing' + ? imageFetchRequest.missingIndices.toSet() + : null, + ), + ); + return; + } + + final voiceAck = VoiceFragmentAck.tryParseBinary(payload); + if (voiceAck != null) { + _completeVoiceFragmentAck(voiceAck.sessionId, voiceAck.index); + return; + } + + final imageAck = ImageFragmentAck.tryParseBinary(payload); + if (imageAck != null) { + _completeImageFragmentAck(imageAck.sessionId, imageAck.index); + return; + } + if (ImagePacket.isImageBinary(payload)) { final frag = ImagePacket.tryParseBinary(payload); if (frag == null) return; @@ -812,6 +819,7 @@ class AppProvider with ChangeNotifier { width: session?.width ?? 0, height: session?.height ?? 0, ); + _sendImageFragmentAck(frag); return; } @@ -820,6 +828,7 @@ class AppProvider with ChangeNotifier { if (pkt == null) return; debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt'); final justComplete = voiceProvider.addPacket(pkt); + _sendVoiceFragmentAck(pkt); _scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete); // Insert or update the placeholder message in the chat list _handleIncomingVoicePacket(pkt, justComplete: justComplete); @@ -1209,15 +1218,18 @@ class AppProvider with ChangeNotifier { missingIndices: missing, requesterKey6: requesterKey6, timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, - version: 1, + version: 2, ); - final sent = await connectionProvider.sendTextMessage( - contactPublicKey: sender.publicKey, - text: request.encodeText(), - contact: sender, - ); - if (!sent) return; + try { + await connectionProvider.sendRawVoicePacket( + contactPath: sender.outPath, + contactPathLen: sender.outPathLen, + payload: request.encodeBinary(), + ); + } catch (_) { + return; + } _voiceMissingRetryAttempts[sessionId] = attempt + 1; _voiceMissingRetryTimers[sessionId]?.cancel(); @@ -1266,6 +1278,102 @@ class AppProvider with ChangeNotifier { messagesProvider.addMessage(placeholder, contactLookup: (_) => ''); } + String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index'; + + Future _waitForVoiceFragmentAck({ + required String sessionId, + required int index, + Duration timeout = const Duration(seconds: 8), + }) async { + final key = _fragmentAckKey(sessionId, index); + final completer = Completer(); + _voiceFragmentAckWaiters[key] = completer; + try { + await completer.future.timeout(timeout); + return true; + } catch (_) { + if (_voiceFragmentAckWaiters[key] == completer) { + _voiceFragmentAckWaiters.remove(key); + } + return false; + } + } + + void _completeVoiceFragmentAck(String sessionId, int index) { + final key = _fragmentAckKey(sessionId, index); + final completer = _voiceFragmentAckWaiters.remove(key); + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + } + + Future _waitForImageFragmentAck({ + required String sessionId, + required int index, + Duration timeout = const Duration(seconds: 8), + }) async { + final key = _fragmentAckKey(sessionId, index); + final completer = Completer(); + _imageFragmentAckWaiters[key] = completer; + try { + await completer.future.timeout(timeout); + return true; + } catch (_) { + if (_imageFragmentAckWaiters[key] == completer) { + _imageFragmentAckWaiters.remove(key); + } + return false; + } + } + + void _completeImageFragmentAck(String sessionId, int index) { + final key = _fragmentAckKey(sessionId, index); + final completer = _imageFragmentAckWaiters.remove(key); + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + } + + void _sendVoiceFragmentAck(VoicePacket packet) { + final senderKey6 = _voiceSessionSenderKey6[packet.sessionId]; + if (senderKey6 == null) return; + final sender = _resolveContactByPrefixHex(senderKey6); + if (sender == null) return; + if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) { + return; + } + unawaited( + connectionProvider.sendRawVoicePacket( + contactPath: sender.outPath, + contactPathLen: sender.outPathLen, + payload: VoiceFragmentAck( + sessionId: packet.sessionId, + index: packet.index, + ).encodeBinary(), + ), + ); + } + + void _sendImageFragmentAck(ImagePacket fragment) { + final senderKey6 = _imageSessionSenderKey6[fragment.sessionId]; + if (senderKey6 == null) return; + final sender = _resolveContactByPrefixHex(senderKey6); + if (sender == null) return; + if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) { + return; + } + unawaited( + connectionProvider.sendRawVoicePacket( + contactPath: sender.outPath, + contactPathLen: sender.outPathLen, + payload: ImageFragmentAck( + sessionId: fragment.sessionId, + index: fragment.index, + ).encodeBinary(), + ), + ); + } + // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // The ConnectionProvider's onMessageWaiting callback handles automatic message fetching diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index ec6aec3..89a08b6 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -68,6 +68,12 @@ class ImageProvider with ChangeNotifier { required Uint8List payload, })? sendRawPacketCallback; + Future Function({ + required String sessionId, + required int index, + Duration timeout, + })? + waitForFragmentAckCallback; ImageProvider() { _restore(); @@ -229,11 +235,25 @@ class ImageProvider with ChangeNotifier { continue; } try { + final ackFuture = waitForFragmentAckCallback?.call( + sessionId: sessionId, + index: fragment.index, + timeout: const Duration(seconds: 8), + ); await sendRawPacketCallback!( contactPath: requester.outPath, contactPathLen: requester.outPathLen, payload: fragment.encodeBinary(), ); + if (ackFuture != null) { + final acked = await ackFuture; + if (!acked) { + debugPrint( + '⚠️ [ImageProvider] ACK timeout for $sessionId#${fragment.index}', + ); + return false; + } + } } catch (e, st) { debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st'); return false; diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 1101a25..f54f88e 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -69,6 +69,12 @@ class VoiceProvider with ChangeNotifier { required Uint8List payload, })? sendRawPacketCallback; + Future Function({ + required String sessionId, + required int index, + Duration timeout, + })? + waitForFragmentAckCallback; final Map _outgoingSessions = {}; @@ -198,11 +204,25 @@ class VoiceProvider with ChangeNotifier { continue; } try { + final ackFuture = waitForFragmentAckCallback?.call( + sessionId: sessionId, + index: packet.index, + timeout: const Duration(seconds: 8), + ); await sendRawPacketCallback!( contactPath: requester.outPath, contactPathLen: requester.outPathLen, payload: packet.encodeBinary(), ); + if (ackFuture != null) { + final acked = await ackFuture; + if (!acked) { + debugPrint( + '⚠️ [VoiceProvider] ACK timeout for $sessionId#${packet.index}', + ); + return false; + } + } } catch (e, st) { debugPrint( '❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st', diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8f05397..813d08e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -621,6 +621,46 @@ class _SettingsScreenState extends State { ); } + Future _clearMessages() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Messages'), + content: const Text( + 'This will permanently delete all stored messages. Are you sure?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.clear), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final messagesProvider = Provider.of( + context, + listen: false, + ); + messagesProvider.clearMessages(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('All messages cleared'), + backgroundColor: Colors.orange, + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -694,6 +734,15 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showLanguageDialog(), ), + ListTile( + leading: const Icon(Icons.delete_sweep, color: Colors.red), + title: const Text( + 'Clear Messages', + style: TextStyle(color: Colors.red), + ), + subtitle: const Text('Delete all stored message history'), + onTap: _clearMessages, + ), const Divider(), // Voice Settings Section diff --git a/lib/utils/image_message_parser.dart b/lib/utils/image_message_parser.dart index 4d00fa6..54776f2 100644 --- a/lib/utils/image_message_parser.dart +++ b/lib/utils/image_message_parser.dart @@ -243,11 +243,11 @@ int _resolveBandwidthHz(int? rawBw) { /// Envelope announcing image availability (control plane). /// /// Text format: -/// IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver} +/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts} /// Example: -/// IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1 +/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8 class ImageEnvelope { - static const String prefix = 'IE1:'; + static const String _prefix = 'IE2:'; final String sessionId; // 8 hex chars final ImageFormat format; @@ -268,38 +268,36 @@ class ImageEnvelope { required this.sizeBytes, required this.senderKey6, required this.timestampSec, - this.version = 1, + this.version = 2, }); - static bool isEnvelope(String text) => text.startsWith(prefix); + static bool isEnvelope(String text) => text.startsWith(_prefix); static ImageEnvelope? tryParse(String text) { if (!isEnvelope(text)) return null; - final body = text.substring(prefix.length); + final body = text.substring(_prefix.length); final parts = body.split(':'); - if (parts.length != 9) return null; + if (parts.length != 8) return null; try { - final sid = parts[0]; - final fmtId = int.tryParse(parts[1]); - final total = int.tryParse(parts[2]); - final w = int.tryParse(parts[3]); - final h = int.tryParse(parts[4]); - final bytes = int.tryParse(parts[5]); + final sid = _decodeSessionId(parts[0]); + final fmtId = _parseInt(parts[1], base36: true); + final total = _parseInt(parts[2], base36: true); + final w = _parseInt(parts[3], base36: true); + final h = _parseInt(parts[4], base36: true); + final bytes = _parseInt(parts[5], base36: true); final senderKey6 = parts[6]; - final ts = int.tryParse(parts[7]); - final ver = int.tryParse(parts[8]); + final ts = _parseInt(parts[7], base36: true); - if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null; + if (sid == null) return null; if (fmtId == null) return null; if (total == null || total < 1 || total > 255) return null; if (w == null || h == null || w < 1 || h < 1) return null; if (bytes == null || bytes < 1) 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 ImageEnvelope( - sessionId: sid.toLowerCase(), + sessionId: sid, format: ImageFormat.fromId(fmtId), total: total, width: w, @@ -307,7 +305,7 @@ class ImageEnvelope { sizeBytes: bytes, senderKey6: senderKey6.toLowerCase(), timestampSec: ts, - version: ver, + version: 2, ); } catch (_) { return null; @@ -315,17 +313,21 @@ class ImageEnvelope { } String encode() => - '$prefix${sessionId.toLowerCase()}:${format.id}:$total:$width:$height:$sizeBytes:${senderKey6.toLowerCase()}:$timestampSec:$version'; + '$_prefix${_encodeSessionId(sessionId)}:' + '${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:' + '${_toBase36(height)}:${_toBase36(sizeBytes)}:' + '${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}'; } /// Direct request to fetch image fragments (control plane). /// /// Text format: -/// IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} +/// IR2:{sid}:{want}:{requesterKey6}:{ts} /// Example: -/// IR1:deadbeef:a:aabbccddeeff:1700000010:1 +/// IR2:deadbeef:a:aabbccddeeff:s44wea class ImageFetchRequest { - static const String prefix = 'IR1:'; + static const String _prefix = 'IR2:'; + static const int _binaryMagic = 0x69; // 'i' final String sessionId; final String want; // 'all' or 'missing' @@ -340,51 +342,89 @@ class ImageFetchRequest { this.missingIndices = const [], required this.requesterKey6, required this.timestampSec, - this.version = 1, + this.version = 2, }); - static bool isRequest(String text) => text.startsWith(prefix); + static bool isRequest(String text) => text.startsWith(_prefix); + static bool isRequestBinary(Uint8List payload) => + payload.isNotEmpty && payload[0] == _binaryMagic; static ImageFetchRequest? tryParse(String text) { if (!isRequest(text)) return null; - final body = text.substring(prefix.length); + final body = text.substring(_prefix.length); final parts = body.split(':'); - if (parts.length != 5) return null; + if (parts.length != 4) return null; try { - final sid = parts[0]; + final sid = _decodeSessionId(parts[0]); final wantToken = parts[1]; final requesterKey6 = parts[2]; - final ts = int.tryParse(parts[3]); - final ver = int.tryParse(parts[4]); + final ts = _parseInt(parts[3], base36: true); final normalizedWant = wantToken == 'a' ? 'all' - : (wantToken.startsWith('m-') ? 'missing' : wantToken); + : ((wantToken.startsWith('m')) + ? 'missing' + : wantToken); - if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null; + if (sid == null) return null; final missingIndices = []; if (normalizedWant == 'missing') { - final encoded = wantToken.substring(2); + final encoded = wantToken.substring(1); if (encoded.isEmpty) return null; - for (final raw in encoded.split(',')) { - final idx = int.tryParse(raw); - if (idx == null || idx < 0 || idx > 254) return null; - missingIndices.add(idx); - } + missingIndices.addAll(_decodeMissingIndicesCompact(encoded)); if (missingIndices.isEmpty) return null; } else 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 ImageFetchRequest( - sessionId: sid.toLowerCase(), + sessionId: sid, want: normalizedWant, missingIndices: missingIndices, requesterKey6: requesterKey6.toLowerCase(), timestampSec: ts, - version: ver, + version: 2, + ); + } catch (_) { + return null; + } + } + + static ImageFetchRequest? tryParseBinary(Uint8List payload) { + if (!isRequestBinary(payload)) return null; + if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count + try { + final sid = payload + .sublist(1, 5) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final flags = payload[5]; + final requesterKey6 = payload + .sublist(6, 12) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final ts = + (payload[12] << 24) | + (payload[13] << 16) | + (payload[14] << 8) | + payload[15]; + final missingCount = payload[16]; + if (payload.length != 17 + missingCount) return null; + final wantMissing = (flags & 0x01) == 0x01; + final missing = []; + for (var i = 0; i < missingCount; i++) { + missing.add(payload[17 + i]); + } + return ImageFetchRequest( + sessionId: sid, + want: wantMissing ? 'missing' : 'all', + missingIndices: missing, + requesterKey6: requesterKey6, + timestampSec: ts, + version: 2, ); } catch (_) { return null; @@ -393,10 +433,168 @@ class ImageFetchRequest { String encode() { final wantToken = want == 'missing' && missingIndices.isNotEmpty - ? 'm-${missingIndices.join(',')}' + ? 'm${_encodeMissingIndicesCompact(missingIndices)}' : (want == 'all' ? 'a' : want); - return '$prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version'; + return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}'; } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) { + throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars'); + } + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { + throw ArgumentError.value( + requesterKey6, + 'requesterKey6', + 'Expected 12 hex chars', + ); + } + final useMissing = want == 'missing' && missingIndices.isNotEmpty; + final missing = useMissing + ? missingIndices.where((v) => v >= 0 && v <= 254).toList() + : []; + + final out = Uint8List(17 + missing.length); + out[0] = _binaryMagic; + for (var i = 0; i < 4; i++) { + out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); + } + out[5] = useMissing ? 0x01 : 0x00; + for (var i = 0; i < 6; i++) { + out[6 + i] = int.parse( + requesterKey6.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } + out[12] = (timestampSec >> 24) & 0xFF; + out[13] = (timestampSec >> 16) & 0xFF; + out[14] = (timestampSec >> 8) & 0xFF; + out[15] = timestampSec & 0xFF; + out[16] = missing.length; + for (var i = 0; i < missing.length; i++) { + out[17 + i] = missing[i]; + } + return out; + } +} + +/// Per-fragment ACK for raw image payload packets. +/// +/// Binary format: +/// [0x6a 'j'][sessionId:4B][index:1B] +class ImageFragmentAck { + static const int _binaryMagic = 0x6a; // 'j' + + final String sessionId; // 8 hex chars + final int index; // 0..254 + + const ImageFragmentAck({required this.sessionId, required this.index}); + + static bool isImageFragmentAckBinary(Uint8List payload) => + payload.length == 6 && payload[0] == _binaryMagic; + + static ImageFragmentAck? tryParseBinary(Uint8List payload) { + if (!isImageFragmentAckBinary(payload)) return null; + try { + final sid = payload + .sublist(1, 5) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final idx = payload[5]; + return ImageFragmentAck(sessionId: sid, index: idx); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) { + throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars'); + } + if (index < 0 || index > 254) { + throw ArgumentError.value(index, 'index', 'Expected 0..254'); + } + final out = Uint8List(6); + out[0] = _binaryMagic; + for (var i = 0; i < 4; i++) { + out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); + } + out[5] = index; + return out; + } +} + +int? _parseInt(String token, {required bool base36}) => + int.tryParse(token, radix: base36 ? 36 : 10); + +String _toBase36(int value) => value.toRadixString(36); + +String _encodeSessionId(String sessionIdHex) { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { + throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars'); + } + final value = int.parse(sessionIdHex, radix: 16); + return value.toRadixString(36); +} + +String? _decodeSessionId(String token) { + if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null; + final value = int.tryParse(token, radix: 36); + if (value == null || value < 0 || value > 0xFFFFFFFF) return null; + return value.toRadixString(16).padLeft(8, '0'); +} + +String _encodeMissingIndicesCompact(List indices) { + final sorted = indices + .where((v) => v >= 0 && v <= 254) + .toSet() + .toList() + ..sort(); + if (sorted.isEmpty) return ''; + final chunks = []; + var start = sorted.first; + var prev = sorted.first; + for (var i = 1; i < sorted.length; i++) { + final curr = sorted[i]; + if (curr == prev + 1) { + prev = curr; + continue; + } + chunks.add( + start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', + ); + start = curr; + prev = curr; + } + chunks.add( + start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', + ); + return chunks.join('.'); +} + +List _decodeMissingIndicesCompact(String encoded) { + final out = []; + for (final token in encoded.split('.')) { + if (token.isEmpty) continue; + if (!token.contains('-')) { + final value = int.tryParse(token, radix: 36); + if (value == null || value < 0 || value > 254) return const []; + out.add(value); + continue; + } + final parts = token.split('-'); + if (parts.length != 2) return const []; + final start = int.tryParse(parts[0], radix: 36); + final end = int.tryParse(parts[1], radix: 36); + if (start == null || end == null || start < 0 || end > 254 || start > end) { + return const []; + } + for (var i = start; i <= end; i++) { + out.add(i); + } + } + return out; } /// Fragment the compressed image bytes into [ImagePacket] list. diff --git a/lib/utils/voice_message_parser.dart b/lib/utils/voice_message_parser.dart index 24346e0..c344d37 100644 --- a/lib/utils/voice_message_parser.dart +++ b/lib/utils/voice_message_parser.dart @@ -181,11 +181,11 @@ class VoicePacket { /// Lightweight public/direct message envelope advertising voice availability. /// /// Text format: -/// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver} +/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts} /// Example: -/// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1 +/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi class VoiceEnvelope { - static const String _prefix = 'VE1:'; + static const String _prefix = 'VE2:'; final String sessionId; final VoicePacketMode mode; @@ -202,7 +202,7 @@ class VoiceEnvelope { required this.durationMs, required this.senderKey6, required this.timestampSec, - this.version = 1, + this.version = 2, }); static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix); @@ -210,43 +210,41 @@ class VoiceEnvelope { static VoiceEnvelope? tryParseText(String text) { if (!isVoiceEnvelopeText(text)) return null; final body = text.substring(_prefix.length); - return _tryParseCompact(body); + return _tryParse(body); } - static VoiceEnvelope? _tryParseCompact(String body) { + static VoiceEnvelope? _tryParse(String body) { final parts = body.split(':'); - if (parts.length != 7) return null; + if (parts.length != 6) 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 sid = _decodeSessionId(parts[0]); + final mode = _parseInt(parts[1], base36: true); + final total = _parseInt(parts[2], base36: true); + final durS = _parseInt(parts[3], base36: true); final senderKey6 = parts[4]; - final ts = int.tryParse(parts[5]); - final ver = int.tryParse(parts[6]); + final ts = _parseInt(parts[5], base36: true); - if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) { + if (sid == null) { 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 (durS == null || durS < 0 || durS > 10 * 60) 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(), + sessionId: sid, mode: VoicePacketMode.fromId(mode), total: total, - durationMs: durMs, + durationMs: durS * 1000, senderKey6: senderKey6.toLowerCase(), timestampSec: ts, - version: ver, + version: 2, ); } catch (_) { return null; @@ -254,7 +252,8 @@ class VoiceEnvelope { } String encodeText() { - return '$_prefix${sessionId.toLowerCase()}:${mode.id}:$total:$durationMs:${senderKey6.toLowerCase()}:$timestampSec:$version'; + final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60); + return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}'; } } @@ -415,11 +414,12 @@ int _resolveBandwidthHz(int? rawBw) { /// Direct control-plane request to fetch voice packets for a session. /// /// Text format: -/// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver} +/// VR2:{sid}:{want}:{requesterKey6}:{ts} /// Example: -/// VR1:00112233:a:aabbccddeeff:1234567890:1 +/// VR2:00112233:a:aabbccddeeff:kf12oi class VoiceFetchRequest { - static const String _prefix = 'VR1:'; + static const String _prefix = 'VR2:'; + static const int _binaryMagic = 0x72; // 'r' final String sessionId; final String want; @@ -434,42 +434,82 @@ class VoiceFetchRequest { this.missingIndices = const [], required this.requesterKey6, required this.timestampSec, - this.version = 1, + this.version = 2, }); - static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix); + static bool isVoiceFetchRequestText(String text) => + text.startsWith(_prefix); + static bool isVoiceFetchRequestBinary(Uint8List payload) => + payload.isNotEmpty && payload[0] == _binaryMagic; static VoiceFetchRequest? tryParseText(String text) { if (!isVoiceFetchRequestText(text)) return null; final body = text.substring(_prefix.length); - return _tryParseCompact(body); + return _tryParse(body); } - static VoiceFetchRequest? _tryParseCompact(String body) { - final parts = body.split(':'); - if (parts.length != 5) return null; + static VoiceFetchRequest? tryParseBinary(Uint8List payload) { + if (!isVoiceFetchRequestBinary(payload)) return null; + if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count try { - final sid = parts[0]; + final sidBytes = payload.sublist(1, 5); + final sid = sidBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final flags = payload[5]; + final requesterKey6 = payload + .sublist(6, 12) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final ts = + (payload[12] << 24) | + (payload[13] << 16) | + (payload[14] << 8) | + payload[15]; + final missingCount = payload[16]; + if (payload.length != 17 + missingCount) return null; + final wantMissing = (flags & 0x01) == 0x01; + final missing = []; + for (var i = 0; i < missingCount; i++) { + missing.add(payload[17 + i]); + } + return VoiceFetchRequest( + sessionId: sid, + want: wantMissing ? 'missing' : 'all', + missingIndices: missing, + requesterKey6: requesterKey6, + timestampSec: ts, + version: 2, + ); + } catch (_) { + return null; + } + } + + static VoiceFetchRequest? _tryParse(String body) { + final parts = body.split(':'); + if (parts.length != 4) return null; + try { + final sid = _decodeSessionId(parts[0]); final wantToken = parts[1]; final requesterKey6 = parts[2]; - final ts = int.tryParse(parts[3]); - final ver = int.tryParse(parts[4]); + final ts = _parseInt(parts[3], base36: true); final normalizedWant = wantToken == 'a' ? 'all' - : (wantToken.startsWith('m-') ? 'missing' : wantToken); + : ((wantToken.startsWith('m')) + ? 'missing' + : wantToken); - if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) { + if (sid == null) { return null; } final missingIndices = []; if (normalizedWant == 'missing') { - final encoded = wantToken.substring(2); + final encoded = wantToken.substring(1); if (encoded.isEmpty) return null; - for (final raw in encoded.split(',')) { - final idx = int.tryParse(raw); - if (idx == null || idx < 0 || idx > 254) return null; - missingIndices.add(idx); - } + missingIndices.addAll(_decodeMissingIndicesCompact(encoded)); if (missingIndices.isEmpty) return null; } else if (normalizedWant != 'all') { return null; @@ -478,15 +518,14 @@ class VoiceFetchRequest { return null; } if (ts == null || ts <= 0) return null; - if (ver == null || ver != 1) return null; return VoiceFetchRequest( - sessionId: sid.toLowerCase(), + sessionId: sid, want: normalizedWant, missingIndices: missingIndices, requesterKey6: requesterKey6.toLowerCase(), timestampSec: ts, - version: ver, + version: 2, ); } catch (_) { return null; @@ -495,10 +534,168 @@ class VoiceFetchRequest { String encodeText() { final wantToken = want == 'missing' && missingIndices.isNotEmpty - ? 'm-${missingIndices.join(',')}' + ? 'm${_encodeMissingIndicesCompact(missingIndices)}' : (want == 'all' ? 'a' : want); - return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version'; + return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}'; } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) { + throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars'); + } + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { + throw ArgumentError.value( + requesterKey6, + 'requesterKey6', + 'Expected 12 hex chars', + ); + } + final useMissing = want == 'missing' && missingIndices.isNotEmpty; + final missing = useMissing + ? missingIndices.where((v) => v >= 0 && v <= 254).toList() + : []; + + final out = Uint8List(17 + missing.length); + out[0] = _binaryMagic; + for (var i = 0; i < 4; i++) { + out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); + } + out[5] = useMissing ? 0x01 : 0x00; + for (var i = 0; i < 6; i++) { + out[6 + i] = int.parse( + requesterKey6.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } + out[12] = (timestampSec >> 24) & 0xFF; + out[13] = (timestampSec >> 16) & 0xFF; + out[14] = (timestampSec >> 8) & 0xFF; + out[15] = timestampSec & 0xFF; + out[16] = missing.length; + for (var i = 0; i < missing.length; i++) { + out[17 + i] = missing[i]; + } + return out; + } +} + +/// Per-fragment ACK for raw voice payload packets. +/// +/// Binary format: +/// [0x76 'v'][sessionId:4B][index:1B] +class VoiceFragmentAck { + static const int _binaryMagic = 0x76; // 'v' + + final String sessionId; // 8 hex chars + final int index; // 0..254 + + const VoiceFragmentAck({required this.sessionId, required this.index}); + + static bool isVoiceFragmentAckBinary(Uint8List payload) => + payload.length == 6 && payload[0] == _binaryMagic; + + static VoiceFragmentAck? tryParseBinary(Uint8List payload) { + if (!isVoiceFragmentAckBinary(payload)) return null; + try { + final sid = payload + .sublist(1, 5) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final idx = payload[5]; + return VoiceFragmentAck(sessionId: sid, index: idx); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) { + throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars'); + } + if (index < 0 || index > 254) { + throw ArgumentError.value(index, 'index', 'Expected 0..254'); + } + final out = Uint8List(6); + out[0] = _binaryMagic; + for (var i = 0; i < 4; i++) { + out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); + } + out[5] = index; + return out; + } +} + +int? _parseInt(String token, {required bool base36}) => + int.tryParse(token, radix: base36 ? 36 : 10); + +String _toBase36(int value) => value.toRadixString(36); + +String _encodeSessionId(String sessionIdHex) { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { + throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars'); + } + final value = int.parse(sessionIdHex, radix: 16); + return value.toRadixString(36); +} + +String? _decodeSessionId(String token) { + if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null; + final value = int.tryParse(token, radix: 36); + if (value == null || value < 0 || value > 0xFFFFFFFF) return null; + return value.toRadixString(16).padLeft(8, '0'); +} + +String _encodeMissingIndicesCompact(List indices) { + final sorted = indices + .where((v) => v >= 0 && v <= 254) + .toSet() + .toList() + ..sort(); + if (sorted.isEmpty) return ''; + final chunks = []; + var start = sorted.first; + var prev = sorted.first; + for (var i = 1; i < sorted.length; i++) { + final curr = sorted[i]; + if (curr == prev + 1) { + prev = curr; + continue; + } + chunks.add( + start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', + ); + start = curr; + prev = curr; + } + chunks.add( + start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', + ); + return chunks.join('.'); +} + +List _decodeMissingIndicesCompact(String encoded) { + final out = []; + for (final token in encoded.split('.')) { + if (token.isEmpty) continue; + if (!token.contains('-')) { + final value = int.tryParse(token, radix: 36); + if (value == null || value < 0 || value > 254) return const []; + out.add(value); + continue; + } + final parts = token.split('-'); + if (parts.length != 2) return const []; + final start = int.tryParse(parts[0], radix: 36); + final end = int.tryParse(parts[1], radix: 36); + if (start == null || end == null || start < 0 || end > 254 || start > end) { + return const []; + } + for (var i = start; i <= end; i++) { + out.add(i); + } + } + return out; } /// Builds a compact visual waveform from real voice packet bytes. diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index 1244b1f..0ac6d75 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -13,8 +13,8 @@ import 'transfer_timeout.dart'; /// A message bubble that shows a received or sent image. /// -/// On first render the image is not yet fetched (only the IE1 envelope is -/// known). The user taps the thumbnail placeholder → IR1 fetch request is +/// On first render the image is not yet fetched (only the IE2 envelope is +/// known). The user taps the thumbnail placeholder → IR2 fetch request is /// sent → binary fragments stream in → bubble rebuilds with the full image. class ImageMessageBubble extends StatefulWidget { final Message message; @@ -279,18 +279,23 @@ class _ImageMessageBubbleState extends State { _errorText = null; }); - final sent = await conn.sendTextMessage( - contactPublicKey: sender.publicKey, - text: request.encode(), - contact: sender, - ); - if (!sent && mounted) { - setState(() { - _isRequesting = false; - _errorText = 'Image unavailable right now'; - }); + final payload = request.encodeBinary(); + try { + await conn.sendRawVoicePacket( + contactPath: sender.outPath, + contactPathLen: sender.outPathLen, + payload: payload, + ); + } catch (_) { + if (mounted) { + setState(() { + _isRequesting = false; + _errorText = 'Image unavailable right now'; + }); + } return; } + if (!mounted) return; // Timeout = 2× estimated LoRa airtime (min 30s). final txEstimate = estimateImageTransmitDuration( diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index b349a17..8ed9d3a 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -1682,6 +1682,13 @@ class _MessageBubbleState extends State { } final message = widget.message; + final ticTacToeEvent = message.isContactMessage + ? TicTacToeMessageParser.tryParse(message.text) + : null; + if (ticTacToeEvent?.type == TicTacToeEventType.move) { + // Hide move control packets from chat; the game bubble updates itself. + return const SizedBox.shrink(); + } final isSarMarker = message.isSarMarker; final isDarkMode = Theme.of(context).brightness == Brightness.dark; @@ -2183,8 +2190,7 @@ class _MessageBubbleState extends State { !widget.isCompact) ImageMessageBubble(message: message, isSentByMe: isOwnMessage) // Tic-Tac-Toe control message content - else if (message.isContactMessage && - TicTacToeMessageParser.isTicTacToe(message.text) && + else if (ticTacToeEvent?.type == TicTacToeEventType.start && !widget.isCompact) TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage) // Regular message content diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index ac3a79e..b73e037 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -259,7 +259,7 @@ class _VoiceMessageBubbleState extends State { sessionId: sessionId, requesterKey6: requesterKey6, timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, - version: 1, + version: 2, ); setState(() { @@ -268,12 +268,13 @@ class _VoiceMessageBubbleState extends State { _errorText = null; }); - final sent = await connectionProvider.sendTextMessage( - contactPublicKey: sender.publicKey, - text: request.encodeText(), - contact: sender, - ); - if (!sent) { + try { + await connectionProvider.sendRawVoicePacket( + contactPath: sender.outPath, + contactPathLen: sender.outPathLen, + payload: request.encodeBinary(), + ); + } catch (_) { _setUnavailable(); return; } diff --git a/test/providers/messages_provider_voice_test.dart b/test/providers/messages_provider_voice_test.dart index d2841a3..ee82816 100644 --- a/test/providers/messages_provider_voice_test.dart +++ b/test/providers/messages_provider_voice_test.dart @@ -8,7 +8,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('MessagesProvider voice detection', () { - test('marks VE1 envelope messages as voice', () { + test('marks VE2 envelope messages as voice', () { final provider = MessagesProvider(); final envelope = VoiceEnvelope( sessionId: 'deafbead', diff --git a/test/utils/image_message_parser_test.dart b/test/utils/image_message_parser_test.dart new file mode 100644 index 0000000..20959f9 --- /dev/null +++ b/test/utils/image_message_parser_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/image_message_parser.dart'; + +void main() { + group('ImageEnvelope', () { + test('encodes and parses IE2 with compressed session id', () { + final env = ImageEnvelope( + sessionId: '0000000a', + format: ImageFormat.avif, + total: 14, + width: 256, + height: 171, + sizeBytes: 2100, + senderKey6: 'aabbccddeeff', + timestampSec: 1700000000, + ); + + final text = env.encode(); + expect(text.startsWith('IE2:'), isTrue); + expect(text.split(':')[1], equals('a')); + + final parsed = ImageEnvelope.tryParse(text); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('0000000a')); + expect(parsed.format, equals(ImageFormat.avif)); + expect(parsed.total, equals(14)); + expect(parsed.width, equals(256)); + expect(parsed.height, equals(171)); + expect(parsed.sizeBytes, equals(2100)); + expect(parsed.senderKey6, equals('aabbccddeeff')); + expect(parsed.version, equals(2)); + }); + + test('rejects IE1 legacy prefix', () { + const legacy = 'IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1'; + expect(ImageEnvelope.tryParse(legacy), isNull); + }); + }); + + group('ImageFetchRequest', () { + test('encodes and parses IR2 with compressed sid', () { + final req = ImageFetchRequest( + sessionId: '0000000a', + requesterKey6: 'ffeeddccbbaa', + timestampSec: 1700000001, + ); + + final text = req.encode(); + expect(text.startsWith('IR2:'), isTrue); + expect(text.split(':')[1], equals('a')); + + final parsed = ImageFetchRequest.tryParse(text); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('0000000a')); + expect(parsed.want, equals('all')); + expect(parsed.requesterKey6, equals('ffeeddccbbaa')); + expect(parsed.version, equals(2)); + }); + + test('encodes and parses compact missing index ranges', () { + final req = ImageFetchRequest( + sessionId: '0000000a', + want: 'missing', + missingIndices: const [0, 1, 2, 5, 6, 8], + requesterKey6: 'ffeeddccbbaa', + timestampSec: 1700000001, + ); + + final text = req.encode(); + expect(text, contains(':m0-2.5-6.8:')); + + final parsed = ImageFetchRequest.tryParse(text); + expect(parsed, isNotNull); + expect(parsed!.want, equals('missing')); + expect(parsed.missingIndices, equals([0, 1, 2, 5, 6, 8])); + }); + + test('rejects IR1 legacy prefix', () { + const legacy = 'IR1:00112233:a:ffeeddccbbaa:1700000001:1'; + expect(ImageFetchRequest.tryParse(legacy), isNull); + }); + + test('encodes and parses binary fetch request', () { + final req = ImageFetchRequest( + sessionId: '01020304', + want: 'missing', + missingIndices: const [0, 2, 5], + requesterKey6: 'ffeeddccbbaa', + timestampSec: 1700000001, + ); + + final payload = req.encodeBinary(); + expect(ImageFetchRequest.isRequestBinary(payload), isTrue); + + final parsed = ImageFetchRequest.tryParseBinary(payload); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.want, equals('missing')); + expect(parsed.missingIndices, equals([0, 2, 5])); + expect(parsed.requesterKey6, equals('ffeeddccbbaa')); + expect(parsed.version, equals(2)); + }); + }); + + group('ImageFragmentAck', () { + test('encodes and parses binary ack', () { + final ack = ImageFragmentAck(sessionId: '01020304', index: 9); + final payload = ack.encodeBinary(); + expect(ImageFragmentAck.isImageFragmentAckBinary(payload), isTrue); + final parsed = ImageFragmentAck.tryParseBinary(payload); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.index, equals(9)); + }); + }); +} diff --git a/test/utils/voice_message_parser_test.dart b/test/utils/voice_message_parser_test.dart index e2d5881..0908f3d 100644 --- a/test/utils/voice_message_parser_test.dart +++ b/test/utils/voice_message_parser_test.dart @@ -6,74 +6,122 @@ void main() { group('VoiceEnvelope', () { test('encodes and parses valid envelope', () { final env = VoiceEnvelope( - sessionId: 'deadbeef', + sessionId: '0000000a', mode: VoicePacketMode.mode1200, total: 4, - durationMs: 3200, + durationMs: 3000, senderKey6: 'aabbccddeeff', timestampSec: 1700000000, ); final text = env.encodeText(); expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue); + expect(text.startsWith('VE2:'), isTrue); + expect(text.split(':')[1], equals('a')); final parsed = VoiceEnvelope.tryParseText(text); expect(parsed, isNotNull); - expect(parsed!.sessionId, equals('deadbeef')); + expect(parsed!.sessionId, equals('0000000a')); expect(parsed.mode, equals(VoicePacketMode.mode1200)); expect(parsed.total, equals(4)); - expect(parsed.durationMs, equals(3200)); + expect(parsed.durationMs, equals(3000)); expect(parsed.senderKey6, equals('aabbccddeeff')); - expect(parsed.version, equals(1)); + expect(parsed.version, equals(2)); }); test('rejects invalid envelope payload', () { - final text = 'VE1:nothex:1:2:1000:aabbccddeeff:1700000000:1'; + final text = 'VE2:bad_sid:1:2:1000:aabbccddeeff:s44we8'; expect(VoiceEnvelope.tryParseText(text), isNull); }); + + test('rejects legacy v1 envelope prefix', () { + const legacy = 'VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1'; + expect(VoiceEnvelope.tryParseText(legacy), isNull); + }); }); group('VoiceFetchRequest', () { test('encodes and parses valid request', () { final req = VoiceFetchRequest( - sessionId: '00112233', + sessionId: '0000000a', requesterKey6: 'ffeeddccbbaa', timestampSec: 1700000001, ); final text = req.encodeText(); expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue); + expect(text.startsWith('VR2:'), isTrue); + expect(text.split(':')[1], equals('a')); final parsed = VoiceFetchRequest.tryParseText(text); expect(parsed, isNotNull); - expect(parsed!.sessionId, equals('00112233')); + expect(parsed!.sessionId, equals('0000000a')); expect(parsed.want, equals('all')); expect(parsed.requesterKey6, equals('ffeeddccbbaa')); - expect(parsed.version, equals(1)); + expect(parsed.version, equals(2)); }); test('rejects invalid request payload', () { expect( VoiceFetchRequest.tryParseText( - 'VR1:00112233:chunk:ffeeddccbbaa:1700000001:1', + 'VR2:a:chunk:ffeeddccbbaa:s44we9', ), isNull, ); }); + test('rejects legacy v1 request prefix', () { + const legacy = 'VR1:00112233:a:ffeeddccbbaa:1700000001:1'; + expect(VoiceFetchRequest.tryParseText(legacy), isNull); + }); + test('encodes and parses missing-packet request', () { final req = VoiceFetchRequest( - sessionId: '00112233', + sessionId: '0000000a', want: 'missing', - missingIndices: const [0, 3, 7], + missingIndices: const [0, 1, 2, 3, 7], requesterKey6: 'ffeeddccbbaa', timestampSec: 1700000001, ); final text = req.encodeText(); + expect(text, contains(':m0-3.7:')); final parsed = VoiceFetchRequest.tryParseText(text); expect(parsed, isNotNull); expect(parsed!.want, equals('missing')); - expect(parsed.missingIndices, equals([0, 3, 7])); + expect(parsed.missingIndices, equals([0, 1, 2, 3, 7])); + }); + + test('encodes and parses binary fetch request', () { + final req = VoiceFetchRequest( + sessionId: '01020304', + want: 'missing', + missingIndices: const [1, 4], + requesterKey6: 'ffeeddccbbaa', + timestampSec: 1700000001, + ); + + final payload = req.encodeBinary(); + expect(VoiceFetchRequest.isVoiceFetchRequestBinary(payload), isTrue); + + final parsed = VoiceFetchRequest.tryParseBinary(payload); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.want, equals('missing')); + expect(parsed.missingIndices, equals([1, 4])); + expect(parsed.requesterKey6, equals('ffeeddccbbaa')); + expect(parsed.version, equals(2)); + }); + }); + + group('VoiceFragmentAck', () { + test('encodes and parses binary ack', () { + final ack = VoiceFragmentAck(sessionId: '01020304', index: 7); + final payload = ack.encodeBinary(); + expect(VoiceFragmentAck.isVoiceFragmentAckBinary(payload), isTrue); + final parsed = VoiceFragmentAck.tryParseBinary(payload); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.index, equals(7)); }); });