Add message clear option

This commit is contained in:
Janez T
2026-03-05 11:04:41 +01:00
parent 2d9cb0ddb9
commit 234edf5bb0
14 changed files with 1075 additions and 319 deletions
+34 -32
View File
@@ -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)
+35 -49
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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;
+20
View File
@@ -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',
+49
View File
@@ -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
+241 -43
View File
@@ -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.
+242 -45
View File
@@ -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.
+17 -12
View File
@@ -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(
+8 -2
View File
@@ -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',
+116
View 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));
});
});
}
+61 -13
View File
@@ -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));
});
});