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