mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add message clear option
This commit is contained in:
@@ -5,8 +5,9 @@
|
|||||||
Image mode mirrors the voice on-demand architecture exactly:
|
Image mode mirrors the voice on-demand architecture exactly:
|
||||||
|
|
||||||
- **Control plane (text messages):**
|
- **Control plane (text messages):**
|
||||||
- `IE1:` image envelope announces image availability in chat.
|
- `IE2:` image envelope announces image availability in chat.
|
||||||
- `IR1:` direct fetch request asks sender to stream image fragments.
|
- **Control plane (raw binary request):**
|
||||||
|
- Binary image fetch request (same raw route as image fragments).
|
||||||
- **Data plane (raw binary packets):**
|
- **Data plane (raw binary packets):**
|
||||||
- `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`.
|
- `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`
|
- `lib/utils/image_message_parser.dart`
|
||||||
- `ImagePacket` (binary fragment format)
|
- `ImagePacket` (binary fragment format)
|
||||||
- `ImageEnvelope` (`IE1`)
|
- `ImageEnvelope` (`IE2`)
|
||||||
- `ImageFetchRequest` (`IR1`)
|
- `ImageFetchRequest` (binary)
|
||||||
- `fragmentImage()` — split compressed bytes into packets
|
- `fragmentImage()` — split compressed bytes into packets
|
||||||
- `reassembleImage()` — join received fragments into bytes
|
- `reassembleImage()` — join received fragments into bytes
|
||||||
- `lib/screens/messages_tab.dart`
|
- `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
|
- Reassembly sessions, outgoing cache, deferred serving
|
||||||
- Outgoing sessions also registered as complete incoming sessions for immediate local display
|
- Outgoing sessions also registered as complete incoming sessions for immediate local display
|
||||||
- `lib/providers/app_provider.dart`
|
- `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`
|
- `lib/widgets/messages/image_message_bubble.dart`
|
||||||
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
|
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
|
||||||
progress ring during fetch; full-screen `InteractiveViewer` on tap
|
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. 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:
|
Fields:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|--------------|--------|------------------------------------------------|
|
|--------------|--------|------------------------------------------------|
|
||||||
| `sid` | string | 8 hex chars (4 bytes), session ID |
|
| `sid` | string | base36 token for 32-bit session ID |
|
||||||
| `fmt` | int | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
|
| `fmt` | base36 | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
|
||||||
| `total` | int | Fragment count (1..255) |
|
| `total` | base36 | Fragment count (1..255) |
|
||||||
| `w` | int | Actual image width after compression (pixels) |
|
| `w` | base36 | Actual image width after compression (pixels) |
|
||||||
| `h` | int | Actual image height after compression (pixels) |
|
| `h` | base36 | Actual image height after compression (pixels) |
|
||||||
| `bytes` | int | Total compressed size in bytes |
|
| `bytes` | base36 | Total compressed size in bytes |
|
||||||
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
|
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
|
||||||
| `ts` | int | Unix timestamp (seconds) |
|
| `ts` | base36 | Unix timestamp (seconds) |
|
||||||
| `ver` | int | Protocol version (currently `1`) |
|
|
||||||
|
|
||||||
Compact format:
|
Compact format:
|
||||||
|
|
||||||
```text
|
```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):
|
Example (256×171 landscape image, 14 fragments):
|
||||||
|
|
||||||
```text
|
```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).
|
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
|
```text
|
||||||
IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|------------------|--------------------------|
|
|------------------|--------------------------|
|
||||||
| `want` | `a` (= "all fragments") |
|
| `flags` | bit0=1 => request missing indices, else all |
|
||||||
| `requesterKey6` | 12 hex chars |
|
| `requesterKey6` | 6-byte requester key prefix |
|
||||||
| `ver` | `1` |
|
| `ts` | unix timestamp seconds (u32) |
|
||||||
|
|
||||||
### 3.3 Raw Image Packet (data plane)
|
### 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:
|
7. Envelope sent via normal message path:
|
||||||
- Channel: `sendChannelMessage`
|
- Channel: `sendChannelMessage`
|
||||||
- Direct: `sendTextMessage`
|
- 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. Incoming Flow (Receive)
|
||||||
|
|
||||||
### 6.1 `IE1` envelope received
|
### 6.1 `IE2` envelope received
|
||||||
|
|
||||||
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
|
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
|
||||||
chat. The bubble shows a grey square placeholder with a download icon.
|
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):
|
`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)`
|
- **Complete session**: `AspectRatio(1.0)` → `AvifImage.memory(fit: cover)`
|
||||||
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
|
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
|
||||||
- **Incomplete/missing**: grey square placeholder with download icon;
|
- **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.
|
- **Loading**: circular progress indicator showing `received/total` count.
|
||||||
- **Error**: broken-image icon.
|
- **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:
|
The estimate is airtime-based (LoRa packet model), not just compressed image size:
|
||||||
|
|
||||||
- Source inputs:
|
- 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
|
- `pathLen` from message metadata
|
||||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||||
- Per-fragment payload model:
|
- Per-fragment payload model:
|
||||||
@@ -270,10 +272,10 @@ sequenceDiagram
|
|||||||
A->>A: Compress: contain resize → grayscale → PNG → AVIF
|
A->>A: Compress: contain resize → grayscale → PNG → AVIF
|
||||||
A->>A: Fragment into ≤152B packets
|
A->>A: Fragment into ≤152B packets
|
||||||
A->>A: Cache outgoing + populate local session (immediate display)
|
A->>A: Cache outgoing + populate local session (immediate display)
|
||||||
A->>M: Send IE1 envelope (actual w×h, fragment count)
|
A->>M: Send IE2 envelope (actual w×h, fragment count)
|
||||||
M->>B: Deliver IE1
|
M->>B: Deliver IE2
|
||||||
B->>B: Render grey placeholder bubble
|
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
|
A->>B: Stream binary ImagePackets
|
||||||
B->>B: Reassemble fragments
|
B->>B: Reassemble fragments
|
||||||
B->>B: Display AVIF image (cover thumbnail)
|
B->>B: Display AVIF image (cover thumbnail)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
Voice mode uses a **two-plane architecture**:
|
Voice mode uses a **two-plane architecture**:
|
||||||
|
|
||||||
- **Control plane (text messages):**
|
- **Control plane (text messages):**
|
||||||
- `VE1:` voice envelope announces voice availability in chat.
|
- `VE2:` voice envelope announces voice availability in chat.
|
||||||
- `VR1:` direct fetch request asks sender to stream voice payload.
|
- **Control plane (raw binary request):**
|
||||||
|
- Binary voice fetch request (same raw route as voice packets).
|
||||||
- **Data plane (raw binary packets):**
|
- **Data plane (raw binary packets):**
|
||||||
- `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`.
|
- `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`
|
- `lib/utils/voice_message_parser.dart`
|
||||||
- `VoicePacket` (legacy text + binary packet format)
|
- `VoicePacket` (legacy text + binary packet format)
|
||||||
- `VoiceEnvelope` (`VE1`)
|
- `VoiceEnvelope` (`VE2`)
|
||||||
- `VoiceFetchRequest` (`VR1`)
|
- `VoiceFetchRequest` (binary)
|
||||||
- `lib/screens/messages_tab.dart`
|
- `lib/screens/messages_tab.dart`
|
||||||
- Capture/encode voice, cache encoded packets, send envelope only
|
- Capture/encode voice, cache encoded packets, send envelope only
|
||||||
- `lib/providers/voice_provider.dart`
|
- `lib/providers/voice_provider.dart`
|
||||||
- Reassembly/playback sessions
|
- Reassembly/playback sessions
|
||||||
- Outgoing session cache + deferred serving
|
- Outgoing session cache + deferred serving
|
||||||
- `lib/providers/app_provider.dart`
|
- `lib/providers/app_provider.dart`
|
||||||
- Incoming routing for `VE1` and `VR1`
|
- Incoming routing for `VE2` and binary voice fetch requests
|
||||||
- Handles raw packet ingestion
|
- Handles raw packet ingestion
|
||||||
- `lib/widgets/messages/voice_message_bubble.dart`
|
- `lib/widgets/messages/voice_message_bubble.dart`
|
||||||
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
||||||
- `lib/providers/messages_provider.dart`
|
- `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`
|
- `lib/services/message_storage_service.dart`
|
||||||
- Persists `isVoice` and `voiceId`
|
- Persists `isVoice` and `voiceId`
|
||||||
|
|
||||||
## 3. Wire Formats
|
## 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:
|
Fields:
|
||||||
|
|
||||||
- `sid` (string, 8 hex chars): session ID
|
- `sid` (string): base36 token for 32-bit session ID
|
||||||
- `mode` (int): codec mode ID (`VoicePacketMode.id`)
|
- `mode` (base36): codec mode ID (`VoicePacketMode.id`)
|
||||||
- `total` (int): packet count (1..255)
|
- `total` (base36): packet count (1..255)
|
||||||
- `durMs` (int): estimated duration in ms
|
- `durS` (base36): estimated duration in seconds
|
||||||
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
|
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
|
||||||
- `ts` (int): unix timestamp seconds
|
- `ts` (base36): unix timestamp seconds
|
||||||
- `ver` (int): protocol version (currently `1`)
|
|
||||||
|
`sid` is base36 on wire and expands to 8-hex internally.
|
||||||
|
|
||||||
Compact format:
|
Compact format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
|
VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```text
|
```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
|
Binary payload format:
|
||||||
|
|
||||||
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
|
```text
|
||||||
VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
VR1:deadbeef:a:112233445566:1700000010:1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 Raw Voice Packet (data plane)
|
### 3.3 Raw Voice Packet (data plane)
|
||||||
@@ -102,18 +88,18 @@ Binary payload structure:
|
|||||||
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
||||||
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
||||||
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
|
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`
|
- channel/room: `sendChannelMessage`
|
||||||
- direct: `sendTextMessage`
|
- direct: `sendTextMessage`
|
||||||
6. **No raw audio packets are sent during initial send.**
|
6. **No raw audio packets are sent during initial send.**
|
||||||
|
|
||||||
## 5. Incoming Routing
|
## 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.
|
`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:
|
`AppProvider` treats it as control-plane only:
|
||||||
|
|
||||||
@@ -132,8 +118,8 @@ In `VoiceMessageBubble`:
|
|||||||
|
|
||||||
- If session already complete: play immediately.
|
- If session already complete: play immediately.
|
||||||
- If incomplete/missing:
|
- If incomplete/missing:
|
||||||
1. Resolve sender contact (message sender prefix or `VE1.senderKey6` fallback)
|
1. Resolve sender contact (message sender prefix or `VE2.senderKey6` fallback)
|
||||||
2. Send direct `VR1` fetch request
|
2. Send direct binary fetch request
|
||||||
3. Show requesting state in UI
|
3. Show requesting state in UI
|
||||||
4. Auto-play when session becomes complete
|
4. Auto-play when session becomes complete
|
||||||
|
|
||||||
@@ -170,10 +156,9 @@ Parser validation enforces:
|
|||||||
- strict hex lengths for IDs and key prefixes
|
- strict hex lengths for IDs and key prefixes
|
||||||
- valid mode range
|
- valid mode range
|
||||||
- valid packet counts and duration bounds
|
- valid packet counts and duration bounds
|
||||||
- fixed protocol version (`ver == 1`)
|
- compact base36 numeric fields in envelope/request
|
||||||
- `VR1.want` token `a` (internally normalized to `all`)
|
- Binary request flags specify `all` or `missing` indices.
|
||||||
|
- Request payload includes `requesterKey6` to resolve return route.
|
||||||
`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk.
|
|
||||||
|
|
||||||
## 10. Transmit Time Estimate (UI)
|
## 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:
|
The estimate is airtime-based (LoRa packet model), not file-duration-only:
|
||||||
|
|
||||||
- Source inputs:
|
- 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
|
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
||||||
- `pathLen` from message metadata
|
- `pathLen` from message metadata
|
||||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
- 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
|
## 12. Backward Compatibility
|
||||||
|
|
||||||
- Legacy `V:` text packet parsing is still supported.
|
- 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
|
## 13. High-Level Sequence
|
||||||
|
|
||||||
@@ -235,10 +221,10 @@ sequenceDiagram
|
|||||||
|
|
||||||
A->>A: Record + encode voice packets
|
A->>A: Record + encode voice packets
|
||||||
A->>A: Cache session packets (TTL 15m)
|
A->>A: Cache session packets (TTL 15m)
|
||||||
A->>M: Send VE1 envelope
|
A->>M: Send VE2 envelope
|
||||||
M->>B: Deliver VE1
|
M->>B: Deliver VE2
|
||||||
B->>B: Render voice bubble (metadata only)
|
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
|
A->>B: Stream raw VoicePacket packets
|
||||||
B->>B: Reassemble session
|
B->>B: Reassemble session
|
||||||
B->>B: Auto-play when complete
|
B->>B: Auto-play when complete
|
||||||
|
|||||||
@@ -57,8 +57,11 @@ class AppProvider with ChangeNotifier {
|
|||||||
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
||||||
static const int _maxPacketRetryAttempts = 4;
|
static const int _maxPacketRetryAttempts = 4;
|
||||||
final Map<String, String> _voiceSessionSenderKey6 = {};
|
final Map<String, String> _voiceSessionSenderKey6 = {};
|
||||||
|
final Map<String, String> _imageSessionSenderKey6 = {};
|
||||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||||
|
final Map<String, Completer<void>> _voiceFragmentAckWaiters = {};
|
||||||
|
final Map<String, Completer<void>> _imageFragmentAckWaiters = {};
|
||||||
Timer? _packetCaptureFlushTimer;
|
Timer? _packetCaptureFlushTimer;
|
||||||
String? _lastPersistedPacketSignature;
|
String? _lastPersistedPacketSignature;
|
||||||
bool _isPersistingPacketCapture = false;
|
bool _isPersistingPacketCapture = false;
|
||||||
@@ -404,6 +407,24 @@ class AppProvider with ChangeNotifier {
|
|||||||
payload: payload,
|
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
|
// When a contact is received from BLE
|
||||||
connectionProvider.onContactReceived = (contact) {
|
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
|
// 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...');
|
||||||
@@ -665,61 +630,12 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
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.
|
// Image envelope (IE1): announce image availability.
|
||||||
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
||||||
if (imageEnvelope != null) {
|
if (imageEnvelope != null) {
|
||||||
|
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope
|
||||||
|
.senderKey6
|
||||||
|
.toLowerCase();
|
||||||
imageProvider.registerEnvelope(imageEnvelope);
|
imageProvider.registerEnvelope(imageEnvelope);
|
||||||
messagesProvider.addMessage(
|
messagesProvider.addMessage(
|
||||||
enrichedMessage,
|
enrichedMessage,
|
||||||
@@ -800,8 +716,99 @@ class AppProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
|
// 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.
|
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
|
||||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
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)) {
|
if (ImagePacket.isImageBinary(payload)) {
|
||||||
final frag = ImagePacket.tryParseBinary(payload);
|
final frag = ImagePacket.tryParseBinary(payload);
|
||||||
if (frag == null) return;
|
if (frag == null) return;
|
||||||
@@ -812,6 +819,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
width: session?.width ?? 0,
|
width: session?.width ?? 0,
|
||||||
height: session?.height ?? 0,
|
height: session?.height ?? 0,
|
||||||
);
|
);
|
||||||
|
_sendImageFragmentAck(frag);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +828,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (pkt == null) return;
|
if (pkt == null) return;
|
||||||
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
||||||
final justComplete = voiceProvider.addPacket(pkt);
|
final justComplete = voiceProvider.addPacket(pkt);
|
||||||
|
_sendVoiceFragmentAck(pkt);
|
||||||
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
||||||
// Insert or update the placeholder message in the chat list
|
// Insert or update the placeholder message in the chat list
|
||||||
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
||||||
@@ -1209,15 +1218,18 @@ class AppProvider with ChangeNotifier {
|
|||||||
missingIndices: missing,
|
missingIndices: missing,
|
||||||
requesterKey6: requesterKey6,
|
requesterKey6: requesterKey6,
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
version: 1,
|
version: 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
final sent = await connectionProvider.sendTextMessage(
|
try {
|
||||||
contactPublicKey: sender.publicKey,
|
await connectionProvider.sendRawVoicePacket(
|
||||||
text: request.encodeText(),
|
contactPath: sender.outPath,
|
||||||
contact: sender,
|
contactPathLen: sender.outPathLen,
|
||||||
|
payload: request.encodeBinary(),
|
||||||
);
|
);
|
||||||
if (!sent) return;
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_voiceMissingRetryAttempts[sessionId] = attempt + 1;
|
_voiceMissingRetryAttempts[sessionId] = attempt + 1;
|
||||||
_voiceMissingRetryTimers[sessionId]?.cancel();
|
_voiceMissingRetryTimers[sessionId]?.cancel();
|
||||||
@@ -1266,6 +1278,102 @@ class AppProvider with ChangeNotifier {
|
|||||||
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
|
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
|
||||||
|
|
||||||
|
Future<bool> _waitForVoiceFragmentAck({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout = const Duration(seconds: 8),
|
||||||
|
}) async {
|
||||||
|
final key = _fragmentAckKey(sessionId, index);
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_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<bool> _waitForImageFragmentAck({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout = const Duration(seconds: 8),
|
||||||
|
}) async {
|
||||||
|
final key = _fragmentAckKey(sessionId, index);
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_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
|
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||||
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
|
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ class ImageProvider with ChangeNotifier {
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
})?
|
})?
|
||||||
sendRawPacketCallback;
|
sendRawPacketCallback;
|
||||||
|
Future<bool> Function({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout,
|
||||||
|
})?
|
||||||
|
waitForFragmentAckCallback;
|
||||||
|
|
||||||
ImageProvider() {
|
ImageProvider() {
|
||||||
_restore();
|
_restore();
|
||||||
@@ -229,11 +235,25 @@ class ImageProvider with ChangeNotifier {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
final ackFuture = waitForFragmentAckCallback?.call(
|
||||||
|
sessionId: sessionId,
|
||||||
|
index: fragment.index,
|
||||||
|
timeout: const Duration(seconds: 8),
|
||||||
|
);
|
||||||
await sendRawPacketCallback!(
|
await sendRawPacketCallback!(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.outPathLen,
|
contactPathLen: requester.outPathLen,
|
||||||
payload: fragment.encodeBinary(),
|
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) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st');
|
debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st');
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
})?
|
})?
|
||||||
sendRawPacketCallback;
|
sendRawPacketCallback;
|
||||||
|
Future<bool> Function({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout,
|
||||||
|
})?
|
||||||
|
waitForFragmentAckCallback;
|
||||||
|
|
||||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||||
|
|
||||||
@@ -198,11 +204,25 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
final ackFuture = waitForFragmentAckCallback?.call(
|
||||||
|
sessionId: sessionId,
|
||||||
|
index: packet.index,
|
||||||
|
timeout: const Duration(seconds: 8),
|
||||||
|
);
|
||||||
await sendRawPacketCallback!(
|
await sendRawPacketCallback!(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.outPathLen,
|
contactPathLen: requester.outPathLen,
|
||||||
payload: packet.encodeBinary(),
|
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) {
|
} catch (e, st) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
|
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
|
||||||
|
|||||||
@@ -621,6 +621,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _clearMessages() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<MessagesProvider>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
|
messagesProvider.clearMessages();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('All messages cleared'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -694,6 +734,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _showLanguageDialog(),
|
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(),
|
const Divider(),
|
||||||
|
|
||||||
// Voice Settings Section
|
// Voice Settings Section
|
||||||
|
|||||||
@@ -243,11 +243,11 @@ int _resolveBandwidthHz(int? rawBw) {
|
|||||||
/// Envelope announcing image availability (control plane).
|
/// Envelope announcing image availability (control plane).
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver}
|
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1
|
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8
|
||||||
class ImageEnvelope {
|
class ImageEnvelope {
|
||||||
static const String prefix = 'IE1:';
|
static const String _prefix = 'IE2:';
|
||||||
|
|
||||||
final String sessionId; // 8 hex chars
|
final String sessionId; // 8 hex chars
|
||||||
final ImageFormat format;
|
final ImageFormat format;
|
||||||
@@ -268,38 +268,36 @@ class ImageEnvelope {
|
|||||||
required this.sizeBytes,
|
required this.sizeBytes,
|
||||||
required this.senderKey6,
|
required this.senderKey6,
|
||||||
required this.timestampSec,
|
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) {
|
static ImageEnvelope? tryParse(String text) {
|
||||||
if (!isEnvelope(text)) return null;
|
if (!isEnvelope(text)) return null;
|
||||||
final body = text.substring(prefix.length);
|
final body = text.substring(_prefix.length);
|
||||||
final parts = body.split(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 9) return null;
|
if (parts.length != 8) return null;
|
||||||
try {
|
try {
|
||||||
final sid = parts[0];
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final fmtId = int.tryParse(parts[1]);
|
final fmtId = _parseInt(parts[1], base36: true);
|
||||||
final total = int.tryParse(parts[2]);
|
final total = _parseInt(parts[2], base36: true);
|
||||||
final w = int.tryParse(parts[3]);
|
final w = _parseInt(parts[3], base36: true);
|
||||||
final h = int.tryParse(parts[4]);
|
final h = _parseInt(parts[4], base36: true);
|
||||||
final bytes = int.tryParse(parts[5]);
|
final bytes = _parseInt(parts[5], base36: true);
|
||||||
final senderKey6 = parts[6];
|
final senderKey6 = parts[6];
|
||||||
final ts = int.tryParse(parts[7]);
|
final ts = _parseInt(parts[7], base36: true);
|
||||||
final ver = int.tryParse(parts[8]);
|
|
||||||
|
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
|
if (sid == null) return null;
|
||||||
if (fmtId == null) return null;
|
if (fmtId == null) return null;
|
||||||
if (total == null || total < 1 || total > 255) return null;
|
if (total == null || total < 1 || total > 255) return null;
|
||||||
if (w == null || h == null || w < 1 || h < 1) return null;
|
if (w == null || h == null || w < 1 || h < 1) return null;
|
||||||
if (bytes == null || bytes < 1) return null;
|
if (bytes == null || bytes < 1) return null;
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
|
||||||
if (ts == null || ts <= 0) return null;
|
if (ts == null || ts <= 0) return null;
|
||||||
if (ver == null || ver != 1) return null;
|
|
||||||
|
|
||||||
return ImageEnvelope(
|
return ImageEnvelope(
|
||||||
sessionId: sid.toLowerCase(),
|
sessionId: sid,
|
||||||
format: ImageFormat.fromId(fmtId),
|
format: ImageFormat.fromId(fmtId),
|
||||||
total: total,
|
total: total,
|
||||||
width: w,
|
width: w,
|
||||||
@@ -307,7 +305,7 @@ class ImageEnvelope {
|
|||||||
sizeBytes: bytes,
|
sizeBytes: bytes,
|
||||||
senderKey6: senderKey6.toLowerCase(),
|
senderKey6: senderKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
timestampSec: ts,
|
||||||
version: ver,
|
version: 2,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -315,17 +313,21 @@ class ImageEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String encode() =>
|
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).
|
/// Direct request to fetch image fragments (control plane).
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
/// IR2:{sid}:{want}:{requesterKey6}:{ts}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// IR1:deadbeef:a:aabbccddeeff:1700000010:1
|
/// IR2:deadbeef:a:aabbccddeeff:s44wea
|
||||||
class ImageFetchRequest {
|
class ImageFetchRequest {
|
||||||
static const String prefix = 'IR1:';
|
static const String _prefix = 'IR2:';
|
||||||
|
static const int _binaryMagic = 0x69; // 'i'
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final String want; // 'all' or 'missing'
|
final String want; // 'all' or 'missing'
|
||||||
@@ -340,51 +342,89 @@ class ImageFetchRequest {
|
|||||||
this.missingIndices = const [],
|
this.missingIndices = const [],
|
||||||
required this.requesterKey6,
|
required this.requesterKey6,
|
||||||
required this.timestampSec,
|
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) {
|
static ImageFetchRequest? tryParse(String text) {
|
||||||
if (!isRequest(text)) return null;
|
if (!isRequest(text)) return null;
|
||||||
final body = text.substring(prefix.length);
|
final body = text.substring(_prefix.length);
|
||||||
final parts = body.split(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 5) return null;
|
if (parts.length != 4) return null;
|
||||||
try {
|
try {
|
||||||
final sid = parts[0];
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final wantToken = parts[1];
|
final wantToken = parts[1];
|
||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final ts = int.tryParse(parts[3]);
|
final ts = _parseInt(parts[3], base36: true);
|
||||||
final ver = int.tryParse(parts[4]);
|
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? '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 = <int>[];
|
final missingIndices = <int>[];
|
||||||
if (normalizedWant == 'missing') {
|
if (normalizedWant == 'missing') {
|
||||||
final encoded = wantToken.substring(2);
|
final encoded = wantToken.substring(1);
|
||||||
if (encoded.isEmpty) return null;
|
if (encoded.isEmpty) return null;
|
||||||
for (final raw in encoded.split(',')) {
|
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
|
||||||
final idx = int.tryParse(raw);
|
|
||||||
if (idx == null || idx < 0 || idx > 254) return null;
|
|
||||||
missingIndices.add(idx);
|
|
||||||
}
|
|
||||||
if (missingIndices.isEmpty) return null;
|
if (missingIndices.isEmpty) return null;
|
||||||
} else if (normalizedWant != 'all') {
|
} else if (normalizedWant != 'all') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
|
||||||
if (ts == null || ts <= 0) return null;
|
if (ts == null || ts <= 0) return null;
|
||||||
if (ver == null || ver != 1) return null;
|
|
||||||
|
|
||||||
return ImageFetchRequest(
|
return ImageFetchRequest(
|
||||||
sessionId: sid.toLowerCase(),
|
sessionId: sid,
|
||||||
want: normalizedWant,
|
want: normalizedWant,
|
||||||
missingIndices: missingIndices,
|
missingIndices: missingIndices,
|
||||||
requesterKey6: requesterKey6.toLowerCase(),
|
requesterKey6: requesterKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
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 = <int>[];
|
||||||
|
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 (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -393,10 +433,168 @@ class ImageFetchRequest {
|
|||||||
|
|
||||||
String encode() {
|
String encode() {
|
||||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||||
? 'm-${missingIndices.join(',')}'
|
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||||
: (want == 'all' ? 'a' : want);
|
: (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()
|
||||||
|
: <int>[];
|
||||||
|
|
||||||
|
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<int> indices) {
|
||||||
|
final sorted = indices
|
||||||
|
.where((v) => v >= 0 && v <= 254)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
if (sorted.isEmpty) return '';
|
||||||
|
final chunks = <String>[];
|
||||||
|
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<int> _decodeMissingIndicesCompact(String encoded) {
|
||||||
|
final out = <int>[];
|
||||||
|
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.
|
/// Fragment the compressed image bytes into [ImagePacket] list.
|
||||||
|
|||||||
@@ -181,11 +181,11 @@ class VoicePacket {
|
|||||||
/// Lightweight public/direct message envelope advertising voice availability.
|
/// Lightweight public/direct message envelope advertising voice availability.
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
|
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1
|
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
|
||||||
class VoiceEnvelope {
|
class VoiceEnvelope {
|
||||||
static const String _prefix = 'VE1:';
|
static const String _prefix = 'VE2:';
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final VoicePacketMode mode;
|
final VoicePacketMode mode;
|
||||||
@@ -202,7 +202,7 @@ class VoiceEnvelope {
|
|||||||
required this.durationMs,
|
required this.durationMs,
|
||||||
required this.senderKey6,
|
required this.senderKey6,
|
||||||
required this.timestampSec,
|
required this.timestampSec,
|
||||||
this.version = 1,
|
this.version = 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
||||||
@@ -210,43 +210,41 @@ class VoiceEnvelope {
|
|||||||
static VoiceEnvelope? tryParseText(String text) {
|
static VoiceEnvelope? tryParseText(String text) {
|
||||||
if (!isVoiceEnvelopeText(text)) return null;
|
if (!isVoiceEnvelopeText(text)) return null;
|
||||||
final body = text.substring(_prefix.length);
|
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(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 7) return null;
|
if (parts.length != 6) return null;
|
||||||
try {
|
try {
|
||||||
final sid = parts[0];
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final mode = int.tryParse(parts[1]);
|
final mode = _parseInt(parts[1], base36: true);
|
||||||
final total = int.tryParse(parts[2]);
|
final total = _parseInt(parts[2], base36: true);
|
||||||
final durMs = int.tryParse(parts[3]);
|
final durS = _parseInt(parts[3], base36: true);
|
||||||
final senderKey6 = parts[4];
|
final senderKey6 = parts[4];
|
||||||
final ts = int.tryParse(parts[5]);
|
final ts = _parseInt(parts[5], base36: true);
|
||||||
final ver = int.tryParse(parts[6]);
|
|
||||||
|
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
|
if (sid == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
|
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (total == null || total < 1 || total > 255) 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)) {
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (ts == null || ts <= 0) return null;
|
if (ts == null || ts <= 0) return null;
|
||||||
if (ver == null || ver != 1) return null;
|
|
||||||
|
|
||||||
return VoiceEnvelope(
|
return VoiceEnvelope(
|
||||||
sessionId: sid.toLowerCase(),
|
sessionId: sid,
|
||||||
mode: VoicePacketMode.fromId(mode),
|
mode: VoicePacketMode.fromId(mode),
|
||||||
total: total,
|
total: total,
|
||||||
durationMs: durMs,
|
durationMs: durS * 1000,
|
||||||
senderKey6: senderKey6.toLowerCase(),
|
senderKey6: senderKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
timestampSec: ts,
|
||||||
version: ver,
|
version: 2,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -254,7 +252,8 @@ class VoiceEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String encodeText() {
|
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.
|
/// Direct control-plane request to fetch voice packets for a session.
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
/// VR2:{sid}:{want}:{requesterKey6}:{ts}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// VR1:00112233:a:aabbccddeeff:1234567890:1
|
/// VR2:00112233:a:aabbccddeeff:kf12oi
|
||||||
class VoiceFetchRequest {
|
class VoiceFetchRequest {
|
||||||
static const String _prefix = 'VR1:';
|
static const String _prefix = 'VR2:';
|
||||||
|
static const int _binaryMagic = 0x72; // 'r'
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final String want;
|
final String want;
|
||||||
@@ -434,42 +434,82 @@ class VoiceFetchRequest {
|
|||||||
this.missingIndices = const [],
|
this.missingIndices = const [],
|
||||||
required this.requesterKey6,
|
required this.requesterKey6,
|
||||||
required this.timestampSec,
|
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) {
|
static VoiceFetchRequest? tryParseText(String text) {
|
||||||
if (!isVoiceFetchRequestText(text)) return null;
|
if (!isVoiceFetchRequestText(text)) return null;
|
||||||
final body = text.substring(_prefix.length);
|
final body = text.substring(_prefix.length);
|
||||||
return _tryParseCompact(body);
|
return _tryParse(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
static VoiceFetchRequest? _tryParseCompact(String body) {
|
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
|
||||||
final parts = body.split(':');
|
if (!isVoiceFetchRequestBinary(payload)) return null;
|
||||||
if (parts.length != 5) return null;
|
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
|
||||||
try {
|
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 = <int>[];
|
||||||
|
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 wantToken = parts[1];
|
||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final ts = int.tryParse(parts[3]);
|
final ts = _parseInt(parts[3], base36: true);
|
||||||
final ver = int.tryParse(parts[4]);
|
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? '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;
|
return null;
|
||||||
}
|
}
|
||||||
final missingIndices = <int>[];
|
final missingIndices = <int>[];
|
||||||
if (normalizedWant == 'missing') {
|
if (normalizedWant == 'missing') {
|
||||||
final encoded = wantToken.substring(2);
|
final encoded = wantToken.substring(1);
|
||||||
if (encoded.isEmpty) return null;
|
if (encoded.isEmpty) return null;
|
||||||
for (final raw in encoded.split(',')) {
|
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
|
||||||
final idx = int.tryParse(raw);
|
|
||||||
if (idx == null || idx < 0 || idx > 254) return null;
|
|
||||||
missingIndices.add(idx);
|
|
||||||
}
|
|
||||||
if (missingIndices.isEmpty) return null;
|
if (missingIndices.isEmpty) return null;
|
||||||
} else if (normalizedWant != 'all') {
|
} else if (normalizedWant != 'all') {
|
||||||
return null;
|
return null;
|
||||||
@@ -478,15 +518,14 @@ class VoiceFetchRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (ts == null || ts <= 0) return null;
|
if (ts == null || ts <= 0) return null;
|
||||||
if (ver == null || ver != 1) return null;
|
|
||||||
|
|
||||||
return VoiceFetchRequest(
|
return VoiceFetchRequest(
|
||||||
sessionId: sid.toLowerCase(),
|
sessionId: sid,
|
||||||
want: normalizedWant,
|
want: normalizedWant,
|
||||||
missingIndices: missingIndices,
|
missingIndices: missingIndices,
|
||||||
requesterKey6: requesterKey6.toLowerCase(),
|
requesterKey6: requesterKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
timestampSec: ts,
|
||||||
version: ver,
|
version: 2,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -495,10 +534,168 @@ class VoiceFetchRequest {
|
|||||||
|
|
||||||
String encodeText() {
|
String encodeText() {
|
||||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||||
? 'm-${missingIndices.join(',')}'
|
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||||
: (want == 'all' ? 'a' : want);
|
: (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()
|
||||||
|
: <int>[];
|
||||||
|
|
||||||
|
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<int> indices) {
|
||||||
|
final sorted = indices
|
||||||
|
.where((v) => v >= 0 && v <= 254)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
if (sorted.isEmpty) return '';
|
||||||
|
final chunks = <String>[];
|
||||||
|
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<int> _decodeMissingIndicesCompact(String encoded) {
|
||||||
|
final out = <int>[];
|
||||||
|
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.
|
/// Builds a compact visual waveform from real voice packet bytes.
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import 'transfer_timeout.dart';
|
|||||||
|
|
||||||
/// A message bubble that shows a received or sent image.
|
/// A message bubble that shows a received or sent image.
|
||||||
///
|
///
|
||||||
/// On first render the image is not yet fetched (only the IE1 envelope is
|
/// On first render the image is not yet fetched (only the IE2 envelope is
|
||||||
/// known). The user taps the thumbnail placeholder → IR1 fetch request is
|
/// known). The user taps the thumbnail placeholder → IR2 fetch request is
|
||||||
/// sent → binary fragments stream in → bubble rebuilds with the full image.
|
/// sent → binary fragments stream in → bubble rebuilds with the full image.
|
||||||
class ImageMessageBubble extends StatefulWidget {
|
class ImageMessageBubble extends StatefulWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
@@ -279,18 +279,23 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
_errorText = null;
|
_errorText = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final sent = await conn.sendTextMessage(
|
final payload = request.encodeBinary();
|
||||||
contactPublicKey: sender.publicKey,
|
try {
|
||||||
text: request.encode(),
|
await conn.sendRawVoicePacket(
|
||||||
contact: sender,
|
contactPath: sender.outPath,
|
||||||
|
contactPathLen: sender.outPathLen,
|
||||||
|
payload: payload,
|
||||||
);
|
);
|
||||||
if (!sent && mounted) {
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRequesting = false;
|
_isRequesting = false;
|
||||||
_errorText = 'Image unavailable right now';
|
_errorText = 'Image unavailable right now';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||||
final txEstimate = estimateImageTransmitDuration(
|
final txEstimate = estimateImageTransmitDuration(
|
||||||
|
|||||||
@@ -1682,6 +1682,13 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final message = widget.message;
|
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 isSarMarker = message.isSarMarker;
|
||||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
@@ -2183,8 +2190,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
!widget.isCompact)
|
!widget.isCompact)
|
||||||
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
|
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||||
// Tic-Tac-Toe control message content
|
// Tic-Tac-Toe control message content
|
||||||
else if (message.isContactMessage &&
|
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
|
||||||
TicTacToeMessageParser.isTicTacToe(message.text) &&
|
|
||||||
!widget.isCompact)
|
!widget.isCompact)
|
||||||
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
|
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||||
// Regular message content
|
// Regular message content
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
requesterKey6: requesterKey6,
|
requesterKey6: requesterKey6,
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
version: 1,
|
version: 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -268,12 +268,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
_errorText = null;
|
_errorText = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final sent = await connectionProvider.sendTextMessage(
|
try {
|
||||||
contactPublicKey: sender.publicKey,
|
await connectionProvider.sendRawVoicePacket(
|
||||||
text: request.encodeText(),
|
contactPath: sender.outPath,
|
||||||
contact: sender,
|
contactPathLen: sender.outPathLen,
|
||||||
|
payload: request.encodeBinary(),
|
||||||
);
|
);
|
||||||
if (!sent) {
|
} catch (_) {
|
||||||
_setUnavailable();
|
_setUnavailable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ void main() {
|
|||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('MessagesProvider voice detection', () {
|
group('MessagesProvider voice detection', () {
|
||||||
test('marks VE1 envelope messages as voice', () {
|
test('marks VE2 envelope messages as voice', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
final envelope = VoiceEnvelope(
|
final envelope = VoiceEnvelope(
|
||||||
sessionId: 'deafbead',
|
sessionId: 'deafbead',
|
||||||
|
|||||||
116
test/utils/image_message_parser_test.dart
Normal file
116
test/utils/image_message_parser_test.dart
Normal file
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,74 +6,122 @@ void main() {
|
|||||||
group('VoiceEnvelope', () {
|
group('VoiceEnvelope', () {
|
||||||
test('encodes and parses valid envelope', () {
|
test('encodes and parses valid envelope', () {
|
||||||
final env = VoiceEnvelope(
|
final env = VoiceEnvelope(
|
||||||
sessionId: 'deadbeef',
|
sessionId: '0000000a',
|
||||||
mode: VoicePacketMode.mode1200,
|
mode: VoicePacketMode.mode1200,
|
||||||
total: 4,
|
total: 4,
|
||||||
durationMs: 3200,
|
durationMs: 3000,
|
||||||
senderKey6: 'aabbccddeeff',
|
senderKey6: 'aabbccddeeff',
|
||||||
timestampSec: 1700000000,
|
timestampSec: 1700000000,
|
||||||
);
|
);
|
||||||
|
|
||||||
final text = env.encodeText();
|
final text = env.encodeText();
|
||||||
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
|
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
|
||||||
|
expect(text.startsWith('VE2:'), isTrue);
|
||||||
|
expect(text.split(':')[1], equals('a'));
|
||||||
|
|
||||||
final parsed = VoiceEnvelope.tryParseText(text);
|
final parsed = VoiceEnvelope.tryParseText(text);
|
||||||
expect(parsed, isNotNull);
|
expect(parsed, isNotNull);
|
||||||
expect(parsed!.sessionId, equals('deadbeef'));
|
expect(parsed!.sessionId, equals('0000000a'));
|
||||||
expect(parsed.mode, equals(VoicePacketMode.mode1200));
|
expect(parsed.mode, equals(VoicePacketMode.mode1200));
|
||||||
expect(parsed.total, equals(4));
|
expect(parsed.total, equals(4));
|
||||||
expect(parsed.durationMs, equals(3200));
|
expect(parsed.durationMs, equals(3000));
|
||||||
expect(parsed.senderKey6, equals('aabbccddeeff'));
|
expect(parsed.senderKey6, equals('aabbccddeeff'));
|
||||||
expect(parsed.version, equals(1));
|
expect(parsed.version, equals(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid envelope payload', () {
|
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);
|
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', () {
|
group('VoiceFetchRequest', () {
|
||||||
test('encodes and parses valid request', () {
|
test('encodes and parses valid request', () {
|
||||||
final req = VoiceFetchRequest(
|
final req = VoiceFetchRequest(
|
||||||
sessionId: '00112233',
|
sessionId: '0000000a',
|
||||||
requesterKey6: 'ffeeddccbbaa',
|
requesterKey6: 'ffeeddccbbaa',
|
||||||
timestampSec: 1700000001,
|
timestampSec: 1700000001,
|
||||||
);
|
);
|
||||||
final text = req.encodeText();
|
final text = req.encodeText();
|
||||||
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
|
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
|
||||||
|
expect(text.startsWith('VR2:'), isTrue);
|
||||||
|
expect(text.split(':')[1], equals('a'));
|
||||||
|
|
||||||
final parsed = VoiceFetchRequest.tryParseText(text);
|
final parsed = VoiceFetchRequest.tryParseText(text);
|
||||||
expect(parsed, isNotNull);
|
expect(parsed, isNotNull);
|
||||||
expect(parsed!.sessionId, equals('00112233'));
|
expect(parsed!.sessionId, equals('0000000a'));
|
||||||
expect(parsed.want, equals('all'));
|
expect(parsed.want, equals('all'));
|
||||||
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
||||||
expect(parsed.version, equals(1));
|
expect(parsed.version, equals(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid request payload', () {
|
test('rejects invalid request payload', () {
|
||||||
expect(
|
expect(
|
||||||
VoiceFetchRequest.tryParseText(
|
VoiceFetchRequest.tryParseText(
|
||||||
'VR1:00112233:chunk:ffeeddccbbaa:1700000001:1',
|
'VR2:a:chunk:ffeeddccbbaa:s44we9',
|
||||||
),
|
),
|
||||||
isNull,
|
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', () {
|
test('encodes and parses missing-packet request', () {
|
||||||
final req = VoiceFetchRequest(
|
final req = VoiceFetchRequest(
|
||||||
sessionId: '00112233',
|
sessionId: '0000000a',
|
||||||
want: 'missing',
|
want: 'missing',
|
||||||
missingIndices: const [0, 3, 7],
|
missingIndices: const [0, 1, 2, 3, 7],
|
||||||
requesterKey6: 'ffeeddccbbaa',
|
requesterKey6: 'ffeeddccbbaa',
|
||||||
timestampSec: 1700000001,
|
timestampSec: 1700000001,
|
||||||
);
|
);
|
||||||
final text = req.encodeText();
|
final text = req.encodeText();
|
||||||
|
expect(text, contains(':m0-3.7:'));
|
||||||
|
|
||||||
final parsed = VoiceFetchRequest.tryParseText(text);
|
final parsed = VoiceFetchRequest.tryParseText(text);
|
||||||
expect(parsed, isNotNull);
|
expect(parsed, isNotNull);
|
||||||
expect(parsed!.want, equals('missing'));
|
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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user