mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
17 Commits
v2026.0305
...
v2026.0306
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3902cea1e | ||
|
|
2482f94cd0 | ||
|
|
2b34bc4162 | ||
|
|
03c0c7ad38 | ||
|
|
42fc1f9cd2 | ||
|
|
7807ad7471 | ||
|
|
310eee1ff5 | ||
|
|
b8f03079e7 | ||
|
|
ab0c531a4b | ||
|
|
9709511e90 | ||
|
|
162d5333ce | ||
|
|
0cb1d49804 | ||
|
|
234edf5bb0 | ||
|
|
2d9cb0ddb9 | ||
|
|
76b093685e | ||
|
|
55a85659d3 | ||
|
|
972a9ba944 |
@@ -5,8 +5,9 @@
|
|||||||
Image mode mirrors the voice on-demand architecture exactly:
|
Image mode mirrors the voice on-demand architecture exactly:
|
||||||
|
|
||||||
- **Control plane (text messages):**
|
- **Control plane (text messages):**
|
||||||
- `IE1:` image envelope announces image availability in chat.
|
- `IE2:` image envelope announces image availability in chat.
|
||||||
- `IR1:` direct fetch request asks sender to stream image fragments.
|
- **Control plane (raw binary request):**
|
||||||
|
- Binary image fetch request (same raw route as image fragments).
|
||||||
- **Data plane (raw binary packets):**
|
- **Data plane (raw binary packets):**
|
||||||
- `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`.
|
- `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`.
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ pixels are fetched on demand when the user taps the image bubble.
|
|||||||
|
|
||||||
- `lib/utils/image_message_parser.dart`
|
- `lib/utils/image_message_parser.dart`
|
||||||
- `ImagePacket` (binary fragment format)
|
- `ImagePacket` (binary fragment format)
|
||||||
- `ImageEnvelope` (`IE1`)
|
- `ImageEnvelope` (`IE2`)
|
||||||
- `ImageFetchRequest` (`IR1`)
|
- `ImageFetchRequest` (binary)
|
||||||
- `fragmentImage()` — split compressed bytes into packets
|
- `fragmentImage()` — split compressed bytes into packets
|
||||||
- `reassembleImage()` — join received fragments into bytes
|
- `reassembleImage()` — join received fragments into bytes
|
||||||
- `lib/screens/messages_tab.dart`
|
- `lib/screens/messages_tab.dart`
|
||||||
@@ -27,7 +28,7 @@ pixels are fetched on demand when the user taps the image bubble.
|
|||||||
- Reassembly sessions, outgoing cache, deferred serving
|
- Reassembly sessions, outgoing cache, deferred serving
|
||||||
- Outgoing sessions also registered as complete incoming sessions for immediate local display
|
- Outgoing sessions also registered as complete incoming sessions for immediate local display
|
||||||
- `lib/providers/app_provider.dart`
|
- `lib/providers/app_provider.dart`
|
||||||
- Incoming routing for `IE1`, `IR1`, binary `0x49` packets
|
- Incoming routing for `IE2`, binary image fetch requests, binary `0x49` packets
|
||||||
- `lib/widgets/messages/image_message_bubble.dart`
|
- `lib/widgets/messages/image_message_bubble.dart`
|
||||||
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
|
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
|
||||||
progress ring during fetch; full-screen `InteractiveViewer` on tap
|
progress ring during fetch; full-screen `InteractiveViewer` on tap
|
||||||
@@ -39,52 +40,52 @@ pixels are fetched on demand when the user taps the image bubble.
|
|||||||
|
|
||||||
## 3. Wire Formats
|
## 3. Wire Formats
|
||||||
|
|
||||||
### 3.1 Image Envelope (`IE1`)
|
### 3.1 Image Envelope (`IE2`)
|
||||||
|
|
||||||
Prefix: `IE1:` + colon-delimited payload
|
Prefix: `IE2:` + colon-delimited compact payload (base36 numeric fields)
|
||||||
|
|
||||||
Fields:
|
Fields:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|--------------|--------|------------------------------------------------|
|
|--------------|--------|------------------------------------------------|
|
||||||
| `sid` | string | 8 hex chars (4 bytes), session ID |
|
| `sid` | string | base36 token for 32-bit session ID |
|
||||||
| `fmt` | int | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
|
| `fmt` | base36 | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
|
||||||
| `total` | int | Fragment count (1..255) |
|
| `total` | base36 | Fragment count (1..255) |
|
||||||
| `w` | int | Actual image width after compression (pixels) |
|
| `w` | base36 | Actual image width after compression (pixels) |
|
||||||
| `h` | int | Actual image height after compression (pixels) |
|
| `h` | base36 | Actual image height after compression (pixels) |
|
||||||
| `bytes` | int | Total compressed size in bytes |
|
| `bytes` | base36 | Total compressed size in bytes |
|
||||||
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
|
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
|
||||||
| `ts` | int | Unix timestamp (seconds) |
|
| `ts` | base36 | Unix timestamp (seconds) |
|
||||||
| `ver` | int | Protocol version (currently `1`) |
|
|
||||||
|
|
||||||
Compact format:
|
Compact format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver}
|
IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (256×171 landscape image, 14 fragments):
|
Example (256×171 landscape image, 14 fragments):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IE1:deadbeef:0:14:256:171:2100:aabbccddeeff:1700000000:1
|
IE2:a:0:e:74:4r:1mc:aabbccddeeff:s44we8
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `w` and `h` reflect the actual post-compression dimensions, which preserve
|
Note: `sid` is base36 on wire and expands to 8-hex internally.
|
||||||
|
`w` and `h` reflect the actual post-compression dimensions, which preserve
|
||||||
the source aspect ratio (contain within the configured max size).
|
the source aspect ratio (contain within the configured max size).
|
||||||
|
|
||||||
### 3.2 Image Fetch Request (`IR1`)
|
### 3.2 Image Fetch Request (binary)
|
||||||
|
|
||||||
Same structure as `VR1`:
|
Binary payload format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|------------------|--------------------------|
|
|------------------|--------------------------|
|
||||||
| `want` | `a` (= "all fragments") |
|
| `flags` | bit0=1 => request missing indices, else all |
|
||||||
| `requesterKey6` | 12 hex chars |
|
| `requesterKey6` | 6-byte requester key prefix |
|
||||||
| `ver` | `1` |
|
| `ts` | unix timestamp seconds (u32) |
|
||||||
|
|
||||||
### 3.3 Raw Image Packet (data plane)
|
### 3.3 Raw Image Packet (data plane)
|
||||||
|
|
||||||
@@ -151,16 +152,16 @@ only the shorter axis is padded — no cropping occurs.
|
|||||||
7. Envelope sent via normal message path:
|
7. Envelope sent via normal message path:
|
||||||
- Channel: `sendChannelMessage`
|
- Channel: `sendChannelMessage`
|
||||||
- Direct: `sendTextMessage`
|
- Direct: `sendTextMessage`
|
||||||
8. Local placeholder message added (`IE1:` text, `deliveryStatus.sending`).
|
8. Local placeholder message added (`IE2:` text, `deliveryStatus.sending`).
|
||||||
|
|
||||||
## 6. Incoming Flow (Receive)
|
## 6. Incoming Flow (Receive)
|
||||||
|
|
||||||
### 6.1 `IE1` envelope received
|
### 6.1 `IE2` envelope received
|
||||||
|
|
||||||
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
|
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
|
||||||
chat. The bubble shows a grey square placeholder with a download icon.
|
chat. The bubble shows a grey square placeholder with a download icon.
|
||||||
|
|
||||||
### 6.2 `IR1` request received
|
### 6.2 Binary image fetch request received
|
||||||
|
|
||||||
`AppProvider` treats it as control-plane only (not added to chat):
|
`AppProvider` treats it as control-plane only (not added to chat):
|
||||||
|
|
||||||
@@ -194,7 +195,7 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
|
|||||||
- **Complete session**: `AspectRatio(1.0)` → `AvifImage.memory(fit: cover)`
|
- **Complete session**: `AspectRatio(1.0)` → `AvifImage.memory(fit: cover)`
|
||||||
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
|
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
|
||||||
- **Incomplete/missing**: grey square placeholder with download icon;
|
- **Incomplete/missing**: grey square placeholder with download icon;
|
||||||
tap → sends `IR1` fetch request.
|
tap → sends binary fetch request.
|
||||||
- **Loading**: circular progress indicator showing `received/total` count.
|
- **Loading**: circular progress indicator showing `received/total` count.
|
||||||
- **Error**: broken-image icon.
|
- **Error**: broken-image icon.
|
||||||
|
|
||||||
@@ -212,7 +213,8 @@ Image bubbles and Message Technical Details show an **estimated transmit time**
|
|||||||
The estimate is airtime-based (LoRa packet model), not just compressed image size:
|
The estimate is airtime-based (LoRa packet model), not just compressed image size:
|
||||||
|
|
||||||
- Source inputs:
|
- Source inputs:
|
||||||
- `total` fragments and `bytes` from `IE1` envelope
|
- `total` fragments and `bytes` from `IE2` envelope
|
||||||
|
- all numeric envelope values are decoded from base36
|
||||||
- `pathLen` from message metadata
|
- `pathLen` from message metadata
|
||||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||||
- Per-fragment payload model:
|
- Per-fragment payload model:
|
||||||
@@ -248,6 +250,16 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
|||||||
- Raw return path requires a valid direct route to requester.
|
- Raw return path requires a valid direct route to requester.
|
||||||
- Available on iOS and Android (`image_picker` + `flutter_avif`).
|
- Available on iOS and Android (`image_picker` + `flutter_avif`).
|
||||||
|
|
||||||
|
### 11.1 Raw Binary Routing Semantics
|
||||||
|
|
||||||
|
- Image fragment payloads use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
|
||||||
|
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
|
||||||
|
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
|
||||||
|
- This flow is direct-route only, not flood/broadcast:
|
||||||
|
- it is sent to one destination path;
|
||||||
|
- only nodes on that path relay it;
|
||||||
|
- it is **not** received by everyone in the mesh.
|
||||||
|
|
||||||
## 12. High-Level Sequence
|
## 12. High-Level Sequence
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -260,10 +272,10 @@ sequenceDiagram
|
|||||||
A->>A: Compress: contain resize → grayscale → PNG → AVIF
|
A->>A: Compress: contain resize → grayscale → PNG → AVIF
|
||||||
A->>A: Fragment into ≤152B packets
|
A->>A: Fragment into ≤152B packets
|
||||||
A->>A: Cache outgoing + populate local session (immediate display)
|
A->>A: Cache outgoing + populate local session (immediate display)
|
||||||
A->>M: Send IE1 envelope (actual w×h, fragment count)
|
A->>M: Send IE2 envelope (actual w×h, fragment count)
|
||||||
M->>B: Deliver IE1
|
M->>B: Deliver IE2
|
||||||
B->>B: Render grey placeholder bubble
|
B->>B: Render grey placeholder bubble
|
||||||
B->>A: Tap → send IR1 fetch request
|
B->>A: Tap → send binary fetch request
|
||||||
A->>B: Stream binary ImagePackets
|
A->>B: Stream binary ImagePackets
|
||||||
B->>B: Reassemble fragments
|
B->>B: Reassemble fragments
|
||||||
B->>B: Display AVIF image (cover thumbnail)
|
B->>B: Display AVIF image (cover thumbnail)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
Voice mode uses a **two-plane architecture**:
|
Voice mode uses a **two-plane architecture**:
|
||||||
|
|
||||||
- **Control plane (text messages):**
|
- **Control plane (text messages):**
|
||||||
- `VE1:` voice envelope announces voice availability in chat.
|
- `VE2:` voice envelope announces voice availability in chat.
|
||||||
- `VR1:` direct fetch request asks sender to stream voice payload.
|
- **Control plane (raw binary request):**
|
||||||
|
- Binary voice fetch request (same raw route as voice packets).
|
||||||
- **Data plane (raw binary packets):**
|
- **Data plane (raw binary packets):**
|
||||||
- `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`.
|
- `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`.
|
||||||
|
|
||||||
@@ -16,73 +17,58 @@ This design avoids broadcasting full voice payloads to channels/rooms. Chat carr
|
|||||||
|
|
||||||
- `lib/utils/voice_message_parser.dart`
|
- `lib/utils/voice_message_parser.dart`
|
||||||
- `VoicePacket` (legacy text + binary packet format)
|
- `VoicePacket` (legacy text + binary packet format)
|
||||||
- `VoiceEnvelope` (`VE1`)
|
- `VoiceEnvelope` (`VE2`)
|
||||||
- `VoiceFetchRequest` (`VR1`)
|
- `VoiceFetchRequest` (binary)
|
||||||
- `lib/screens/messages_tab.dart`
|
- `lib/screens/messages_tab.dart`
|
||||||
- Capture/encode voice, cache encoded packets, send envelope only
|
- Capture/encode voice, cache encoded packets, send envelope only
|
||||||
- `lib/providers/voice_provider.dart`
|
- `lib/providers/voice_provider.dart`
|
||||||
- Reassembly/playback sessions
|
- Reassembly/playback sessions
|
||||||
- Outgoing session cache + deferred serving
|
- Outgoing session cache + deferred serving
|
||||||
- `lib/providers/app_provider.dart`
|
- `lib/providers/app_provider.dart`
|
||||||
- Incoming routing for `VE1` and `VR1`
|
- Incoming routing for `VE2` and binary voice fetch requests
|
||||||
- Handles raw packet ingestion
|
- Handles raw packet ingestion
|
||||||
- `lib/widgets/messages/voice_message_bubble.dart`
|
- `lib/widgets/messages/voice_message_bubble.dart`
|
||||||
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
||||||
- `lib/providers/messages_provider.dart`
|
- `lib/providers/messages_provider.dart`
|
||||||
- Message-level voice detection (`VE1` + legacy `V:`)
|
- Message-level voice detection (`VE2` + legacy `V:`)
|
||||||
- `lib/services/message_storage_service.dart`
|
- `lib/services/message_storage_service.dart`
|
||||||
- Persists `isVoice` and `voiceId`
|
- Persists `isVoice` and `voiceId`
|
||||||
|
|
||||||
## 3. Wire Formats
|
## 3. Wire Formats
|
||||||
|
|
||||||
### 3.1 Voice Envelope (`VE1`)
|
### 3.1 Voice Envelope (`VE2`)
|
||||||
|
|
||||||
Prefix: `VE1:` + colon-delimited compact payload
|
Prefix: `VE2:` + colon-delimited compact payload (base36 numeric fields)
|
||||||
|
|
||||||
Fields:
|
Fields:
|
||||||
|
|
||||||
- `sid` (string, 8 hex chars): session ID
|
- `sid` (string): base36 token for 32-bit session ID
|
||||||
- `mode` (int): codec mode ID (`VoicePacketMode.id`)
|
- `mode` (base36): codec mode ID (`VoicePacketMode.id`)
|
||||||
- `total` (int): packet count (1..255)
|
- `total` (base36): packet count (1..255)
|
||||||
- `durMs` (int): estimated duration in ms
|
- `durS` (base36): estimated duration in seconds
|
||||||
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
|
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
|
||||||
- `ts` (int): unix timestamp seconds
|
- `ts` (base36): unix timestamp seconds
|
||||||
- `ver` (int): protocol version (currently `1`)
|
|
||||||
|
`sid` is base36 on wire and expands to 8-hex internally.
|
||||||
|
|
||||||
Compact format:
|
Compact format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
|
VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1
|
VE2:a:1:4:4:aabbccddeeff:s44we8
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 Voice Fetch Request (`VR1`)
|
### 3.2 Voice Fetch Request (binary)
|
||||||
|
|
||||||
Prefix: `VR1:` + colon-delimited compact payload
|
Binary payload format:
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- `sid` (string, 8 hex chars): requested session
|
|
||||||
- `want` (string): currently `a` (compact token for `all`)
|
|
||||||
- `requesterKey6` (string, 12 hex chars): requester key prefix
|
|
||||||
- `ts` (int): unix timestamp seconds
|
|
||||||
- `ver` (int): protocol version (`1`)
|
|
||||||
|
|
||||||
Compact format:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
VR1:deadbeef:a:112233445566:1700000010:1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 Raw Voice Packet (data plane)
|
### 3.3 Raw Voice Packet (data plane)
|
||||||
@@ -102,18 +88,18 @@ Binary payload structure:
|
|||||||
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
||||||
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
||||||
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
|
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
|
||||||
5. Sender sends one envelope (`VE1`) through normal message path:
|
5. Sender sends one envelope (`VE2`) through normal message path:
|
||||||
- channel/room: `sendChannelMessage`
|
- channel/room: `sendChannelMessage`
|
||||||
- direct: `sendTextMessage`
|
- direct: `sendTextMessage`
|
||||||
6. **No raw audio packets are sent during initial send.**
|
6. **No raw audio packets are sent during initial send.**
|
||||||
|
|
||||||
## 5. Incoming Routing
|
## 5. Incoming Routing
|
||||||
|
|
||||||
### 5.1 `VE1` envelope received
|
### 5.1 `VE2` envelope received
|
||||||
|
|
||||||
`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat.
|
`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat.
|
||||||
|
|
||||||
### 5.2 `VR1` request received
|
### 5.2 Binary voice fetch request received
|
||||||
|
|
||||||
`AppProvider` treats it as control-plane only:
|
`AppProvider` treats it as control-plane only:
|
||||||
|
|
||||||
@@ -132,8 +118,8 @@ In `VoiceMessageBubble`:
|
|||||||
|
|
||||||
- If session already complete: play immediately.
|
- If session already complete: play immediately.
|
||||||
- If incomplete/missing:
|
- If incomplete/missing:
|
||||||
1. Resolve sender contact (message sender prefix or `VE1.senderKey6` fallback)
|
1. Resolve sender contact (message sender prefix or `VE2.senderKey6` fallback)
|
||||||
2. Send direct `VR1` fetch request
|
2. Send direct binary fetch request
|
||||||
3. Show requesting state in UI
|
3. Show requesting state in UI
|
||||||
4. Auto-play when session becomes complete
|
4. Auto-play when session becomes complete
|
||||||
|
|
||||||
@@ -170,10 +156,9 @@ Parser validation enforces:
|
|||||||
- strict hex lengths for IDs and key prefixes
|
- strict hex lengths for IDs and key prefixes
|
||||||
- valid mode range
|
- valid mode range
|
||||||
- valid packet counts and duration bounds
|
- valid packet counts and duration bounds
|
||||||
- fixed protocol version (`ver == 1`)
|
- compact base36 numeric fields in envelope/request
|
||||||
- `VR1.want` token `a` (internally normalized to `all`)
|
- Binary request flags specify `all` or `missing` indices.
|
||||||
|
- Request payload includes `requesterKey6` to resolve return route.
|
||||||
`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk.
|
|
||||||
|
|
||||||
## 10. Transmit Time Estimate (UI)
|
## 10. Transmit Time Estimate (UI)
|
||||||
|
|
||||||
@@ -182,7 +167,8 @@ Voice bubbles and Message Technical Details show an **estimated transmit time**
|
|||||||
The estimate is airtime-based (LoRa packet model), not file-duration-only:
|
The estimate is airtime-based (LoRa packet model), not file-duration-only:
|
||||||
|
|
||||||
- Source inputs:
|
- Source inputs:
|
||||||
- `packetCount` and `durationMs` from `VE1` envelope, or
|
- `packetCount` and `durationMs` from `VE2` envelope, or
|
||||||
|
- numeric envelope values decoded from base36
|
||||||
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
||||||
- `pathLen` from message metadata
|
- `pathLen` from message metadata
|
||||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||||
@@ -210,10 +196,20 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
|||||||
- Raw return path needs a currently valid direct route to requester.
|
- Raw return path needs a currently valid direct route to requester.
|
||||||
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
|
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
|
||||||
|
|
||||||
|
### 11.1 Raw Binary Routing Semantics
|
||||||
|
|
||||||
|
- Voice payload packets use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
|
||||||
|
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
|
||||||
|
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
|
||||||
|
- This flow is direct-route only, not flood/broadcast:
|
||||||
|
- it is sent to one destination path;
|
||||||
|
- only nodes on that path relay it;
|
||||||
|
- it is **not** received by everyone in the mesh.
|
||||||
|
|
||||||
## 12. Backward Compatibility
|
## 12. Backward Compatibility
|
||||||
|
|
||||||
- Legacy `V:` text packet parsing is still supported.
|
- Legacy `V:` text packet parsing is still supported.
|
||||||
- Message voice detection accepts both new `VE1` and legacy `V:` formats.
|
- Message voice detection accepts `VE2` and legacy `V:` formats.
|
||||||
|
|
||||||
## 13. High-Level Sequence
|
## 13. High-Level Sequence
|
||||||
|
|
||||||
@@ -225,10 +221,10 @@ sequenceDiagram
|
|||||||
|
|
||||||
A->>A: Record + encode voice packets
|
A->>A: Record + encode voice packets
|
||||||
A->>A: Cache session packets (TTL 15m)
|
A->>A: Cache session packets (TTL 15m)
|
||||||
A->>M: Send VE1 envelope
|
A->>M: Send VE2 envelope
|
||||||
M->>B: Deliver VE1
|
M->>B: Deliver VE2
|
||||||
B->>B: Render voice bubble (metadata only)
|
B->>B: Render voice bubble (metadata only)
|
||||||
B->>A: Send VR1 request on Play
|
B->>A: Send binary fetch request on Play
|
||||||
A->>B: Stream raw VoicePacket packets
|
A->>B: Stream raw VoicePacket packets
|
||||||
B->>B: Reassemble session
|
B->>B: Reassemble session
|
||||||
B->>B: Auto-play when complete
|
B->>B: Auto-play when complete
|
||||||
|
|||||||
@@ -489,7 +489,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 74;
|
CURRENT_PROJECT_VERSION = 84;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 74;
|
CURRENT_PROJECT_VERSION = 84;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 74;
|
CURRENT_PROJECT_VERSION = 84;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 74;
|
CURRENT_PROJECT_VERSION = 84;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 74;
|
CURRENT_PROJECT_VERSION = 84;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 74;
|
CURRENT_PROJECT_VERSION = 84;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>74</string>
|
<string>84</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000195">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.405396">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.408783">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="101.387002">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="120.492323">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="209.873722">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="12.544865">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'drawing_provider.dart';
|
|||||||
import 'channels_provider.dart';
|
import 'channels_provider.dart';
|
||||||
import 'voice_provider.dart';
|
import 'voice_provider.dart';
|
||||||
import 'image_provider.dart' as ip;
|
import 'image_provider.dart' as ip;
|
||||||
|
import 'helpers/fragment_ack_wait_registry.dart';
|
||||||
|
import 'helpers/session_metadata_restore.dart';
|
||||||
import '../services/tile_cache_service.dart';
|
import '../services/tile_cache_service.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/packet_capture_storage_service.dart';
|
import '../services/packet_capture_storage_service.dart';
|
||||||
@@ -42,6 +44,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool get isMapEnabled => _isMapEnabled;
|
bool get isMapEnabled => _isMapEnabled;
|
||||||
|
bool _isContactsEnabled = true;
|
||||||
|
bool get isContactsEnabled => _isContactsEnabled;
|
||||||
|
|
||||||
bool _isVoiceSilenceTrimmingEnabled = true;
|
bool _isVoiceSilenceTrimmingEnabled = true;
|
||||||
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
|
||||||
@@ -57,8 +61,13 @@ class AppProvider with ChangeNotifier {
|
|||||||
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
||||||
static const int _maxPacketRetryAttempts = 4;
|
static const int _maxPacketRetryAttempts = 4;
|
||||||
final Map<String, String> _voiceSessionSenderKey6 = {};
|
final Map<String, String> _voiceSessionSenderKey6 = {};
|
||||||
|
final Map<String, String> _imageSessionSenderKey6 = {};
|
||||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||||
|
final FragmentAckWaitRegistry _voiceFragmentAckWaiters =
|
||||||
|
FragmentAckWaitRegistry();
|
||||||
|
final FragmentAckWaitRegistry _imageFragmentAckWaiters =
|
||||||
|
FragmentAckWaitRegistry();
|
||||||
Timer? _packetCaptureFlushTimer;
|
Timer? _packetCaptureFlushTimer;
|
||||||
String? _lastPersistedPacketSignature;
|
String? _lastPersistedPacketSignature;
|
||||||
bool _isPersistingPacketCapture = false;
|
bool _isPersistingPacketCapture = false;
|
||||||
@@ -78,6 +87,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
_initializeLocationTracking();
|
_initializeLocationTracking();
|
||||||
_loadSimpleMode();
|
_loadSimpleMode();
|
||||||
_loadMapEnabled();
|
_loadMapEnabled();
|
||||||
|
_loadContactsEnabled();
|
||||||
_loadVoiceSilenceTrimmingEnabled();
|
_loadVoiceSilenceTrimmingEnabled();
|
||||||
_loadVoiceBandPassFilterEnabled();
|
_loadVoiceBandPassFilterEnabled();
|
||||||
_loadVoiceCompressorEnabled();
|
_loadVoiceCompressorEnabled();
|
||||||
@@ -159,12 +169,35 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Give DrawingProvider a moment to finish loading too
|
// Give DrawingProvider a moment to finish loading too
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
_restoreSessionMetadataFromMessages();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
|
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
|
||||||
);
|
);
|
||||||
messagesProvider.syncDrawingsWithProvider(drawingProvider);
|
messagesProvider.syncDrawingsWithProvider(drawingProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _restoreSessionMetadataFromMessages() {
|
||||||
|
final restored = restoreSessionMetadataFromMessages(
|
||||||
|
messagesProvider.messages.map((message) => message.text),
|
||||||
|
);
|
||||||
|
|
||||||
|
_voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession);
|
||||||
|
for (final entry in restored.imageEnvelopeBySession.entries) {
|
||||||
|
_imageSessionSenderKey6[entry.key] = entry.value.senderKey6.toLowerCase();
|
||||||
|
imageProvider.registerEnvelope(entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
final restoredVoice = restored.voiceSenderKeyBySession.length;
|
||||||
|
final restoredImage = restored.imageEnvelopeBySession.length;
|
||||||
|
if (restoredVoice > 0 || restoredImage > 0) {
|
||||||
|
debugPrint(
|
||||||
|
'🔄 [AppProvider] Restored session metadata from messages: '
|
||||||
|
'$restoredVoice voice, $restoredImage image',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load simple mode setting from shared preferences
|
/// Load simple mode setting from shared preferences
|
||||||
Future<void> _loadSimpleMode() async {
|
Future<void> _loadSimpleMode() async {
|
||||||
try {
|
try {
|
||||||
@@ -211,6 +244,29 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load contacts enabled setting from shared preferences
|
||||||
|
Future<void> _loadContactsEnabled() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_isContactsEnabled = prefs.getBool('contacts_enabled') ?? true;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading contacts enabled setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle contacts tab on/off
|
||||||
|
Future<void> toggleContactsEnabled(bool enabled) async {
|
||||||
|
try {
|
||||||
|
_isContactsEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('contacts_enabled', enabled);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving contacts enabled setting: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load voice silence trimming setting from shared preferences.
|
/// Load voice silence trimming setting from shared preferences.
|
||||||
Future<void> _loadVoiceSilenceTrimmingEnabled() async {
|
Future<void> _loadVoiceSilenceTrimmingEnabled() async {
|
||||||
try {
|
try {
|
||||||
@@ -404,6 +460,26 @@ class AppProvider with ChangeNotifier {
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
voiceProvider.waitForFragmentAckCallback =
|
||||||
|
({
|
||||||
|
required sessionId,
|
||||||
|
required index,
|
||||||
|
timeout = const Duration(seconds: 8),
|
||||||
|
}) => _waitForVoiceFragmentAck(
|
||||||
|
sessionId: sessionId,
|
||||||
|
index: index,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
imageProvider.waitForFragmentAckCallback =
|
||||||
|
({
|
||||||
|
required sessionId,
|
||||||
|
required index,
|
||||||
|
timeout = const Duration(seconds: 8),
|
||||||
|
}) => _waitForImageFragmentAck(
|
||||||
|
sessionId: sessionId,
|
||||||
|
index: index,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
|
||||||
// When a contact is received from BLE
|
// When a contact is received from BLE
|
||||||
connectionProvider.onContactReceived = (contact) {
|
connectionProvider.onContactReceived = (contact) {
|
||||||
@@ -540,62 +616,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice control plane: request sender to stream raw voice packets.
|
|
||||||
final voiceFetchRequest = VoiceFetchRequest.tryParseText(
|
|
||||||
enrichedMessage.text,
|
|
||||||
);
|
|
||||||
if (voiceFetchRequest != null) {
|
|
||||||
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
|
|
||||||
if (senderPrefix == null) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AppProvider] Voice fetch request without sender prefix',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final senderPrefixHex = senderPrefix
|
|
||||||
.take(6)
|
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
||||||
.join('');
|
|
||||||
if (senderPrefixHex.toLowerCase() !=
|
|
||||||
voiceFetchRequest.requesterKey6.toLowerCase()) {
|
|
||||||
debugPrint('⚠️ [AppProvider] Voice fetch requester key mismatch');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final requester = contactsProvider.findContactByPrefix(senderPrefix);
|
|
||||||
if (requester == null) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AppProvider] Voice fetch requester contact not found',
|
|
||||||
);
|
|
||||||
messagesProvider.logSystemMessage(
|
|
||||||
text:
|
|
||||||
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
|
|
||||||
level: 'warning',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (requester.outPathLen > _maxDirectPayloadHops) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
|
|
||||||
);
|
|
||||||
messagesProvider.logSystemMessage(
|
|
||||||
text:
|
|
||||||
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
|
||||||
level: 'warning',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unawaited(
|
|
||||||
voiceProvider.serveSessionTo(
|
|
||||||
sessionId: voiceFetchRequest.sessionId,
|
|
||||||
requester: requester,
|
|
||||||
requestedIndices: voiceFetchRequest.want == 'missing'
|
|
||||||
? voiceFetchRequest.missingIndices.toSet()
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if message is a drawing broadcast
|
// Check if message is a drawing broadcast
|
||||||
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
||||||
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
|
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
|
||||||
@@ -665,61 +685,12 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image fetch request (IR1): requester asks us to stream image fragments.
|
|
||||||
final imageFetchRequest = ImageFetchRequest.tryParse(
|
|
||||||
enrichedMessage.text,
|
|
||||||
);
|
|
||||||
if (imageFetchRequest != null) {
|
|
||||||
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
|
|
||||||
if (senderPrefix != null) {
|
|
||||||
final senderPrefixHex = senderPrefix
|
|
||||||
.take(6)
|
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
||||||
.join('');
|
|
||||||
if (senderPrefixHex.toLowerCase() ==
|
|
||||||
imageFetchRequest.requesterKey6.toLowerCase()) {
|
|
||||||
final requester = contactsProvider.findContactByPrefix(
|
|
||||||
senderPrefix,
|
|
||||||
);
|
|
||||||
if (requester != null) {
|
|
||||||
if (requester.outPathLen > _maxDirectPayloadHops) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
|
|
||||||
);
|
|
||||||
messagesProvider.logSystemMessage(
|
|
||||||
text:
|
|
||||||
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
|
||||||
level: 'warning',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unawaited(
|
|
||||||
imageProvider.serveSessionTo(
|
|
||||||
sessionId: imageFetchRequest.sessionId,
|
|
||||||
requester: requester,
|
|
||||||
requestedIndices: imageFetchRequest.want == 'missing'
|
|
||||||
? imageFetchRequest.missingIndices.toSet()
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [AppProvider] Image fetch requester contact not found',
|
|
||||||
);
|
|
||||||
messagesProvider.logSystemMessage(
|
|
||||||
text:
|
|
||||||
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
|
|
||||||
level: 'warning',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return; // IR1 is control-plane only; not displayed in chat
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image envelope (IE1): announce image availability.
|
// Image envelope (IE1): announce image availability.
|
||||||
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
||||||
if (imageEnvelope != null) {
|
if (imageEnvelope != null) {
|
||||||
|
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope
|
||||||
|
.senderKey6
|
||||||
|
.toLowerCase();
|
||||||
imageProvider.registerEnvelope(imageEnvelope);
|
imageProvider.registerEnvelope(imageEnvelope);
|
||||||
messagesProvider.addMessage(
|
messagesProvider.addMessage(
|
||||||
enrichedMessage,
|
enrichedMessage,
|
||||||
@@ -800,8 +771,105 @@ class AppProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
|
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
|
||||||
|
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
|
||||||
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
|
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
|
||||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
||||||
|
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
|
||||||
|
if (voiceFetchRequest != null) {
|
||||||
|
final requester = contactsProvider.findContactByPrefixHex(
|
||||||
|
voiceFetchRequest.requesterKey6,
|
||||||
|
);
|
||||||
|
if (requester == null) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Voice fetch requester contact not found (binary)',
|
||||||
|
);
|
||||||
|
messagesProvider.logSystemMessage(
|
||||||
|
text:
|
||||||
|
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
|
||||||
|
level: 'warning',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requester.outPathLen > _maxDirectPayloadHops) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
|
||||||
|
);
|
||||||
|
messagesProvider.logSystemMessage(
|
||||||
|
text:
|
||||||
|
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
||||||
|
level: 'warning',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(
|
||||||
|
voiceProvider.serveSessionTo(
|
||||||
|
sessionId: voiceFetchRequest.sessionId,
|
||||||
|
requester: requester,
|
||||||
|
requestedIndices: voiceFetchRequest.want == 'missing'
|
||||||
|
? voiceFetchRequest.missingIndices.toSet()
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload);
|
||||||
|
if (imageFetchRequest != null) {
|
||||||
|
final requester = _resolveImageFetchRequester(imageFetchRequest);
|
||||||
|
if (requester == null) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [AppProvider] Image fetch requester contact not found (binary) '
|
||||||
|
'for session ${imageFetchRequest.sessionId} / '
|
||||||
|
'${imageFetchRequest.requesterKey6}',
|
||||||
|
);
|
||||||
|
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 for session '
|
||||||
|
'${imageFetchRequest.sessionId}',
|
||||||
|
);
|
||||||
|
messagesProvider.logSystemMessage(
|
||||||
|
text:
|
||||||
|
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
||||||
|
level: 'warning',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} '
|
||||||
|
'to ${requester.advName} via ${requester.outPathLen} hop(s)',
|
||||||
|
);
|
||||||
|
unawaited(
|
||||||
|
imageProvider.serveSessionTo(
|
||||||
|
sessionId: imageFetchRequest.sessionId,
|
||||||
|
requester: requester,
|
||||||
|
requestedIndices: imageFetchRequest.want == 'missing'
|
||||||
|
? imageFetchRequest.missingIndices.toSet()
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final voiceAck = VoiceFragmentAck.tryParseBinary(payload);
|
||||||
|
if (voiceAck != null) {
|
||||||
|
_completeVoiceFragmentAck(voiceAck.sessionId, voiceAck.index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageAck = ImageFragmentAck.tryParseBinary(payload);
|
||||||
|
if (imageAck != null) {
|
||||||
|
_completeImageFragmentAck(imageAck.sessionId, imageAck.index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ImagePacket.isImageBinary(payload)) {
|
if (ImagePacket.isImageBinary(payload)) {
|
||||||
final frag = ImagePacket.tryParseBinary(payload);
|
final frag = ImagePacket.tryParseBinary(payload);
|
||||||
if (frag == null) return;
|
if (frag == null) return;
|
||||||
@@ -812,6 +880,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
width: session?.width ?? 0,
|
width: session?.width ?? 0,
|
||||||
height: session?.height ?? 0,
|
height: session?.height ?? 0,
|
||||||
);
|
);
|
||||||
|
_sendImageFragmentAck(frag);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +889,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (pkt == null) return;
|
if (pkt == null) return;
|
||||||
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
||||||
final justComplete = voiceProvider.addPacket(pkt);
|
final justComplete = voiceProvider.addPacket(pkt);
|
||||||
|
_sendVoiceFragmentAck(pkt);
|
||||||
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
||||||
// Insert or update the placeholder message in the chat list
|
// Insert or update the placeholder message in the chat list
|
||||||
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
||||||
@@ -1152,6 +1222,42 @@ class AppProvider with ChangeNotifier {
|
|||||||
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
|
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Contact? _resolveImageFetchRequester(ImageFetchRequest request) {
|
||||||
|
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
|
||||||
|
if (liveContact != null) {
|
||||||
|
return liveContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final message in messagesProvider.messages.reversed) {
|
||||||
|
final envelope = ImageEnvelope.tryParse(message.text);
|
||||||
|
if (envelope == null || envelope.sessionId != request.sessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final recipientKey = message.recipientPublicKey;
|
||||||
|
if (recipientKey == null || recipientKey.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final recipient = contactsProvider.findContactByKey(recipientKey);
|
||||||
|
if (recipient == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final recipientKey6 = recipient.publicKey
|
||||||
|
.sublist(0, 6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
if (recipientKey6 != request.requesterKey6) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'📷 [AppProvider] Resolved image requester from sent message metadata '
|
||||||
|
'for session ${request.sessionId}: ${recipient.advName}',
|
||||||
|
);
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _scheduleVoiceMissingRetry(
|
void _scheduleVoiceMissingRetry(
|
||||||
String sessionId, {
|
String sessionId, {
|
||||||
required bool justComplete,
|
required bool justComplete,
|
||||||
@@ -1209,15 +1315,18 @@ class AppProvider with ChangeNotifier {
|
|||||||
missingIndices: missing,
|
missingIndices: missing,
|
||||||
requesterKey6: requesterKey6,
|
requesterKey6: requesterKey6,
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
version: 1,
|
version: 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
final sent = await connectionProvider.sendTextMessage(
|
try {
|
||||||
contactPublicKey: sender.publicKey,
|
await connectionProvider.sendRawVoicePacket(
|
||||||
text: request.encodeText(),
|
contactPath: sender.outPath,
|
||||||
contact: sender,
|
contactPathLen: sender.outPathLen,
|
||||||
|
payload: request.encodeBinary(),
|
||||||
);
|
);
|
||||||
if (!sent) return;
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_voiceMissingRetryAttempts[sessionId] = attempt + 1;
|
_voiceMissingRetryAttempts[sessionId] = attempt + 1;
|
||||||
_voiceMissingRetryTimers[sessionId]?.cancel();
|
_voiceMissingRetryTimers[sessionId]?.cancel();
|
||||||
@@ -1266,6 +1375,96 @@ class AppProvider with ChangeNotifier {
|
|||||||
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
|
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
|
||||||
|
|
||||||
|
Future<bool> _waitForVoiceFragmentAck({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout = const Duration(seconds: 8),
|
||||||
|
}) => _voiceFragmentAckWaiters.waitFor(
|
||||||
|
_fragmentAckKey(sessionId, index),
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
|
||||||
|
void _completeVoiceFragmentAck(String sessionId, int index) {
|
||||||
|
final completed = _voiceFragmentAckWaiters.complete(
|
||||||
|
_fragmentAckKey(sessionId, index),
|
||||||
|
);
|
||||||
|
if (completed == 0) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [AppProvider] Voice fragment ACK had no waiter: $sessionId#$index',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'✅ [AppProvider] Voice fragment ACK received for $sessionId#$index ($completed waiter(s))',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _waitForImageFragmentAck({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout = const Duration(seconds: 8),
|
||||||
|
}) => _imageFragmentAckWaiters.waitFor(
|
||||||
|
_fragmentAckKey(sessionId, index),
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
|
||||||
|
void _completeImageFragmentAck(String sessionId, int index) {
|
||||||
|
final completed = _imageFragmentAckWaiters.complete(
|
||||||
|
_fragmentAckKey(sessionId, index),
|
||||||
|
);
|
||||||
|
if (completed == 0) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [AppProvider] Image fragment ACK had no waiter: $sessionId#$index',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'✅ [AppProvider] Image fragment ACK received for $sessionId#$index ($completed waiter(s))',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendVoiceFragmentAck(VoicePacket packet) {
|
||||||
|
final senderKey6 = _voiceSessionSenderKey6[packet.sessionId];
|
||||||
|
if (senderKey6 == null) return;
|
||||||
|
final sender = _resolveContactByPrefixHex(senderKey6);
|
||||||
|
if (sender == null) return;
|
||||||
|
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(
|
||||||
|
connectionProvider.sendRawVoicePacket(
|
||||||
|
contactPath: sender.outPath,
|
||||||
|
contactPathLen: sender.outPathLen,
|
||||||
|
payload: VoiceFragmentAck(
|
||||||
|
sessionId: packet.sessionId,
|
||||||
|
index: packet.index,
|
||||||
|
).encodeBinary(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendImageFragmentAck(ImagePacket fragment) {
|
||||||
|
final senderKey6 = _imageSessionSenderKey6[fragment.sessionId];
|
||||||
|
if (senderKey6 == null) return;
|
||||||
|
final sender = _resolveContactByPrefixHex(senderKey6);
|
||||||
|
if (sender == null) return;
|
||||||
|
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(
|
||||||
|
connectionProvider.sendRawVoicePacket(
|
||||||
|
contactPath: sender.outPath,
|
||||||
|
contactPathLen: sender.outPathLen,
|
||||||
|
payload: ImageFragmentAck(
|
||||||
|
sessionId: fragment.sessionId,
|
||||||
|
index: fragment.index,
|
||||||
|
).encodeBinary(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||||
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
|
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import '../models/device_info.dart';
|
import '../models/device_info.dart';
|
||||||
@@ -117,6 +118,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
bool _noMoreMessages = false;
|
bool _noMoreMessages = false;
|
||||||
// Prevent overlapping/too-frequent sync requests
|
// Prevent overlapping/too-frequent sync requests
|
||||||
bool _isSyncingMessages = false;
|
bool _isSyncingMessages = false;
|
||||||
|
// If MSG_WAITING arrives while a sync loop is active, queue one more pass.
|
||||||
|
bool _syncRequestedWhileBusy = false;
|
||||||
DateTime? _lastSyncNextRequestedAt;
|
DateTime? _lastSyncNextRequestedAt;
|
||||||
static const Duration _minSyncNextInterval = Duration(milliseconds: 150);
|
static const Duration _minSyncNextInterval = Duration(milliseconds: 150);
|
||||||
|
|
||||||
@@ -310,7 +313,14 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
service.onMessageWaiting = () {
|
service.onMessageWaiting = () {
|
||||||
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
||||||
syncAllMessages();
|
if (_isSyncingMessages) {
|
||||||
|
_syncRequestedWhileBusy = true;
|
||||||
|
debugPrint(
|
||||||
|
' ↪️ [Provider] Sync already running; queued follow-up sync',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(syncAllMessages());
|
||||||
};
|
};
|
||||||
|
|
||||||
service.onLoginSuccess =
|
service.onLoginSuccess =
|
||||||
@@ -452,7 +462,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
_isScanning = true;
|
_isScanning = true;
|
||||||
_scannedDevices.clear();
|
_scannedDevices.clear();
|
||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
_notifyListenersSafely();
|
||||||
debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
|
debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -468,7 +478,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}',
|
'✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}',
|
||||||
);
|
);
|
||||||
notifyListeners();
|
_notifyListenersSafely();
|
||||||
} else {
|
} else {
|
||||||
// Update RSSI if device already exists
|
// Update RSSI if device already exists
|
||||||
final index = _scannedDevices.indexWhere(
|
final index = _scannedDevices.indexWhere(
|
||||||
@@ -479,7 +489,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm',
|
' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm',
|
||||||
);
|
);
|
||||||
notifyListeners();
|
_notifyListenersSafely();
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ⏭️ [Provider] Device already in list with same RSSI, skipping',
|
' ⏭️ [Provider] Device already in list with same RSSI, skipping',
|
||||||
@@ -493,7 +503,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
} finally {
|
} finally {
|
||||||
debugPrint('🏁 [Provider] Scan completed');
|
debugPrint('🏁 [Provider] Scan completed');
|
||||||
_isScanning = false;
|
_isScanning = false;
|
||||||
notifyListeners();
|
_notifyListenersSafely();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +511,18 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
Future<void> stopScan() async {
|
Future<void> stopScan() async {
|
||||||
await FlutterBluePlus.stopScan();
|
await FlutterBluePlus.stopScan();
|
||||||
_isScanning = false;
|
_isScanning = false;
|
||||||
|
_notifyListenersSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifyListenersSafely() {
|
||||||
|
final phase = SchedulerBinding.instance.schedulerPhase;
|
||||||
|
if (phase == SchedulerPhase.transientCallbacks ||
|
||||||
|
phase == SchedulerPhase.persistentCallbacks) {
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,7 +580,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final success = await _tcpService!.connect(host, port);
|
final success = await _tcpService!.connect(host, port);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
_deviceInfo = _deviceInfo.copyWith(connectionState: ConnectionState.error);
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
|
connectionState: ConnectionState.error,
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
return success;
|
return success;
|
||||||
@@ -1169,7 +1193,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint(' Text: $text');
|
debugPrint(' Text: $text');
|
||||||
debugPrint(' MessageID: $messageId');
|
debugPrint(' MessageID: $messageId');
|
||||||
|
|
||||||
await _activeService.sendChannelMessage(channelIdx: channelIdx, text: text);
|
await _activeService.sendChannelMessage(
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
text: text,
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint('✅ [ConnectionProvider] BLE send completed');
|
debugPrint('✅ [ConnectionProvider] BLE send completed');
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1655,6 +1682,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
Future<int> syncAllMessages() async {
|
Future<int> syncAllMessages() async {
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
// Already syncing; avoid overlapping loops
|
// Already syncing; avoid overlapping loops
|
||||||
|
_syncRequestedWhileBusy = true;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1664,11 +1692,14 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int count = 0;
|
int totalCount = 0;
|
||||||
_noMoreMessages = false; // Reset flag
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_isSyncingMessages = true;
|
_isSyncingMessages = true;
|
||||||
|
do {
|
||||||
|
_syncRequestedWhileBusy = false;
|
||||||
|
_noMoreMessages = false; // Reset flag per pass
|
||||||
|
int passCount = 0;
|
||||||
debugPrint('🔄 [Provider] Starting message sync loop...');
|
debugPrint('🔄 [Provider] Starting message sync loop...');
|
||||||
debugPrint(' Initial _noMoreMessages state: $_noMoreMessages');
|
debugPrint(' Initial _noMoreMessages state: $_noMoreMessages');
|
||||||
|
|
||||||
@@ -1680,7 +1711,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// Check flag BEFORE sending (not after)
|
// Check flag BEFORE sending (not after)
|
||||||
if (_noMoreMessages) {
|
if (_noMoreMessages) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [Provider] Message sync complete - NoMoreMessages flag set after $count requests',
|
'✅ [Provider] Message sync complete - NoMoreMessages flag set after $passCount requests',
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1704,7 +1735,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
await _activeService.syncNextMessage();
|
await _activeService.syncNextMessage();
|
||||||
_lastSyncNextRequestedAt = DateTime.now();
|
_lastSyncNextRequestedAt = DateTime.now();
|
||||||
count++;
|
passCount++;
|
||||||
|
totalCount++;
|
||||||
|
|
||||||
// Wait for response (true = message received, false = no more messages)
|
// Wait for response (true = message received, false = no more messages)
|
||||||
// Timeout after 2 seconds to prevent hanging
|
// Timeout after 2 seconds to prevent hanging
|
||||||
@@ -1726,21 +1758,28 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_noMoreMessages && count >= 100) {
|
if (!_noMoreMessages && passCount >= 100) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages',
|
'⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_syncRequestedWhileBusy) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🏁 [Provider] Message sync finished: sent $count sync requests, _noMoreMessages=$_noMoreMessages',
|
'↻ [Provider] MSG_WAITING received during sync; running another pass',
|
||||||
);
|
);
|
||||||
return count;
|
}
|
||||||
|
} while (_syncRequestedWhileBusy && _activeService.isConnected);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'🏁 [Provider] Message sync finished: sent $totalCount sync requests, _noMoreMessages=$_noMoreMessages',
|
||||||
|
);
|
||||||
|
return totalCount;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [Provider] Failed to sync messages: $e');
|
debugPrint('❌ [Provider] Failed to sync messages: $e');
|
||||||
_error = 'Failed to sync messages: $e';
|
_error = 'Failed to sync messages: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return count;
|
return totalCount;
|
||||||
} finally {
|
} finally {
|
||||||
_isSyncingMessages = false;
|
_isSyncingMessages = false;
|
||||||
_syncResponseCompleter = null;
|
_syncResponseCompleter = null;
|
||||||
|
|||||||
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// Tracks one or more in-flight waiters for the same fragment ACK key.
|
||||||
|
///
|
||||||
|
/// Duplicate fetch requests can race and wait on the same fragment ACK at once.
|
||||||
|
/// Completing all registered waiters avoids losing the earlier completer when a
|
||||||
|
/// later request registers for the same key.
|
||||||
|
class FragmentAckWaitRegistry {
|
||||||
|
final Map<String, List<Completer<void>>> _waiters = {};
|
||||||
|
|
||||||
|
Future<bool> waitFor(
|
||||||
|
String key, {
|
||||||
|
Duration timeout = const Duration(seconds: 8),
|
||||||
|
}) async {
|
||||||
|
final completer = Completer<void>();
|
||||||
|
final waiters = _waiters.putIfAbsent(key, () => <Completer<void>>[]);
|
||||||
|
waiters.add(completer);
|
||||||
|
try {
|
||||||
|
await completer.future.timeout(timeout);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
final pending = _waiters[key];
|
||||||
|
pending?.remove(completer);
|
||||||
|
if (pending != null && pending.isEmpty) {
|
||||||
|
_waiters.remove(key);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int complete(String key) {
|
||||||
|
final waiters = _waiters.remove(key);
|
||||||
|
if (waiters == null || waiters.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (final completer in waiters) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return waiters.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
lib/providers/helpers/raw_session_retransmit.dart
Normal file
102
lib/providers/helpers/raw_session_retransmit.dart
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../models/contact.dart';
|
||||||
|
|
||||||
|
typedef RawPacketSender =
|
||||||
|
Future<void> Function({
|
||||||
|
required Uint8List contactPath,
|
||||||
|
required int contactPathLen,
|
||||||
|
required Uint8List payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
typedef FragmentAckWaiter =
|
||||||
|
Future<bool> Function({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<bool> serveCachedSessionFragments<T>({
|
||||||
|
required String providerLabel,
|
||||||
|
required String sessionId,
|
||||||
|
required Contact requester,
|
||||||
|
required List<T> fragments,
|
||||||
|
required int maxDirectPayloadHops,
|
||||||
|
required int Function(T fragment) indexOf,
|
||||||
|
required Uint8List Function(T fragment) encodeBinary,
|
||||||
|
required RawPacketSender? sendRawPacket,
|
||||||
|
FragmentAckWaiter? waitForFragmentAck,
|
||||||
|
Set<int>? requestedIndices,
|
||||||
|
Duration ackTimeout = const Duration(seconds: 8),
|
||||||
|
}) async {
|
||||||
|
if (fragments.isEmpty) {
|
||||||
|
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (sendRawPacket == null) {
|
||||||
|
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requester.outPathLen < 0) {
|
||||||
|
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requester.outPathLen > maxDirectPayloadHops) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requester.outPath.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var servedCount = 0;
|
||||||
|
for (final fragment in fragments) {
|
||||||
|
final index = indexOf(fragment);
|
||||||
|
if (index < 0) {
|
||||||
|
debugPrint('⚠️ [$providerLabel] Invalid fragment index $index');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (requestedIndices != null && !requestedIndices.contains(index)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final ackFuture = waitForFragmentAck?.call(
|
||||||
|
sessionId: sessionId,
|
||||||
|
index: index,
|
||||||
|
timeout: ackTimeout,
|
||||||
|
);
|
||||||
|
await sendRawPacket(
|
||||||
|
contactPath: requester.outPath,
|
||||||
|
contactPathLen: requester.outPathLen,
|
||||||
|
payload: encodeBinary(fragment),
|
||||||
|
);
|
||||||
|
servedCount++;
|
||||||
|
if (ackFuture != null) {
|
||||||
|
final acked = await ackFuture;
|
||||||
|
if (!acked) {
|
||||||
|
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servedCount == 0) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [$providerLabel] No fragments matched request for $sessionId',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
debugPrint('✅ [$providerLabel] Served $servedCount fragments for $sessionId');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
38
lib/providers/helpers/session_metadata_restore.dart
Normal file
38
lib/providers/helpers/session_metadata_restore.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import '../../utils/image_message_parser.dart';
|
||||||
|
import '../../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
class RestoredSessionMetadata {
|
||||||
|
final Map<String, String> voiceSenderKeyBySession;
|
||||||
|
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
||||||
|
|
||||||
|
const RestoredSessionMetadata({
|
||||||
|
required this.voiceSenderKeyBySession,
|
||||||
|
required this.imageEnvelopeBySession,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
||||||
|
Iterable<String> messageTexts,
|
||||||
|
) {
|
||||||
|
final voiceSenderKeyBySession = <String, String>{};
|
||||||
|
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
||||||
|
|
||||||
|
for (final text in messageTexts) {
|
||||||
|
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
||||||
|
if (voiceEnvelope != null) {
|
||||||
|
voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope
|
||||||
|
.senderKey6
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageEnvelope = ImageEnvelope.tryParse(text);
|
||||||
|
if (imageEnvelope != null) {
|
||||||
|
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RestoredSessionMetadata(
|
||||||
|
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
||||||
|
imageEnvelopeBySession: imageEnvelopeBySession,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import 'helpers/raw_session_retransmit.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
|
|
||||||
/// Reassembly state for one incoming image session.
|
/// Reassembly state for one incoming image session.
|
||||||
@@ -13,6 +14,8 @@ class ImageSession {
|
|||||||
final int width;
|
final int width;
|
||||||
final int height;
|
final int height;
|
||||||
final List<ImagePacket?> fragments; // indexed by fragment.index
|
final List<ImagePacket?> fragments; // indexed by fragment.index
|
||||||
|
DateTime? firstFragmentAt;
|
||||||
|
DateTime? lastFragmentAt;
|
||||||
|
|
||||||
ImageSession({
|
ImageSession({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
@@ -25,6 +28,21 @@ class ImageSession {
|
|||||||
int get receivedCount => fragments.where((f) => f != null).length;
|
int get receivedCount => fragments.where((f) => f != null).length;
|
||||||
bool get isComplete => receivedCount == total;
|
bool get isComplete => receivedCount == total;
|
||||||
|
|
||||||
|
Duration? estimateRemaining() {
|
||||||
|
if (isComplete) return Duration.zero;
|
||||||
|
if (firstFragmentAt == null || lastFragmentAt == null) return null;
|
||||||
|
if (receivedCount < 2) return null;
|
||||||
|
|
||||||
|
final elapsedMs = lastFragmentAt!
|
||||||
|
.difference(firstFragmentAt!)
|
||||||
|
.inMilliseconds;
|
||||||
|
if (elapsedMs <= 0) return null;
|
||||||
|
final avgMsPerFragment = elapsedMs / (receivedCount - 1);
|
||||||
|
final remaining = total - receivedCount;
|
||||||
|
if (remaining <= 0) return Duration.zero;
|
||||||
|
return Duration(milliseconds: (avgMsPerFragment * remaining).round());
|
||||||
|
}
|
||||||
|
|
||||||
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
||||||
Uint8List? get imageBytes => reassembleImage(fragments);
|
Uint8List? get imageBytes => reassembleImage(fragments);
|
||||||
}
|
}
|
||||||
@@ -40,6 +58,7 @@ class ImageProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Incoming sessions keyed by sessionId.
|
/// Incoming sessions keyed by sessionId.
|
||||||
final Map<String, ImageSession> _sessions = {};
|
final Map<String, ImageSession> _sessions = {};
|
||||||
|
final Set<String> _ignoredIncomingSessions = {};
|
||||||
|
|
||||||
/// Outgoing sessions cached for deferred serving.
|
/// Outgoing sessions cached for deferred serving.
|
||||||
final Map<String, _OutgoingSession> _outgoing = {};
|
final Map<String, _OutgoingSession> _outgoing = {};
|
||||||
@@ -51,6 +70,12 @@ class ImageProvider with ChangeNotifier {
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
})?
|
})?
|
||||||
sendRawPacketCallback;
|
sendRawPacketCallback;
|
||||||
|
Future<bool> Function({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout,
|
||||||
|
})?
|
||||||
|
waitForFragmentAckCallback;
|
||||||
|
|
||||||
ImageProvider() {
|
ImageProvider() {
|
||||||
_restore();
|
_restore();
|
||||||
@@ -62,6 +87,10 @@ class ImageProvider with ChangeNotifier {
|
|||||||
bool isComplete(String sessionId) =>
|
bool isComplete(String sessionId) =>
|
||||||
_sessions[sessionId]?.isComplete ?? false;
|
_sessions[sessionId]?.isComplete ?? false;
|
||||||
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
||||||
|
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||||
|
_sessions[sessionId]?.estimateRemaining();
|
||||||
|
bool isReceiveCanceled(String sessionId) =>
|
||||||
|
_ignoredIncomingSessions.contains(sessionId);
|
||||||
|
|
||||||
List<int> missingFragmentIndices(String sessionId) {
|
List<int> missingFragmentIndices(String sessionId) {
|
||||||
final session = _sessions[sessionId];
|
final session = _sessions[sessionId];
|
||||||
@@ -81,6 +110,12 @@ class ImageProvider with ChangeNotifier {
|
|||||||
///
|
///
|
||||||
/// Returns true when the session just became complete.
|
/// Returns true when the session just became complete.
|
||||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||||
|
if (_ignoredIncomingSessions.contains(fragment.sessionId)) {
|
||||||
|
debugPrint(
|
||||||
|
'⏹️ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
_sessions.putIfAbsent(
|
_sessions.putIfAbsent(
|
||||||
fragment.sessionId,
|
fragment.sessionId,
|
||||||
() => ImageSession(
|
() => ImageSession(
|
||||||
@@ -94,7 +129,13 @@ class ImageProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final session = _sessions[fragment.sessionId]!;
|
final session = _sessions[fragment.sessionId]!;
|
||||||
if (fragment.index < session.total) {
|
if (fragment.index < session.total) {
|
||||||
|
final wasMissing = session.fragments[fragment.index] == null;
|
||||||
session.fragments[fragment.index] = fragment;
|
session.fragments[fragment.index] = fragment;
|
||||||
|
if (wasMissing) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
session.firstFragmentAt ??= now;
|
||||||
|
session.lastFragmentAt = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final justComplete = session.isComplete;
|
final justComplete = session.isComplete;
|
||||||
@@ -103,22 +144,27 @@ class ImageProvider with ChangeNotifier {
|
|||||||
return justComplete;
|
return justComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void cancelIncomingSession(String sessionId) {
|
||||||
|
_ignoredIncomingSessions.add(sessionId);
|
||||||
|
_sessions.remove(sessionId);
|
||||||
|
unawaited(_persist());
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resumeIncomingSession(String sessionId) {
|
||||||
|
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Register envelope metadata for a session (called when IE1 is received
|
/// Register envelope metadata for a session (called when IE1 is received
|
||||||
/// before any binary fragments arrive).
|
/// before any binary fragments arrive).
|
||||||
void registerEnvelope(ImageEnvelope envelope) {
|
void registerEnvelope(ImageEnvelope envelope) {
|
||||||
_sessions.putIfAbsent(
|
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||||
envelope.sessionId,
|
return;
|
||||||
() => ImageSession(
|
}
|
||||||
sessionId: envelope.sessionId,
|
final existing = _sessions[envelope.sessionId];
|
||||||
format: envelope.format,
|
if (existing == null) {
|
||||||
total: envelope.total,
|
|
||||||
width: envelope.width,
|
|
||||||
height: envelope.height,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// Update dimensions if we created the session from a fragment (w/h = 0).
|
|
||||||
final session = _sessions[envelope.sessionId]!;
|
|
||||||
if (session.width == 0 || session.height == 0) {
|
|
||||||
_sessions[envelope.sessionId] = ImageSession(
|
_sessions[envelope.sessionId] = ImageSession(
|
||||||
sessionId: envelope.sessionId,
|
sessionId: envelope.sessionId,
|
||||||
format: envelope.format,
|
format: envelope.format,
|
||||||
@@ -126,12 +172,38 @@ class ImageProvider with ChangeNotifier {
|
|||||||
width: envelope.width,
|
width: envelope.width,
|
||||||
height: envelope.height,
|
height: envelope.height,
|
||||||
);
|
);
|
||||||
// Copy existing fragments into the new session.
|
unawaited(_persist());
|
||||||
final old = _sessions[envelope.sessionId]!;
|
notifyListeners();
|
||||||
for (var i = 0; i < session.fragments.length && i < old.total; i++) {
|
return;
|
||||||
old.fragments[i] = session.fragments[i];
|
}
|
||||||
|
|
||||||
|
final needsMerge =
|
||||||
|
existing.width == 0 ||
|
||||||
|
existing.height == 0 ||
|
||||||
|
existing.total != envelope.total ||
|
||||||
|
existing.format != envelope.format;
|
||||||
|
if (!needsMerge) {
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final merged = ImageSession(
|
||||||
|
sessionId: envelope.sessionId,
|
||||||
|
format: envelope.format,
|
||||||
|
total: envelope.total,
|
||||||
|
width: envelope.width,
|
||||||
|
height: envelope.height,
|
||||||
|
);
|
||||||
|
merged.firstFragmentAt = existing.firstFragmentAt;
|
||||||
|
merged.lastFragmentAt = existing.lastFragmentAt;
|
||||||
|
for (final fragment in existing.fragments) {
|
||||||
|
if (fragment == null) continue;
|
||||||
|
if (fragment.index < merged.total) {
|
||||||
|
merged.fragments[fragment.index] = fragment;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_sessions[envelope.sessionId] = merged;
|
||||||
|
unawaited(_persist());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,41 +255,18 @@ class ImageProvider with ChangeNotifier {
|
|||||||
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
|
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (sendRawPacketCallback == null) {
|
return serveCachedSessionFragments<ImagePacket>(
|
||||||
debugPrint('⚠️ [ImageProvider] sendRawPacketCallback not set');
|
providerLabel: 'ImageProvider',
|
||||||
return false;
|
sessionId: sessionId,
|
||||||
}
|
requester: requester,
|
||||||
if (requester.outPathLen < 0) {
|
fragments: cached.fragments,
|
||||||
debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path');
|
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||||
return false;
|
indexOf: (fragment) => fragment.index,
|
||||||
}
|
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||||
if (requester.outPathLen > maxDirectPayloadHops) {
|
sendRawPacket: sendRawPacketCallback,
|
||||||
debugPrint(
|
waitForFragmentAck: waitForFragmentAckCallback,
|
||||||
'⚠️ [ImageProvider] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
requestedIndices: requestedIndices,
|
||||||
);
|
);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final fragment in cached.fragments) {
|
|
||||||
if (requestedIndices != null &&
|
|
||||||
!requestedIndices.contains(fragment.index)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await sendRawPacketCallback!(
|
|
||||||
contactPath: requester.outPath,
|
|
||||||
contactPathLen: requester.outPathLen,
|
|
||||||
payload: fragment.encodeBinary(),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debugPrint(
|
|
||||||
'📷 [ImageProvider] Served ${cached.fragments.length} fragments of $sessionId',
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistence ──────────────────────────────────────────────────────────
|
// ── Persistence ──────────────────────────────────────────────────────────
|
||||||
@@ -225,6 +274,7 @@ class ImageProvider with ChangeNotifier {
|
|||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
_sessions.clear();
|
_sessions.clear();
|
||||||
_outgoing.clear();
|
_outgoing.clear();
|
||||||
|
_ignoredIncomingSessions.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import 'helpers/raw_session_retransmit.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../services/voice_codec_service.dart';
|
import '../services/voice_codec_service.dart';
|
||||||
import '../services/voice_player_service.dart';
|
import '../services/voice_player_service.dart';
|
||||||
@@ -13,6 +14,8 @@ class VoiceSession {
|
|||||||
final VoicePacketMode mode;
|
final VoicePacketMode mode;
|
||||||
final int total;
|
final int total;
|
||||||
final List<VoicePacket?> packets; // indexed by packet.index
|
final List<VoicePacket?> packets; // indexed by packet.index
|
||||||
|
DateTime? firstPacketAt;
|
||||||
|
DateTime? lastPacketAt;
|
||||||
|
|
||||||
VoiceSession({
|
VoiceSession({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
@@ -23,6 +26,19 @@ class VoiceSession {
|
|||||||
int get receivedCount => packets.where((p) => p != null).length;
|
int get receivedCount => packets.where((p) => p != null).length;
|
||||||
bool get isComplete => receivedCount == total;
|
bool get isComplete => receivedCount == total;
|
||||||
|
|
||||||
|
Duration? estimateRemaining() {
|
||||||
|
if (isComplete) return Duration.zero;
|
||||||
|
if (firstPacketAt == null || lastPacketAt == null) return null;
|
||||||
|
if (receivedCount < 2) return null;
|
||||||
|
|
||||||
|
final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds;
|
||||||
|
if (elapsedMs <= 0) return null;
|
||||||
|
final avgMsPerPacket = elapsedMs / (receivedCount - 1);
|
||||||
|
final remaining = total - receivedCount;
|
||||||
|
if (remaining <= 0) return Duration.zero;
|
||||||
|
return Duration(milliseconds: (avgMsPerPacket * remaining).round());
|
||||||
|
}
|
||||||
|
|
||||||
/// Total estimated audio duration in seconds (sum of all received packets).
|
/// Total estimated audio duration in seconds (sum of all received packets).
|
||||||
double get estimatedDurationSeconds {
|
double get estimatedDurationSeconds {
|
||||||
var ms = 0;
|
var ms = 0;
|
||||||
@@ -43,6 +59,7 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Active sessions keyed by sessionId.
|
/// Active sessions keyed by sessionId.
|
||||||
final Map<String, VoiceSession> _sessions = {};
|
final Map<String, VoiceSession> _sessions = {};
|
||||||
|
final Set<String> _ignoredIncomingSessions = {};
|
||||||
|
|
||||||
/// Currently playing session ID, or null.
|
/// Currently playing session ID, or null.
|
||||||
String? _playingSessionId;
|
String? _playingSessionId;
|
||||||
@@ -54,6 +71,12 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
})?
|
})?
|
||||||
sendRawPacketCallback;
|
sendRawPacketCallback;
|
||||||
|
Future<bool> Function({
|
||||||
|
required String sessionId,
|
||||||
|
required int index,
|
||||||
|
Duration timeout,
|
||||||
|
})?
|
||||||
|
waitForFragmentAckCallback;
|
||||||
|
|
||||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||||
|
|
||||||
@@ -93,6 +116,10 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
|
|
||||||
bool hasOutgoingSession(String sessionId) =>
|
bool hasOutgoingSession(String sessionId) =>
|
||||||
_outgoingSessions.containsKey(sessionId);
|
_outgoingSessions.containsKey(sessionId);
|
||||||
|
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||||
|
_sessions[sessionId]?.estimateRemaining();
|
||||||
|
bool isReceiveCanceled(String sessionId) =>
|
||||||
|
_ignoredIncomingSessions.contains(sessionId);
|
||||||
|
|
||||||
List<int> missingPacketIndices(String sessionId) {
|
List<int> missingPacketIndices(String sessionId) {
|
||||||
final session = _sessions[sessionId];
|
final session = _sessions[sessionId];
|
||||||
@@ -109,6 +136,12 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
||||||
/// Returns true if the session just became complete.
|
/// Returns true if the session just became complete.
|
||||||
bool addPacket(VoicePacket packet) {
|
bool addPacket(VoicePacket packet) {
|
||||||
|
if (_ignoredIncomingSessions.contains(packet.sessionId)) {
|
||||||
|
debugPrint(
|
||||||
|
'⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
_sessions.putIfAbsent(
|
_sessions.putIfAbsent(
|
||||||
packet.sessionId,
|
packet.sessionId,
|
||||||
() => VoiceSession(
|
() => VoiceSession(
|
||||||
@@ -120,7 +153,13 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final session = _sessions[packet.sessionId]!;
|
final session = _sessions[packet.sessionId]!;
|
||||||
if (packet.index < session.total) {
|
if (packet.index < session.total) {
|
||||||
|
final wasMissing = session.packets[packet.index] == null;
|
||||||
session.packets[packet.index] = packet;
|
session.packets[packet.index] = packet;
|
||||||
|
if (wasMissing) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
session.firstPacketAt ??= now;
|
||||||
|
session.lastPacketAt = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final justComplete = session.isComplete;
|
final justComplete = session.isComplete;
|
||||||
@@ -129,6 +168,23 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
return justComplete;
|
return justComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void cancelIncomingSession(String sessionId) {
|
||||||
|
_ignoredIncomingSessions.add(sessionId);
|
||||||
|
_sessions.remove(sessionId);
|
||||||
|
if (_playingSessionId == sessionId) {
|
||||||
|
unawaited(_player.stop());
|
||||||
|
_playingSessionId = null;
|
||||||
|
}
|
||||||
|
_persistVoiceData();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resumeIncomingSession(String sessionId) {
|
||||||
|
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cache encoded packets for deferred voice serving.
|
/// Cache encoded packets for deferred voice serving.
|
||||||
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||||
if (packets.isEmpty) return;
|
if (packets.isEmpty) return;
|
||||||
@@ -152,42 +208,18 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (sendRawPacketCallback == null) {
|
return serveCachedSessionFragments<VoicePacket>(
|
||||||
debugPrint('⚠️ [VoiceProvider] sendRawPacketCallback is not set');
|
providerLabel: 'VoiceProvider',
|
||||||
return false;
|
sessionId: sessionId,
|
||||||
}
|
requester: requester,
|
||||||
if (requester.outPathLen < 0) {
|
fragments: cached.packets,
|
||||||
debugPrint(
|
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||||
'⚠️ [VoiceProvider] Requester ${requester.advName} has no direct path',
|
indexOf: (packet) => packet.index,
|
||||||
|
encodeBinary: (packet) => packet.encodeBinary(),
|
||||||
|
sendRawPacket: sendRawPacketCallback,
|
||||||
|
waitForFragmentAck: waitForFragmentAckCallback,
|
||||||
|
requestedIndices: requestedIndices,
|
||||||
);
|
);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (requester.outPathLen > maxDirectPayloadHops) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [VoiceProvider] Requester ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final packet in cached.packets) {
|
|
||||||
if (requestedIndices != null &&
|
|
||||||
!requestedIndices.contains(packet.index)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await sendRawPacketCallback!(
|
|
||||||
contactPath: requester.outPath,
|
|
||||||
contactPathLen: requester.outPathLen,
|
|
||||||
payload: packet.encodeBinary(),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint(
|
|
||||||
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Playback ─────────────────────────────────────────────────────────────
|
// ── Playback ─────────────────────────────────────────────────────────────
|
||||||
@@ -231,6 +263,7 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
Future<void> clearStoredVoiceData() async {
|
Future<void> clearStoredVoiceData() async {
|
||||||
_sessions.clear();
|
_sessions.clear();
|
||||||
_outgoingSessions.clear();
|
_outgoingSessions.clear();
|
||||||
|
_ignoredIncomingSessions.clear();
|
||||||
_playingSessionId = null;
|
_playingSessionId = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import '../widgets/permission_request_dialog.dart';
|
|||||||
import '../widgets/connection_dialog.dart';
|
import '../widgets/connection_dialog.dart';
|
||||||
import '../utils/battery_display_helper.dart';
|
import '../utils/battery_display_helper.dart';
|
||||||
|
|
||||||
|
enum _HomeTab { messages, contacts, map }
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final Function(AppThemeMode) onThemeChanged;
|
final Function(AppThemeMode) onThemeChanged;
|
||||||
final Function(Locale?) onLocaleChanged;
|
final Function(Locale?) onLocaleChanged;
|
||||||
@@ -50,13 +52,30 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
bool _isMapFullscreen = false;
|
bool _isMapFullscreen = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
|
bool _isContactsEnabled = true;
|
||||||
|
|
||||||
|
List<_HomeTab> get _enabledTabs {
|
||||||
|
return [
|
||||||
|
_HomeTab.messages,
|
||||||
|
if (_isContactsEnabled) _HomeTab.contacts,
|
||||||
|
if (_isMapEnabled) _HomeTab.map,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
_HomeTab get _currentTab {
|
||||||
|
final tabs = _enabledTabs;
|
||||||
|
final safeIndex = _currentIndex < tabs.length
|
||||||
|
? _currentIndex
|
||||||
|
: tabs.length - 1;
|
||||||
|
return tabs[safeIndex < 0 ? 0 : safeIndex];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Initialize synchronously so first build always has a valid controller.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
_initTabController();
|
_initTabController();
|
||||||
_loadMapEnabledAndInitTabs();
|
_loadTabVisibilityAndInitTabs();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// Show permission dialog after the first frame if needed
|
||||||
@@ -67,58 +86,75 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadMapEnabledAndInitTabs() async {
|
Future<void> _loadTabVisibilityAndInitTabs() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final mapEnabled = prefs.getBool('map_enabled') ?? true;
|
final mapEnabled = prefs.getBool('map_enabled') ?? true;
|
||||||
|
final contactsEnabled = prefs.getBool('contacts_enabled') ?? true;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_isMapEnabled != mapEnabled) {
|
if (_isMapEnabled != mapEnabled || _isContactsEnabled != contactsEnabled) {
|
||||||
_updateTabController(mapEnabled);
|
_updateTabController(
|
||||||
|
mapEnabled: mapEnabled,
|
||||||
|
contactsEnabled: contactsEnabled,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initTabController() {
|
void _initTabController() {
|
||||||
final tabCount = _isMapEnabled ? 3 : 2;
|
_tabController = TabController(length: _enabledTabs.length, vsync: this);
|
||||||
_tabController = TabController(length: tabCount, vsync: this);
|
|
||||||
_tabController.addListener(_onTabChanged);
|
_tabController.addListener(_onTabChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTabChanged() {
|
void _onTabChanged() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentIndex = _tabController.index;
|
_currentIndex = _tabController.index;
|
||||||
// Exit fullscreen when switching away from map tab (only if map is enabled and is tab 2)
|
if (_currentTab != _HomeTab.map) {
|
||||||
if (_isMapEnabled && _currentIndex != 2) {
|
|
||||||
_isMapFullscreen = false;
|
_isMapFullscreen = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateTabController(bool mapEnabled) {
|
void _updateTabController({
|
||||||
if (_isMapEnabled == mapEnabled) return;
|
required bool mapEnabled,
|
||||||
|
required bool contactsEnabled,
|
||||||
|
}) {
|
||||||
|
if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Save current index before rebuilding
|
final oldTabs = _enabledTabs;
|
||||||
final oldIndex = _tabController.index;
|
final oldIndex = _tabController.index;
|
||||||
|
final oldTab = oldTabs[oldIndex];
|
||||||
|
|
||||||
// Remove old listener and dispose
|
final oldController = _tabController;
|
||||||
_tabController.removeListener(_onTabChanged);
|
oldController.removeListener(_onTabChanged);
|
||||||
_tabController.dispose();
|
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
_isMapEnabled = mapEnabled;
|
_isMapEnabled = mapEnabled;
|
||||||
|
_isContactsEnabled = contactsEnabled;
|
||||||
|
|
||||||
|
final newTabs = _enabledTabs;
|
||||||
|
final newIndex = newTabs.indexOf(oldTab);
|
||||||
|
|
||||||
// Create new controller
|
// Create new controller
|
||||||
final tabCount = mapEnabled ? 3 : 2;
|
_tabController = TabController(length: newTabs.length, vsync: this);
|
||||||
_tabController = TabController(length: tabCount, vsync: this);
|
|
||||||
_tabController.addListener(_onTabChanged);
|
_tabController.addListener(_onTabChanged);
|
||||||
|
|
||||||
// Restore index (clamp to valid range)
|
_currentIndex = newIndex >= 0 ? newIndex : 0;
|
||||||
if (oldIndex < tabCount) {
|
_tabController.index = _currentIndex;
|
||||||
_tabController.index = oldIndex;
|
|
||||||
_currentIndex = oldIndex;
|
|
||||||
} else {
|
|
||||||
_currentIndex = tabCount - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
|
||||||
|
// Dispose old controller after widgets have rebound to the new controller.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
oldController.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToTab(_HomeTab tab) {
|
||||||
|
final targetIndex = _enabledTabs.indexOf(tab);
|
||||||
|
if (targetIndex >= 0 && targetIndex != _tabController.index) {
|
||||||
|
_tabController.animateTo(targetIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadRxTxPreference() async {
|
Future<void> _loadRxTxPreference() async {
|
||||||
@@ -276,18 +312,23 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
messagesProvider.setLocalizations(localizations);
|
messagesProvider.setLocalizations(localizations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if map enabled setting changed and update tab controller
|
// Check if tab visibility settings changed and update tab controller
|
||||||
final appProvider = context.watch<AppProvider>();
|
final appProvider = context.watch<AppProvider>();
|
||||||
if (_isMapEnabled != appProvider.isMapEnabled) {
|
if (_isMapEnabled != appProvider.isMapEnabled ||
|
||||||
|
_isContactsEnabled != appProvider.isContactsEnabled) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_updateTabController(appProvider.isMapEnabled);
|
_updateTabController(
|
||||||
|
mapEnabled: appProvider.isMapEnabled,
|
||||||
|
contactsEnabled: appProvider.isContactsEnabled,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if we should hide the UI (only in fullscreen on map tab)
|
final enabledTabs = _enabledTabs;
|
||||||
final shouldHideUI =
|
final isMapTabActive = _currentTab == _HomeTab.map;
|
||||||
_isMapEnabled && _isMapFullscreen && _currentIndex == 2;
|
final shouldHideUI = _isMapEnabled && _isMapFullscreen && isMapTabActive;
|
||||||
|
final shouldShowTabBar = enabledTabs.length > 1;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: shouldHideUI
|
appBar: shouldHideUI
|
||||||
@@ -374,29 +415,33 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
body: TabBarView(
|
body: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: enabledTabs.map((tab) {
|
||||||
MessagesTab(
|
switch (tab) {
|
||||||
|
case _HomeTab.messages:
|
||||||
|
return MessagesTab(
|
||||||
onNavigateToMap: _isMapEnabled
|
onNavigateToMap: _isMapEnabled
|
||||||
? () => _tabController.animateTo(2)
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
: null,
|
: null,
|
||||||
),
|
);
|
||||||
ContactsTab(
|
case _HomeTab.contacts:
|
||||||
|
return ContactsTab(
|
||||||
onNavigateToMap: _isMapEnabled
|
onNavigateToMap: _isMapEnabled
|
||||||
? () => _tabController.animateTo(2)
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
: null,
|
: null,
|
||||||
),
|
);
|
||||||
if (_isMapEnabled)
|
case _HomeTab.map:
|
||||||
MapTab(
|
return MapTab(
|
||||||
onFullscreenChanged: (isFullscreen) {
|
onFullscreenChanged: (isFullscreen) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isMapFullscreen = isFullscreen;
|
_isMapFullscreen = isFullscreen;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onNavigateToMessages: () => _tabController.animateTo(0),
|
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
],
|
bottomNavigationBar: shouldHideUI || !shouldShowTabBar
|
||||||
),
|
|
||||||
bottomNavigationBar: shouldHideUI
|
|
||||||
? null
|
? null
|
||||||
: Consumer2<MessagesProvider, ContactsProvider>(
|
: Consumer2<MessagesProvider, ContactsProvider>(
|
||||||
builder: (context, messagesProvider, contactsProvider, child) {
|
builder: (context, messagesProvider, contactsProvider, child) {
|
||||||
@@ -415,27 +460,31 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
child: TabBar(
|
child: TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: [
|
tabs: enabledTabs.map((tab) {
|
||||||
Tab(
|
switch (tab) {
|
||||||
|
case _HomeTab.messages:
|
||||||
|
return Tab(
|
||||||
icon: _buildTabIconWithBadge(
|
icon: _buildTabIconWithBadge(
|
||||||
Icons.message,
|
Icons.message,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
),
|
),
|
||||||
text: AppLocalizations.of(context)!.messages,
|
text: AppLocalizations.of(context)!.messages,
|
||||||
),
|
);
|
||||||
Tab(
|
case _HomeTab.contacts:
|
||||||
|
return Tab(
|
||||||
icon: _buildTabIconWithBadge(
|
icon: _buildTabIconWithBadge(
|
||||||
Icons.contacts,
|
Icons.contacts,
|
||||||
newContactsCount,
|
newContactsCount,
|
||||||
),
|
),
|
||||||
text: AppLocalizations.of(context)!.contacts,
|
text: AppLocalizations.of(context)!.contacts,
|
||||||
),
|
);
|
||||||
if (_isMapEnabled)
|
case _HomeTab.map:
|
||||||
Tab(
|
return Tab(
|
||||||
icon: const Icon(Icons.map),
|
icon: const Icon(Icons.map),
|
||||||
text: AppLocalizations.of(context)!.map,
|
text: AppLocalizations.of(context)!.map,
|
||||||
),
|
);
|
||||||
],
|
}
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import '../utils/toast_logger.dart';
|
|||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
|
import '../utils/tictactoe_message_parser.dart';
|
||||||
import '../providers/image_provider.dart' as ip;
|
import '../providers/image_provider.dart' as ip;
|
||||||
import '../services/image_codec_service.dart';
|
import '../services/image_codec_service.dart';
|
||||||
import '../services/image_preferences.dart';
|
import '../services/image_preferences.dart';
|
||||||
@@ -286,6 +287,61 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return 'Select recipient';
|
return 'Select recipient';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getCurrentScopeLabel() {
|
||||||
|
if (_destinationType ==
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
|
final channelName =
|
||||||
|
_selectedRecipient?.getLocalizedDisplayName(context) ??
|
||||||
|
AppLocalizations.of(context)!.publicChannel;
|
||||||
|
return 'Channel > $channelName';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_destinationType == MessageDestinationPreferences.destinationTypeRoom) {
|
||||||
|
final roomName =
|
||||||
|
_selectedRecipient?.displayName ??
|
||||||
|
AppLocalizations.of(context)!.messages;
|
||||||
|
return 'Room > $roomName';
|
||||||
|
}
|
||||||
|
|
||||||
|
final contactName =
|
||||||
|
_selectedRecipient?.displayName ??
|
||||||
|
AppLocalizations.of(context)!.messages;
|
||||||
|
return 'Direct > $contactName';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScopeIndicator() {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.visibility_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'View: ${_getCurrentScopeLabel()}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _sendMessage() async {
|
||||||
final text = _textController.text.trim();
|
final text = _textController.text.trim();
|
||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
@@ -428,6 +484,54 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _startTicTacToeGame() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_destinationType !=
|
||||||
|
MessageDestinationPreferences.destinationTypeContact ||
|
||||||
|
_selectedRecipient == null) {
|
||||||
|
ToastLogger.warning(
|
||||||
|
context,
|
||||||
|
'Tic-Tac-Toe works only in direct messages. Choose a contact first.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
ToastLogger.error(context, 'Not connected to device');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
if (devicePublicKey == null || devicePublicKey.length < 6) {
|
||||||
|
ToastLogger.error(context, 'Device key unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final gameId = List.generate(
|
||||||
|
8,
|
||||||
|
(_) => math.Random.secure().nextInt(16).toRadixString(16),
|
||||||
|
).join();
|
||||||
|
final starterKey6 = devicePublicKey
|
||||||
|
.sublist(0, 6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final startMessage = TicTacToeMessageParser.encodeStart(
|
||||||
|
gameId: gameId,
|
||||||
|
starterKey6: starterKey6,
|
||||||
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _sendToRecipient(
|
||||||
|
startMessage,
|
||||||
|
connectionProvider,
|
||||||
|
messagesProvider,
|
||||||
|
contactsProvider,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Image sending ───────────────────────────────────────────────────────────
|
// ── Image sending ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _pickAndSendImage({
|
Future<void> _pickAndSendImage({
|
||||||
@@ -592,21 +696,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
'chunk=${imageDataBytesPerFragment}B',
|
'chunk=${imageDataBytesPerFragment}B',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Push all fragments immediately for direct contacts.
|
// Image fragments are always served on demand after an explicit IR2
|
||||||
// For channels, fragments are served on demand via IR1 fetch requests.
|
// fetch request, including direct contacts.
|
||||||
if (!isChannel && recipient != null) {
|
|
||||||
// Small delay so the IE1 envelope can propagate before fragments arrive.
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
if (!mounted) return;
|
|
||||||
final served = await imageProvider.serveSessionTo(
|
|
||||||
sessionId: sessionId,
|
|
||||||
requester: recipient,
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
'📷 [Image] Pushed ${served ? fragments.length : 0} '
|
|
||||||
'fragments to ${recipient.advName}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
|
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -1077,6 +1168,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_pickAndSendImage(source: ImageSource.camera);
|
_pickAndSendImage(source: ImageSource.camera);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.grid_3x3),
|
||||||
|
title: const Text('Start Tic-Tac-Toe'),
|
||||||
|
subtitle: const Text('DM only'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_startTicTacToeGame();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1513,14 +1613,23 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
// Quick actions (+) button
|
// Quick actions (+) button
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(_isRecording ? Icons.stop : Icons.add),
|
icon: Icon(_isRecording ? Icons.stop : Icons.add),
|
||||||
tooltip: _isRecording ? 'Stop recording' : 'More actions',
|
tooltip: _isRecording
|
||||||
|
? 'Stop recording'
|
||||||
|
: 'More actions',
|
||||||
onPressed: _isRecording
|
onPressed: _isRecording
|
||||||
? _stopAndSendVoice
|
? _stopAndSendVoice
|
||||||
: _showComposerActions,
|
: _showComposerActions,
|
||||||
@@ -1530,7 +1639,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
).colorScheme.primaryContainer,
|
).colorScheme.primaryContainer,
|
||||||
foregroundColor: _isRecording
|
foregroundColor: _isRecording
|
||||||
? Colors.red
|
? Colors.red
|
||||||
: Theme.of(context).colorScheme.onPrimaryContainer,
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -1547,27 +1658,46 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
? Theme.of(
|
? Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.surfaceContainerHighest
|
).colorScheme.surfaceContainerHighest
|
||||||
: Theme.of(context).colorScheme.secondaryContainer,
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.secondaryContainer,
|
||||||
foregroundColor:
|
foregroundColor:
|
||||||
_destinationType ==
|
_destinationType ==
|
||||||
MessageDestinationPreferences
|
MessageDestinationPreferences
|
||||||
.destinationTypeChannel
|
.destinationTypeChannel
|
||||||
? Theme.of(context).colorScheme.onSurface
|
? Theme.of(context).colorScheme.onSurface
|
||||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSecondaryContainer,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
// Text field with embedded send button
|
// Scope label + text field share the same alignment
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 4,
|
||||||
|
right: 4,
|
||||||
|
bottom: 6,
|
||||||
|
),
|
||||||
|
child: _buildScopeIndicator(),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
controller: _textController,
|
controller: _textController,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
maxLength: _maxCharacters,
|
maxLength: _maxCharacters,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
maxLengthEnforcement:
|
||||||
|
MaxLengthEnforcement.enforced,
|
||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: AppLocalizations.of(context)!.typeYourMessage,
|
hintText: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.typeYourMessage,
|
||||||
hintStyle: const TextStyle(fontSize: 14),
|
hintStyle: const TextStyle(fontSize: 14),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
@@ -1582,19 +1712,24 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
: '',
|
: '',
|
||||||
counterStyle: TextStyle(
|
counterStyle: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: _characterCount > _maxCharacters * 0.9
|
color:
|
||||||
|
_characterCount > _maxCharacters * 0.9
|
||||||
? Colors.orange
|
? Colors.orange
|
||||||
: Theme.of(context).textTheme.bodySmall?.color,
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.color,
|
||||||
),
|
),
|
||||||
suffixIcon: GestureDetector(
|
suffixIcon: GestureDetector(
|
||||||
onLongPressStart:
|
onLongPressStart:
|
||||||
(_voiceSupported && !_isSendingVoice)
|
(_voiceSupported && !_isSendingVoice)
|
||||||
? (_) => _startVoiceRecording()
|
? (_) => _startVoiceRecording()
|
||||||
: null,
|
: null,
|
||||||
onLongPressEnd: (_voiceSupported && _isRecording)
|
onLongPressEnd:
|
||||||
|
(_voiceSupported && _isRecording)
|
||||||
? (_) => _stopAndSendVoice()
|
? (_) => _stopAndSendVoice()
|
||||||
: null,
|
: null,
|
||||||
onLongPressCancel: (_voiceSupported && _isRecording)
|
onLongPressCancel:
|
||||||
|
(_voiceSupported && _isRecording)
|
||||||
? () => _stopAndSendVoice()
|
? () => _stopAndSendVoice()
|
||||||
: null,
|
: null,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
@@ -1613,16 +1748,22 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
size: 22,
|
size: 22,
|
||||||
color: _isRecording
|
color: _isRecording
|
||||||
? Colors.red
|
? Colors.red
|
||||||
: (_textController.text.trim().isEmpty
|
: (_textController.text
|
||||||
? Theme.of(context).disabledColor
|
.trim()
|
||||||
: Theme.of(
|
.isEmpty
|
||||||
|
? Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.primary),
|
).disabledColor
|
||||||
|
: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary),
|
||||||
),
|
),
|
||||||
onPressed:
|
onPressed:
|
||||||
_isRecording ||
|
_isRecording ||
|
||||||
_isSendingVoice ||
|
_isSendingVoice ||
|
||||||
_textController.text.trim().isEmpty
|
_textController.text
|
||||||
|
.trim()
|
||||||
|
.isEmpty
|
||||||
? null
|
? null
|
||||||
: _sendMessage,
|
: _sendMessage,
|
||||||
tooltip: _isRecording
|
tooltip: _isRecording
|
||||||
@@ -1638,6 +1779,11 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
textInputAction: TextInputAction.send,
|
textInputAction: TextInputAction.send,
|
||||||
onSubmitted: (_) => _sendMessage(),
|
onSubmitted: (_) => _sendMessage(),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -621,6 +621,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _clearMessages() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear Messages'),
|
||||||
|
content: const Text(
|
||||||
|
'This will permanently delete all stored messages. Are you sure?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: Text(AppLocalizations.of(context)!.clear),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final messagesProvider = Provider.of<MessagesProvider>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
|
messagesProvider.clearMessages();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('All messages cleared'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -687,6 +727,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.contacts_outlined),
|
||||||
|
title: const Text('Disable Contacts'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Hide the contacts tab to simplify navigation',
|
||||||
|
),
|
||||||
|
value: !appProvider.isContactsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleContactsEnabled(!value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.language),
|
leading: const Icon(Icons.language),
|
||||||
title: Text(AppLocalizations.of(context)!.language),
|
title: Text(AppLocalizations.of(context)!.language),
|
||||||
@@ -694,6 +747,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _showLanguageDialog(),
|
onTap: () => _showLanguageDialog(),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
||||||
|
title: const Text(
|
||||||
|
'Clear Messages',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
subtitle: const Text('Delete all stored message history'),
|
||||||
|
onTap: _clearMessages,
|
||||||
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
// Voice Settings Section
|
// Voice Settings Section
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
int fieldCount = 0;
|
int fieldCount = 0;
|
||||||
while (reader.hasRemaining) {
|
while (reader.hasRemaining) {
|
||||||
|
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||||
|
debugPrint(
|
||||||
|
' Detected zero-padded telemetry tail, stopping parse at position '
|
||||||
|
'${data.length - reader.remainingBytesCount}',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fieldCount++;
|
fieldCount++;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -250,6 +258,14 @@ class CayenneLppParser {
|
|||||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
||||||
|
final start = data.length - remainingBytes;
|
||||||
|
for (int i = start; i < data.length; i++) {
|
||||||
|
if (data[i] != 0) return false;
|
||||||
|
}
|
||||||
|
return remainingBytes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Create Cayenne LPP data for GPS location
|
/// Create Cayenne LPP data for GPS location
|
||||||
/// Standard Cayenne LPP GPS format (type 0x88):
|
/// Standard Cayenne LPP GPS format (type 0x88):
|
||||||
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000
|
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000
|
||||||
|
|||||||
@@ -125,7 +125,10 @@ int safeImageDataBytesForPath(int pathLen) {
|
|||||||
? maxRawPayloadFromCommandFrame
|
? maxRawPayloadFromCommandFrame
|
||||||
: maxRawPayloadFromMesh;
|
: maxRawPayloadFromMesh;
|
||||||
final maxData = maxRawPayload - ImagePacket._headerLen;
|
final maxData = maxRawPayload - ImagePacket._headerLen;
|
||||||
return maxData.clamp(1, 255).toInt();
|
// Keep a safety margin below the theoretical direct-route ceiling.
|
||||||
|
// Fragments at the absolute 172-byte command-frame limit have proven flaky
|
||||||
|
// in practice, so cap to the conservative protocol default.
|
||||||
|
return maxData.clamp(1, ImagePacket.maxDataBytes).toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa.
|
/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa.
|
||||||
@@ -243,11 +246,11 @@ int _resolveBandwidthHz(int? rawBw) {
|
|||||||
/// Envelope announcing image availability (control plane).
|
/// Envelope announcing image availability (control plane).
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver}
|
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1
|
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8
|
||||||
class ImageEnvelope {
|
class ImageEnvelope {
|
||||||
static const String prefix = 'IE1:';
|
static const String _prefix = 'IE2:';
|
||||||
|
|
||||||
final String sessionId; // 8 hex chars
|
final String sessionId; // 8 hex chars
|
||||||
final ImageFormat format;
|
final ImageFormat format;
|
||||||
@@ -268,38 +271,36 @@ class ImageEnvelope {
|
|||||||
required this.sizeBytes,
|
required this.sizeBytes,
|
||||||
required this.senderKey6,
|
required this.senderKey6,
|
||||||
required this.timestampSec,
|
required this.timestampSec,
|
||||||
this.version = 1,
|
this.version = 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isEnvelope(String text) => text.startsWith(prefix);
|
static bool isEnvelope(String text) => text.startsWith(_prefix);
|
||||||
|
|
||||||
static ImageEnvelope? tryParse(String text) {
|
static ImageEnvelope? tryParse(String text) {
|
||||||
if (!isEnvelope(text)) return null;
|
if (!isEnvelope(text)) return null;
|
||||||
final body = text.substring(prefix.length);
|
final body = text.substring(_prefix.length);
|
||||||
final parts = body.split(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 9) return null;
|
if (parts.length != 8) return null;
|
||||||
try {
|
try {
|
||||||
final sid = parts[0];
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final fmtId = int.tryParse(parts[1]);
|
final fmtId = _parseInt(parts[1], base36: true);
|
||||||
final total = int.tryParse(parts[2]);
|
final total = _parseInt(parts[2], base36: true);
|
||||||
final w = int.tryParse(parts[3]);
|
final w = _parseInt(parts[3], base36: true);
|
||||||
final h = int.tryParse(parts[4]);
|
final h = _parseInt(parts[4], base36: true);
|
||||||
final bytes = int.tryParse(parts[5]);
|
final bytes = _parseInt(parts[5], base36: true);
|
||||||
final senderKey6 = parts[6];
|
final senderKey6 = parts[6];
|
||||||
final ts = int.tryParse(parts[7]);
|
final ts = _parseInt(parts[7], base36: true);
|
||||||
final ver = int.tryParse(parts[8]);
|
|
||||||
|
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
|
if (sid == null) return null;
|
||||||
if (fmtId == null) return null;
|
if (fmtId == null) return null;
|
||||||
if (total == null || total < 1 || total > 255) return null;
|
if (total == null || total < 1 || total > 255) return null;
|
||||||
if (w == null || h == null || w < 1 || h < 1) return null;
|
if (w == null || h == null || w < 1 || h < 1) return null;
|
||||||
if (bytes == null || bytes < 1) return null;
|
if (bytes == null || bytes < 1) return null;
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
|
||||||
if (ts == null || ts <= 0) return null;
|
if (ts == null || ts <= 0) return null;
|
||||||
if (ver == null || ver != 1) return null;
|
|
||||||
|
|
||||||
return ImageEnvelope(
|
return ImageEnvelope(
|
||||||
sessionId: sid.toLowerCase(),
|
sessionId: sid,
|
||||||
format: ImageFormat.fromId(fmtId),
|
format: ImageFormat.fromId(fmtId),
|
||||||
total: total,
|
total: total,
|
||||||
width: w,
|
width: w,
|
||||||
@@ -307,7 +308,7 @@ class ImageEnvelope {
|
|||||||
sizeBytes: bytes,
|
sizeBytes: bytes,
|
||||||
senderKey6: senderKey6.toLowerCase(),
|
senderKey6: senderKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
timestampSec: ts,
|
||||||
version: ver,
|
version: 2,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -315,17 +316,21 @@ class ImageEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String encode() =>
|
String encode() =>
|
||||||
'$prefix${sessionId.toLowerCase()}:${format.id}:$total:$width:$height:$sizeBytes:${senderKey6.toLowerCase()}:$timestampSec:$version';
|
'$_prefix${_encodeSessionId(sessionId)}:'
|
||||||
|
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
|
||||||
|
'${_toBase36(height)}:${_toBase36(sizeBytes)}:'
|
||||||
|
'${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct request to fetch image fragments (control plane).
|
/// Direct request to fetch image fragments (control plane).
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
/// IR2:{sid}:{want}:{requesterKey6}:{ts}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// IR1:deadbeef:a:aabbccddeeff:1700000010:1
|
/// IR2:deadbeef:a:aabbccddeeff:s44wea
|
||||||
class ImageFetchRequest {
|
class ImageFetchRequest {
|
||||||
static const String prefix = 'IR1:';
|
static const String _prefix = 'IR2:';
|
||||||
|
static const int _binaryMagic = 0x69; // 'i'
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final String want; // 'all' or 'missing'
|
final String want; // 'all' or 'missing'
|
||||||
@@ -340,51 +345,87 @@ class ImageFetchRequest {
|
|||||||
this.missingIndices = const [],
|
this.missingIndices = const [],
|
||||||
required this.requesterKey6,
|
required this.requesterKey6,
|
||||||
required this.timestampSec,
|
required this.timestampSec,
|
||||||
this.version = 1,
|
this.version = 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isRequest(String text) => text.startsWith(prefix);
|
static bool isRequest(String text) => text.startsWith(_prefix);
|
||||||
|
static bool isRequestBinary(Uint8List payload) =>
|
||||||
|
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||||
|
|
||||||
static ImageFetchRequest? tryParse(String text) {
|
static ImageFetchRequest? tryParse(String text) {
|
||||||
if (!isRequest(text)) return null;
|
if (!isRequest(text)) return null;
|
||||||
final body = text.substring(prefix.length);
|
final body = text.substring(_prefix.length);
|
||||||
final parts = body.split(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 5) return null;
|
if (parts.length != 4) return null;
|
||||||
try {
|
try {
|
||||||
final sid = parts[0];
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final wantToken = parts[1];
|
final wantToken = parts[1];
|
||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final ts = int.tryParse(parts[3]);
|
final ts = _parseInt(parts[3], base36: true);
|
||||||
final ver = int.tryParse(parts[4]);
|
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? 'all'
|
||||||
: (wantToken.startsWith('m-') ? 'missing' : wantToken);
|
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||||
|
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
|
if (sid == null) return null;
|
||||||
final missingIndices = <int>[];
|
final missingIndices = <int>[];
|
||||||
if (normalizedWant == 'missing') {
|
if (normalizedWant == 'missing') {
|
||||||
final encoded = wantToken.substring(2);
|
final encoded = wantToken.substring(1);
|
||||||
if (encoded.isEmpty) return null;
|
if (encoded.isEmpty) return null;
|
||||||
for (final raw in encoded.split(',')) {
|
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
|
||||||
final idx = int.tryParse(raw);
|
|
||||||
if (idx == null || idx < 0 || idx > 254) return null;
|
|
||||||
missingIndices.add(idx);
|
|
||||||
}
|
|
||||||
if (missingIndices.isEmpty) return null;
|
if (missingIndices.isEmpty) return null;
|
||||||
} else if (normalizedWant != 'all') {
|
} else if (normalizedWant != 'all') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
|
||||||
if (ts == null || ts <= 0) return null;
|
if (ts == null || ts <= 0) return null;
|
||||||
if (ver == null || ver != 1) return null;
|
|
||||||
|
|
||||||
return ImageFetchRequest(
|
return ImageFetchRequest(
|
||||||
sessionId: sid.toLowerCase(),
|
sessionId: sid,
|
||||||
want: normalizedWant,
|
want: normalizedWant,
|
||||||
missingIndices: missingIndices,
|
missingIndices: missingIndices,
|
||||||
requesterKey6: requesterKey6.toLowerCase(),
|
requesterKey6: requesterKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
timestampSec: ts,
|
||||||
version: ver,
|
version: 2,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
|
||||||
|
if (!isRequestBinary(payload)) return null;
|
||||||
|
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
|
||||||
|
try {
|
||||||
|
final sid = payload
|
||||||
|
.sublist(1, 5)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
final flags = payload[5];
|
||||||
|
final requesterKey6 = payload
|
||||||
|
.sublist(6, 12)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
final ts =
|
||||||
|
(payload[12] << 24) |
|
||||||
|
(payload[13] << 16) |
|
||||||
|
(payload[14] << 8) |
|
||||||
|
payload[15];
|
||||||
|
final missingCount = payload[16];
|
||||||
|
if (payload.length != 17 + missingCount) return null;
|
||||||
|
final wantMissing = (flags & 0x01) == 0x01;
|
||||||
|
final missing = <int>[];
|
||||||
|
for (var i = 0; i < missingCount; i++) {
|
||||||
|
missing.add(payload[17 + i]);
|
||||||
|
}
|
||||||
|
return ImageFetchRequest(
|
||||||
|
sessionId: sid,
|
||||||
|
want: wantMissing ? 'missing' : 'all',
|
||||||
|
missingIndices: missing,
|
||||||
|
requesterKey6: requesterKey6,
|
||||||
|
timestampSec: ts,
|
||||||
|
version: 2,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -393,10 +434,171 @@ class ImageFetchRequest {
|
|||||||
|
|
||||||
String encode() {
|
String encode() {
|
||||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||||
? 'm-${missingIndices.join(',')}'
|
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||||
: (want == 'all' ? 'a' : want);
|
: (want == 'all' ? 'a' : want);
|
||||||
return '$prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
|
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Uint8List encodeBinary() {
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||||
|
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||||
|
}
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
requesterKey6,
|
||||||
|
'requesterKey6',
|
||||||
|
'Expected 12 hex chars',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
|
||||||
|
final missing = useMissing
|
||||||
|
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||||
|
: <int>[];
|
||||||
|
|
||||||
|
final out = Uint8List(17 + missing.length);
|
||||||
|
out[0] = _binaryMagic;
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||||
|
}
|
||||||
|
out[5] = useMissing ? 0x01 : 0x00;
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
out[6 + i] = int.parse(
|
||||||
|
requesterKey6.substring(i * 2, i * 2 + 2),
|
||||||
|
radix: 16,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out[12] = (timestampSec >> 24) & 0xFF;
|
||||||
|
out[13] = (timestampSec >> 16) & 0xFF;
|
||||||
|
out[14] = (timestampSec >> 8) & 0xFF;
|
||||||
|
out[15] = timestampSec & 0xFF;
|
||||||
|
out[16] = missing.length;
|
||||||
|
for (var i = 0; i < missing.length; i++) {
|
||||||
|
out[17 + i] = missing[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-fragment ACK for raw image payload packets.
|
||||||
|
///
|
||||||
|
/// Binary format:
|
||||||
|
/// [0x6a 'j'][sessionId:4B][index:1B]
|
||||||
|
class ImageFragmentAck {
|
||||||
|
static const int _binaryMagic = 0x6a; // 'j'
|
||||||
|
|
||||||
|
final String sessionId; // 8 hex chars
|
||||||
|
final int index; // 0..254
|
||||||
|
|
||||||
|
const ImageFragmentAck({required this.sessionId, required this.index});
|
||||||
|
|
||||||
|
static bool isImageFragmentAckBinary(Uint8List payload) =>
|
||||||
|
payload.length == 6 && payload[0] == _binaryMagic;
|
||||||
|
|
||||||
|
static ImageFragmentAck? tryParseBinary(Uint8List payload) {
|
||||||
|
if (!isImageFragmentAckBinary(payload)) return null;
|
||||||
|
try {
|
||||||
|
final sid = payload
|
||||||
|
.sublist(1, 5)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
final idx = payload[5];
|
||||||
|
return ImageFragmentAck(sessionId: sid, index: idx);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List encodeBinary() {
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||||
|
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||||
|
}
|
||||||
|
if (index < 0 || index > 254) {
|
||||||
|
throw ArgumentError.value(index, 'index', 'Expected 0..254');
|
||||||
|
}
|
||||||
|
final out = Uint8List(6);
|
||||||
|
out[0] = _binaryMagic;
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||||
|
}
|
||||||
|
out[5] = index;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _parseInt(String token, {required bool base36}) =>
|
||||||
|
int.tryParse(token, radix: base36 ? 36 : 10);
|
||||||
|
|
||||||
|
String _toBase36(int value) => value.toRadixString(36);
|
||||||
|
|
||||||
|
String _encodeSessionId(String sessionIdHex) {
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
sessionIdHex,
|
||||||
|
'sessionIdHex',
|
||||||
|
'Expected 8 hex chars',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final value = int.parse(sessionIdHex, radix: 16);
|
||||||
|
return value.toRadixString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _decodeSessionId(String token) {
|
||||||
|
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
|
||||||
|
final value = int.tryParse(token, radix: 36);
|
||||||
|
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
|
||||||
|
return value.toRadixString(16).padLeft(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||||
|
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
|
||||||
|
..sort();
|
||||||
|
if (sorted.isEmpty) return '';
|
||||||
|
final chunks = <String>[];
|
||||||
|
var start = sorted.first;
|
||||||
|
var prev = sorted.first;
|
||||||
|
for (var i = 1; i < sorted.length; i++) {
|
||||||
|
final curr = sorted[i];
|
||||||
|
if (curr == prev + 1) {
|
||||||
|
prev = curr;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunks.add(
|
||||||
|
start == prev
|
||||||
|
? _toBase36(start)
|
||||||
|
: '${_toBase36(start)}-${_toBase36(prev)}',
|
||||||
|
);
|
||||||
|
start = curr;
|
||||||
|
prev = curr;
|
||||||
|
}
|
||||||
|
chunks.add(
|
||||||
|
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||||
|
);
|
||||||
|
return chunks.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _decodeMissingIndicesCompact(String encoded) {
|
||||||
|
final out = <int>[];
|
||||||
|
for (final token in encoded.split('.')) {
|
||||||
|
if (token.isEmpty) continue;
|
||||||
|
if (!token.contains('-')) {
|
||||||
|
final value = int.tryParse(token, radix: 36);
|
||||||
|
if (value == null || value < 0 || value > 254) return const [];
|
||||||
|
out.add(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final parts = token.split('-');
|
||||||
|
if (parts.length != 2) return const [];
|
||||||
|
final start = int.tryParse(parts[0], radix: 36);
|
||||||
|
final end = int.tryParse(parts[1], radix: 36);
|
||||||
|
if (start == null || end == null || start < 0 || end > 254 || start > end) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
for (var i = start; i <= end; i++) {
|
||||||
|
out.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fragment the compressed image bytes into [ImagePacket] list.
|
/// Fragment the compressed image bytes into [ImagePacket] list.
|
||||||
|
|||||||
163
lib/utils/tictactoe_message_parser.dart
Normal file
163
lib/utils/tictactoe_message_parser.dart
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
enum TicTacToeEventType { start, move }
|
||||||
|
|
||||||
|
class TicTacToeEvent {
|
||||||
|
final TicTacToeEventType type;
|
||||||
|
final String gameId;
|
||||||
|
final String playerKey6;
|
||||||
|
final int? cell;
|
||||||
|
final int timestampSec;
|
||||||
|
|
||||||
|
const TicTacToeEvent({
|
||||||
|
required this.type,
|
||||||
|
required this.gameId,
|
||||||
|
required this.playerKey6,
|
||||||
|
this.cell,
|
||||||
|
required this.timestampSec,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class TicTacToeMessageParser {
|
||||||
|
static const String _prefix = 'TTT1:';
|
||||||
|
|
||||||
|
static bool isTicTacToe(String text) => text.startsWith(_prefix);
|
||||||
|
|
||||||
|
static TicTacToeEvent? tryParse(String text) {
|
||||||
|
if (!isTicTacToe(text)) return null;
|
||||||
|
final body = text.substring(_prefix.length);
|
||||||
|
final parts = body.split(':');
|
||||||
|
if (parts.length < 4) return null;
|
||||||
|
|
||||||
|
final action = parts[0];
|
||||||
|
final gameId = parts[1].toLowerCase();
|
||||||
|
if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(gameId)) return null;
|
||||||
|
|
||||||
|
if (action == 'S' && parts.length == 4) {
|
||||||
|
final starterKey6 = parts[2].toLowerCase();
|
||||||
|
final ts = int.tryParse(parts[3]);
|
||||||
|
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(starterKey6)) return null;
|
||||||
|
if (ts == null || ts <= 0) return null;
|
||||||
|
return TicTacToeEvent(
|
||||||
|
type: TicTacToeEventType.start,
|
||||||
|
gameId: gameId,
|
||||||
|
playerKey6: starterKey6,
|
||||||
|
timestampSec: ts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == 'M' && parts.length == 5) {
|
||||||
|
final cell = int.tryParse(parts[2]);
|
||||||
|
final playerKey6 = parts[3].toLowerCase();
|
||||||
|
final ts = int.tryParse(parts[4]);
|
||||||
|
if (cell == null || cell < 0 || cell > 8) return null;
|
||||||
|
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(playerKey6)) return null;
|
||||||
|
if (ts == null || ts <= 0) return null;
|
||||||
|
return TicTacToeEvent(
|
||||||
|
type: TicTacToeEventType.move,
|
||||||
|
gameId: gameId,
|
||||||
|
playerKey6: playerKey6,
|
||||||
|
cell: cell,
|
||||||
|
timestampSec: ts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String encodeStart({
|
||||||
|
required String gameId,
|
||||||
|
required String starterKey6,
|
||||||
|
required int timestampSec,
|
||||||
|
}) {
|
||||||
|
return '$_prefix'
|
||||||
|
'S:${gameId.toLowerCase()}:${starterKey6.toLowerCase()}:$timestampSec';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String encodeMove({
|
||||||
|
required String gameId,
|
||||||
|
required int cell,
|
||||||
|
required String playerKey6,
|
||||||
|
required int timestampSec,
|
||||||
|
}) {
|
||||||
|
return '$_prefix'
|
||||||
|
'M:${gameId.toLowerCase()}:$cell:${playerKey6.toLowerCase()}:$timestampSec';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TicTacToeGameState {
|
||||||
|
final String gameId;
|
||||||
|
final String xPlayerKey6;
|
||||||
|
final String oPlayerKey6;
|
||||||
|
final List<String?> board; // 'X' / 'O' / null
|
||||||
|
final String nextSymbol;
|
||||||
|
final String? winnerSymbol;
|
||||||
|
|
||||||
|
const TicTacToeGameState({
|
||||||
|
required this.gameId,
|
||||||
|
required this.xPlayerKey6,
|
||||||
|
required this.oPlayerKey6,
|
||||||
|
required this.board,
|
||||||
|
required this.nextSymbol,
|
||||||
|
this.winnerSymbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isDraw =>
|
||||||
|
winnerSymbol == null && board.every((cell) => cell != null);
|
||||||
|
bool get isFinished => winnerSymbol != null || isDraw;
|
||||||
|
}
|
||||||
|
|
||||||
|
TicTacToeGameState buildTicTacToeState({
|
||||||
|
required String gameId,
|
||||||
|
required String xPlayerKey6,
|
||||||
|
required String oPlayerKey6,
|
||||||
|
required List<TicTacToeEvent> events,
|
||||||
|
}) {
|
||||||
|
final board = List<String?>.filled(9, null);
|
||||||
|
var next = 'X';
|
||||||
|
String? winner;
|
||||||
|
|
||||||
|
final sorted = [...events]
|
||||||
|
..sort((a, b) => a.timestampSec.compareTo(b.timestampSec));
|
||||||
|
|
||||||
|
for (final event in sorted) {
|
||||||
|
if (event.type != TicTacToeEventType.move) continue;
|
||||||
|
if (winner != null) break;
|
||||||
|
|
||||||
|
final expectedKey = next == 'X' ? xPlayerKey6 : oPlayerKey6;
|
||||||
|
final cell = event.cell;
|
||||||
|
if (cell == null) continue;
|
||||||
|
if (event.playerKey6 != expectedKey) continue;
|
||||||
|
if (board[cell] != null) continue;
|
||||||
|
|
||||||
|
board[cell] = next;
|
||||||
|
winner = _computeWinner(board);
|
||||||
|
next = next == 'X' ? 'O' : 'X';
|
||||||
|
}
|
||||||
|
|
||||||
|
return TicTacToeGameState(
|
||||||
|
gameId: gameId,
|
||||||
|
xPlayerKey6: xPlayerKey6,
|
||||||
|
oPlayerKey6: oPlayerKey6,
|
||||||
|
board: board,
|
||||||
|
nextSymbol: next,
|
||||||
|
winnerSymbol: winner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _computeWinner(List<String?> board) {
|
||||||
|
const lines = <List<int>>[
|
||||||
|
[0, 1, 2],
|
||||||
|
[3, 4, 5],
|
||||||
|
[6, 7, 8],
|
||||||
|
[0, 3, 6],
|
||||||
|
[1, 4, 7],
|
||||||
|
[2, 5, 8],
|
||||||
|
[0, 4, 8],
|
||||||
|
[2, 4, 6],
|
||||||
|
];
|
||||||
|
for (final line in lines) {
|
||||||
|
final a = board[line[0]];
|
||||||
|
if (a == null) continue;
|
||||||
|
if (a == board[line[1]] && a == board[line[2]]) return a;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
157
lib/utils/transmission_target_resolver.dart
Normal file
157
lib/utils/transmission_target_resolver.dart
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
|
|
||||||
|
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar }
|
||||||
|
|
||||||
|
class TransmissionTargetResolution {
|
||||||
|
final Contact? target;
|
||||||
|
final TransmissionTargetFailure? failure;
|
||||||
|
final int maxHops;
|
||||||
|
|
||||||
|
const TransmissionTargetResolution({
|
||||||
|
required this.target,
|
||||||
|
required this.failure,
|
||||||
|
required this.maxHops,
|
||||||
|
});
|
||||||
|
|
||||||
|
int get hops => target?.outPathLen ?? -1;
|
||||||
|
bool get isValid => target != null && failure == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransmissionTargetResolver {
|
||||||
|
const TransmissionTargetResolver._();
|
||||||
|
|
||||||
|
static Contact? resolveLocalTarget({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required bool isSentByMe,
|
||||||
|
Uint8List? recipientPublicKey,
|
||||||
|
Uint8List? senderPublicKeyPrefix,
|
||||||
|
String? senderKey6FromEnvelope,
|
||||||
|
String? senderName,
|
||||||
|
}) {
|
||||||
|
if (isSentByMe) {
|
||||||
|
final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey);
|
||||||
|
if (recipient != null) return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope);
|
||||||
|
if (byEnvelope != null) return byEnvelope;
|
||||||
|
|
||||||
|
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
|
||||||
|
if (byPrefix != null) return byPrefix;
|
||||||
|
|
||||||
|
return _findByName(contactsProvider, senderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<TransmissionTargetResolution> resolveFetchTarget({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required Future<void> Function() refreshContacts,
|
||||||
|
required bool isSentByMe,
|
||||||
|
Uint8List? recipientPublicKey,
|
||||||
|
Uint8List? senderPublicKeyPrefix,
|
||||||
|
String? senderKey6FromEnvelope,
|
||||||
|
String? senderName,
|
||||||
|
required int maxFetchHops,
|
||||||
|
}) async {
|
||||||
|
var target = resolveLocalTarget(
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
isSentByMe: isSentByMe,
|
||||||
|
recipientPublicKey: recipientPublicKey,
|
||||||
|
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||||
|
senderKey6FromEnvelope: senderKey6FromEnvelope,
|
||||||
|
senderName: senderName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) {
|
||||||
|
await refreshContacts();
|
||||||
|
target = resolveLocalTarget(
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
isSentByMe: isSentByMe,
|
||||||
|
recipientPublicKey: recipientPublicKey,
|
||||||
|
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||||
|
senderKey6FromEnvelope: senderKey6FromEnvelope,
|
||||||
|
senderName: senderName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target == null) {
|
||||||
|
return TransmissionTargetResolution(
|
||||||
|
target: null,
|
||||||
|
failure: TransmissionTargetFailure.unknownContact,
|
||||||
|
maxHops: maxFetchHops,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (target.outPathLen < 0) {
|
||||||
|
return TransmissionTargetResolution(
|
||||||
|
target: target,
|
||||||
|
failure: TransmissionTargetFailure.unknownRoute,
|
||||||
|
maxHops: maxFetchHops,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (target.outPathLen > maxFetchHops) {
|
||||||
|
return TransmissionTargetResolution(
|
||||||
|
target: target,
|
||||||
|
failure: TransmissionTargetFailure.tooFar,
|
||||||
|
maxHops: maxFetchHops,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return TransmissionTargetResolution(
|
||||||
|
target: target,
|
||||||
|
failure: null,
|
||||||
|
maxHops: maxFetchHops,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Contact? _findByRecipientKey(
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
Uint8List? recipientKey,
|
||||||
|
) {
|
||||||
|
if (recipientKey == null || recipientKey.isEmpty) return null;
|
||||||
|
final byKey = contactsProvider.findContactByKey(recipientKey);
|
||||||
|
if (byKey != null) return byKey;
|
||||||
|
if (recipientKey.length >= 6) {
|
||||||
|
return contactsProvider.findContactByPrefix(
|
||||||
|
Uint8List.fromList(recipientKey.sublist(0, 6)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Contact? _findByEnvelopeKey6(
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
String? senderKey6FromEnvelope,
|
||||||
|
) {
|
||||||
|
if (senderKey6FromEnvelope == null || senderKey6FromEnvelope.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return contactsProvider.findContactByPrefixHex(senderKey6FromEnvelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Contact? _findByPrefix(
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
Uint8List? senderPublicKeyPrefix,
|
||||||
|
) {
|
||||||
|
if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return contactsProvider.findContactByPrefix(
|
||||||
|
Uint8List.fromList(senderPublicKeyPrefix.sublist(0, 6)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Contact? _findByName(
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
String? senderName,
|
||||||
|
) {
|
||||||
|
final normalized = senderName?.trim();
|
||||||
|
if (normalized == null || normalized.isEmpty) return null;
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.advName.trim().toLowerCase() == normalized.toLowerCase()) {
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,11 +181,11 @@ class VoicePacket {
|
|||||||
/// Lightweight public/direct message envelope advertising voice availability.
|
/// Lightweight public/direct message envelope advertising voice availability.
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
|
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1
|
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
|
||||||
class VoiceEnvelope {
|
class VoiceEnvelope {
|
||||||
static const String _prefix = 'VE1:';
|
static const String _prefix = 'VE2:';
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final VoicePacketMode mode;
|
final VoicePacketMode mode;
|
||||||
@@ -202,7 +202,7 @@ class VoiceEnvelope {
|
|||||||
required this.durationMs,
|
required this.durationMs,
|
||||||
required this.senderKey6,
|
required this.senderKey6,
|
||||||
required this.timestampSec,
|
required this.timestampSec,
|
||||||
this.version = 1,
|
this.version = 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
||||||
@@ -210,43 +210,41 @@ class VoiceEnvelope {
|
|||||||
static VoiceEnvelope? tryParseText(String text) {
|
static VoiceEnvelope? tryParseText(String text) {
|
||||||
if (!isVoiceEnvelopeText(text)) return null;
|
if (!isVoiceEnvelopeText(text)) return null;
|
||||||
final body = text.substring(_prefix.length);
|
final body = text.substring(_prefix.length);
|
||||||
return _tryParseCompact(body);
|
return _tryParse(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
static VoiceEnvelope? _tryParseCompact(String body) {
|
static VoiceEnvelope? _tryParse(String body) {
|
||||||
final parts = body.split(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 7) return null;
|
if (parts.length != 6) return null;
|
||||||
try {
|
try {
|
||||||
final sid = parts[0];
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final mode = int.tryParse(parts[1]);
|
final mode = _parseInt(parts[1], base36: true);
|
||||||
final total = int.tryParse(parts[2]);
|
final total = _parseInt(parts[2], base36: true);
|
||||||
final durMs = int.tryParse(parts[3]);
|
final durS = _parseInt(parts[3], base36: true);
|
||||||
final senderKey6 = parts[4];
|
final senderKey6 = parts[4];
|
||||||
final ts = int.tryParse(parts[5]);
|
final ts = _parseInt(parts[5], base36: true);
|
||||||
final ver = int.tryParse(parts[6]);
|
|
||||||
|
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
|
if (sid == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
|
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (total == null || total < 1 || total > 255) return null;
|
if (total == null || total < 1 || total > 255) return null;
|
||||||
if (durMs == null || durMs < 0 || durMs > 10 * 60 * 1000) return null;
|
if (durS == null || durS < 0 || durS > 10 * 60) return null;
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (ts == null || ts <= 0) return null;
|
if (ts == null || ts <= 0) return null;
|
||||||
if (ver == null || ver != 1) return null;
|
|
||||||
|
|
||||||
return VoiceEnvelope(
|
return VoiceEnvelope(
|
||||||
sessionId: sid.toLowerCase(),
|
sessionId: sid,
|
||||||
mode: VoicePacketMode.fromId(mode),
|
mode: VoicePacketMode.fromId(mode),
|
||||||
total: total,
|
total: total,
|
||||||
durationMs: durMs,
|
durationMs: durS * 1000,
|
||||||
senderKey6: senderKey6.toLowerCase(),
|
senderKey6: senderKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
timestampSec: ts,
|
||||||
version: ver,
|
version: 2,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -254,7 +252,8 @@ class VoiceEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String encodeText() {
|
String encodeText() {
|
||||||
return '$_prefix${sessionId.toLowerCase()}:${mode.id}:$total:$durationMs:${senderKey6.toLowerCase()}:$timestampSec:$version';
|
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
|
||||||
|
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,11 +414,12 @@ int _resolveBandwidthHz(int? rawBw) {
|
|||||||
/// Direct control-plane request to fetch voice packets for a session.
|
/// Direct control-plane request to fetch voice packets for a session.
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
/// VR2:{sid}:{want}:{requesterKey6}:{ts}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// VR1:00112233:a:aabbccddeeff:1234567890:1
|
/// VR2:00112233:a:aabbccddeeff:kf12oi
|
||||||
class VoiceFetchRequest {
|
class VoiceFetchRequest {
|
||||||
static const String _prefix = 'VR1:';
|
static const String _prefix = 'VR2:';
|
||||||
|
static const int _binaryMagic = 0x72; // 'r'
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final String want;
|
final String want;
|
||||||
@@ -434,42 +434,82 @@ class VoiceFetchRequest {
|
|||||||
this.missingIndices = const [],
|
this.missingIndices = const [],
|
||||||
required this.requesterKey6,
|
required this.requesterKey6,
|
||||||
required this.timestampSec,
|
required this.timestampSec,
|
||||||
this.version = 1,
|
this.version = 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
|
static bool isVoiceFetchRequestText(String text) =>
|
||||||
|
text.startsWith(_prefix);
|
||||||
|
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
||||||
|
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||||
|
|
||||||
static VoiceFetchRequest? tryParseText(String text) {
|
static VoiceFetchRequest? tryParseText(String text) {
|
||||||
if (!isVoiceFetchRequestText(text)) return null;
|
if (!isVoiceFetchRequestText(text)) return null;
|
||||||
final body = text.substring(_prefix.length);
|
final body = text.substring(_prefix.length);
|
||||||
return _tryParseCompact(body);
|
return _tryParse(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
static VoiceFetchRequest? _tryParseCompact(String body) {
|
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
|
||||||
final parts = body.split(':');
|
if (!isVoiceFetchRequestBinary(payload)) return null;
|
||||||
if (parts.length != 5) return null;
|
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
|
||||||
try {
|
try {
|
||||||
final sid = parts[0];
|
final sidBytes = payload.sublist(1, 5);
|
||||||
|
final sid = sidBytes
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
final flags = payload[5];
|
||||||
|
final requesterKey6 = payload
|
||||||
|
.sublist(6, 12)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
final ts =
|
||||||
|
(payload[12] << 24) |
|
||||||
|
(payload[13] << 16) |
|
||||||
|
(payload[14] << 8) |
|
||||||
|
payload[15];
|
||||||
|
final missingCount = payload[16];
|
||||||
|
if (payload.length != 17 + missingCount) return null;
|
||||||
|
final wantMissing = (flags & 0x01) == 0x01;
|
||||||
|
final missing = <int>[];
|
||||||
|
for (var i = 0; i < missingCount; i++) {
|
||||||
|
missing.add(payload[17 + i]);
|
||||||
|
}
|
||||||
|
return VoiceFetchRequest(
|
||||||
|
sessionId: sid,
|
||||||
|
want: wantMissing ? 'missing' : 'all',
|
||||||
|
missingIndices: missing,
|
||||||
|
requesterKey6: requesterKey6,
|
||||||
|
timestampSec: ts,
|
||||||
|
version: 2,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static VoiceFetchRequest? _tryParse(String body) {
|
||||||
|
final parts = body.split(':');
|
||||||
|
if (parts.length != 4) return null;
|
||||||
|
try {
|
||||||
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final wantToken = parts[1];
|
final wantToken = parts[1];
|
||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final ts = int.tryParse(parts[3]);
|
final ts = _parseInt(parts[3], base36: true);
|
||||||
final ver = int.tryParse(parts[4]);
|
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? 'all'
|
||||||
: (wantToken.startsWith('m-') ? 'missing' : wantToken);
|
: ((wantToken.startsWith('m'))
|
||||||
|
? 'missing'
|
||||||
|
: wantToken);
|
||||||
|
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
|
if (sid == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final missingIndices = <int>[];
|
final missingIndices = <int>[];
|
||||||
if (normalizedWant == 'missing') {
|
if (normalizedWant == 'missing') {
|
||||||
final encoded = wantToken.substring(2);
|
final encoded = wantToken.substring(1);
|
||||||
if (encoded.isEmpty) return null;
|
if (encoded.isEmpty) return null;
|
||||||
for (final raw in encoded.split(',')) {
|
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
|
||||||
final idx = int.tryParse(raw);
|
|
||||||
if (idx == null || idx < 0 || idx > 254) return null;
|
|
||||||
missingIndices.add(idx);
|
|
||||||
}
|
|
||||||
if (missingIndices.isEmpty) return null;
|
if (missingIndices.isEmpty) return null;
|
||||||
} else if (normalizedWant != 'all') {
|
} else if (normalizedWant != 'all') {
|
||||||
return null;
|
return null;
|
||||||
@@ -478,15 +518,14 @@ class VoiceFetchRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (ts == null || ts <= 0) return null;
|
if (ts == null || ts <= 0) return null;
|
||||||
if (ver == null || ver != 1) return null;
|
|
||||||
|
|
||||||
return VoiceFetchRequest(
|
return VoiceFetchRequest(
|
||||||
sessionId: sid.toLowerCase(),
|
sessionId: sid,
|
||||||
want: normalizedWant,
|
want: normalizedWant,
|
||||||
missingIndices: missingIndices,
|
missingIndices: missingIndices,
|
||||||
requesterKey6: requesterKey6.toLowerCase(),
|
requesterKey6: requesterKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
timestampSec: ts,
|
||||||
version: ver,
|
version: 2,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -495,10 +534,168 @@ class VoiceFetchRequest {
|
|||||||
|
|
||||||
String encodeText() {
|
String encodeText() {
|
||||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||||
? 'm-${missingIndices.join(',')}'
|
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||||
: (want == 'all' ? 'a' : want);
|
: (want == 'all' ? 'a' : want);
|
||||||
return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
|
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Uint8List encodeBinary() {
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||||
|
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||||
|
}
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
requesterKey6,
|
||||||
|
'requesterKey6',
|
||||||
|
'Expected 12 hex chars',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
|
||||||
|
final missing = useMissing
|
||||||
|
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||||
|
: <int>[];
|
||||||
|
|
||||||
|
final out = Uint8List(17 + missing.length);
|
||||||
|
out[0] = _binaryMagic;
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||||
|
}
|
||||||
|
out[5] = useMissing ? 0x01 : 0x00;
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
out[6 + i] = int.parse(
|
||||||
|
requesterKey6.substring(i * 2, i * 2 + 2),
|
||||||
|
radix: 16,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out[12] = (timestampSec >> 24) & 0xFF;
|
||||||
|
out[13] = (timestampSec >> 16) & 0xFF;
|
||||||
|
out[14] = (timestampSec >> 8) & 0xFF;
|
||||||
|
out[15] = timestampSec & 0xFF;
|
||||||
|
out[16] = missing.length;
|
||||||
|
for (var i = 0; i < missing.length; i++) {
|
||||||
|
out[17 + i] = missing[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-fragment ACK for raw voice payload packets.
|
||||||
|
///
|
||||||
|
/// Binary format:
|
||||||
|
/// [0x76 'v'][sessionId:4B][index:1B]
|
||||||
|
class VoiceFragmentAck {
|
||||||
|
static const int _binaryMagic = 0x76; // 'v'
|
||||||
|
|
||||||
|
final String sessionId; // 8 hex chars
|
||||||
|
final int index; // 0..254
|
||||||
|
|
||||||
|
const VoiceFragmentAck({required this.sessionId, required this.index});
|
||||||
|
|
||||||
|
static bool isVoiceFragmentAckBinary(Uint8List payload) =>
|
||||||
|
payload.length == 6 && payload[0] == _binaryMagic;
|
||||||
|
|
||||||
|
static VoiceFragmentAck? tryParseBinary(Uint8List payload) {
|
||||||
|
if (!isVoiceFragmentAckBinary(payload)) return null;
|
||||||
|
try {
|
||||||
|
final sid = payload
|
||||||
|
.sublist(1, 5)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
final idx = payload[5];
|
||||||
|
return VoiceFragmentAck(sessionId: sid, index: idx);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List encodeBinary() {
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||||
|
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||||
|
}
|
||||||
|
if (index < 0 || index > 254) {
|
||||||
|
throw ArgumentError.value(index, 'index', 'Expected 0..254');
|
||||||
|
}
|
||||||
|
final out = Uint8List(6);
|
||||||
|
out[0] = _binaryMagic;
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||||
|
}
|
||||||
|
out[5] = index;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _parseInt(String token, {required bool base36}) =>
|
||||||
|
int.tryParse(token, radix: base36 ? 36 : 10);
|
||||||
|
|
||||||
|
String _toBase36(int value) => value.toRadixString(36);
|
||||||
|
|
||||||
|
String _encodeSessionId(String sessionIdHex) {
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||||
|
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
|
||||||
|
}
|
||||||
|
final value = int.parse(sessionIdHex, radix: 16);
|
||||||
|
return value.toRadixString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _decodeSessionId(String token) {
|
||||||
|
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
|
||||||
|
final value = int.tryParse(token, radix: 36);
|
||||||
|
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
|
||||||
|
return value.toRadixString(16).padLeft(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||||
|
final sorted = indices
|
||||||
|
.where((v) => v >= 0 && v <= 254)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
if (sorted.isEmpty) return '';
|
||||||
|
final chunks = <String>[];
|
||||||
|
var start = sorted.first;
|
||||||
|
var prev = sorted.first;
|
||||||
|
for (var i = 1; i < sorted.length; i++) {
|
||||||
|
final curr = sorted[i];
|
||||||
|
if (curr == prev + 1) {
|
||||||
|
prev = curr;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunks.add(
|
||||||
|
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||||
|
);
|
||||||
|
start = curr;
|
||||||
|
prev = curr;
|
||||||
|
}
|
||||||
|
chunks.add(
|
||||||
|
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||||
|
);
|
||||||
|
return chunks.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _decodeMissingIndicesCompact(String encoded) {
|
||||||
|
final out = <int>[];
|
||||||
|
for (final token in encoded.split('.')) {
|
||||||
|
if (token.isEmpty) continue;
|
||||||
|
if (!token.contains('-')) {
|
||||||
|
final value = int.tryParse(token, radix: 36);
|
||||||
|
if (value == null || value < 0 || value > 254) return const [];
|
||||||
|
out.add(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final parts = token.split('-');
|
||||||
|
if (parts.length != 2) return const [];
|
||||||
|
final start = int.tryParse(parts[0], radix: 36);
|
||||||
|
final end = int.tryParse(parts[1], radix: 36);
|
||||||
|
if (start == null || end == null || start < 0 || end > 254 || start > end) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
for (var i = start; i <= end; i++) {
|
||||||
|
out.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a compact visual waveform from real voice packet bytes.
|
/// Builds a compact visual waveform from real voice packet bytes.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ConnectionDialog extends StatefulWidget {
|
|||||||
class _ConnectionDialogState extends State<ConnectionDialog>
|
class _ConnectionDialogState extends State<ConnectionDialog>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
late final ConnectionProvider _connectionProvider;
|
||||||
final NetworkScannerService _networkScanner = NetworkScannerService();
|
final NetworkScannerService _networkScanner = NetworkScannerService();
|
||||||
final List<DiscoveredServer> _discoveredServers = [];
|
final List<DiscoveredServer> _discoveredServers = [];
|
||||||
int _scannedCount = 0;
|
int _scannedCount = 0;
|
||||||
@@ -46,13 +47,14 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_connectionProvider = Provider.of<ConnectionProvider>(context, listen: false);
|
||||||
|
|
||||||
// Start BLE scan by default
|
// Defer scan startup until after the first frame so Provider listeners
|
||||||
final connectionProvider = Provider.of<ConnectionProvider>(
|
// are not notified while this dialog is still being built.
|
||||||
context,
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
listen: false,
|
if (!mounted) return;
|
||||||
);
|
_connectionProvider.startScan();
|
||||||
connectionProvider.startScan();
|
});
|
||||||
|
|
||||||
// Set up network scanner callbacks
|
// Set up network scanner callbacks
|
||||||
_networkScanner.onServerDiscovered = (server) {
|
_networkScanner.onServerDiscovered = (server) {
|
||||||
@@ -81,11 +83,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
final connectionProvider = Provider.of<ConnectionProvider>(
|
_connectionProvider.stopScan();
|
||||||
context,
|
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
connectionProvider.stopScan();
|
|
||||||
_networkScanner.stopScan();
|
_networkScanner.stopScan();
|
||||||
// Remove listener before disposing to prevent memory leaks
|
// Remove listener before disposing to prevent memory leaks
|
||||||
_tabController.removeListener(_onTabChanged);
|
_tabController.removeListener(_onTabChanged);
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_avif/flutter_avif.dart';
|
import 'package:flutter_avif/flutter_avif.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../models/contact.dart';
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/image_provider.dart' as ip;
|
import '../../providers/image_provider.dart' as ip;
|
||||||
import '../../utils/image_message_parser.dart';
|
import '../../utils/image_message_parser.dart';
|
||||||
|
import '../../utils/transmission_target_resolver.dart';
|
||||||
import 'transfer_timeout.dart';
|
import 'transfer_timeout.dart';
|
||||||
|
|
||||||
/// A message bubble that shows a received or sent image.
|
/// A message bubble that shows a received or sent image.
|
||||||
///
|
///
|
||||||
/// On first render the image is not yet fetched (only the IE1 envelope is
|
/// On first render the image is not yet fetched (only the IE2 envelope is
|
||||||
/// known). The user taps the thumbnail placeholder → IR1 fetch request is
|
/// known). The user taps the thumbnail placeholder → IR2 fetch request is
|
||||||
/// sent → binary fragments stream in → bubble rebuilds with the full image.
|
/// sent → binary fragments stream in → bubble rebuilds with the full image.
|
||||||
class ImageMessageBubble extends StatefulWidget {
|
class ImageMessageBubble extends StatefulWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
@@ -58,12 +58,32 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
|
|
||||||
return Consumer<ip.ImageProvider>(
|
return Consumer<ip.ImageProvider>(
|
||||||
builder: (context, imageProvider, _) {
|
builder: (context, imageProvider, _) {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final session = imageProvider.session(envelope.sessionId);
|
final session = imageProvider.session(envelope.sessionId);
|
||||||
|
final sender = TransmissionTargetResolver.resolveLocalTarget(
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
isSentByMe: widget.isSentByMe,
|
||||||
|
recipientPublicKey: widget.message.recipientPublicKey,
|
||||||
|
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||||
|
senderKey6FromEnvelope: envelope.senderKey6,
|
||||||
|
senderName: widget.message.senderName,
|
||||||
|
);
|
||||||
|
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||||
|
? sender.outPathLen
|
||||||
|
: widget.message.pathLen;
|
||||||
final isComplete = imageProvider.isComplete(envelope.sessionId);
|
final isComplete = imageProvider.isComplete(envelope.sessionId);
|
||||||
|
final eta = imageProvider.estimateRemainingTransferTime(
|
||||||
|
envelope.sessionId,
|
||||||
|
);
|
||||||
|
|
||||||
if (_isRequesting && isComplete) {
|
if (_isRequesting && isComplete) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) setState(() => _isRequesting = false);
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = false;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +115,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
radioBw: radioBw,
|
radioBw: radioBw,
|
||||||
radioSf: radioSf,
|
radioSf: radioSf,
|
||||||
radioCr: radioCr,
|
radioCr: radioCr,
|
||||||
|
pathLen: effectivePathLen,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -106,12 +127,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
received: received,
|
received: received,
|
||||||
total: total,
|
total: total,
|
||||||
envelope: envelope,
|
envelope: envelope,
|
||||||
pathLen: widget.message.pathLen,
|
|
||||||
radioBw: radioBw,
|
radioBw: radioBw,
|
||||||
radioSf: radioSf,
|
radioSf: radioSf,
|
||||||
radioCr: radioCr,
|
radioCr: radioCr,
|
||||||
error: _errorText,
|
error: _errorText,
|
||||||
isSentByMe: widget.isSentByMe,
|
isSentByMe: widget.isSentByMe,
|
||||||
|
eta: eta,
|
||||||
|
pathLen: effectivePathLen,
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -139,6 +161,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
required int? radioBw,
|
required int? radioBw,
|
||||||
required int? radioSf,
|
required int? radioSf,
|
||||||
required int? radioCr,
|
required int? radioCr,
|
||||||
|
required int pathLen,
|
||||||
}) {
|
}) {
|
||||||
if (isComplete && imageBytes != null) {
|
if (isComplete && imageBytes != null) {
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
@@ -170,8 +193,38 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
'$received/$total',
|
'$received/$total',
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => _cancelReceive(envelope.sessionId),
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
color: Colors.white70,
|
||||||
|
tooltip: 'Cancel image receive',
|
||||||
|
),
|
||||||
|
),
|
||||||
] else if (_errorText != null) ...[
|
] else if (_errorText != null) ...[
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
const Icon(Icons.broken_image, color: Colors.red, size: 36),
|
const Icon(Icons.broken_image, color: Colors.red, size: 36),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _requestAndFetch(
|
||||||
|
envelope,
|
||||||
|
radioBw: radioBw,
|
||||||
|
radioSf: radioSf,
|
||||||
|
radioCr: radioCr,
|
||||||
|
pathLen: pathLen,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
// Tap-to-load icon.
|
// Tap-to-load icon.
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -180,7 +233,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
radioBw: radioBw,
|
radioBw: radioBw,
|
||||||
radioSf: radioSf,
|
radioSf: radioSf,
|
||||||
radioCr: radioCr,
|
radioCr: radioCr,
|
||||||
pathLen: widget.message.pathLen,
|
pathLen: pathLen,
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.download_rounded, size: 40),
|
icon: const Icon(Icons.download_rounded, size: 40),
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
@@ -201,34 +254,45 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
int pathLen = 0,
|
int pathLen = 0,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isRequesting) return;
|
if (_isRequesting) return;
|
||||||
var sender = _resolveSender(envelope);
|
|
||||||
if (sender == null) {
|
|
||||||
final conn = context.read<ConnectionProvider>();
|
final conn = context.read<ConnectionProvider>();
|
||||||
await conn.getContacts();
|
final imageProvider = context.read<ip.ImageProvider>();
|
||||||
|
imageProvider.resumeIncomingSession(envelope.sessionId);
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
refreshContacts: conn.getContacts,
|
||||||
|
isSentByMe: widget.isSentByMe,
|
||||||
|
recipientPublicKey: widget.message.recipientPublicKey,
|
||||||
|
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||||
|
senderKey6FromEnvelope: envelope.senderKey6,
|
||||||
|
senderName: widget.message.senderName,
|
||||||
|
maxFetchHops: _maxFetchHops,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
sender = _resolveSender(envelope);
|
|
||||||
}
|
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||||
if (sender == null) {
|
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch image',
|
'Cannot fetch image',
|
||||||
'Sender contact is unknown. Sync contacts first.',
|
'Sender contact is unknown. Sync contacts first.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sender.outPathLen < 0) {
|
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch image',
|
'Cannot fetch image',
|
||||||
'Sender route is unknown. Sync contacts/path first.',
|
'Sender route is unknown. Sync contacts/path first.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sender.outPathLen > _maxFetchHops) {
|
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch image',
|
'Cannot fetch image',
|
||||||
'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).',
|
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final sender = resolution.target!;
|
||||||
if (sender.outPathLen >= 2) {
|
if (sender.outPathLen >= 2) {
|
||||||
_showToast(
|
_showToast(
|
||||||
'Image fetch over ${sender.outPathLen} hops may take a while.',
|
'Image fetch over ${sender.outPathLen} hops may take a while.',
|
||||||
@@ -236,8 +300,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _errorText = null);
|
setState(() => _errorText = null);
|
||||||
final conn = context.read<ConnectionProvider>();
|
|
||||||
final imageProvider = context.read<ip.ImageProvider>();
|
|
||||||
final deviceKey = conn.deviceInfo.publicKey;
|
final deviceKey = conn.deviceInfo.publicKey;
|
||||||
if (deviceKey == null || deviceKey.length < 6) {
|
if (deviceKey == null || deviceKey.length < 6) {
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
@@ -275,26 +337,35 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
_errorText = null;
|
_errorText = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final sent = await conn.sendTextMessage(
|
final payload = request.encodeBinary();
|
||||||
contactPublicKey: sender.publicKey,
|
try {
|
||||||
text: request.encode(),
|
await conn.sendRawVoicePacket(
|
||||||
contact: sender,
|
contactPath: sender.outPath,
|
||||||
|
contactPathLen: sender.outPathLen,
|
||||||
|
payload: payload,
|
||||||
);
|
);
|
||||||
if (!sent && mounted) {
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
_showToast('Image fetch failed to send request');
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRequesting = false;
|
_isRequesting = false;
|
||||||
_errorText = 'Image unavailable right now';
|
_errorText = 'Image unavailable right now';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||||
|
final effectivePathLen = sender.outPathLen >= 0
|
||||||
|
? sender.outPathLen
|
||||||
|
: pathLen;
|
||||||
final txEstimate = estimateImageTransmitDuration(
|
final txEstimate = estimateImageTransmitDuration(
|
||||||
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
|
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
|
||||||
sizeBytes: missing.isEmpty
|
sizeBytes: missing.isEmpty
|
||||||
? envelope.sizeBytes
|
? envelope.sizeBytes
|
||||||
: (envelope.sizeBytes * missing.length / envelope.total).round(),
|
: (envelope.sizeBytes * missing.length / envelope.total).round(),
|
||||||
pathLen: pathLen,
|
pathLen: effectivePathLen,
|
||||||
radioBw: radioBw,
|
radioBw: radioBw,
|
||||||
radioSf: radioSf,
|
radioSf: radioSf,
|
||||||
radioCr: radioCr,
|
radioCr: radioCr,
|
||||||
@@ -306,38 +377,16 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
if (mounted &&
|
if (mounted &&
|
||||||
_isRequesting &&
|
_isRequesting &&
|
||||||
!imageProvider.isComplete(envelope.sessionId)) {
|
!imageProvider.isComplete(envelope.sessionId)) {
|
||||||
setState(() => _isRequesting = false);
|
_showToast('Image fetch timed out');
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = false;
|
||||||
|
_errorText = 'Image fetch timed out';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact? _resolveSender(ImageEnvelope envelope) {
|
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
|
||||||
final senderPrefix = widget.message.senderPublicKeyPrefix;
|
|
||||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
|
||||||
final c = contactsProvider.findContactByPrefix(
|
|
||||||
Uint8List.fromList(senderPrefix.sublist(0, 6)),
|
|
||||||
);
|
|
||||||
if (c != null) return c;
|
|
||||||
}
|
|
||||||
final contact = contactsProvider.findContactByPrefixHex(
|
|
||||||
envelope.senderKey6,
|
|
||||||
);
|
|
||||||
if (contact != null) return contact;
|
|
||||||
|
|
||||||
final senderName = widget.message.senderName?.trim();
|
|
||||||
if (senderName != null && senderName.isNotEmpty) {
|
|
||||||
for (final c in contactsProvider.contacts) {
|
|
||||||
if (c.advName.trim().toLowerCase() == senderName.toLowerCase()) {
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showToast(String message) {
|
void _showToast(String message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -345,8 +394,20 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _cancelReceive(String sessionId) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_requestTimeoutTimer?.cancel();
|
||||||
|
context.read<ip.ImageProvider>().cancelIncomingSession(sessionId);
|
||||||
|
_showToast('Image receive canceled');
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = false;
|
||||||
|
_errorText = 'Image receive canceled';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showBlockingAlert(String title, String message) async {
|
Future<void> _showBlockingAlert(String title, String message) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
_showToast('$title: $message');
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
@@ -374,6 +435,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
required int? radioCr,
|
required int? radioCr,
|
||||||
required String? error,
|
required String? error,
|
||||||
required bool isSentByMe,
|
required bool isSentByMe,
|
||||||
|
required Duration? eta,
|
||||||
}) {
|
}) {
|
||||||
final txEstimate = estimateImageTransmitDuration(
|
final txEstimate = estimateImageTransmitDuration(
|
||||||
fragmentCount: envelope.total,
|
fragmentCount: envelope.total,
|
||||||
@@ -386,7 +448,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||||
|
|
||||||
if (error != null) return error;
|
if (error != null) return error;
|
||||||
if (isRequesting) return '📥 Loading… $received/$total · $txEstimateLabel';
|
if (isRequesting) {
|
||||||
|
final etaLabel = _formatEta(eta);
|
||||||
|
return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel';
|
||||||
|
}
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
final base =
|
final base =
|
||||||
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
|
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
|
||||||
@@ -404,6 +469,14 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
return '~${minutes}m ${seconds}s tx';
|
return '~${minutes}m ${seconds}s tx';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _formatEta(Duration? eta) {
|
||||||
|
if (eta == null || eta <= Duration.zero) return 'ETA --';
|
||||||
|
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
|
||||||
|
final minutes = eta.inMinutes;
|
||||||
|
final seconds = eta.inSeconds % 60;
|
||||||
|
return 'ETA ~${minutes}m ${seconds}s';
|
||||||
|
}
|
||||||
|
|
||||||
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
|
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
|
||||||
showGeneralDialog<void>(
|
showGeneralDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ import '../../utils/sar_message_parser.dart';
|
|||||||
import '../../utils/key_comparison.dart';
|
import '../../utils/key_comparison.dart';
|
||||||
import '../../utils/voice_message_parser.dart';
|
import '../../utils/voice_message_parser.dart';
|
||||||
import '../../utils/image_message_parser.dart';
|
import '../../utils/image_message_parser.dart';
|
||||||
|
import '../../utils/tictactoe_message_parser.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../utils/message_extensions.dart';
|
import '../../utils/message_extensions.dart';
|
||||||
import 'voice_message_bubble.dart';
|
import 'voice_message_bubble.dart';
|
||||||
import 'image_message_bubble.dart';
|
import 'image_message_bubble.dart';
|
||||||
|
import 'tictactoe_message_bubble.dart';
|
||||||
import 'message_trace_sheet.dart';
|
import 'message_trace_sheet.dart';
|
||||||
|
|
||||||
/// Reusable message bubble widget that displays messages with various types:
|
/// Reusable message bubble widget that displays messages with various types:
|
||||||
@@ -466,7 +468,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
'Sent message: ${widget.message.isSentMessage}',
|
'Sent message: ${widget.message.isSentMessage}',
|
||||||
'Read: ${widget.message.isRead}',
|
'Read: ${widget.message.isRead}',
|
||||||
'Status: ${widget.message.deliveryStatus.name}',
|
'Status: ${widget.message.deliveryStatus.name}',
|
||||||
'Path length (nodes/hops): ${widget.message.pathLen}',
|
'Path length (nodes/hops): ${_hopDebugLabel(widget.message)}',
|
||||||
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
|
'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})',
|
||||||
'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
|
'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}',
|
||||||
'Channel index: ${widget.message.channelIdx ?? '-'}',
|
'Channel index: ${widget.message.channelIdx ?? '-'}',
|
||||||
@@ -633,8 +635,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
_techBadge(
|
_techBadge(
|
||||||
context,
|
context,
|
||||||
icon: Icons.route,
|
icon: Icons.route,
|
||||||
label:
|
label: _hopDisplayLabel(widget.message),
|
||||||
'${widget.message.pathLen} hop${widget.message.pathLen == 1 ? '' : 's'}',
|
|
||||||
),
|
),
|
||||||
_techBadge(
|
_techBadge(
|
||||||
context,
|
context,
|
||||||
@@ -1525,9 +1526,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
required int? rssiDbm,
|
required int? rssiDbm,
|
||||||
required double? snrDb,
|
required double? snrDb,
|
||||||
}) {
|
}) {
|
||||||
final hopLabel = message.pathLen == 0
|
final hopLabel = _hopDisplayLabel(message);
|
||||||
? 'Direct'
|
|
||||||
: '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
|
||||||
|
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
@@ -1568,6 +1567,21 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _hopDisplayLabel(Message message) {
|
||||||
|
if (message.pathLen == 0) return 'Direct';
|
||||||
|
if (message.pathLen >= 255 && message.isContactMessage) return 'Direct';
|
||||||
|
if (message.pathLen >= 255) return 'Unknown';
|
||||||
|
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _hopDebugLabel(Message message) {
|
||||||
|
if (message.pathLen >= 255 && message.isContactMessage) {
|
||||||
|
return 'Direct (raw: ${message.pathLen})';
|
||||||
|
}
|
||||||
|
if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})';
|
||||||
|
return _hopDisplayLabel(message);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _techChip(
|
Widget _techChip(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
@@ -1680,6 +1694,13 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final message = widget.message;
|
final message = widget.message;
|
||||||
|
final ticTacToeEvent = message.isContactMessage
|
||||||
|
? TicTacToeMessageParser.tryParse(message.text)
|
||||||
|
: null;
|
||||||
|
if (ticTacToeEvent?.type == TicTacToeEventType.move) {
|
||||||
|
// Hide move control packets from chat; the game bubble updates itself.
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
final isSarMarker = message.isSarMarker;
|
final isSarMarker = message.isSarMarker;
|
||||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
@@ -1727,9 +1748,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
: message.getRichDisplayName(senderContact);
|
: message.getRichDisplayName(senderContact);
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
// For sent direct/channel messages, look up destination display label
|
// Look up destination/source display labels for direct/channel messages
|
||||||
dynamic recipientContact;
|
dynamic recipientContact;
|
||||||
String? recipientDisplayName;
|
String? recipientDisplayName;
|
||||||
|
String? channelDisplayName;
|
||||||
if (isOwnMessage &&
|
if (isOwnMessage &&
|
||||||
message.isContactMessage &&
|
message.isContactMessage &&
|
||||||
message.recipientPublicKey != null) {
|
message.recipientPublicKey != null) {
|
||||||
@@ -1757,30 +1779,47 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
recipientContact.displayName ?? recipientContact.advName;
|
recipientContact.displayName ?? recipientContact.advName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isOwnMessage && message.isChannelMessage) {
|
} else if (message.isChannelMessage) {
|
||||||
if (message.channelIdx == 0) {
|
if (message.channelIdx == 0) {
|
||||||
recipientDisplayName = l10n.publicChannel;
|
channelDisplayName = l10n.publicChannel;
|
||||||
} else {
|
} else {
|
||||||
final channelContact = contactsProvider.channels.where((c) {
|
final channelContact = contactsProvider.channels.where((c) {
|
||||||
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx;
|
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx;
|
||||||
}).firstOrNull;
|
}).firstOrNull;
|
||||||
recipientDisplayName =
|
channelDisplayName =
|
||||||
channelContact?.getLocalizedDisplayName(context) ??
|
channelContact?.getLocalizedDisplayName(context) ??
|
||||||
'${l10n.channel} ${message.channelIdx}';
|
'${l10n.channel} ${message.channelIdx}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOwnMessage) {
|
||||||
|
recipientDisplayName = channelDisplayName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final recipientSubtitle =
|
final recipientSubtitle =
|
||||||
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
|
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
|
||||||
? '${l10n.channel}: $recipientDisplayName'
|
? '${l10n.channel}: $recipientDisplayName'
|
||||||
: recipientDisplayName;
|
: recipientDisplayName;
|
||||||
|
final receivedChannelSubtitle =
|
||||||
|
!isOwnMessage && message.isChannelMessage && channelDisplayName != null
|
||||||
|
? '${l10n.channel}: $channelDisplayName'
|
||||||
|
: null;
|
||||||
|
|
||||||
return GestureDetector(
|
final shouldFloatBubble = message.isChannelMessage || widget.isCompact;
|
||||||
|
final bubble = ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: shouldFloatBubble
|
||||||
|
? MediaQuery.of(context).size.width * 0.78
|
||||||
|
: double.infinity,
|
||||||
|
),
|
||||||
|
child: GestureDetector(
|
||||||
onTap: () => _handleBubbleTap(
|
onTap: () => _handleBubbleTap(
|
||||||
isSarMarker: isSarMarker,
|
isSarMarker: isSarMarker,
|
||||||
isDrawing: message.isDrawing,
|
isDrawing: message.isDrawing,
|
||||||
),
|
),
|
||||||
onLongPress: widget.isCompact ? null : () => _showMessageOptions(context),
|
onLongPress: widget.isCompact
|
||||||
|
? null
|
||||||
|
: () => _showMessageOptions(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
@@ -1844,7 +1883,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
BoxShadow(
|
BoxShadow(
|
||||||
color:
|
color:
|
||||||
(isSarMarker
|
(isSarMarker
|
||||||
? _getSarMarkerBorderColor(context, isDarkMode)
|
? _getSarMarkerBorderColor(
|
||||||
|
context,
|
||||||
|
isDarkMode,
|
||||||
|
)
|
||||||
: Theme.of(context).colorScheme.primary)
|
: Theme.of(context).colorScheme.primary)
|
||||||
.withValues(alpha: 0.3),
|
.withValues(alpha: 0.3),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
@@ -1975,15 +2017,17 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
// Show destination for sent direct/channel messages on a separate line
|
// Show destination/source context on a separate line.
|
||||||
if (isOwnMessage &&
|
if (!widget.isCompact &&
|
||||||
recipientSubtitle != null &&
|
(recipientSubtitle != null ||
|
||||||
!widget.isCompact) ...[
|
receivedChannelSubtitle != null)) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.arrow_forward,
|
isOwnMessage
|
||||||
|
? Icons.arrow_forward
|
||||||
|
: Icons.arrow_back,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
@@ -1994,7 +2038,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
recipientSubtitle,
|
isOwnMessage
|
||||||
|
? recipientSubtitle!
|
||||||
|
: receivedChannelSubtitle!,
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
@@ -2180,6 +2226,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
else if (ImageEnvelope.isEnvelope(message.text) &&
|
else if (ImageEnvelope.isEnvelope(message.text) &&
|
||||||
!widget.isCompact)
|
!widget.isCompact)
|
||||||
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
|
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||||
|
// Tic-Tac-Toe control message content
|
||||||
|
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
|
||||||
|
!widget.isCompact)
|
||||||
|
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||||
// Regular message content
|
// Regular message content
|
||||||
else if (!message.isDrawing || widget.isCompact)
|
else if (!message.isDrawing || widget.isCompact)
|
||||||
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
|
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
@@ -2449,6 +2499,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldFloatBubble) {
|
||||||
|
return bubble;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: isOwnMessage
|
||||||
|
? MainAxisAlignment.end
|
||||||
|
: MainAxisAlignment.start,
|
||||||
|
children: [Flexible(child: bubble)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
297
lib/widgets/messages/tictactoe_message_bubble.dart
Normal file
297
lib/widgets/messages/tictactoe_message_bubble.dart
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../models/contact.dart';
|
||||||
|
import '../../models/message.dart';
|
||||||
|
import '../../providers/connection_provider.dart';
|
||||||
|
import '../../providers/contacts_provider.dart';
|
||||||
|
import '../../providers/messages_provider.dart';
|
||||||
|
import '../../utils/tictactoe_message_parser.dart';
|
||||||
|
import '../../utils/toast_logger.dart';
|
||||||
|
|
||||||
|
class TicTacToeMessageBubble extends StatelessWidget {
|
||||||
|
final Message message;
|
||||||
|
final bool isSentByMe;
|
||||||
|
|
||||||
|
const TicTacToeMessageBubble({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
required this.isSentByMe,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final event = TicTacToeMessageParser.tryParse(message.text);
|
||||||
|
if (event == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final conn = context.watch<ConnectionProvider>();
|
||||||
|
final contacts = context.read<ContactsProvider>();
|
||||||
|
final messages = context.watch<MessagesProvider>().messages;
|
||||||
|
final selfKey = conn.deviceInfo.publicKey;
|
||||||
|
if (selfKey == null || selfKey.length < 6) {
|
||||||
|
return const Text('Tic-Tac-Toe unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
final selfKey6 = _key6Hex(selfKey);
|
||||||
|
final opponent = _resolveOpponentContact(
|
||||||
|
message: message,
|
||||||
|
contactsProvider: contacts,
|
||||||
|
isSentByMe: isSentByMe,
|
||||||
|
);
|
||||||
|
if (opponent == null) {
|
||||||
|
return const Text('Tic-Tac-Toe: opponent unknown');
|
||||||
|
}
|
||||||
|
final opponentKey6 = _key6Hex(opponent.publicKey);
|
||||||
|
|
||||||
|
final gameEvents = <TicTacToeEvent>[];
|
||||||
|
TicTacToeEvent? start;
|
||||||
|
for (final m in messages) {
|
||||||
|
if (!m.isContactMessage) continue;
|
||||||
|
final parsed = TicTacToeMessageParser.tryParse(m.text);
|
||||||
|
if (parsed == null || parsed.gameId != event.gameId) continue;
|
||||||
|
if (!_isSameDmThread(
|
||||||
|
message: m,
|
||||||
|
selfKey: selfKey,
|
||||||
|
opponentKey6: opponentKey6,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parsed.type == TicTacToeEventType.start) {
|
||||||
|
start ??= parsed;
|
||||||
|
} else {
|
||||||
|
gameEvents.add(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start ??= event.type == TicTacToeEventType.start ? event : null;
|
||||||
|
if (start == null) {
|
||||||
|
return const Text('Tic-Tac-Toe: waiting for start');
|
||||||
|
}
|
||||||
|
|
||||||
|
final xPlayer = start.playerKey6;
|
||||||
|
final oPlayer = xPlayer == selfKey6 ? opponentKey6 : selfKey6;
|
||||||
|
final state = buildTicTacToeState(
|
||||||
|
gameId: event.gameId,
|
||||||
|
xPlayerKey6: xPlayer,
|
||||||
|
oPlayerKey6: oPlayer,
|
||||||
|
events: gameEvents,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O';
|
||||||
|
final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol;
|
||||||
|
final titleColor = isSentByMe
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurface;
|
||||||
|
final statusColor = isSentByMe
|
||||||
|
? colorScheme.onPrimaryContainer.withValues(alpha: 0.85)
|
||||||
|
: colorScheme.onSurface.withValues(alpha: 0.85);
|
||||||
|
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 230),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Tic-Tac-Toe · Game ${state.gameId}',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: titleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_BoardGrid(
|
||||||
|
board: state.board,
|
||||||
|
enabled: isMyTurn,
|
||||||
|
isSentByMe: isSentByMe,
|
||||||
|
onTapCell: (idx) => _onCellTap(
|
||||||
|
context: context,
|
||||||
|
idx: idx,
|
||||||
|
state: state,
|
||||||
|
selfKey6: selfKey6,
|
||||||
|
opponent: opponent,
|
||||||
|
connectionProvider: conn,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_statusText(state: state, mySymbol: mySymbol),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: statusColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onCellTap({
|
||||||
|
required BuildContext context,
|
||||||
|
required int idx,
|
||||||
|
required TicTacToeGameState state,
|
||||||
|
required String selfKey6,
|
||||||
|
required Contact opponent,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) async {
|
||||||
|
if (idx < 0 || idx > 8 || state.board[idx] != null) return;
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
ToastLogger.error(context, 'Not connected to device');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
final text = TicTacToeMessageParser.encodeMove(
|
||||||
|
gameId: state.gameId,
|
||||||
|
cell: idx,
|
||||||
|
playerKey6: selfKey6,
|
||||||
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
final messageId = '${DateTime.now().millisecondsSinceEpoch}_ttt_move';
|
||||||
|
final senderPublicKeyPrefix = connectionProvider.deviceInfo.publicKey!
|
||||||
|
.sublist(0, 6);
|
||||||
|
|
||||||
|
final sentMessage = Message(
|
||||||
|
id: messageId,
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
text: text,
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
recipientPublicKey: opponent.publicKey,
|
||||||
|
);
|
||||||
|
messagesProvider.addSentMessage(sentMessage);
|
||||||
|
|
||||||
|
final sent = await connectionProvider.sendTextMessage(
|
||||||
|
contactPublicKey: opponent.publicKey,
|
||||||
|
text: text,
|
||||||
|
messageId: messageId,
|
||||||
|
contact: opponent,
|
||||||
|
);
|
||||||
|
if (!sent) {
|
||||||
|
messagesProvider.markMessageFailed(messageId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.error(context, 'Failed to send Tic-Tac-Toe move');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _statusText({
|
||||||
|
required TicTacToeGameState state,
|
||||||
|
required String mySymbol,
|
||||||
|
}) {
|
||||||
|
if (state.winnerSymbol != null) {
|
||||||
|
return state.winnerSymbol == mySymbol ? 'You won' : 'Opponent won';
|
||||||
|
}
|
||||||
|
if (state.isDraw) return 'Draw';
|
||||||
|
return state.nextSymbol == mySymbol ? 'Your turn' : 'Opponent turn';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _key6Hex(Uint8List key) => key
|
||||||
|
.sublist(0, math.min(6, key.length))
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
static Contact? _resolveOpponentContact({
|
||||||
|
required Message message,
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required bool isSentByMe,
|
||||||
|
}) {
|
||||||
|
if (isSentByMe && message.recipientPublicKey != null) {
|
||||||
|
return contactsProvider.findContactByKey(message.recipientPublicKey!);
|
||||||
|
}
|
||||||
|
final sender = message.senderPublicKeyPrefix;
|
||||||
|
if (sender == null || sender.length < 6) return null;
|
||||||
|
return contactsProvider.findContactByPrefix(
|
||||||
|
Uint8List.fromList(sender.sublist(0, 6)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isSameDmThread({
|
||||||
|
required Message message,
|
||||||
|
required Uint8List selfKey,
|
||||||
|
required String opponentKey6,
|
||||||
|
}) {
|
||||||
|
final isOwn = message.isSentMessage || message.isFromSelf(selfKey);
|
||||||
|
if (isOwn) {
|
||||||
|
final recipient = message.recipientPublicKey;
|
||||||
|
if (recipient == null || recipient.length < 6) return false;
|
||||||
|
return _key6Hex(recipient) == opponentKey6;
|
||||||
|
}
|
||||||
|
final sender = message.senderPublicKeyPrefix;
|
||||||
|
if (sender == null || sender.length < 6) return false;
|
||||||
|
return _key6Hex(sender) == opponentKey6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoardGrid extends StatelessWidget {
|
||||||
|
final List<String?> board;
|
||||||
|
final bool enabled;
|
||||||
|
final bool isSentByMe;
|
||||||
|
final ValueChanged<int> onTapCell;
|
||||||
|
|
||||||
|
const _BoardGrid({
|
||||||
|
required this.board,
|
||||||
|
required this.enabled,
|
||||||
|
required this.isSentByMe,
|
||||||
|
required this.onTapCell,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final cellBackground = isSentByMe
|
||||||
|
? colorScheme.primaryContainer.withValues(alpha: 0.35)
|
||||||
|
: colorScheme.surface;
|
||||||
|
final cellBorder = isSentByMe
|
||||||
|
? colorScheme.primary.withValues(alpha: 0.45)
|
||||||
|
: colorScheme.outline.withValues(alpha: 0.35);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
child: GridView.builder(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
crossAxisSpacing: 4,
|
||||||
|
mainAxisSpacing: 4,
|
||||||
|
),
|
||||||
|
itemCount: 9,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final value = board[idx];
|
||||||
|
return InkWell(
|
||||||
|
onTap: enabled && value == null ? () => onTapCell(idx) : null,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: cellBackground,
|
||||||
|
border: Border.all(color: cellBorder),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
value ?? '',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: value == 'X'
|
||||||
|
? colorScheme.primary
|
||||||
|
: value == 'O'
|
||||||
|
? colorScheme.tertiary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/contact.dart';
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/voice_provider.dart';
|
import '../../providers/voice_provider.dart';
|
||||||
|
import '../../utils/transmission_target_resolver.dart';
|
||||||
import '../../utils/voice_message_parser.dart';
|
import '../../utils/voice_message_parser.dart';
|
||||||
import 'transfer_timeout.dart';
|
import 'transfer_timeout.dart';
|
||||||
|
|
||||||
@@ -55,8 +54,20 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
|
|
||||||
return Consumer<VoiceProvider>(
|
return Consumer<VoiceProvider>(
|
||||||
builder: (context, voiceProvider, _) {
|
builder: (context, voiceProvider, _) {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final session = voiceProvider.session(voiceId);
|
final session = voiceProvider.session(voiceId);
|
||||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||||
|
final sender = TransmissionTargetResolver.resolveLocalTarget(
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
isSentByMe: widget.isSentByMe,
|
||||||
|
recipientPublicKey: widget.message.recipientPublicKey,
|
||||||
|
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||||
|
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||||
|
senderName: widget.message.senderName,
|
||||||
|
);
|
||||||
|
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||||
|
? sender.outPathLen
|
||||||
|
: widget.message.pathLen;
|
||||||
final isPlaying = voiceProvider.isPlaying(voiceId);
|
final isPlaying = voiceProvider.isPlaying(voiceId);
|
||||||
final isComplete = voiceProvider.isComplete(voiceId);
|
final isComplete = voiceProvider.isComplete(voiceId);
|
||||||
|
|
||||||
@@ -65,6 +76,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRequesting = false;
|
_isRequesting = false;
|
||||||
|
_errorText = null;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -96,12 +108,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
session: session,
|
session: session,
|
||||||
envelope: envelope,
|
envelope: envelope,
|
||||||
messageText: widget.message.text,
|
messageText: widget.message.text,
|
||||||
pathLen: widget.message.pathLen,
|
pathLen: effectivePathLen,
|
||||||
radioBw: radioBw,
|
radioBw: radioBw,
|
||||||
radioSf: radioSf,
|
radioSf: radioSf,
|
||||||
radioCr: radioCr,
|
radioCr: radioCr,
|
||||||
);
|
);
|
||||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||||
|
final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -112,6 +125,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
await voiceProvider.stop();
|
await voiceProvider.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_isRequesting) {
|
||||||
|
_cancelReceive(voiceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
await voiceProvider.play(voiceId);
|
await voiceProvider.play(voiceId);
|
||||||
return;
|
return;
|
||||||
@@ -122,7 +139,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
radioBw: radioBw,
|
radioBw: radioBw,
|
||||||
radioSf: radioSf,
|
radioSf: radioSf,
|
||||||
radioCr: radioCr,
|
radioCr: radioCr,
|
||||||
pathLen: widget.message.pathLen,
|
pathLen: effectivePathLen,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
@@ -138,7 +155,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
isPlaying
|
isPlaying
|
||||||
? Icons.stop
|
? Icons.stop
|
||||||
: (_isRequesting ? Icons.downloading : Icons.play_arrow),
|
: (_isRequesting ? Icons.close : Icons.play_arrow),
|
||||||
size: 28,
|
size: 28,
|
||||||
color: widget.isSentByMe
|
color: widget.isSentByMe
|
||||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||||
@@ -175,6 +192,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
requestingLabel: AppLocalizations.of(
|
requestingLabel: AppLocalizations.of(
|
||||||
context,
|
context,
|
||||||
)!.requestingVoice,
|
)!.requestingVoice,
|
||||||
|
eta: eta,
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -200,34 +218,45 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
int pathLen = 0,
|
int pathLen = 0,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isRequesting) return;
|
if (_isRequesting) return;
|
||||||
var sender = _resolveSenderContact();
|
|
||||||
if (sender == null) {
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
await connectionProvider.getContacts();
|
final voiceProvider = context.read<VoiceProvider>();
|
||||||
|
voiceProvider.resumeIncomingSession(sessionId);
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
refreshContacts: connectionProvider.getContacts,
|
||||||
|
isSentByMe: widget.isSentByMe,
|
||||||
|
recipientPublicKey: widget.message.recipientPublicKey,
|
||||||
|
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||||
|
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||||
|
senderName: widget.message.senderName,
|
||||||
|
maxFetchHops: _maxFetchHops,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
sender = _resolveSenderContact();
|
|
||||||
}
|
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||||
if (sender == null) {
|
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch voice',
|
'Cannot fetch voice',
|
||||||
'Sender contact is unknown. Sync contacts first.',
|
'Sender contact is unknown. Sync contacts first.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sender.outPathLen < 0) {
|
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch voice',
|
'Cannot fetch voice',
|
||||||
'Sender route is unknown. Sync contacts/path first.',
|
'Sender route is unknown. Sync contacts/path first.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sender.outPathLen > _maxFetchHops) {
|
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
'Cannot fetch voice',
|
'Cannot fetch voice',
|
||||||
'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).',
|
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final sender = resolution.target!;
|
||||||
if (sender.outPathLen >= 2) {
|
if (sender.outPathLen >= 2) {
|
||||||
_showToast(
|
_showToast(
|
||||||
'Voice fetch over ${sender.outPathLen} hops may take a while.',
|
'Voice fetch over ${sender.outPathLen} hops may take a while.',
|
||||||
@@ -239,7 +268,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
_errorText = null;
|
_errorText = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
|
||||||
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
||||||
if (deviceKey == null || deviceKey.length < 6) {
|
if (deviceKey == null || deviceKey.length < 6) {
|
||||||
await _showBlockingAlert(
|
await _showBlockingAlert(
|
||||||
@@ -257,7 +285,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
requesterKey6: requesterKey6,
|
requesterKey6: requesterKey6,
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
version: 1,
|
version: 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -266,23 +294,27 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
_errorText = null;
|
_errorText = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final sent = await connectionProvider.sendTextMessage(
|
try {
|
||||||
contactPublicKey: sender.publicKey,
|
await connectionProvider.sendRawVoicePacket(
|
||||||
text: request.encodeText(),
|
contactPath: sender.outPath,
|
||||||
contact: sender,
|
contactPathLen: sender.outPathLen,
|
||||||
|
payload: request.encodeBinary(),
|
||||||
);
|
);
|
||||||
if (!sent) {
|
} catch (_) {
|
||||||
_setUnavailable();
|
_setUnavailable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||||
|
final effectivePathLen = sender.outPathLen >= 0
|
||||||
|
? sender.outPathLen
|
||||||
|
: pathLen;
|
||||||
final txEstimate = envelope != null
|
final txEstimate = envelope != null
|
||||||
? estimateVoiceTransmitDuration(
|
? estimateVoiceTransmitDuration(
|
||||||
packetCount: envelope.total,
|
packetCount: envelope.total,
|
||||||
mode: envelope.mode,
|
mode: envelope.mode,
|
||||||
durationMs: envelope.durationMs,
|
durationMs: envelope.durationMs,
|
||||||
pathLen: pathLen,
|
pathLen: effectivePathLen,
|
||||||
radioBw: radioBw,
|
radioBw: radioBw,
|
||||||
radioSf: radioSf,
|
radioSf: radioSf,
|
||||||
radioCr: radioCr,
|
radioCr: radioCr,
|
||||||
@@ -301,6 +333,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
|
|
||||||
void _setUnavailable() {
|
void _setUnavailable() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRequesting = false;
|
_isRequesting = false;
|
||||||
_autoPlayWhenReady = false;
|
_autoPlayWhenReady = false;
|
||||||
@@ -308,34 +341,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Contact? _resolveSenderContact() {
|
void _cancelReceive(String sessionId) {
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
if (!mounted) return;
|
||||||
final senderPrefix = widget.message.senderPublicKeyPrefix;
|
_requestTimeoutTimer?.cancel();
|
||||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
context.read<VoiceProvider>().cancelIncomingSession(sessionId);
|
||||||
final contact = contactsProvider.findContactByPrefix(
|
_showToast('Voice receive canceled');
|
||||||
Uint8List.fromList(senderPrefix.sublist(0, 6)),
|
setState(() {
|
||||||
);
|
_isRequesting = false;
|
||||||
if (contact != null) return contact;
|
_autoPlayWhenReady = false;
|
||||||
}
|
_errorText = 'Voice receive canceled';
|
||||||
|
});
|
||||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
|
||||||
if (envelope != null) {
|
|
||||||
final contact = contactsProvider.findContactByPrefixHex(
|
|
||||||
envelope.senderKey6,
|
|
||||||
);
|
|
||||||
if (contact != null) return contact;
|
|
||||||
}
|
|
||||||
|
|
||||||
final senderName = widget.message.senderName?.trim();
|
|
||||||
if (senderName != null && senderName.isNotEmpty) {
|
|
||||||
for (final contact in contactsProvider.contacts) {
|
|
||||||
if (contact.advName.trim().toLowerCase() == senderName.toLowerCase()) {
|
|
||||||
return contact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showToast(String message) {
|
void _showToast(String message) {
|
||||||
@@ -347,6 +362,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
|
|
||||||
Future<void> _showBlockingAlert(String title, String message) async {
|
Future<void> _showBlockingAlert(String title, String message) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
_showToast('$title: $message');
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
@@ -378,11 +394,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
required bool isRequesting,
|
required bool isRequesting,
|
||||||
required String? errorText,
|
required String? errorText,
|
||||||
required String requestingLabel,
|
required String requestingLabel,
|
||||||
|
required Duration? eta,
|
||||||
}) {
|
}) {
|
||||||
if (errorText != null) return errorText;
|
if (errorText != null) return errorText;
|
||||||
final progress = total > 0 ? ' ($received/$total)' : '';
|
final progress = total > 0 ? ' ($received/$total)' : '';
|
||||||
if (isRequesting) {
|
if (isRequesting) {
|
||||||
return '$requestingLabel$progress · $txEstimateLabel';
|
return '$requestingLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||||
}
|
}
|
||||||
if (!isComplete && total > 0) {
|
if (!isComplete && total > 0) {
|
||||||
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||||
@@ -462,6 +479,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
final seconds = value.inSeconds % 60;
|
final seconds = value.inSeconds % 60;
|
||||||
return '~${minutes}m ${seconds}s tx';
|
return '~${minutes}m ${seconds}s tx';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _formatEta(Duration? eta) {
|
||||||
|
if (eta == null || eta <= Duration.zero) return 'ETA --';
|
||||||
|
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
|
||||||
|
final minutes = eta.inMinutes;
|
||||||
|
final seconds = eta.inSeconds % 60;
|
||||||
|
return 'ETA ~${minutes}m ${seconds}s';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Voice waveform rendered as a row of bars.
|
/// Voice waveform rendered as a row of bars.
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: main
|
||||||
resolved-ref: d6f91774f19136ff71b0087feaf95fa5490524d9
|
resolved-ref: "11f51ccaba850531496179bf63e023cb4c9ad797"
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0305.1+7
|
version: 2026.0305.3+9
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal file
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/helpers/fragment_ack_wait_registry.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('FragmentAckWaitRegistry', () {
|
||||||
|
test('completes multiple waiters registered for the same key', () async {
|
||||||
|
final registry = FragmentAckWaitRegistry();
|
||||||
|
|
||||||
|
final first = registry.waitFor(
|
||||||
|
'voice:1',
|
||||||
|
timeout: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
final second = registry.waitFor(
|
||||||
|
'voice:1',
|
||||||
|
timeout: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registry.complete('voice:1'), equals(2));
|
||||||
|
expect(await first, isTrue);
|
||||||
|
expect(await second, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('times out and cleans up a waiter when no ack arrives', () async {
|
||||||
|
final registry = FragmentAckWaitRegistry();
|
||||||
|
|
||||||
|
final completed = await registry.waitFor(
|
||||||
|
'voice:2',
|
||||||
|
timeout: const Duration(milliseconds: 20),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(completed, isFalse);
|
||||||
|
expect(registry.complete('voice:2'), equals(0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
145
test/providers/helpers/raw_session_retransmit_test.dart
Normal file
145
test/providers/helpers/raw_session_retransmit_test.dart
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/helpers/raw_session_retransmit.dart';
|
||||||
|
|
||||||
|
class _Fragment {
|
||||||
|
final int index;
|
||||||
|
final Uint8List payload;
|
||||||
|
|
||||||
|
_Fragment(this.index, this.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact _buildContact({required int outPathLen}) {
|
||||||
|
return Contact(
|
||||||
|
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: outPathLen,
|
||||||
|
outPath: Uint8List.fromList(List<int>.generate(8, (i) => i + 1)),
|
||||||
|
advName: 'Requester',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('serveCachedSessionFragments', () {
|
||||||
|
test('returns false when sender callback is missing', () async {
|
||||||
|
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||||
|
providerLabel: 'TestProvider',
|
||||||
|
sessionId: 'deadbeef',
|
||||||
|
requester: _buildContact(outPathLen: 1),
|
||||||
|
fragments: [
|
||||||
|
_Fragment(0, Uint8List.fromList([1])),
|
||||||
|
],
|
||||||
|
maxDirectPayloadHops: 3,
|
||||||
|
indexOf: (f) => f.index,
|
||||||
|
encodeBinary: (f) => f.payload,
|
||||||
|
sendRawPacket: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sends only requested indices and waits for ack', () async {
|
||||||
|
final sent = <Uint8List>[];
|
||||||
|
final waited = <int>[];
|
||||||
|
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||||
|
providerLabel: 'TestProvider',
|
||||||
|
sessionId: 'deadbeef',
|
||||||
|
requester: _buildContact(outPathLen: 1),
|
||||||
|
fragments: [
|
||||||
|
_Fragment(0, Uint8List.fromList([10])),
|
||||||
|
_Fragment(1, Uint8List.fromList([20])),
|
||||||
|
_Fragment(2, Uint8List.fromList([30])),
|
||||||
|
],
|
||||||
|
maxDirectPayloadHops: 3,
|
||||||
|
indexOf: (f) => f.index,
|
||||||
|
encodeBinary: (f) => f.payload,
|
||||||
|
sendRawPacket:
|
||||||
|
({
|
||||||
|
required contactPath,
|
||||||
|
required contactPathLen,
|
||||||
|
required payload,
|
||||||
|
}) async {
|
||||||
|
sent.add(payload);
|
||||||
|
},
|
||||||
|
waitForFragmentAck:
|
||||||
|
({
|
||||||
|
required sessionId,
|
||||||
|
required index,
|
||||||
|
timeout = const Duration(seconds: 8),
|
||||||
|
}) async {
|
||||||
|
waited.add(index);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
requestedIndices: {1, 2},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok, isTrue);
|
||||||
|
expect(sent.length, equals(2));
|
||||||
|
expect(sent[0], equals(Uint8List.fromList([20])));
|
||||||
|
expect(sent[1], equals(Uint8List.fromList([30])));
|
||||||
|
expect(waited, equals([1, 2]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails when ack does not arrive', () async {
|
||||||
|
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||||
|
providerLabel: 'TestProvider',
|
||||||
|
sessionId: 'deadbeef',
|
||||||
|
requester: _buildContact(outPathLen: 1),
|
||||||
|
fragments: [
|
||||||
|
_Fragment(0, Uint8List.fromList([1])),
|
||||||
|
],
|
||||||
|
maxDirectPayloadHops: 3,
|
||||||
|
indexOf: (f) => f.index,
|
||||||
|
encodeBinary: (f) => f.payload,
|
||||||
|
sendRawPacket:
|
||||||
|
({
|
||||||
|
required contactPath,
|
||||||
|
required contactPathLen,
|
||||||
|
required payload,
|
||||||
|
}) async {},
|
||||||
|
waitForFragmentAck:
|
||||||
|
({
|
||||||
|
required sessionId,
|
||||||
|
required index,
|
||||||
|
timeout = const Duration(seconds: 8),
|
||||||
|
}) async {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails when no requested index matches cached fragments', () async {
|
||||||
|
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||||
|
providerLabel: 'TestProvider',
|
||||||
|
sessionId: 'deadbeef',
|
||||||
|
requester: _buildContact(outPathLen: 1),
|
||||||
|
fragments: [
|
||||||
|
_Fragment(0, Uint8List.fromList([1])),
|
||||||
|
],
|
||||||
|
maxDirectPayloadHops: 3,
|
||||||
|
indexOf: (f) => f.index,
|
||||||
|
encodeBinary: (f) => f.payload,
|
||||||
|
sendRawPacket:
|
||||||
|
({
|
||||||
|
required contactPath,
|
||||||
|
required contactPathLen,
|
||||||
|
required payload,
|
||||||
|
}) async {},
|
||||||
|
requestedIndices: {99},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
86
test/providers/helpers/session_metadata_restore_test.dart
Normal file
86
test/providers/helpers/session_metadata_restore_test.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/helpers/session_metadata_restore.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('restoreSessionMetadataFromMessages', () {
|
||||||
|
test(
|
||||||
|
'restores voice and image session senders from persisted envelopes',
|
||||||
|
() {
|
||||||
|
final voiceEnvelope = VoiceEnvelope(
|
||||||
|
sessionId: '00112233',
|
||||||
|
mode: VoicePacketMode.mode1200,
|
||||||
|
total: 4,
|
||||||
|
durationMs: 4000,
|
||||||
|
senderKey6: 'AABBCCDDEEFF',
|
||||||
|
timestampSec: 123456,
|
||||||
|
);
|
||||||
|
final imageEnvelope = ImageEnvelope(
|
||||||
|
sessionId: '195cb2fb',
|
||||||
|
format: ImageFormat.avif,
|
||||||
|
total: 7,
|
||||||
|
width: 118,
|
||||||
|
height: 256,
|
||||||
|
sizeBytes: 1069,
|
||||||
|
senderKey6: 'FE8B30EE05FC',
|
||||||
|
timestampSec: 123457,
|
||||||
|
);
|
||||||
|
|
||||||
|
final restored = restoreSessionMetadataFromMessages([
|
||||||
|
'plain text',
|
||||||
|
voiceEnvelope.encodeText(),
|
||||||
|
imageEnvelope.encode(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
restored.voiceSenderKeyBySession,
|
||||||
|
equals({'00112233': 'aabbccddeeff'}),
|
||||||
|
);
|
||||||
|
expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'}));
|
||||||
|
expect(
|
||||||
|
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
|
||||||
|
equals('fe8b30ee05fc'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('keeps latest envelope when a session appears multiple times', () {
|
||||||
|
final first = ImageEnvelope(
|
||||||
|
sessionId: '195cb2fb',
|
||||||
|
format: ImageFormat.avif,
|
||||||
|
total: 7,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
sizeBytes: 900,
|
||||||
|
senderKey6: '001122334455',
|
||||||
|
timestampSec: 100,
|
||||||
|
);
|
||||||
|
final second = ImageEnvelope(
|
||||||
|
sessionId: '195cb2fb',
|
||||||
|
format: ImageFormat.jpeg,
|
||||||
|
total: 8,
|
||||||
|
width: 118,
|
||||||
|
height: 256,
|
||||||
|
sizeBytes: 1069,
|
||||||
|
senderKey6: 'AABBCCDDEEFF',
|
||||||
|
timestampSec: 101,
|
||||||
|
);
|
||||||
|
|
||||||
|
final restored = restoreSessionMetadataFromMessages([
|
||||||
|
first.encode(),
|
||||||
|
second.encode(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(restored.imageEnvelopeBySession.length, equals(1));
|
||||||
|
expect(
|
||||||
|
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
|
||||||
|
equals('aabbccddeeff'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
restored.imageEnvelopeBySession['195cb2fb']?.format,
|
||||||
|
equals(ImageFormat.jpeg),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
52
test/providers/image_provider_cancel_test.dart
Normal file
52
test/providers/image_provider_cancel_test.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/image_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
group('ImageProvider cancel receive', () {
|
||||||
|
test('ignores incoming fragments after cancel until resumed', () {
|
||||||
|
final provider = ImageProvider();
|
||||||
|
const sessionId = '01020304';
|
||||||
|
const envelope = ImageEnvelope(
|
||||||
|
sessionId: sessionId,
|
||||||
|
format: ImageFormat.avif,
|
||||||
|
total: 2,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
sizeBytes: 4,
|
||||||
|
senderKey6: 'aabbccddeeff',
|
||||||
|
timestampSec: 1700000000,
|
||||||
|
);
|
||||||
|
final fragment = ImagePacket(
|
||||||
|
sessionId: sessionId,
|
||||||
|
format: ImageFormat.avif,
|
||||||
|
index: 0,
|
||||||
|
total: 2,
|
||||||
|
data: Uint8List.fromList([1, 2]),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.registerEnvelope(envelope);
|
||||||
|
provider.cancelIncomingSession(sessionId);
|
||||||
|
|
||||||
|
expect(provider.isReceiveCanceled(sessionId), isTrue);
|
||||||
|
expect(provider.session(sessionId), isNull);
|
||||||
|
|
||||||
|
provider.addFragment(fragment, width: 32, height: 32);
|
||||||
|
|
||||||
|
expect(provider.session(sessionId), isNull);
|
||||||
|
|
||||||
|
provider.resumeIncomingSession(sessionId);
|
||||||
|
provider.registerEnvelope(envelope);
|
||||||
|
provider.addFragment(fragment, width: 32, height: 32);
|
||||||
|
|
||||||
|
expect(provider.isReceiveCanceled(sessionId), isFalse);
|
||||||
|
expect(provider.session(sessionId)?.receivedCount, equals(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ void main() {
|
|||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('MessagesProvider voice detection', () {
|
group('MessagesProvider voice detection', () {
|
||||||
test('marks VE1 envelope messages as voice', () {
|
test('marks VE2 envelope messages as voice', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
final envelope = VoiceEnvelope(
|
final envelope = VoiceEnvelope(
|
||||||
sessionId: 'deafbead',
|
sessionId: 'deafbead',
|
||||||
|
|||||||
@@ -410,6 +410,24 @@ void main() {
|
|||||||
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
|
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stops parsing at zero-padded telemetry tail', () {
|
||||||
|
final payload = Uint8List.fromList([
|
||||||
|
0x01, 0x74, 0x01, 0x5F, // voltage 3.51V
|
||||||
|
0x01, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00,
|
||||||
|
]);
|
||||||
|
|
||||||
|
final decoded = CayenneLppParser.parse(payload);
|
||||||
|
|
||||||
|
expect(decoded.batteryMilliVolts, closeTo(3510, 1));
|
||||||
|
expect(decoded.batteryPercentage, closeTo(42.5, 0.1));
|
||||||
|
expect(decoded.gpsLocation, isNotNull);
|
||||||
|
expect(decoded.gpsLocation!.latitude, 0.0);
|
||||||
|
expect(decoded.gpsLocation!.longitude, 0.0);
|
||||||
|
expect(decoded.extraSensorData?['digital_input_0'], isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('empty data returns empty telemetry', () {
|
test('empty data returns empty telemetry', () {
|
||||||
final empty = Uint8List(0);
|
final empty = Uint8List(0);
|
||||||
final decoded = CayenneLppParser.parse(empty);
|
final decoded = CayenneLppParser.parse(empty);
|
||||||
|
|||||||
129
test/utils/image_message_parser_test.dart
Normal file
129
test/utils/image_message_parser_test.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('safeImageDataBytesForPath', () {
|
||||||
|
test('caps direct-route fragments to conservative default size', () {
|
||||||
|
expect(safeImageDataBytesForPath(0), equals(ImagePacket.maxDataBytes));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shrinks for longer paths but never exceeds conservative default', () {
|
||||||
|
expect(
|
||||||
|
safeImageDataBytesForPath(2),
|
||||||
|
lessThanOrEqualTo(ImagePacket.maxDataBytes),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,74 +6,122 @@ void main() {
|
|||||||
group('VoiceEnvelope', () {
|
group('VoiceEnvelope', () {
|
||||||
test('encodes and parses valid envelope', () {
|
test('encodes and parses valid envelope', () {
|
||||||
final env = VoiceEnvelope(
|
final env = VoiceEnvelope(
|
||||||
sessionId: 'deadbeef',
|
sessionId: '0000000a',
|
||||||
mode: VoicePacketMode.mode1200,
|
mode: VoicePacketMode.mode1200,
|
||||||
total: 4,
|
total: 4,
|
||||||
durationMs: 3200,
|
durationMs: 3000,
|
||||||
senderKey6: 'aabbccddeeff',
|
senderKey6: 'aabbccddeeff',
|
||||||
timestampSec: 1700000000,
|
timestampSec: 1700000000,
|
||||||
);
|
);
|
||||||
|
|
||||||
final text = env.encodeText();
|
final text = env.encodeText();
|
||||||
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
|
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
|
||||||
|
expect(text.startsWith('VE2:'), isTrue);
|
||||||
|
expect(text.split(':')[1], equals('a'));
|
||||||
|
|
||||||
final parsed = VoiceEnvelope.tryParseText(text);
|
final parsed = VoiceEnvelope.tryParseText(text);
|
||||||
expect(parsed, isNotNull);
|
expect(parsed, isNotNull);
|
||||||
expect(parsed!.sessionId, equals('deadbeef'));
|
expect(parsed!.sessionId, equals('0000000a'));
|
||||||
expect(parsed.mode, equals(VoicePacketMode.mode1200));
|
expect(parsed.mode, equals(VoicePacketMode.mode1200));
|
||||||
expect(parsed.total, equals(4));
|
expect(parsed.total, equals(4));
|
||||||
expect(parsed.durationMs, equals(3200));
|
expect(parsed.durationMs, equals(3000));
|
||||||
expect(parsed.senderKey6, equals('aabbccddeeff'));
|
expect(parsed.senderKey6, equals('aabbccddeeff'));
|
||||||
expect(parsed.version, equals(1));
|
expect(parsed.version, equals(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid envelope payload', () {
|
test('rejects invalid envelope payload', () {
|
||||||
final text = 'VE1:nothex:1:2:1000:aabbccddeeff:1700000000:1';
|
final text = 'VE2:bad_sid:1:2:1000:aabbccddeeff:s44we8';
|
||||||
expect(VoiceEnvelope.tryParseText(text), isNull);
|
expect(VoiceEnvelope.tryParseText(text), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects legacy v1 envelope prefix', () {
|
||||||
|
const legacy = 'VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1';
|
||||||
|
expect(VoiceEnvelope.tryParseText(legacy), isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('VoiceFetchRequest', () {
|
group('VoiceFetchRequest', () {
|
||||||
test('encodes and parses valid request', () {
|
test('encodes and parses valid request', () {
|
||||||
final req = VoiceFetchRequest(
|
final req = VoiceFetchRequest(
|
||||||
sessionId: '00112233',
|
sessionId: '0000000a',
|
||||||
requesterKey6: 'ffeeddccbbaa',
|
requesterKey6: 'ffeeddccbbaa',
|
||||||
timestampSec: 1700000001,
|
timestampSec: 1700000001,
|
||||||
);
|
);
|
||||||
final text = req.encodeText();
|
final text = req.encodeText();
|
||||||
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
|
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
|
||||||
|
expect(text.startsWith('VR2:'), isTrue);
|
||||||
|
expect(text.split(':')[1], equals('a'));
|
||||||
|
|
||||||
final parsed = VoiceFetchRequest.tryParseText(text);
|
final parsed = VoiceFetchRequest.tryParseText(text);
|
||||||
expect(parsed, isNotNull);
|
expect(parsed, isNotNull);
|
||||||
expect(parsed!.sessionId, equals('00112233'));
|
expect(parsed!.sessionId, equals('0000000a'));
|
||||||
expect(parsed.want, equals('all'));
|
expect(parsed.want, equals('all'));
|
||||||
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
||||||
expect(parsed.version, equals(1));
|
expect(parsed.version, equals(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid request payload', () {
|
test('rejects invalid request payload', () {
|
||||||
expect(
|
expect(
|
||||||
VoiceFetchRequest.tryParseText(
|
VoiceFetchRequest.tryParseText(
|
||||||
'VR1:00112233:chunk:ffeeddccbbaa:1700000001:1',
|
'VR2:a:chunk:ffeeddccbbaa:s44we9',
|
||||||
),
|
),
|
||||||
isNull,
|
isNull,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects legacy v1 request prefix', () {
|
||||||
|
const legacy = 'VR1:00112233:a:ffeeddccbbaa:1700000001:1';
|
||||||
|
expect(VoiceFetchRequest.tryParseText(legacy), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('encodes and parses missing-packet request', () {
|
test('encodes and parses missing-packet request', () {
|
||||||
final req = VoiceFetchRequest(
|
final req = VoiceFetchRequest(
|
||||||
sessionId: '00112233',
|
sessionId: '0000000a',
|
||||||
want: 'missing',
|
want: 'missing',
|
||||||
missingIndices: const [0, 3, 7],
|
missingIndices: const [0, 1, 2, 3, 7],
|
||||||
requesterKey6: 'ffeeddccbbaa',
|
requesterKey6: 'ffeeddccbbaa',
|
||||||
timestampSec: 1700000001,
|
timestampSec: 1700000001,
|
||||||
);
|
);
|
||||||
final text = req.encodeText();
|
final text = req.encodeText();
|
||||||
|
expect(text, contains(':m0-3.7:'));
|
||||||
|
|
||||||
final parsed = VoiceFetchRequest.tryParseText(text);
|
final parsed = VoiceFetchRequest.tryParseText(text);
|
||||||
expect(parsed, isNotNull);
|
expect(parsed, isNotNull);
|
||||||
expect(parsed!.want, equals('missing'));
|
expect(parsed!.want, equals('missing'));
|
||||||
expect(parsed.missingIndices, equals([0, 3, 7]));
|
expect(parsed.missingIndices, equals([0, 1, 2, 3, 7]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encodes and parses binary fetch request', () {
|
||||||
|
final req = VoiceFetchRequest(
|
||||||
|
sessionId: '01020304',
|
||||||
|
want: 'missing',
|
||||||
|
missingIndices: const [1, 4],
|
||||||
|
requesterKey6: 'ffeeddccbbaa',
|
||||||
|
timestampSec: 1700000001,
|
||||||
|
);
|
||||||
|
|
||||||
|
final payload = req.encodeBinary();
|
||||||
|
expect(VoiceFetchRequest.isVoiceFetchRequestBinary(payload), isTrue);
|
||||||
|
|
||||||
|
final parsed = VoiceFetchRequest.tryParseBinary(payload);
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.sessionId, equals('01020304'));
|
||||||
|
expect(parsed.want, equals('missing'));
|
||||||
|
expect(parsed.missingIndices, equals([1, 4]));
|
||||||
|
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
||||||
|
expect(parsed.version, equals(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('VoiceFragmentAck', () {
|
||||||
|
test('encodes and parses binary ack', () {
|
||||||
|
final ack = VoiceFragmentAck(sessionId: '01020304', index: 7);
|
||||||
|
final payload = ack.encodeBinary();
|
||||||
|
expect(VoiceFragmentAck.isVoiceFragmentAckBinary(payload), isTrue);
|
||||||
|
final parsed = VoiceFragmentAck.tryParseBinary(payload);
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.sessionId, equals('01020304'));
|
||||||
|
expect(parsed.index, equals(7));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user