Add swarm mode transport doc

This commit is contained in:
Janez T
2026-03-07 14:13:54 +01:00
parent 4e76898c8d
commit 0e4f727e26
43 changed files with 3831 additions and 1078 deletions

View File

@@ -2,10 +2,11 @@
## 1. Overview ## 1. Overview
Image mode mirrors the voice on-demand architecture exactly: Image mode mirrors the voice on-demand architecture exactly, including
swarm-assisted recovery for stalled partial transfers:
- **Control plane (text messages):** - **Control plane (text messages):**
- `IE2:` image envelope announces image availability in chat. - `IE4:` image envelope announces image availability in chat.
- **Control plane (raw binary request):** - **Control plane (raw binary request):**
- Binary image fetch request (same raw route as image fragments). - Binary image fetch request (same raw route as image fragments).
- **Data plane (raw binary packets):** - **Data plane (raw binary packets):**
@@ -14,11 +15,14 @@ Image mode mirrors the voice on-demand architecture exactly:
Images are never broadcast in full to channels. Chat carries only metadata; Images are never broadcast in full to channels. Chat carries only metadata;
pixels are fetched on demand when the user taps the image bubble. pixels are fetched on demand when the user taps the image bubble.
Shared swarm fallback is documented in
[Swarm Mode Technical Design](./swarm-mode-technical.md).
## 2. Key Modules ## 2. Key Modules
- `lib/utils/image_message_parser.dart` - `lib/utils/image_message_parser.dart`
- `ImagePacket` (binary fragment format) - `ImagePacket` (binary fragment format)
- `ImageEnvelope` (`IE2`) - `ImageEnvelope` (`IE4`)
- `ImageFetchRequest` (binary) - `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
@@ -27,8 +31,9 @@ pixels are fetched on demand when the user taps the image bubble.
- `lib/providers/image_provider.dart` - `lib/providers/image_provider.dart`
- 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
- Received partial sessions can be re-served during swarm recovery
- `lib/providers/app_provider.dart` - `lib/providers/app_provider.dart`
- Incoming routing for `IE2`, binary image fetch requests, binary `0x49` packets - Incoming routing for `IE4`, binary image fetch requests, binary `0x49` packets, and raw swarm control payloads
- `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
@@ -40,9 +45,9 @@ pixels are fetched on demand when the user taps the image bubble.
## 3. Wire Formats ## 3. Wire Formats
### 3.1 Image Envelope (`IE2`) ### 3.1 Image Envelope (`IE4`)
Prefix: `IE2:` + colon-delimited compact payload (base36 numeric fields) Prefix: `IE4:` + colon-delimited compact payload (base36 numeric fields)
Fields: Fields:
@@ -54,51 +59,50 @@ Fields:
| `w` | base36 | Actual image width after compression (pixels) | | `w` | base36 | Actual image width after compression (pixels) |
| `h` | base36 | Actual image height after compression (pixels) | | `h` | base36 | Actual image height after compression (pixels) |
| `bytes` | base36 | Total compressed size in bytes | | `bytes` | base36 | Total compressed size in bytes |
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
| `ts` | base36 | Unix timestamp (seconds) |
Compact format: Compact format:
```text ```text
IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts} IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
``` ```
Example (256×171 landscape image, 14 fragments): Example (256×171 landscape image, 14 fragments):
```text ```text
IE2:a:0:e:74:4r:1mc:aabbccddeeff:s44we8 IE4:a:0:e:74:4r:1mc
``` ```
Note: `sid` is base36 on wire and expands to 8-hex internally. Note: `sid` is base36 on wire and expands to 8-hex internally.
`w` and `h` reflect the actual post-compression dimensions, which preserve `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 (binary) ### 3.2 Image Fetch Request (`IR4` + binary)
Text format:
```text
IR4:{sid}:{want}:{requesterKey6}
```
Binary payload format: Binary payload format:
```text ```text
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...] [magic=0x69][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
``` ```
| Field | Value | | Field | Value |
|------------------|--------------------------| |------------------|--------------------------|
| `flags` | bit0=1 => request missing indices, else all | | `flags` | bit0=1 => request missing indices, else all |
| `requesterKey6` | 6-byte requester key prefix | | `requesterKey6` | 6-byte requester key prefix |
| `ts` | unix timestamp seconds (u32) |
### 3.3 Raw Image Packet (data plane) ### 3.3 Raw Image Packet (data plane)
Binary payload structure: Binary payload structure:
- Byte 0: magic `0x49` (`'I'`) - Byte 0: magic `0x49` (`'I'`)
- Bytes 1..4: session ID (4 bytes) - Bytes 1..4: session ID (4 bytes)
- Byte 5: format ID - Byte 5: fragment index (0-based)
- Byte 6: fragment index (0-based) - Bytes 6..N: image data (max 152 bytes per fragment)
- Byte 7: total fragments
- Bytes 8..N: image data (max 152 bytes per fragment)
Header is 8 bytes — identical layout to `VoicePacket`. Header is 6 bytes. Image format and total fragment count come from the `IE4` envelope.
## 4. Compression Pipeline ## 4. Compression Pipeline
@@ -152,11 +156,11 @@ 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 (`IE2:` text, `deliveryStatus.sending`). 8. Local placeholder message added (`IE4:` text, `deliveryStatus.sending`).
## 6. Incoming Flow (Receive) ## 6. Incoming Flow (Receive)
### 6.1 `IE2` envelope received ### 6.1 `IE4` 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.
@@ -165,11 +169,21 @@ chat. The bubble shows a grey square placeholder with a download icon.
`AppProvider` treats it as control-plane only (not added to chat): `AppProvider` treats it as control-plane only (not added to chat):
- Validates requester key prefix.
- Resolves requester contact. - Resolves requester contact.
- Calls `imageProvider.serveSessionTo()` which streams all cached fragments. - Calls `imageProvider.serveSessionTo()` which streams all cached fragments.
### 6.3 Raw packet received (`pushRawData`, magic `0x49`) ### 6.3 Swarm control messages received
`AppProvider` also handles shared raw swarm discovery payloads:
- binary swarm requests advertise which image fragments are still missing
- binary swarm availability responses advertise which fragments another peer can relay
- swarm payloads arrive via `pushRawData` and are intercepted instead of being added to chat
Shared discovery and responder semantics are documented in
[Swarm Mode Technical Design](./swarm-mode-technical.md).
### 6.4 Raw packet received (`pushRawData`, magic `0x49`)
`AppProvider.onRawDataReceived` parses `ImagePacket` binary and calls `AppProvider.onRawDataReceived` parses `ImagePacket` binary and calls
`imageProvider.addFragment()`. When the session becomes complete, the bubble `imageProvider.addFragment()`. When the session becomes complete, the bubble
@@ -188,6 +202,9 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
`_sessions[sessionId]`, so the sender sees the image immediately in the bubble `_sessions[sessionId]`, so the sender sees the image immediately in the bubble
(no tap-to-load required for own messages). (no tap-to-load required for own messages).
If a peer later receives only part of an image session, those received fragments
can also be served onward to another requester during swarm recovery.
## 8. Display ## 8. Display
`ImageMessageBubble` (max width 256 px): `ImageMessageBubble` (max width 256 px):
@@ -195,7 +212,8 @@ 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 binary fetch request. tap → sends binary fetch request, with raw swarm fallback if the original
sender path stalls.
- **Loading**: circular progress indicator showing `received/total` count. - **Loading**: circular progress indicator showing `received/total` count.
- **Error**: broken-image icon. - **Error**: broken-image icon.
@@ -213,12 +231,12 @@ 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 `IE2` envelope - `total` fragments and `bytes` from `IE4` envelope
- all numeric envelope values are decoded from base36 - 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:
- `meshHeader(2)` + `pathLen` + `imageHeader(8)` + `fragmentBytes` - `meshHeader(2)` + `pathLen` + `imageHeader(6)` + `fragmentBytes`
- LoRa airtime: - LoRa airtime:
- standard symbol-time formula (preamble + payload symbols) - standard symbol-time formula (preamble + payload symbols)
- Mesh pacing/hops: - Mesh pacing/hops:
@@ -246,9 +264,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
## 11. Operational Constraints ## 11. Operational Constraints
- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`). - No firmware changes required (reuses `cmdSendRawData` / `pushRawData`).
- On-demand fetch works only if sender app is online and has cached session. - On-demand fetch prefers the original sender, but a partial image can also be
completed from alternate peers that already hold matching fragments.
- 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`).
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as image
fetch and fragment delivery.
### 11.1 Raw Binary Routing Semantics ### 11.1 Raw Binary Routing Semantics
@@ -260,7 +281,28 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
- only nodes on that path relay it; - only nodes on that path relay it;
- it is **not** received by everyone in the mesh. - it is **not** received by everyone in the mesh.
## 12. High-Level Sequence ## 12. Swarm Fallback Sequence
```mermaid
sequenceDiagram
participant A as Original Sender
participant P as Peer With Fragments
participant N as Reachable Peers
participant B as Receiver App
A->>M: Send IE4 envelope
M->>B: Deliver IE4
B->>A: Direct binary image fetch request
A->>B: Stream raw ImagePacket fragments (partial)
Note over A,B: Sender path stops responding
B->>N: Raw swarm requests with missing image fragment indices
P->>B: Raw swarm availability response
B->>P: Direct binary fetch request for missing subset
P->>B: Stream remaining raw ImagePacket fragments
B->>B: Reassemble completed image
```
## 13. High-Level Sequence
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -272,8 +314,8 @@ 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 IE2 envelope (actual w×h, fragment count) A->>M: Send IE4 envelope (actual w×h, fragment count)
M->>B: Deliver IE2 M->>B: Deliver IE4
B->>B: Render grey placeholder bubble B->>B: Render grey placeholder bubble
B->>A: Tap → send binary fetch request B->>A: Tap → send binary fetch request
A->>B: Stream binary ImagePackets A->>B: Stream binary ImagePackets

View File

@@ -0,0 +1,148 @@
# Swarm Mode Technical Design
## 1. Overview
Swarm mode is a shared media-recovery transport used by both voice and image
sessions when the original sender path stops responding after some fragments
have already propagated through the mesh.
It adds a lightweight **swarm discovery plane** on top of the existing
**direct raw-data transfer plane**:
- **Discovery plane (raw custom control payloads):**
- `MediaSwarmRequest`
- `MediaSwarmAvailability`
- **Transfer plane (direct raw binary):**
- existing `VoiceFetchRequest` / `ImageFetchRequest`
- existing `VoicePacket` / `ImagePacket`
Swarm mode does not broadcast media payloads. It fans out raw control requests
to reachable peers, collects raw availability responses, and then fetches media
from the best responder.
## 2. Problem It Solves
Without swarm mode, media fetch is limited to the original sender's direct raw
path. If that sender goes offline, moves, or stops responding, a receiver can
be left with a partial session even when other peers already hold useful
fragments.
Swarm mode lets the receiver discover alternate peers and fetch the missing
subset directly from them.
## 3. Control Messages
### 3.1 Media Swarm Request (binary)
```text
[magic=0x6d][kind=0x01][mediaType:1B][sessionId:4B][requesterKey6:6B][missingCount:1B][missingIndices...]
```
Fields:
- `mediaType``voice` or `image`
- `sessionId` — 8 hex chars
- `requesterKey6` — 12 hex chars identifying the requesting device
- `missingCount``0` means the requester needs all fragments
- `missingIndices` — exact missing fragment indices when `missingCount > 0`
Example:
```text
6d 01 02 de ad be ef aa bb cc dd ee ff 03 00 02 09
```
### 3.2 Media Swarm Availability (binary)
```text
[magic=0x6d][kind=0x02][mediaType:1B][sessionId:4B][requesterKey6:6B][responderKey6:6B][availableCount:1B][availableIndices...]
```
Fields:
- `mediaType``voice` or `image`
- `sessionId` — 8 hex chars
- `requesterKey6` — copied from the swarm request
- `responderKey6` — 12 hex chars identifying the responding peer
- `availableCount``0` means the responder can satisfy the full request
- `availableIndices` — exact fragment indices held when `availableCount > 0`
Example:
```text
6d 02 02 de ad be ef aa bb cc dd ee ff 11 22 33 44 55 66 02 02 09
```
## 4. Requester Behavior
When a voice or image session is incomplete:
1. Prefer the original sender if its direct raw route is healthy.
2. If the original sender path is unavailable or not responding, send a raw
swarm request to reachable peers with the exact missing fragment indices.
3. Wait up to **10 seconds** for raw availability responses.
4. Rank responders by overlap with the current missing set.
5. Skip the original sender when choosing alternate peers.
6. Send a direct binary fetch request to the best responder for only the
missing subset that responder advertised.
Actual media transfer uses the same `cmdSendRawData` / `pushRawData` path as the
swarm control messages.
## 5. Responder Behavior
A peer that receives a swarm request:
- checks whether it has fragments for the requested media session
- replies only if it has at least one requested fragment
- advertises only fragments it actually holds
- may reply from:
- its outgoing cache, or
- a partially/fully received incoming session
This enables torrent-like relay behavior without requiring the peer to be the
original sender.
## 6. Visibility and Routing
- Swarm control payloads are carried by `cmdSendRawData` and received through
`pushRawData`.
- They are intercepted by the app and **must not be surfaced in chat**.
- Discovery is a direct raw fan-out to reachable peers, not a public-channel
broadcast.
- Media packets themselves remain direct-route raw packets.
## 7. Constraints
- No firmware changes are required.
- Swarm mode only helps if at least one peer has already received some useful
fragments.
- Peers can only relay fragments they actually have.
- Alternate peers still need a valid direct raw route back to the requester.
- Swarm mode improves recovery from sender-path failure, but it does not change
the underlying raw-packet size, airtime, or hop constraints.
## 8. High-Level Sequence
```mermaid
sequenceDiagram
participant S as Original Sender
participant P as Peer With Fragments
participant N as Reachable Peers
participant R as Requester
S->>R: Direct raw fragments (partial)
Note over S,R: Original sender path stops responding
R->>N: Raw swarm requests with exact missing fragments
P->>R: Raw swarm availability response
R->>P: Direct binary fetch request for missing subset
P->>R: Direct raw fragments
R->>R: Reassemble completed session
```
## 9. Integration Points
- Voice-specific behavior is summarized in `docs/voice-mode-technical.md`.
- Image-specific behavior is summarized in `docs/image-mode-technical.md`.
- This document is the shared source of truth for swarm discovery and fallback
semantics.

View File

@@ -2,10 +2,11 @@
## 1. Overview ## 1. Overview
Voice mode uses a **two-plane architecture**: Voice mode uses a **two-plane architecture** with optional swarm-assisted
recovery:
- **Control plane (text messages):** - **Control plane (text messages):**
- `VE2:` voice envelope announces voice availability in chat. - `VE3:` voice envelope announces voice availability in chat.
- **Control plane (raw binary request):** - **Control plane (raw binary request):**
- Binary voice fetch request (same raw route as voice packets). - Binary voice fetch request (same raw route as voice packets).
- **Data plane (raw binary packets):** - **Data plane (raw binary packets):**
@@ -13,11 +14,13 @@ Voice mode uses a **two-plane architecture**:
This design avoids broadcasting full voice payloads to channels/rooms. Chat carries only metadata; audio is fetched on demand when user presses play. This design avoids broadcasting full voice payloads to channels/rooms. Chat carries only metadata; audio is fetched on demand when user presses play.
Swarm fallback is documented in [Swarm Mode Technical Design](./swarm-mode-technical.md).
## 2. Key Modules ## 2. Key Modules
- `lib/utils/voice_message_parser.dart` - `lib/utils/voice_message_parser.dart`
- `VoicePacket` (legacy text + binary packet format) - `VoicePacket` (binary direct-packet format)
- `VoiceEnvelope` (`VE2`) - `VoiceEnvelope` (`VE3`)
- `VoiceFetchRequest` (binary) - `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
@@ -25,20 +28,20 @@ This design avoids broadcasting full voice payloads to channels/rooms. Chat carr
- 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 `VE2` and binary voice fetch requests - Incoming routing for `VE3`, binary voice fetch requests, and raw swarm control payloads
- 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 (`VE2` + legacy `V:`) - Message-level voice detection (`VE3`)
- `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 (`VE2`) ### 3.1 Voice Envelope (`VE3`)
Prefix: `VE2:` + colon-delimited compact payload (base36 numeric fields) Prefix: `VE3:` + colon-delimited compact payload (base36 numeric fields)
Fields: Fields:
@@ -46,21 +49,19 @@ Fields:
- `mode` (base36): codec mode ID (`VoicePacketMode.id`) - `mode` (base36): codec mode ID (`VoicePacketMode.id`)
- `total` (base36): packet count (1..255) - `total` (base36): packet count (1..255)
- `durS` (base36): estimated duration in seconds - `durS` (base36): estimated duration in seconds
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
- `ts` (base36): unix timestamp seconds
`sid` is base36 on wire and expands to 8-hex internally. `sid` is base36 on wire and expands to 8-hex internally.
Compact format: Compact format:
```text ```text
VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts} VE3:{sid}:{mode}:{total}:{durS}
``` ```
Example: Example:
```text ```text
VE2:a:1:4:4:aabbccddeeff:s44we8 VE3:a:1:4:4
``` ```
### 3.2 Voice Fetch Request (binary) ### 3.2 Voice Fetch Request (binary)
@@ -68,7 +69,7 @@ VE2:a:1:4:4:aabbccddeeff:s44we8
Binary payload format: Binary payload format:
```text ```text
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...] [magic=0x72][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
``` ```
### 3.3 Raw Voice Packet (data plane) ### 3.3 Raw Voice Packet (data plane)
@@ -77,10 +78,10 @@ Binary payload structure:
- Byte 0: magic `0x56` (`'V'`) - Byte 0: magic `0x56` (`'V'`)
- Bytes 1..4: session ID (4 bytes) - Bytes 1..4: session ID (4 bytes)
- Byte 5: mode ID - Byte 5: packet index
- Byte 6: packet index - Bytes 6..N: codec2 data
- Byte 7: total packets
- Bytes 8..N: codec2 data Header is 6 bytes. Mode and total packet count come from the `VE3` envelope.
## 4. Outgoing Flow (Send) ## 4. Outgoing Flow (Send)
@@ -88,27 +89,38 @@ 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 (`VE2`) through normal message path: 5. Sender sends one envelope (`VE3`) 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 `VE2` envelope received ### 5.1 `VE3` envelope received
`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat. `AppProvider` records sender identity from message metadata, registers the voice
session envelope, marks the message as voice (`isVoice`, `voiceId`), and adds it to chat.
### 5.2 Binary voice fetch request received ### 5.2 Binary voice fetch request received
`AppProvider` treats it as control-plane only: `AppProvider` treats it as control-plane only:
- request is not added to chat - request is not added to chat
- validates requester prefix match against sender metadata
- resolves requester contact via key prefix - resolves requester contact via key prefix
- calls `voiceProvider.serveSessionTo(...)` - calls `voiceProvider.serveSessionTo(...)`
### 5.3 Raw packet received (`pushRawData`) ### 5.3 Swarm control messages received
`AppProvider` also handles raw swarm control payloads:
- binary swarm requests advertise which voice fragments are still missing
- binary swarm availability responses advertise which fragments a peer can relay
- swarm control payloads arrive via `pushRawData` and are not added to chat history
Swarm semantics are shared with image mode and documented in
[Swarm Mode Technical Design](./swarm-mode-technical.md).
### 5.4 Raw packet received (`pushRawData`)
`AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`. `AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`.
@@ -118,10 +130,15 @@ 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 `VE2.senderKey6` fallback) 1. Resolve sender contact from message sender metadata
2. Send direct binary fetch request 2. Prefer a direct fetch from the original sender if its raw route is healthy
3. Show requesting state in UI 3. If the sender path does not respond, fan out a raw swarm request with the
4. Auto-play when session becomes complete exact missing packet indices to reachable peers
4. Wait up to 10 seconds for raw peer availability responses
5. Send a direct binary fetch request to the best alternate peer for the
missing subset it advertised
6. Show requesting state in UI
7. Auto-play when session becomes complete
If sender cannot be resolved or request cannot be sent, bubble remains and shows: **"Voice unavailable right now"**. If sender cannot be resolved or request cannot be sent, bubble remains and shows: **"Voice unavailable right now"**.
@@ -136,10 +153,13 @@ If sender cannot be resolved or request cannot be sent, bubble remains and shows
Serving prerequisites: Serving prerequisites:
- session exists in cache - session exists in outgoing cache or already-received session state
- `sendRawPacketCallback` configured - `sendRawPacketCallback` configured
- requester has direct path (`outPathLen >= 0`) - requester has direct path (`outPathLen >= 0`)
Received partial sessions can therefore act as relay sources during swarm
recovery.
## 8. Persistence ## 8. Persistence
`MessageStorageService` now stores and restores: `MessageStorageService` now stores and restores:
@@ -167,13 +187,13 @@ 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 `VE2` envelope, or - `packetCount` and `durationMs` from `VE3` envelope, or
- numeric envelope values decoded from base36 - 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`
- Per-packet payload model: - Per-packet payload model:
- `meshHeader(2)` + `pathLen` + `voiceHeader(8)` + `codec2Bytes` - `meshHeader(2)` + `pathLen` + `voiceHeader(6)` + `codec2Bytes`
- LoRa airtime: - LoRa airtime:
- standard symbol-time formula (preamble + payload symbols) - standard symbol-time formula (preamble + payload symbols)
- Mesh pacing/hops: - Mesh pacing/hops:
@@ -192,9 +212,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
## 11. Operational Constraints ## 11. Operational Constraints
- No firmware changes required. - No firmware changes required.
- On-demand fetch works only if sender app is online and has cached session. - On-demand fetch prefers the original sender, but a partial session can also be
completed from alternate peers that already hold packets.
- 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`).
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as voice
fetch and packet delivery.
### 11.1 Raw Binary Routing Semantics ### 11.1 Raw Binary Routing Semantics
@@ -206,26 +229,27 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
- only nodes on that path relay it; - only nodes on that path relay it;
- it is **not** received by everyone in the mesh. - it is **not** received by everyone in the mesh.
## 12. Backward Compatibility ## 12. High-Level Sequence
- Legacy `V:` text packet parsing is still supported.
- Message voice detection accepts `VE2` and legacy `V:` formats.
## 13. High-Level Sequence
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant A as Sender App participant A as Sender App
participant M as Mesh Chat participant P as Peer With Packets
participant N as Reachable Peers
participant B as Receiver App participant B as Receiver App
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 VE2 envelope A->>M: Send VE3 envelope
M->>B: Deliver VE2 M->>B: Deliver VE3
B->>B: Render voice bubble (metadata only) B->>B: Render voice bubble (metadata only)
B->>A: Send binary fetch request on Play B->>A: Send binary fetch request on Play
A->>B: Stream raw VoicePacket packets A->>B: Stream raw VoicePacket packets (partial)
Note over A,B: Sender path stops responding
B->>N: Raw swarm requests with missing voice packet indices
P->>B: Raw swarm availability response
B->>P: Direct binary fetch request for missing subset
P->>B: Stream remaining raw VoicePacket packets
B->>B: Reassemble session B->>B: Reassemble session
B->>B: Auto-play when complete B->>B: Auto-play when complete
``` ```

View File

@@ -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 = 95; CURRENT_PROJECT_VERSION = 96;
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 = 95; CURRENT_PROJECT_VERSION = 96;
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 = 95; CURRENT_PROJECT_VERSION = 96;
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 = 95; CURRENT_PROJECT_VERSION = 96;
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 = 95; CURRENT_PROJECT_VERSION = 96;
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 = 95; CURRENT_PROJECT_VERSION = 96;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>95</string> <string>96</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000273"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.453879"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.43503">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="117.623994"> <testcase classname="fastlane.lanes" name="2: build_app" time="107.968551">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="209.300865"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="271.15329">
</testcase> </testcase>

View File

@@ -1,10 +1,172 @@
export 'package:meshcore_client/meshcore_client.dart' export 'package:meshcore_client/meshcore_client.dart'
show Contact, ContactType, ContactTelemetry, AdvertLocation; show Contact, ContactType, ContactTelemetry, AdvertLocation;
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart';
class ParsedContactRoute {
final String canonicalText;
final int hashSize;
final int hopCount;
final int encodedPathLen;
final int signedEncodedPathLen;
final Uint8List pathBytes;
final Uint8List paddedPathBytes;
const ParsedContactRoute({
required this.canonicalText,
required this.hashSize,
required this.hopCount,
required this.encodedPathLen,
required this.signedEncodedPathLen,
required this.pathBytes,
required this.paddedPathBytes,
});
int get byteLength => pathBytes.length;
String get summary => hopCount == 0
? 'Direct'
: '$hopCount hop${hopCount == 1 ? '' : 's'} via $hashSize-byte hashes';
}
class ContactRouteFormatException implements Exception {
final String message;
const ContactRouteFormatException(this.message);
@override
String toString() => message;
}
class ContactRouteCodec {
static const int maxHashSize = 3;
static const int maxPathBytes = 64;
static const int _unknownDescriptor = 0xFF;
static ParsedContactRoute parse(String input) {
final normalized = input.trim().toUpperCase();
if (normalized.isEmpty) {
throw const ContactRouteFormatException('Route cannot be empty.');
}
final hopTokens = normalized
.split(',')
.map((token) => token.trim())
.toList();
if (hopTokens.any((token) => token.isEmpty)) {
throw const ContactRouteFormatException('Route contains an empty hop.');
}
final hopBytes = <List<int>>[];
int? hashSize;
for (final token in hopTokens) {
final compact = token.replaceAll(':', '');
if (compact.isEmpty || !RegExp(r'^[0-9A-F]+$').hasMatch(compact)) {
throw ContactRouteFormatException('Invalid hop "$token".');
}
if (compact.length.isOdd) {
throw ContactRouteFormatException(
'Hop "$token" must contain full bytes.',
);
}
final currentHashSize = compact.length ~/ 2;
if (currentHashSize < 1 || currentHashSize > maxHashSize) {
throw ContactRouteFormatException(
'Hop "$token" must be 1, 2, or 3 bytes.',
);
}
hashSize ??= currentHashSize;
if (hashSize != currentHashSize) {
throw const ContactRouteFormatException(
'All hops in a route must use the same hash size.',
);
}
final bytes = <int>[];
for (var i = 0; i < compact.length; i += 2) {
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
}
hopBytes.add(bytes);
}
final resolvedHashSize = hashSize ?? 1;
final flatBytes = Uint8List.fromList(
hopBytes.expand((hop) => hop).toList(),
);
if (flatBytes.length > maxPathBytes) {
throw const ContactRouteFormatException(
'Route exceeds the 64-byte firmware limit.',
);
}
final encodedPathLen =
((resolvedHashSize - 1) << 6) | (hopBytes.length & 0x3F);
final padded = Uint8List(maxPathBytes);
padded.setRange(0, flatBytes.length, flatBytes);
return ParsedContactRoute(
canonicalText: hopBytes
.map(
(hop) => hop
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
)
.join(','),
hashSize: resolvedHashSize,
hopCount: hopBytes.length,
encodedPathLen: encodedPathLen,
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
pathBytes: flatBytes,
paddedPathBytes: padded,
);
}
static ParsedContactRoute? fromContact(Contact contact) {
if (!contact.routeHasPath || contact.routeHopCount == 0) {
return null;
}
return ParsedContactRoute(
canonicalText: contact.routeCanonicalText,
hashSize: contact.routeHashSize,
hopCount: contact.routeHopCount,
encodedPathLen: contact.routeEncodedPathLen,
signedEncodedPathLen: contact.routeSignedPathLen,
pathBytes: contact.routePathBytes,
paddedPathBytes: _padPath(contact.routePathBytes),
);
}
static Uint8List _padPath(Uint8List bytes) {
final padded = Uint8List(maxPathBytes);
padded.setRange(0, math.min(bytes.length, maxPathBytes), bytes);
return padded;
}
static int toSignedDescriptor(int encodedPathLen) =>
encodedPathLen > 127 ? encodedPathLen - 256 : encodedPathLen;
static int toUnsignedDescriptor(int signedPathLen) => signedPathLen & 0xFF;
static bool isUnknownDescriptor(int signedPathLen) =>
toUnsignedDescriptor(signedPathLen) == _unknownDescriptor;
static bool isValidDescriptor(int signedPathLen) {
final raw = toUnsignedDescriptor(signedPathLen);
if (raw == _unknownDescriptor) return false;
final hashSize = ((raw >> 6) + 1);
if (hashSize > maxHashSize) return false;
final hopCount = raw & 0x3F;
return hopCount * hashSize <= maxPathBytes;
}
}
extension ContactLocalization on Contact { extension ContactLocalization on Contact {
/// Returns the localized display name for special contacts (e.g. Public Channel). /// Returns the localized display name for special contacts (e.g. Public Channel).
/// For all other contacts, returns [displayName]. /// For all other contacts, returns [displayName].
@@ -14,4 +176,56 @@ extension ContactLocalization on Contact {
} }
return displayName; return displayName;
} }
int get routeEncodedPathLen =>
ContactRouteCodec.toUnsignedDescriptor(outPathLen);
int get routeSignedPathLen =>
ContactRouteCodec.toSignedDescriptor(routeEncodedPathLen);
bool get routeIsUnknown => ContactRouteCodec.isUnknownDescriptor(outPathLen);
bool get routeHasPath =>
!routeIsUnknown && ContactRouteCodec.isValidDescriptor(outPathLen);
int get routeHashSize => routeHasPath ? ((routeEncodedPathLen >> 6) + 1) : 1;
int get routeHopCount => routeHasPath ? (routeEncodedPathLen & 0x3F) : -1;
int get routeByteLength => routeHasPath
? math.min(routeHopCount * routeHashSize, outPath.length)
: 0;
Uint8List get routePathBytes => routeByteLength <= 0
? Uint8List(0)
: Uint8List.fromList(outPath.sublist(0, routeByteLength));
String get routeCanonicalText {
if (!routeHasPath || routeHopCount <= 0) return '';
final bytes = routePathBytes;
final hops = <String>[];
for (var i = 0; i < bytes.length; i += routeHashSize) {
hops.add(
bytes
.sublist(i, i + routeHashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
String get routeSummary {
if (routeIsUnknown || !routeHasPath) {
return 'Flood/Unknown';
}
if (routeHopCount == 0) {
return 'Direct';
}
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
}
bool get routeSupportsLegacyRawTransport =>
routeHasPath && routeSignedPathLen >= 0;
} }

View File

@@ -0,0 +1,142 @@
class MessageTransferDownloader {
final String requesterKey6;
final String? requesterName;
final int transferCount;
final DateTime lastTransferredAt;
const MessageTransferDownloader({
required this.requesterKey6,
this.requesterName,
required this.transferCount,
required this.lastTransferredAt,
});
MessageTransferDownloader copyWith({
String? requesterKey6,
String? requesterName,
int? transferCount,
DateTime? lastTransferredAt,
}) {
return MessageTransferDownloader(
requesterKey6: requesterKey6 ?? this.requesterKey6,
requesterName: requesterName ?? this.requesterName,
transferCount: transferCount ?? this.transferCount,
lastTransferredAt: lastTransferredAt ?? this.lastTransferredAt,
);
}
Map<String, dynamic> toJson() {
return {
'requesterKey6': requesterKey6,
'requesterName': requesterName,
'transferCount': transferCount,
'lastTransferredAtMillis': lastTransferredAt.millisecondsSinceEpoch,
};
}
static MessageTransferDownloader? fromJson(Map<String, dynamic> json) {
final requesterKey6 = json['requesterKey6'];
final transferCount = json['transferCount'];
final lastTransferredAtMillis = json['lastTransferredAtMillis'];
if (requesterKey6 is! String ||
transferCount is! int ||
lastTransferredAtMillis is! int) {
return null;
}
return MessageTransferDownloader(
requesterKey6: requesterKey6,
requesterName: json['requesterName'] as String?,
transferCount: transferCount,
lastTransferredAt: DateTime.fromMillisecondsSinceEpoch(
lastTransferredAtMillis,
),
);
}
}
class MessageTransferDetails {
final int totalTransfers;
final List<MessageTransferDownloader> downloaders;
const MessageTransferDetails({
required this.totalTransfers,
required this.downloaders,
});
const MessageTransferDetails.empty()
: totalTransfers = 0,
downloaders = const [];
MessageTransferDetails registerTransfer({
required String requesterKey6,
String? requesterName,
DateTime? transferredAt,
}) {
final eventAt = transferredAt ?? DateTime.now();
final normalizedName = requesterName?.trim();
final updatedDownloaders = List<MessageTransferDownloader>.from(
downloaders,
);
final index = updatedDownloaders.indexWhere(
(entry) => entry.requesterKey6 == requesterKey6,
);
if (index == -1) {
updatedDownloaders.add(
MessageTransferDownloader(
requesterKey6: requesterKey6,
requesterName: normalizedName?.isEmpty ?? true
? null
: normalizedName,
transferCount: 1,
lastTransferredAt: eventAt,
),
);
} else {
final existing = updatedDownloaders[index];
updatedDownloaders[index] = existing.copyWith(
requesterName: normalizedName?.isEmpty ?? true
? existing.requesterName
: normalizedName,
transferCount: existing.transferCount + 1,
lastTransferredAt: eventAt,
);
}
updatedDownloaders.sort(
(a, b) => b.lastTransferredAt.compareTo(a.lastTransferredAt),
);
return MessageTransferDetails(
totalTransfers: totalTransfers + 1,
downloaders: updatedDownloaders,
);
}
Map<String, dynamic> toJson() {
return {
'totalTransfers': totalTransfers,
'downloaders': downloaders.map((entry) => entry.toJson()).toList(),
};
}
static MessageTransferDetails? fromJson(Map<String, dynamic> json) {
final totalTransfers = json['totalTransfers'];
if (totalTransfers is! int) {
return null;
}
final rawDownloaders = json['downloaders'] as List<dynamic>? ?? const [];
final downloaders = rawDownloaders
.whereType<Map<String, dynamic>>()
.map(MessageTransferDownloader.fromJson)
.whereType<MessageTransferDownloader>()
.toList();
return MessageTransferDetails(
totalTransfers: totalTransfers,
downloaders: downloaders,
);
}
}

View File

@@ -22,6 +22,7 @@ import '../utils/drawing_message_parser.dart';
import '../utils/raw_route_probe.dart'; import '../utils/raw_route_probe.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/media_swarm_protocol.dart';
import '../utils/message_airtime_estimator.dart'; import '../utils/message_airtime_estimator.dart';
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
@@ -63,6 +64,7 @@ class AppProvider with ChangeNotifier {
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts; bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
static const Duration _packetRetryDelay = Duration(milliseconds: 1200); static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
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, String> _imageSessionSenderKey6 = {};
@@ -72,6 +74,9 @@ class AppProvider with ChangeNotifier {
final Map<String, int> _imageMissingRetryAttempts = {}; final Map<String, int> _imageMissingRetryAttempts = {};
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {}; final Map<String, Future<bool>> _pendingRawRouteProbes = {};
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
final Map<String, Map<String, MediaSwarmAvailability>>
_pendingMediaSwarmResponses = {};
Timer? _packetCaptureFlushTimer; Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature; String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false; bool _isPersistingPacketCapture = false;
@@ -183,12 +188,12 @@ class AppProvider with ChangeNotifier {
void _restoreSessionMetadataFromMessages() { void _restoreSessionMetadataFromMessages() {
final restored = restoreSessionMetadataFromMessages( final restored = restoreSessionMetadataFromMessages(
messagesProvider.messages.map((message) => message.text), messagesProvider.messages,
); );
_voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession); _voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession);
_imageSessionSenderKey6.addAll(restored.imageSenderKeyBySession);
for (final entry in restored.imageEnvelopeBySession.entries) { for (final entry in restored.imageEnvelopeBySession.entries) {
_imageSessionSenderKey6[entry.key] = entry.value.senderKey6.toLowerCase();
imageProvider.registerEnvelope(entry.value); imageProvider.registerEnvelope(entry.value);
} }
@@ -681,9 +686,15 @@ class AppProvider with ChangeNotifier {
// Voice envelope message (new public/direct on-demand format). // Voice envelope message (new public/direct on-demand format).
final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text); final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text);
if (voiceEnvelope != null) { if (voiceEnvelope != null) {
_voiceSessionSenderKey6[voiceEnvelope.sessionId] = voiceEnvelope final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
.senderKey6 if (senderPrefix != null && senderPrefix.length >= 6) {
.toLowerCase(); _voiceSessionSenderKey6[voiceEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
voiceProvider.registerEnvelope(voiceEnvelope);
enrichedMessage = enrichedMessage.copyWith( enrichedMessage = enrichedMessage.copyWith(
isVoice: true, isVoice: true,
voiceId: voiceEnvelope.sessionId, voiceId: voiceEnvelope.sessionId,
@@ -713,9 +724,14 @@ class AppProvider with ChangeNotifier {
// 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 final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
.senderKey6 if (senderPrefix != null && senderPrefix.length >= 6) {
.toLowerCase(); _imageSessionSenderKey6[imageEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
imageProvider.registerEnvelope(imageEnvelope); imageProvider.registerEnvelope(imageEnvelope);
messagesProvider.addMessage( messagesProvider.addMessage(
enrichedMessage, enrichedMessage,
@@ -739,19 +755,6 @@ class AppProvider with ChangeNotifier {
return; return;
} }
// If it's a text-format voice packet, feed it to VoiceProvider
if (VoicePacket.isVoiceText(enrichedMessage.text)) {
final pkt = VoicePacket.tryParseText(enrichedMessage.text);
if (pkt != null) {
voiceProvider.addPacket(pkt);
// Mark the message with voice metadata before adding to chat
enrichedMessage = enrichedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
// Pass contact lookup function to link channel messages with contacts // Pass contact lookup function to link channel messages with contacts
messagesProvider.addMessage( messagesProvider.addMessage(
enrichedMessage, enrichedMessage,
@@ -803,8 +806,9 @@ 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 0x6d 'm' = swarm control; 0x72 'r' = voice fetch request.
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet. // Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
// Magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload); final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
if (rawProbeRequest != null) { if (rawProbeRequest != null) {
@@ -824,6 +828,20 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final mediaSwarmRequest = MediaSwarmRequest.tryParseBinary(payload);
if (mediaSwarmRequest != null) {
_handleIncomingMediaSwarmRequest(mediaSwarmRequest);
return;
}
final mediaSwarmAvailability = MediaSwarmAvailability.tryParseBinary(
payload,
);
if (mediaSwarmAvailability != null) {
_handleIncomingMediaSwarmAvailability(mediaSwarmAvailability);
return;
}
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload); final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
if (voiceFetchRequest != null) { if (voiceFetchRequest != null) {
debugPrint( debugPrint(
@@ -841,26 +859,34 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
if (requester.outPathLen > _maxDirectPayloadHops) { if (requester.routeHopCount > _maxDirectPayloadHops) {
debugPrint( debugPrint(
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops', '⚠️ [AppProvider] Voice fetch requester too far: ${requester.routeHopCount} hops',
); );
messagesProvider.logSystemMessage( messagesProvider.logSystemMessage(
text: text:
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', 'Cannot fetch voice for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).',
level: 'warning', level: 'warning',
); );
return; return;
} }
unawaited( unawaited(() async {
voiceProvider.serveSessionTo( final served = await voiceProvider.serveSessionTo(
sessionId: voiceFetchRequest.sessionId, sessionId: voiceFetchRequest.sessionId,
requester: requester, requester: requester,
requestedIndices: voiceFetchRequest.want == 'missing' requestedIndices: voiceFetchRequest.want == 'missing'
? voiceFetchRequest.missingIndices.toSet() ? voiceFetchRequest.missingIndices.toSet()
: null, : null,
), );
); if (served) {
messagesProvider.recordMediaTransfer(
sessionId: voiceFetchRequest.sessionId,
mediaType: 'voice',
requesterKey6: voiceFetchRequest.requesterKey6,
requesterName: requester.advName,
);
}
}());
return; return;
} }
@@ -880,32 +906,40 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
if (requester.outPathLen > _maxDirectPayloadHops) { if (requester.routeHopCount > _maxDirectPayloadHops) {
debugPrint( debugPrint(
'⚠️ [AppProvider] Image fetch requester too far: ' '⚠️ [AppProvider] Image fetch requester too far: '
'${requester.outPathLen} hops for session ' '${requester.routeHopCount} hops for session '
'${imageFetchRequest.sessionId}', '${imageFetchRequest.sessionId}',
); );
messagesProvider.logSystemMessage( messagesProvider.logSystemMessage(
text: text:
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', 'Cannot fetch image for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).',
level: 'warning', level: 'warning',
); );
return; return;
} }
debugPrint( debugPrint(
'📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} ' '📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} '
'to ${requester.advName} via ${requester.outPathLen} hop(s)', 'to ${requester.advName} via ${requester.routeHopCount} hop(s)',
); );
unawaited( unawaited(() async {
imageProvider.serveSessionTo( final served = await imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId, sessionId: imageFetchRequest.sessionId,
requester: requester, requester: requester,
requestedIndices: imageFetchRequest.want == 'missing' requestedIndices: imageFetchRequest.want == 'missing'
? imageFetchRequest.missingIndices.toSet() ? imageFetchRequest.missingIndices.toSet()
: null, : null,
), );
); if (served) {
messagesProvider.recordMediaTransfer(
sessionId: imageFetchRequest.sessionId,
mediaType: 'image',
requesterKey6: imageFetchRequest.requesterKey6,
requesterName: requester.advName,
);
}
}());
return; return;
} }
@@ -914,8 +948,23 @@ class AppProvider with ChangeNotifier {
if (frag == null) return; if (frag == null) return;
debugPrint('📷 [AppProvider] Binary image fragment received: $frag'); debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId); final session = imageProvider.session(frag.sessionId);
if (session == null && frag.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact image fragment without envelope '
'for session ${frag.sessionId}',
);
return;
}
imageProvider.addFragment( imageProvider.addFragment(
frag, session == null
? frag
: ImagePacket(
sessionId: frag.sessionId,
format: session.format,
index: frag.index,
total: session.total,
data: frag.data,
),
width: session?.width ?? 0, width: session?.width ?? 0,
height: session?.height ?? 0, height: session?.height ?? 0,
); );
@@ -930,7 +979,25 @@ class AppProvider with ChangeNotifier {
final pkt = VoicePacket.tryParseBinary(payload); final pkt = VoicePacket.tryParseBinary(payload);
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 session = voiceProvider.session(pkt.sessionId);
if (session == null && pkt.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact voice packet without envelope '
'for session ${pkt.sessionId}',
);
return;
}
final justComplete = voiceProvider.addPacket(
session == null
? pkt
: VoicePacket(
sessionId: pkt.sessionId,
mode: session.mode,
index: pkt.index,
total: session.total,
codec2Data: pkt.codec2Data,
),
);
_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);
@@ -1362,6 +1429,291 @@ class AppProvider with ChangeNotifier {
return null; return null;
} }
String _mediaSwarmKey(String mediaType, String sessionId) =>
'$mediaType:$sessionId';
String? _deviceKey6Hex() {
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
return null;
}
return deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
List<int> _availableIndicesForSession(String mediaType, String sessionId) {
return switch (mediaType) {
'voice' => voiceProvider.availablePacketIndices(sessionId),
'image' => imageProvider.availableFragmentIndices(sessionId),
_ => const <int>[],
};
}
List<int> _matchingAvailableIndices(MediaSwarmRequest request) {
final available = _availableIndicesForSession(
request.mediaType,
request.sessionId,
);
if (available.isEmpty) return const [];
if (request.requestsAll) return available;
final requested = request.missingIndices.toSet();
return available.where(requested.contains).toList()..sort();
}
List<Contact> _eligibleSwarmPeers({String? excludeKey6}) {
final ownKey6 = _deviceKey6Hex();
return contactsProvider.contacts.where((contact) {
if (!contact.routeHasPath ||
contact.routeHopCount > _maxDirectPayloadHops ||
!contact.routeSupportsLegacyRawTransport ||
contact.outPath.isEmpty ||
contact.publicKey.length < 6) {
return false;
}
final key6 = contact.publicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
if (key6 == ownKey6 || key6 == excludeKey6) {
return false;
}
return true;
}).toList();
}
void _handleIncomingMediaSwarmRequest(MediaSwarmRequest request) {
final ownKey6 = _deviceKey6Hex();
if (ownKey6 == null || request.requesterKey6 == ownKey6) {
return;
}
final available = _matchingAvailableIndices(request);
if (available.isEmpty) {
return;
}
final availability = MediaSwarmAvailability(
mediaType: request.mediaType,
sessionId: request.sessionId,
requesterKey6: request.requesterKey6,
responderKey6: ownKey6,
availableIndices: available,
);
debugPrint(
'🌐 [AppProvider] Media swarm availability for ${request.mediaType} '
'${request.sessionId}: ${available.length} fragment(s)',
);
final requester = _resolveContactByPrefixHex(request.requesterKey6);
if (requester == null ||
!requester.routeHasPath ||
requester.routeHopCount > _maxDirectPayloadHops ||
!requester.routeSupportsLegacyRawTransport ||
requester.outPath.isEmpty) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
payload: availability.encodeBinary(),
),
);
}
void _handleIncomingMediaSwarmAvailability(
MediaSwarmAvailability availability,
) {
final ownKey6 = _deviceKey6Hex();
if (ownKey6 == null || availability.requesterKey6 != ownKey6) {
return;
}
final key = _mediaSwarmKey(availability.mediaType, availability.sessionId);
final responses = _pendingMediaSwarmResponses[key];
if (responses == null) {
return;
}
responses[availability.responderKey6] = availability;
debugPrint(
'🌐 [AppProvider] Media swarm response for ${availability.mediaType} '
'${availability.sessionId} from ${availability.responderKey6} '
'(${availability.servesAll ? 'all' : availability.availableIndices.length})',
);
}
Future<bool> _requestMissingMediaViaSwarm({
required String mediaType,
required String sessionId,
required List<int> missingIndices,
required String? originalSenderKey6,
}) async {
if (!connectionProvider.deviceInfo.isConnected || missingIndices.isEmpty) {
return false;
}
final key = _mediaSwarmKey(mediaType, sessionId);
final pending = _pendingMediaSwarmFetches[key];
if (pending != null) {
return pending;
}
final requesterKey6 = _deviceKey6Hex();
if (requesterKey6 == null) {
return false;
}
final future = () async {
final responses = <String, MediaSwarmAvailability>{};
_pendingMediaSwarmResponses[key] = responses;
try {
final request = MediaSwarmRequest(
mediaType: mediaType,
sessionId: sessionId,
requesterKey6: requesterKey6,
missingIndices: missingIndices,
);
final peers = _eligibleSwarmPeers(excludeKey6: originalSenderKey6);
if (peers.isEmpty) {
return false;
}
debugPrint(
'🌐 [AppProvider] Media swarm request for $mediaType $sessionId '
'(${missingIndices.length} needed fragment(s), ${peers.length} peer(s))',
);
for (final peer in peers) {
await connectionProvider.sendRawVoicePacket(
contactPath: peer.outPath,
contactPathLen: peer.routeSignedPathLen,
payload: request.encodeBinary(),
);
}
await Future<void>.delayed(_mediaSwarmResponseWindow);
final orderedResponses =
responses.values
.where(
(response) => response.responderKey6 != originalSenderKey6,
)
.toList()
..sort((a, b) {
final aScore = _swarmResponseScore(a, missingIndices);
final bScore = _swarmResponseScore(b, missingIndices);
return bScore.compareTo(aScore);
});
for (final response in orderedResponses) {
final responder = _resolveContactByPrefixHex(response.responderKey6);
if (responder == null ||
!responder.routeHasPath ||
responder.routeHopCount > _maxDirectPayloadHops ||
!responder.routeSupportsLegacyRawTransport ||
responder.outPath.isEmpty) {
continue;
}
final requestedSubset = response.servesAll
? missingIndices
: missingIndices
.where(response.availableIndices.toSet().contains)
.toList();
if (requestedSubset.isEmpty) {
continue;
}
final requestedSet = requestedSubset.toSet();
final sent = await _sendDirectMediaFetchRequest(
mediaType: mediaType,
sessionId: sessionId,
target: responder,
requesterKey6: requesterKey6,
missingIndices: requestedSet,
);
if (sent) {
debugPrint(
'🌐 [AppProvider] Requested $mediaType $sessionId '
'from swarm peer ${responder.advName} '
'(${requestedSubset.length} fragment(s))',
);
return true;
}
}
return false;
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Media swarm request failed for $mediaType '
'$sessionId: $e',
);
return false;
} finally {
_pendingMediaSwarmResponses.remove(key);
}
}();
_pendingMediaSwarmFetches[key] = future;
try {
return await future;
} finally {
_pendingMediaSwarmFetches.remove(key);
}
}
int _swarmResponseScore(
MediaSwarmAvailability response,
List<int> missingIndices,
) {
if (response.servesAll) {
return missingIndices.length;
}
final needed = missingIndices.toSet();
return response.availableIndices.where(needed.contains).length;
}
Future<bool> _sendDirectMediaFetchRequest({
required String mediaType,
required String sessionId,
required Contact target,
required String requesterKey6,
required Set<int> missingIndices,
}) async {
try {
final payload = switch (mediaType) {
'voice' => VoiceFetchRequest(
sessionId: sessionId,
want: missingIndices.isEmpty ? 'all' : 'missing',
missingIndices: missingIndices.toList()..sort(),
requesterKey6: requesterKey6,
).encodeBinary(),
'image' => ImageFetchRequest(
sessionId: sessionId,
want: missingIndices.isEmpty ? 'all' : 'missing',
missingIndices: missingIndices.toList()..sort(),
requesterKey6: requesterKey6,
).encodeBinary(),
_ => null,
};
if (payload == null) {
return false;
}
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen,
payload: payload,
);
return true;
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Direct $mediaType fetch via ${target.advName} failed: $e',
);
return false;
}
}
void _scheduleVoiceMissingRetry( void _scheduleVoiceMissingRetry(
String sessionId, { String sessionId, {
required bool justComplete, required bool justComplete,
@@ -1434,8 +1786,8 @@ class AppProvider with ChangeNotifier {
final senderKey6 = _voiceSessionSenderKey6[sessionId]; final senderKey6 = _voiceSessionSenderKey6[sessionId];
if (senderKey6 == null) return; if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6); final sender = _resolveContactByPrefixHex(senderKey6);
final deviceKey = connectionProvider.deviceInfo.publicKey; final requesterKey6 = _deviceKey6Hex();
if (sender == null || deviceKey == null || deviceKey.length < 6) return; if (requesterKey6 == null) return;
final missing = voiceProvider.missingPacketIndices(sessionId); final missing = voiceProvider.missingPacketIndices(sessionId);
if (missing.isEmpty) { if (missing.isEmpty) {
@@ -1443,27 +1795,28 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final requesterKey6 = deviceKey var sent = false;
.sublist(0, 6) if (sender != null) {
.map((b) => b.toRadixString(16).padLeft(2, '0')) final routeOk = await verifyRawTransportRoute(sender);
.join(''); if (routeOk) {
sent = await _sendDirectMediaFetchRequest(
final request = VoiceFetchRequest( mediaType: 'voice',
sessionId: sessionId, sessionId: sessionId,
want: 'missing', target: sender,
missingIndices: missing, requesterKey6: requesterKey6,
requesterKey6: requesterKey6, missingIndices: missing.toSet(),
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, );
version: 2, }
); }
if (!sent) {
try { sent = await _requestMissingMediaViaSwarm(
await connectionProvider.sendRawVoicePacket( mediaType: 'voice',
contactPath: sender.outPath, sessionId: sessionId,
contactPathLen: sender.outPathLen, missingIndices: missing,
payload: request.encodeBinary(), originalSenderKey6: senderKey6,
); );
} catch (_) { }
if (!sent) {
return; return;
} }
@@ -1496,8 +1849,8 @@ class AppProvider with ChangeNotifier {
final senderKey6 = _imageSessionSenderKey6[sessionId]; final senderKey6 = _imageSessionSenderKey6[sessionId];
if (senderKey6 == null) return; if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6); final sender = _resolveContactByPrefixHex(senderKey6);
final deviceKey = connectionProvider.deviceInfo.publicKey; final requesterKey6 = _deviceKey6Hex();
if (sender == null || deviceKey == null || deviceKey.length < 6) return; if (requesterKey6 == null) return;
final missing = imageProvider.missingFragmentIndices(sessionId); final missing = imageProvider.missingFragmentIndices(sessionId);
if (missing.isEmpty) { if (missing.isEmpty) {
@@ -1505,26 +1858,28 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final requesterKey6 = deviceKey var sent = false;
.sublist(0, 6) if (sender != null) {
.map((b) => b.toRadixString(16).padLeft(2, '0')) final routeOk = await verifyRawTransportRoute(sender);
.join(''); if (routeOk) {
sent = await _sendDirectMediaFetchRequest(
final request = ImageFetchRequest( mediaType: 'image',
sessionId: sessionId, sessionId: sessionId,
want: 'missing', target: sender,
missingIndices: missing, requesterKey6: requesterKey6,
requesterKey6: requesterKey6, missingIndices: missing.toSet(),
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, );
); }
}
try { if (!sent) {
await connectionProvider.sendRawVoicePacket( sent = await _requestMissingMediaViaSwarm(
contactPath: sender.outPath, mediaType: 'image',
contactPathLen: sender.outPathLen, sessionId: sessionId,
payload: request.encodeBinary(), missingIndices: missing,
originalSenderKey6: senderKey6,
); );
} catch (_) { }
if (!sent) {
return; return;
} }
@@ -1585,7 +1940,10 @@ class AppProvider with ChangeNotifier {
if (!connectionProvider.deviceInfo.isConnected) { if (!connectionProvider.deviceInfo.isConnected) {
return false; return false;
} }
if (target.outPathLen < 0 || target.outPathLen > _maxDirectPayloadHops) { if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
return false;
}
if (!target.routeSupportsLegacyRawTransport) {
return false; return false;
} }
if (target.outPath.isEmpty) { if (target.outPath.isEmpty) {
@@ -1616,11 +1974,11 @@ class AppProvider with ChangeNotifier {
try { try {
debugPrint( debugPrint(
'📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.outPathLen} nonce=${nonce.toRadixString(16)}', '📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.routeHopCount} nonce=${nonce.toRadixString(16)}',
); );
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath, contactPath: target.outPath,
contactPathLen: target.outPathLen, contactPathLen: target.routeSignedPathLen,
payload: RawRouteProbeRequest( payload: RawRouteProbeRequest(
nonce: nonce, nonce: nonce,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
@@ -1648,7 +2006,7 @@ class AppProvider with ChangeNotifier {
if (target.publicKeyHex.isNotEmpty) { if (target.publicKeyHex.isNotEmpty) {
return 'pk:${target.publicKeyHex}'; return 'pk:${target.publicKeyHex}';
} }
return 'name:${target.advName}:${target.outPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
} }
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) { void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
@@ -1659,23 +2017,26 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
if (requester.outPathLen < 0 || if (!requester.routeHasPath ||
requester.outPathLen > _maxDirectPayloadHops) { requester.routeHopCount > _maxDirectPayloadHops) {
debugPrint( debugPrint(
'⚠️ [AppProvider] Raw route probe requester out of range: ${requester.outPathLen}', '⚠️ [AppProvider] Raw route probe requester out of range: ${requester.routeHopCount}',
); );
return; return;
} }
if (!requester.routeSupportsLegacyRawTransport) {
return;
}
if (requester.outPath.isEmpty) { if (requester.outPath.isEmpty) {
return; return;
} }
debugPrint( debugPrint(
'📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.outPathLen} nonce=${request.nonce.toRadixString(16)}', '📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.routeHopCount} nonce=${request.nonce.toRadixString(16)}',
); );
unawaited( unawaited(
connectionProvider.sendRawVoicePacket( connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.outPathLen, contactPathLen: requester.routeSignedPathLen,
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
), ),
); );

View File

@@ -4,10 +4,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.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/contact.dart';
import '../models/device_info.dart'; import '../models/device_info.dart';
import '../models/room_login_state.dart'; import '../models/room_login_state.dart';
import '../models/sse_server_config.dart'; import '../models/sse_server_config.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart' hide Contact;
import '../services/sse_server_service.dart'; import '../services/sse_server_service.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import 'helpers/room_login_manager.dart'; import 'helpers/room_login_manager.dart';
@@ -1121,9 +1122,11 @@ class ConnectionProvider with ChangeNotifier {
); );
} }
debugPrint(' Type: ${contact.type.displayName}'); debugPrint(' Type: ${contact.type.displayName}');
debugPrint(' Path status: ${contact.pathDescription}'); debugPrint(' Path status: ${contact.routeSummary}');
if (contact.hasPath) { if (contact.routeHasPath) {
debugPrint(' ✅ Using learned path (${contact.outPathLen} bytes)'); debugPrint(
' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)',
);
} else { } else {
debugPrint(' ⚠️ No path available - will use flood mode'); debugPrint(' ⚠️ No path available - will use flood mode');
} }
@@ -1173,6 +1176,19 @@ class ConnectionProvider with ChangeNotifier {
attempt: retryAttempt, attempt: retryAttempt,
); );
if (messageId != null) {
Future.delayed(const Duration(milliseconds: 350), () {
if (_messageDeliveryTracker.hasAckForMessage(messageId)) {
return;
}
debugPrint(
' [ConnectionProvider] Missing RESP_CODE_SENT for $messageId; promoting to sent via fallback',
);
onMessageSent?.call(messageId, 0, 0);
});
}
// Clear pending operation after successful send (no error) // Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically // If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (contact != null) { if (contact != null) {
@@ -2006,6 +2022,30 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<void> setContactRoute(
Contact contact, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
final updatedContact = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
await _activeService.addOrUpdateContact(updatedContact);
} catch (e) {
_error = 'Failed to set route: $e';
notifyListeners();
rethrow;
}
}
/// Remove a contact from the companion radio /// Remove a contact from the companion radio
/// ///
/// Deletes the contact from the device's internal contact table. /// Deletes the contact from the device's internal contact table.

View File

@@ -521,7 +521,39 @@ class ContactsProvider with ChangeNotifier {
/// prefer flood routing until the radio reports a fresh route. /// prefer flood routing until the radio reports a fresh route.
void markPathUnhealthy(Uint8List publicKey) { void markPathUnhealthy(Uint8List publicKey) {
final contact = findContactByKey(publicKey); final contact = findContactByKey(publicKey);
if (contact == null || !contact.hasPath) { if (contact == null || !contact.routeHasPath) {
return;
}
_contacts[contact.publicKeyHex] = contact.copyWith(
outPathLen: -1,
outPath: Uint8List(0),
);
_persistContacts();
notifyListeners();
}
void setContactRouteLocal(
Uint8List publicKey, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
}) {
final contact = findContactByKey(publicKey);
if (contact == null) {
return;
}
_contacts[contact.publicKeyHex] = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
_persistContacts();
notifyListeners();
}
void resetContactRouteLocal(Uint8List publicKey) {
final contact = findContactByKey(publicKey);
if (contact == null) {
return; return;
} }

View File

@@ -98,6 +98,11 @@ class MessageDeliveryTracker {
return _ackTagToMessageId[ackCode]; return _ackTagToMessageId[ackCode];
} }
/// Returns true once a message has been matched to a concrete ACK tag.
bool hasAckForMessage(String messageId) {
return _messageIdToAckTag.containsKey(messageId);
}
/// Remove ACK tag mapping after delivery confirmed or timeout /// Remove ACK tag mapping after delivery confirmed or timeout
/// ///
/// Cleans up both forward and reverse mappings. /// Cleans up both forward and reverse mappings.

View File

@@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math;
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
@@ -55,8 +54,8 @@ class MessageRetryManager {
final payloadBytes = utf8.encode(text).length; final payloadBytes = utf8.encode(text).length;
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes); final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
final hopCount = contact?.hasPath == true final hopCount = contact?.routeHasPath == true
? math.max(contact!.outPathLen, 0) ? contact!.routeHopCount
: -1; : -1;
if (hopCount < 0) { if (hopCount < 0) {
@@ -87,7 +86,7 @@ class MessageRetryManager {
// Only retry if contact has a learned path // Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help // If no path, the device uses flood mode automatically - retrying won't help
return contact.hasPath; return contact.routeHasPath;
} }
/// Check if should fall back to flood mode /// Check if should fall back to flood mode
@@ -101,7 +100,7 @@ class MessageRetryManager {
/// Contacts without paths already use flood mode automatically. /// Contacts without paths already use flood mode automatically.
bool shouldUseFloodFallback(Message message, Contact contact) { bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 && return message.retryAttempt >= 3 &&
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths contact.routeHasPath &&
!message.usedFloodFallback; !message.usedFloodFallback;
} }

View File

@@ -28,13 +28,19 @@ Future<bool> serveCachedSessionFragments<T>({
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set'); debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
return false; return false;
} }
if (requester.outPathLen < 0) { if (!requester.routeHasPath) {
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path'); debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
return false; return false;
} }
if (requester.outPathLen > maxDirectPayloadHops) { if (requester.routeHopCount > maxDirectPayloadHops) {
debugPrint( debugPrint(
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)', '⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.routeHopCount} hops (max $maxDirectPayloadHops)',
);
return false;
}
if (!requester.routeSupportsLegacyRawTransport) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
); );
return false; return false;
} }
@@ -58,7 +64,7 @@ Future<bool> serveCachedSessionFragments<T>({
try { try {
await sendRawPacket( await sendRawPacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.outPathLen, contactPathLen: requester.routeSignedPathLen,
payload: encodeBinary(fragment), payload: encodeBinary(fragment),
); );
servedCount++; servedCount++;

View File

@@ -1,38 +1,57 @@
import '../../models/message.dart';
import '../../utils/image_message_parser.dart'; import '../../utils/image_message_parser.dart';
import '../../utils/voice_message_parser.dart'; import '../../utils/voice_message_parser.dart';
class RestoredSessionMetadata { class RestoredSessionMetadata {
final Map<String, String> voiceSenderKeyBySession; final Map<String, String> voiceSenderKeyBySession;
final Map<String, String> imageSenderKeyBySession;
final Map<String, ImageEnvelope> imageEnvelopeBySession; final Map<String, ImageEnvelope> imageEnvelopeBySession;
const RestoredSessionMetadata({ const RestoredSessionMetadata({
required this.voiceSenderKeyBySession, required this.voiceSenderKeyBySession,
required this.imageSenderKeyBySession,
required this.imageEnvelopeBySession, required this.imageEnvelopeBySession,
}); });
} }
RestoredSessionMetadata restoreSessionMetadataFromMessages( RestoredSessionMetadata restoreSessionMetadataFromMessages(
Iterable<String> messageTexts, Iterable<Message> messages,
) { ) {
final voiceSenderKeyBySession = <String, String>{}; final voiceSenderKeyBySession = <String, String>{};
final imageSenderKeyBySession = <String, String>{};
final imageEnvelopeBySession = <String, ImageEnvelope>{}; final imageEnvelopeBySession = <String, ImageEnvelope>{};
for (final text in messageTexts) { for (final message in messages) {
final text = message.text;
final voiceEnvelope = VoiceEnvelope.tryParseText(text); final voiceEnvelope = VoiceEnvelope.tryParseText(text);
if (voiceEnvelope != null) { if (voiceEnvelope != null) {
voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope final senderPrefix = message.senderPublicKeyPrefix;
.senderKey6 if (senderPrefix != null && senderPrefix.length >= 6) {
.toLowerCase(); voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
} }
final imageEnvelope = ImageEnvelope.tryParse(text); final imageEnvelope = ImageEnvelope.tryParse(text);
if (imageEnvelope != null) { if (imageEnvelope != null) {
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope; imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
imageSenderKeyBySession[imageEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
} }
} }
return RestoredSessionMetadata( return RestoredSessionMetadata(
voiceSenderKeyBySession: voiceSenderKeyBySession, voiceSenderKeyBySession: voiceSenderKeyBySession,
imageSenderKeyBySession: imageSenderKeyBySession,
imageEnvelopeBySession: imageEnvelopeBySession, imageEnvelopeBySession: imageEnvelopeBySession,
); );
} }

View File

@@ -96,11 +96,28 @@ class ImageProvider with ChangeNotifier {
return missing; return missing;
} }
List<int> availableFragmentIndices(String sessionId) {
final outgoing = _outgoing[sessionId];
if (outgoing != null) {
return outgoing.fragments.map((fragment) => fragment.index).toList()
..sort();
}
final session = _sessions[sessionId];
if (session == null) return const [];
final indices = <int>[];
for (var i = 0; i < session.fragments.length; i++) {
if (session.fragments[i] != null) {
indices.add(i);
}
}
return indices;
}
// ── Incoming fragment reception ────────────────────────────────────────── // ── Incoming fragment reception ──────────────────────────────────────────
/// Add a received [fragment]. Creates the session on first fragment using /// Add a received [fragment]. New compact fragments rely on prior envelope
/// metadata from the fragment itself (requires envelope to have been /// metadata for total/format, while legacy fragments can still self-describe.
/// announced first; if not, defaults width/height to 0 — corrected on save).
/// ///
/// 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}) {
@@ -110,16 +127,20 @@ class ImageProvider with ChangeNotifier {
); );
return false; return false;
} }
_sessions.putIfAbsent( _sessions.putIfAbsent(fragment.sessionId, () {
fragment.sessionId, if (fragment.total < 1) {
() => ImageSession( throw StateError(
'Image envelope missing for compact fragment ${fragment.sessionId}',
);
}
return ImageSession(
sessionId: fragment.sessionId, sessionId: fragment.sessionId,
format: fragment.format, format: fragment.format,
total: fragment.total, total: fragment.total,
width: width, width: width,
height: height, height: height,
), );
); });
final session = _sessions[fragment.sessionId]!; final session = _sessions[fragment.sessionId]!;
if (fragment.index < session.total) { if (fragment.index < session.total) {
@@ -244,16 +265,22 @@ class ImageProvider with ChangeNotifier {
required Contact requester, required Contact requester,
Set<int>? requestedIndices, Set<int>? requestedIndices,
}) async { }) async {
final cached = _outgoing[sessionId]; final outgoing = _outgoing[sessionId];
if (cached == null) { final fragments = outgoing != null
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId'); ? List<ImagePacket>.from(outgoing.fragments)
: _sessions[sessionId]?.fragments.whereType<ImagePacket>().toList() ??
const <ImagePacket>[];
if (fragments.isEmpty) {
debugPrint(
'⚠️ [ImageProvider] No cached or received session for $sessionId',
);
return false; return false;
} }
return serveCachedSessionFragments<ImagePacket>( return serveCachedSessionFragments<ImagePacket>(
providerLabel: 'ImageProvider', providerLabel: 'ImageProvider',
sessionId: sessionId, sessionId: sessionId,
requester: requester, requester: requester,
fragments: cached.fragments, fragments: fragments,
maxDirectPayloadHops: maxDirectPayloadHops, maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (fragment) => fragment.index, indexOf: (fragment) => fragment.index,
encodeBinary: (fragment) => fragment.encodeBinary(), encodeBinary: (fragment) => fragment.encodeBinary(),

View File

@@ -4,6 +4,7 @@ import '../models/message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart'; import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../services/message_storage_service.dart'; import '../services/message_storage_service.dart';
@@ -25,6 +26,7 @@ class MessagesProvider with ChangeNotifier {
AppLocalizations? _localizations; AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {}; final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {}; final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
// Track pending sent messages by expected ACK/TAG // Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {}; final Map<int, Message> _pendingSentMessages = {};
@@ -121,6 +123,9 @@ class MessagesProvider with ChangeNotifier {
MessageReceptionDetails? getMessageReceptionDetails(String messageId) => MessageReceptionDetails? getMessageReceptionDetails(String messageId) =>
_messageReceptionDetails[messageId]; _messageReceptionDetails[messageId];
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
_messageTransferDetails[messageId];
/// Set localizations for notifications /// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) { void setLocalizations(AppLocalizations localizations) {
_localizations = localizations; _localizations = localizations;
@@ -153,12 +158,17 @@ class MessagesProvider with ChangeNotifier {
.loadMessageContactLocations(); .loadMessageContactLocations();
final storedReceptionDetails = await _storageService final storedReceptionDetails = await _storageService
.loadMessageReceptionDetails(); .loadMessageReceptionDetails();
final storedTransferDetails = await _storageService
.loadMessageTransferDetails();
_messageContactLocations _messageContactLocations
..clear() ..clear()
..addAll(storedContactLocations); ..addAll(storedContactLocations);
_messageReceptionDetails _messageReceptionDetails
..clear() ..clear()
..addAll(storedReceptionDetails); ..addAll(storedReceptionDetails);
_messageTransferDetails
..clear()
..addAll(storedTransferDetails);
// Add stored messages with enhancement to ensure SAR detection // Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) { for (final message in storedMessages) {
@@ -198,14 +208,6 @@ class MessagesProvider with ChangeNotifier {
isVoice: true, isVoice: true,
voiceId: envelope.sessionId, voiceId: envelope.sessionId,
); );
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
if (pkt != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
} }
} }
@@ -353,14 +355,6 @@ class MessagesProvider with ChangeNotifier {
isVoice: true, isVoice: true,
voiceId: envelope.sessionId, voiceId: envelope.sessionId,
); );
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
if (pkt != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
} }
} }
@@ -649,18 +643,16 @@ class MessagesProvider with ChangeNotifier {
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text); final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
if (voiceEnvelope != null) { if (voiceEnvelope != null) {
final seconds = (voiceEnvelope.durationMs / 1000).ceil(); final seconds = (voiceEnvelope.durationMs / 1000).ceil();
final route = isChannelMessage final summary =
? 'Channel: ${channelName ?? _resolveChannelName(message.channelIdx)}' 'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
: 'From: $senderName'; return isChannelMessage ? '$senderName\n$summary' : summary;
return '$route\nVoice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
} }
final imageEnvelope = ImageEnvelope.tryParse(message.text); final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) { if (imageEnvelope != null) {
final route = isChannelMessage final summary =
? 'Channel: ${channelName ?? _resolveChannelName(message.channelIdx)}' 'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
: 'From: $senderName'; return isChannelMessage ? '$senderName\n$summary' : summary;
return '$route\nImage - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
} }
if (!isChannelMessage && message.recipientPublicKey != null) { if (!isChannelMessage && message.recipientPublicKey != null) {
@@ -669,12 +661,12 @@ class MessagesProvider with ChangeNotifier {
fallback: null, fallback: null,
); );
if (recipientName != 'Unknown') { if (recipientName != 'Unknown') {
return 'From: $senderName\nTo: $recipientName\n${message.text}'; return 'To: $recipientName\n${message.text}';
} }
} }
if (isChannelMessage && channelName != null && channelName.isNotEmpty) { if (isChannelMessage) {
return 'Channel: $channelName\n${message.text}'; return '$senderName\n${message.text}';
} }
return message.text; return message.text;
@@ -695,6 +687,7 @@ class MessagesProvider with ChangeNotifier {
_messages, _messages,
messageContactLocations: _messageContactLocations, messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails, messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
); );
} catch (e) { } catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
@@ -812,6 +805,7 @@ class MessagesProvider with ChangeNotifier {
_groupedMessageMapping.remove(messageId); _groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId); _messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId); _messageReceptionDetails.remove(messageId);
_messageTransferDetails.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -842,6 +836,7 @@ class MessagesProvider with ChangeNotifier {
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear(); _messageContactLocations.clear();
_messageReceptionDetails.clear(); _messageReceptionDetails.clear();
_messageTransferDetails.clear();
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
@@ -858,10 +853,69 @@ class MessagesProvider with ChangeNotifier {
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear(); _messageContactLocations.clear();
_messageReceptionDetails.clear(); _messageReceptionDetails.clear();
_messageTransferDetails.clear();
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
int transferCountForSession({
String? voiceSessionId,
String? imageSessionId,
}) {
final messageId = _findMessageIdByMediaSession(
voiceSessionId: voiceSessionId,
imageSessionId: imageSessionId,
);
if (messageId == null) return 0;
return _messageTransferDetails[messageId]?.totalTransfers ?? 0;
}
void recordMediaTransfer({
required String sessionId,
required String mediaType,
required String requesterKey6,
String? requesterName,
}) {
final messageId = _findMessageIdByMediaSession(
voiceSessionId: mediaType == 'voice' ? sessionId : null,
imageSessionId: mediaType == 'image' ? sessionId : null,
);
if (messageId == null) {
debugPrint(
'⚠️ [MessagesProvider] No message found for $mediaType session $sessionId',
);
return;
}
final current =
_messageTransferDetails[messageId] ??
const MessageTransferDetails.empty();
_messageTransferDetails[messageId] = current.registerTransfer(
requesterKey6: requesterKey6,
requesterName: requesterName,
);
_persistMessages();
notifyListeners();
}
String? _findMessageIdByMediaSession({
String? voiceSessionId,
String? imageSessionId,
}) {
for (final message in _messages.reversed) {
if (voiceSessionId != null && message.voiceId == voiceSessionId) {
return message.id;
}
if (imageSessionId != null) {
final envelope = ImageEnvelope.tryParse(message.text);
if (envelope != null && envelope.sessionId == imageSessionId) {
return message.id;
}
}
}
return null;
}
/// Get storage statistics /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats() async {
return await _storageService.getStorageStats(); return await _storageService.getStorageStats();
@@ -957,14 +1011,6 @@ class MessagesProvider with ChangeNotifier {
isVoice: true, isVoice: true,
voiceId: envelope.sessionId, voiceId: envelope.sessionId,
); );
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
if (pkt != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
} }
} }
@@ -1628,7 +1674,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed'); debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
debugPrint(' Retry attempt: ${message.retryAttempt}'); debugPrint(' Retry attempt: ${message.retryAttempt}');
debugPrint(' Contact has path: ${contact?.hasPath ?? false}'); debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
debugPrint(' Used flood fallback: ${message.usedFloodFallback}'); debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
// Decision tree for retry/flood/fail // Decision tree for retry/flood/fail
@@ -1784,7 +1830,7 @@ class MessagesProvider with ChangeNotifier {
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
final failedContact = _messageContactMap[messageId]; final failedContact = _messageContactMap[messageId];
if (failedContact != null && failedContact.hasPath) { if (failedContact != null && failedContact.routeHasPath) {
final failureStreak = _retryManager.recordPathFailure(failedContact); final failureStreak = _retryManager.recordPathFailure(failedContact);
debugPrint( debugPrint(
' Path failure streak for ${failedContact.advName}: $failureStreak', ' Path failure streak for ${failedContact.advName}: $failureStreak',
@@ -1804,8 +1850,69 @@ class MessagesProvider with ChangeNotifier {
} }
} }
/// Reset an existing failed message back into a sending state so a manual
/// retry can reuse the same record instead of appending a duplicate.
bool prepareMessageForRetry(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
debugPrint(
'⚠️ [MessagesProvider] prepareMessageForRetry: Message not found: $messageId',
);
return false;
}
final message = _messages[index];
_timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id);
if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag);
}
_clearAckHistoryForMessage(messageId);
_retryManager.clearRetry(messageId);
_messages[index] = Message(
id: message.id,
messageType: message.messageType,
senderPublicKeyPrefix: message.senderPublicKeyPrefix,
channelIdx: message.channelIdx,
pathLen: message.pathLen,
textType: message.textType,
senderTimestamp: message.senderTimestamp,
text: message.text,
isSarMarker: message.isSarMarker,
sarGpsCoordinates: message.sarGpsCoordinates,
sarNotes: message.sarNotes,
sarCustomEmoji: message.sarCustomEmoji,
sarColorIndex: message.sarColorIndex,
receivedAt: message.receivedAt,
senderName: message.senderName,
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: message.recipientPublicKey,
retryAttempt: 0,
lastRetryAt: DateTime.now(),
usedFloodFallback: false,
isRead: message.isRead,
echoCount: message.echoCount,
firstEchoAt: message.firstEchoAt,
lastEchoSnrRaw: message.lastEchoSnrRaw,
lastEchoRssiDbm: message.lastEchoRssiDbm,
lastEchoAt: message.lastEchoAt,
isDrawing: message.isDrawing,
drawingId: message.drawingId,
groupId: message.groupId,
recipients: message.recipients,
isVoice: message.isVoice,
voiceId: message.voiceId,
);
_persistMessages();
notifyListeners();
return true;
}
/// Resend a failed message /// Resend a failed message
Future<void> resendMessage(String messageId) async { Future<void> resendMessage(String messageId, {Contact? contact}) async {
final index = _messages.indexWhere((m) => m.id == messageId); final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) { if (index == -1) {
debugPrint( debugPrint(
@@ -1815,9 +1922,9 @@ class MessagesProvider with ChangeNotifier {
} }
final message = _messages[index]; final message = _messages[index];
final contact = _messageContactMap[messageId]; final resolvedContact = contact ?? _messageContactMap[messageId];
if (contact == null) { if (resolvedContact == null) {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId', '⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId',
); );
@@ -1826,26 +1933,19 @@ class MessagesProvider with ChangeNotifier {
debugPrint('🔁 [MessagesProvider] Resending message $messageId'); debugPrint('🔁 [MessagesProvider] Resending message $messageId');
// Reset retry state _messageContactMap[messageId] = resolvedContact;
_messages[index] = message.copyWith( final prepared = prepareMessageForRetry(messageId);
retryAttempt: 0, if (!prepared) {
usedFloodFallback: false, return;
deliveryStatus: MessageDeliveryStatus.sending, }
lastRetryAt: DateTime.now(),
);
// Clear retry tracking
_retryManager.clearRetry(messageId);
notifyListeners();
// Send again // Send again
if (sendMessageCallback != null) { if (sendMessageCallback != null) {
final queued = await sendMessageCallback!( final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey, contactPublicKey: resolvedContact.publicKey,
text: message.text, text: message.text,
messageId: messageId, messageId: messageId,
contact: contact, contact: resolvedContact,
retryAttempt: 0, retryAttempt: 0,
); );
if (!queued) { if (!queued) {

View File

@@ -125,6 +125,23 @@ class VoiceProvider with ChangeNotifier {
return missing; return missing;
} }
List<int> availablePacketIndices(String sessionId) {
final outgoing = _outgoingSessions[sessionId];
if (outgoing != null) {
return outgoing.packets.map((packet) => packet.index).toList()..sort();
}
final session = _sessions[sessionId];
if (session == null) return const [];
final indices = <int>[];
for (var i = 0; i < session.packets.length; i++) {
if (session.packets[i] != null) {
indices.add(i);
}
}
return indices;
}
// ── Packet reception ───────────────────────────────────────────────────── // ── Packet reception ─────────────────────────────────────────────────────
/// 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.
@@ -136,14 +153,18 @@ class VoiceProvider with ChangeNotifier {
); );
return false; return false;
} }
_sessions.putIfAbsent( _sessions.putIfAbsent(packet.sessionId, () {
packet.sessionId, if (packet.total < 1) {
() => VoiceSession( throw StateError(
'Voice envelope missing for compact packet ${packet.sessionId}',
);
}
return VoiceSession(
sessionId: packet.sessionId, sessionId: packet.sessionId,
mode: packet.mode, mode: packet.mode,
total: packet.total, total: packet.total,
), );
); });
final session = _sessions[packet.sessionId]!; final session = _sessions[packet.sessionId]!;
if (packet.index < session.total) { if (packet.index < session.total) {
@@ -179,6 +200,47 @@ class VoiceProvider with ChangeNotifier {
} }
} }
void registerEnvelope(VoiceEnvelope envelope) {
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
return;
}
final existing = _sessions[envelope.sessionId];
if (existing == null) {
_sessions[envelope.sessionId] = VoiceSession(
sessionId: envelope.sessionId,
mode: envelope.mode,
total: envelope.total,
);
_persistVoiceData();
notifyListeners();
return;
}
final needsMerge =
existing.total != envelope.total || existing.mode != envelope.mode;
if (!needsMerge) {
notifyListeners();
return;
}
final merged = VoiceSession(
sessionId: envelope.sessionId,
mode: envelope.mode,
total: envelope.total,
);
merged.firstPacketAt = existing.firstPacketAt;
merged.lastPacketAt = existing.lastPacketAt;
for (final packet in existing.packets) {
if (packet == null) continue;
if (packet.index < merged.total) {
merged.packets[packet.index] = packet;
}
}
_sessions[envelope.sessionId] = merged;
_persistVoiceData();
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;
@@ -195,10 +257,14 @@ class VoiceProvider with ChangeNotifier {
required Contact requester, required Contact requester,
Set<int>? requestedIndices, Set<int>? requestedIndices,
}) async { }) async {
final cached = _outgoingSessions[sessionId]; final outgoing = _outgoingSessions[sessionId];
if (cached == null) { final packets = outgoing != null
? List<VoicePacket>.from(outgoing.packets)
: _sessions[sessionId]?.packets.whereType<VoicePacket>().toList() ??
const <VoicePacket>[];
if (packets.isEmpty) {
debugPrint( debugPrint(
'⚠️ [VoiceProvider] No cached outgoing session for $sessionId', '⚠️ [VoiceProvider] No cached or received session for $sessionId',
); );
return false; return false;
} }
@@ -206,7 +272,7 @@ class VoiceProvider with ChangeNotifier {
providerLabel: 'VoiceProvider', providerLabel: 'VoiceProvider',
sessionId: sessionId, sessionId: sessionId,
requester: requester, requester: requester,
fragments: cached.packets, fragments: packets,
maxDirectPayloadHops: maxDirectPayloadHops, maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (packet) => packet.index, indexOf: (packet) => packet.index,
encodeBinary: (packet) => packet.encodeBinary(), encodeBinary: (packet) => packet.encodeBinary(),

View File

@@ -502,6 +502,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
), ),
child: TabBar( child: TabBar(
controller: _tabController, controller: _tabController,
onTap: (index) {
final tabs = _enabledTabs;
if (index < 0 || index >= tabs.length) {
return;
}
_handleTabActivated(tabs[index]);
},
tabs: enabledTabs.map((tab) { tabs: enabledTabs.map((tab) {
switch (tab) { switch (tab) {
case _HomeTab.messages: case _HomeTab.messages:

View File

@@ -575,9 +575,9 @@ class _MessagesTabState extends State<MessagesTab> {
if (_destinationType == if (_destinationType ==
MessageDestinationPreferences.destinationTypeContact && MessageDestinationPreferences.destinationTypeContact &&
_selectedRecipient != null && _selectedRecipient != null &&
_selectedRecipient!.outPathLen >= 0) { _selectedRecipient!.routeHasPath) {
imageDataBytesPerFragment = safeImageDataBytesForPath( imageDataBytesPerFragment = safeImageDataBytesForPath(
_selectedRecipient!.outPathLen, _selectedRecipient!.routeHopCount,
); );
} }
@@ -601,11 +601,6 @@ class _MessagesTabState extends State<MessagesTab> {
ToastLogger.error(context, 'Device key unavailable'); ToastLogger.error(context, 'Device key unavailable');
return; return;
} }
final senderKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final envelope = ImageEnvelope( final envelope = ImageEnvelope(
sessionId: sessionId, sessionId: sessionId,
format: ImageFormat.avif, format: ImageFormat.avif,
@@ -613,8 +608,6 @@ class _MessagesTabState extends State<MessagesTab> {
width: result.width, width: result.width,
height: result.height, height: result.height,
sizeBytes: compressed.length, sizeBytes: compressed.length,
senderKey6: senderKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
); );
if (!mounted) return; if (!mounted) return;
@@ -915,9 +908,6 @@ class _MessagesTabState extends State<MessagesTab> {
return; return;
} }
final senderKey6 = senderPublicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final durationMs = encodedPackets.fold<int>( final durationMs = encodedPackets.fold<int>(
0, 0,
(sum, p) => sum + p.durationMs, (sum, p) => sum + p.durationMs,
@@ -927,9 +917,7 @@ class _MessagesTabState extends State<MessagesTab> {
mode: mode, mode: mode,
total: encodedPackets.length, total: encodedPackets.length,
durationMs: durationMs, durationMs: durationMs,
senderKey6: senderKey6, version: 3,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1,
); );
final envelopeText = envelope.encodeText(); final envelopeText = envelope.encodeText();

View File

@@ -35,6 +35,7 @@ class MeshMapNodesService {
'https://api.meshcore.nz/api/v1/map/nodes'; 'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2); static const Duration _cacheTtl = Duration(minutes: 2);
static const Duration traceCacheTtl = Duration(minutes: 10); static const Duration traceCacheTtl = Duration(minutes: 10);
static const Duration traceTimeout = Duration(seconds: 30);
static List<MeshMapNode>? _cachedNodes; static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt; static DateTime? _cachedAt;
@@ -52,7 +53,7 @@ class MeshMapNodesService {
final response = await http final response = await http
.get(Uri.parse(_nodesEndpoint)) .get(Uri.parse(_nodesEndpoint))
.timeout(const Duration(seconds: 12)); .timeout(traceTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Map nodes API returned ${response.statusCode}'); throw Exception('Map nodes API returned ${response.statusCode}');
} }

View File

@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart'; import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage /// Service for persisting messages to local storage
@@ -13,6 +14,8 @@ class MessageStorageService {
'stored_message_contact_locations'; 'stored_message_contact_locations';
static const String _messageReceptionDetailsKey = static const String _messageReceptionDetailsKey =
'stored_message_reception_details'; 'stored_message_reception_details';
static const String _messageTransferDetailsKey =
'stored_message_transfer_details';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage /// Save messages to persistent storage
@@ -20,6 +23,7 @@ class MessageStorageService {
List<Message> messages, { List<Message> messages, {
Map<String, MessageContactLocation> messageContactLocations = const {}, Map<String, MessageContactLocation> messageContactLocations = const {},
Map<String, MessageReceptionDetails> messageReceptionDetails = const {}, Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
Map<String, MessageTransferDetails> messageTransferDetails = const {},
}) async { }) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -39,6 +43,7 @@ class MessageStorageService {
.toSet(); .toSet();
final locationJson = <String, dynamic>{}; final locationJson = <String, dynamic>{};
final receptionJson = <String, dynamic>{}; final receptionJson = <String, dynamic>{};
final transferJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) { for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) { if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson(); locationJson[entry.key] = entry.value.toJson();
@@ -49,6 +54,11 @@ class MessageStorageService {
receptionJson[entry.key] = entry.value.toJson(); receptionJson[entry.key] = entry.value.toJson();
} }
} }
for (final entry in messageTransferDetails.entries) {
if (retainedMessageIds.contains(entry.key)) {
transferJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString( await prefs.setString(
_messageContactLocationsKey, _messageContactLocationsKey,
jsonEncode(locationJson), jsonEncode(locationJson),
@@ -57,6 +67,10 @@ class MessageStorageService {
_messageReceptionDetailsKey, _messageReceptionDetailsKey,
jsonEncode(receptionJson), jsonEncode(receptionJson),
); );
await prefs.setString(
_messageTransferDetailsKey,
jsonEncode(transferJson),
);
debugPrint( debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage', '✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -126,6 +140,36 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageTransferDetails>>
loadMessageTransferDetails() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageTransferDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageTransferDetails>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final details = MessageTransferDetails.fromJson(value);
if (details != null) {
result[entry.key] = details;
}
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading transfer details: $e');
return const {};
}
}
/// Load messages from persistent storage /// Load messages from persistent storage
Future<List<Message>> loadMessages() async { Future<List<Message>> loadMessages() async {
try { try {
@@ -161,6 +205,7 @@ class MessageStorageService {
await prefs.remove(_messagesKey); await prefs.remove(_messagesKey);
await prefs.remove(_messageContactLocationsKey); await prefs.remove(_messageContactLocationsKey);
await prefs.remove(_messageReceptionDetailsKey); await prefs.remove(_messageReceptionDetailsKey);
await prefs.remove(_messageTransferDetailsKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages'); debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e'); debugPrint('❌ [MessageStorage] Error clearing messages: $e');

View File

@@ -4,7 +4,7 @@ const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
const int _imagePacketHeaderBytes = 8; // image packet binary header in payload const int _imagePacketHeaderBytes = 6; // image packet binary header in payload
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10) const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
@@ -31,7 +31,7 @@ enum ImageFormat {
/// A single binary fragment of a compressed image. /// A single binary fragment of a compressed image.
/// ///
/// Binary format (direct contacts, via pushRawData / cmdSendRawData): /// Binary format (direct contacts, via pushRawData / cmdSendRawData):
/// [0x49 'I'][sessionId:4B][fmt:1B][idx:1B][total:1B][imageData...] /// [0x49 'I'][sessionId:4B][idx:1B][imageData...]
/// ///
/// Legacy default is 152 data bytes per fragment. /// Legacy default is 152 data bytes per fragment.
class ImagePacket { class ImagePacket {
@@ -50,7 +50,7 @@ class ImagePacket {
}); });
static const int _magic = 0x49; // 'I' static const int _magic = 0x49; // 'I'
static const int _headerLen = 8; // magic(1)+session(4)+fmt(1)+idx(1)+total(1) static const int _headerLen = 6; // magic(1)+session(4)+idx(1)
static const int maxDataBytes = static const int maxDataBytes =
152; // Conservative default for compatibility. 152; // Conservative default for compatibility.
@@ -65,16 +65,14 @@ class ImagePacket {
.sublist(1, 5) .sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(); .join();
final fmtId = payload[5]; final index = payload[5];
final index = payload[6]; final data = payload.sublist(_headerLen);
final total = payload[7];
if (total < 1) return null;
return ImagePacket( return ImagePacket(
sessionId: sessionId, sessionId: sessionId,
format: ImageFormat.fromId(fmtId), format: ImageFormat.avif,
index: index, index: index,
total: total, total: 0,
data: payload.sublist(_headerLen), data: data,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -92,16 +90,16 @@ class ImagePacket {
final out = Uint8List(_headerLen + data.length); final out = Uint8List(_headerLen + data.length);
out[0] = _magic; out[0] = _magic;
out.setRange(1, 5, sessionBytes); out.setRange(1, 5, sessionBytes);
out[5] = format.id; out[5] = index;
out[6] = index;
out[7] = total;
out.setRange(_headerLen, out.length, data); out.setRange(_headerLen, out.length, data);
return out; return out;
} }
@override @override
String toString() => String toString() {
'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)'; final suffix = total > 0 ? ' ${format.label} [$index/${total - 1}]' : ' [$index]';
return 'ImagePacket($sessionId$suffix ${data.length}B)';
}
} }
/// Compute the maximum safe image data bytes for a direct route path. /// Compute the maximum safe image data bytes for a direct route path.
@@ -246,11 +244,11 @@ int _resolveBandwidthHz(int? rawBw) {
/// Envelope announcing image availability (control plane). /// Envelope announcing image availability (control plane).
/// ///
/// Text format: /// Text format:
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts} /// IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
/// Example: /// Example:
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8 /// IE4:deadbeef:0:7:3k:3k:t6
class ImageEnvelope { class ImageEnvelope {
static const String _prefix = 'IE2:'; static const String _prefixV4 = 'IE4:';
final String sessionId; // 8 hex chars final String sessionId; // 8 hex chars
final ImageFormat format; final ImageFormat format;
@@ -258,8 +256,6 @@ class ImageEnvelope {
final int width; final int width;
final int height; final int height;
final int sizeBytes; // total compressed image size final int sizeBytes; // total compressed image size
final String senderKey6; // 12 hex chars (6 bytes)
final int timestampSec;
final int version; final int version;
const ImageEnvelope({ const ImageEnvelope({
@@ -269,18 +265,16 @@ class ImageEnvelope {
required this.width, required this.width,
required this.height, required this.height,
required this.sizeBytes, required this.sizeBytes,
required this.senderKey6, this.version = 4,
required this.timestampSec,
this.version = 2,
}); });
static bool isEnvelope(String text) => text.startsWith(_prefix); static bool isEnvelope(String text) => text.startsWith(_prefixV4);
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(_prefixV4.length);
final parts = body.split(':'); final parts = body.split(':');
if (parts.length != 8) return null; if (parts.length != 6) return null;
try { try {
final sid = _decodeSessionId(parts[0]); final sid = _decodeSessionId(parts[0]);
final fmtId = _parseInt(parts[1], base36: true); final fmtId = _parseInt(parts[1], base36: true);
@@ -288,16 +282,12 @@ class ImageEnvelope {
final w = _parseInt(parts[3], base36: true); final w = _parseInt(parts[3], base36: true);
final h = _parseInt(parts[4], base36: true); final h = _parseInt(parts[4], base36: true);
final bytes = _parseInt(parts[5], base36: true); final bytes = _parseInt(parts[5], base36: true);
final senderKey6 = parts[6];
final ts = _parseInt(parts[7], base36: true);
if (sid == null) 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 (ts == null || ts <= 0) return null;
return ImageEnvelope( return ImageEnvelope(
sessionId: sid, sessionId: sid,
@@ -306,9 +296,7 @@ class ImageEnvelope {
width: w, width: w,
height: h, height: h,
sizeBytes: bytes, sizeBytes: bytes,
senderKey6: senderKey6.toLowerCase(), version: 4,
timestampSec: ts,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -316,27 +304,25 @@ class ImageEnvelope {
} }
String encode() => String encode() =>
'$_prefix${_encodeSessionId(sessionId)}:' '$_prefixV4${_encodeSessionId(sessionId)}:'
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:' '${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
'${_toBase36(height)}:${_toBase36(sizeBytes)}:' '${_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:
/// IR2:{sid}:{want}:{requesterKey6}:{ts} /// IR4:{sid}:{want}:{requesterKey6}
/// Example: /// Example:
/// IR2:deadbeef:a:aabbccddeeff:s44wea /// IR4:deadbeef:a:aabbccddeeff
class ImageFetchRequest { class ImageFetchRequest {
static const String _prefix = 'IR2:'; static const String _prefixV4 = 'IR4:';
static const int _binaryMagic = 0x69; // 'i' static const int _binaryMagic = 0x69; // 'i'
final String sessionId; final String sessionId;
final String want; // 'all' or 'missing' final String want; // 'all' or 'missing'
final List<int> missingIndices; final List<int> missingIndices;
final String requesterKey6; // 12 hex chars final String requesterKey6; // 12 hex chars
final int timestampSec;
final int version; final int version;
const ImageFetchRequest({ const ImageFetchRequest({
@@ -344,24 +330,22 @@ class ImageFetchRequest {
this.want = 'all', this.want = 'all',
this.missingIndices = const [], this.missingIndices = const [],
required this.requesterKey6, required this.requesterKey6,
required this.timestampSec, this.version = 4,
this.version = 2,
}); });
static bool isRequest(String text) => text.startsWith(_prefix); static bool isRequest(String text) => text.startsWith(_prefixV4);
static bool isRequestBinary(Uint8List payload) => static bool isRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic; 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(_prefixV4.length);
final parts = body.split(':'); final parts = body.split(':');
if (parts.length != 4) return null; if (parts.length != 3) return null;
try { try {
final sid = _decodeSessionId(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 = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a' final normalizedWant = wantToken == 'a'
? 'all' ? 'all'
: ((wantToken.startsWith('m')) ? 'missing' : wantToken); : ((wantToken.startsWith('m')) ? 'missing' : wantToken);
@@ -377,15 +361,13 @@ class ImageFetchRequest {
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;
return ImageFetchRequest( return ImageFetchRequest(
sessionId: sid, sessionId: sid,
want: normalizedWant, want: normalizedWant,
missingIndices: missingIndices, missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(), requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts, version: 4,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -394,7 +376,7 @@ class ImageFetchRequest {
static ImageFetchRequest? tryParseBinary(Uint8List payload) { static ImageFetchRequest? tryParseBinary(Uint8List payload) {
if (!isRequestBinary(payload)) return null; if (!isRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count if (payload.length < 13) return null; // magic+sid+flags+key6+count
try { try {
final sid = payload final sid = payload
.sublist(1, 5) .sublist(1, 5)
@@ -407,25 +389,19 @@ class ImageFetchRequest {
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join() .join()
.toLowerCase(); .toLowerCase();
final ts = final missingCount = payload[12];
(payload[12] << 24) | if (payload.length != 13 + missingCount) return null;
(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 wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[]; final missing = <int>[];
for (var i = 0; i < missingCount; i++) { for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]); missing.add(payload[13 + i]);
} }
return ImageFetchRequest( return ImageFetchRequest(
sessionId: sid, sessionId: sid,
want: wantMissing ? 'missing' : 'all', want: wantMissing ? 'missing' : 'all',
missingIndices: missing, missingIndices: missing,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
timestampSec: ts, version: 4,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -436,7 +412,7 @@ class ImageFetchRequest {
final wantToken = want == 'missing' && missingIndices.isNotEmpty final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm${_encodeMissingIndicesCompact(missingIndices)}' ? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want); : (want == 'all' ? 'a' : want);
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}'; return '$_prefixV4${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
} }
Uint8List encodeBinary() { Uint8List encodeBinary() {
@@ -455,7 +431,7 @@ class ImageFetchRequest {
? missingIndices.where((v) => v >= 0 && v <= 254).toList() ? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[]; : <int>[];
final out = Uint8List(17 + missing.length); final out = Uint8List(13 + missing.length);
out[0] = _binaryMagic; out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) { for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
@@ -467,13 +443,9 @@ class ImageFetchRequest {
radix: 16, radix: 16,
); );
} }
out[12] = (timestampSec >> 24) & 0xFF; out[12] = missing.length;
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++) { for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i]; out[13 + i] = missing[i];
} }
return out; return out;
} }

View File

@@ -0,0 +1,168 @@
import 'dart:typed_data';
const int _swarmMagic = 0x6d; // 'm'
const int _swarmKindRequest = 0x01;
const int _swarmKindAvailability = 0x02;
class MediaSwarmRequest {
final String mediaType;
final String sessionId;
final String requesterKey6;
final List<int> missingIndices;
const MediaSwarmRequest({
required this.mediaType,
required this.sessionId,
required this.requesterKey6,
this.missingIndices = const [],
});
bool get requestsAll => missingIndices.isEmpty;
Uint8List encodeBinary() {
final normalizedMissing = missingIndices.toSet().toList()..sort();
final out = Uint8List(14 + normalizedMissing.length);
out[0] = _swarmMagic;
out[1] = _swarmKindRequest;
out[2] = _encodeMediaType(mediaType);
_writeSessionId(out, 3, sessionId);
_writeKey6(out, 7, requesterKey6);
out[13] = normalizedMissing.length;
for (var i = 0; i < normalizedMissing.length; i++) {
out[14 + i] = normalizedMissing[i];
}
return out;
}
static MediaSwarmRequest? tryParseBinary(Uint8List payload) {
if (payload.length < 14 ||
payload[0] != _swarmMagic ||
payload[1] != _swarmKindRequest) {
return null;
}
final mediaType = _decodeMediaType(payload[2]);
if (mediaType == null) return null;
final missingCount = payload[13];
if (payload.length != 14 + missingCount) {
return null;
}
return MediaSwarmRequest(
mediaType: mediaType,
sessionId: _readSessionId(payload, 3),
requesterKey6: _readKey6(payload, 7),
missingIndices: payload.sublist(14),
);
}
}
class MediaSwarmAvailability {
final String mediaType;
final String sessionId;
final String requesterKey6;
final String responderKey6;
final List<int> availableIndices;
const MediaSwarmAvailability({
required this.mediaType,
required this.sessionId,
required this.requesterKey6,
required this.responderKey6,
required this.availableIndices,
});
bool get servesAll => availableIndices.isEmpty;
Uint8List encodeBinary() {
final normalizedAvailable = availableIndices.toSet().toList()..sort();
final out = Uint8List(20 + normalizedAvailable.length);
out[0] = _swarmMagic;
out[1] = _swarmKindAvailability;
out[2] = _encodeMediaType(mediaType);
_writeSessionId(out, 3, sessionId);
_writeKey6(out, 7, requesterKey6);
_writeKey6(out, 13, responderKey6);
out[19] = normalizedAvailable.length;
for (var i = 0; i < normalizedAvailable.length; i++) {
out[20 + i] = normalizedAvailable[i];
}
return out;
}
static MediaSwarmAvailability? tryParseBinary(Uint8List payload) {
if (payload.length < 20 ||
payload[0] != _swarmMagic ||
payload[1] != _swarmKindAvailability) {
return null;
}
final mediaType = _decodeMediaType(payload[2]);
if (mediaType == null) return null;
final availableCount = payload[19];
if (payload.length != 20 + availableCount) {
return null;
}
return MediaSwarmAvailability(
mediaType: mediaType,
sessionId: _readSessionId(payload, 3),
requesterKey6: _readKey6(payload, 7),
responderKey6: _readKey6(payload, 13),
availableIndices: payload.sublist(20),
);
}
}
int _encodeMediaType(String mediaType) {
return switch (mediaType) {
'voice' => 0x01,
'image' => 0x02,
_ => throw ArgumentError.value(mediaType, 'mediaType'),
};
}
String? _decodeMediaType(int raw) {
return switch (raw) {
0x01 => 'voice',
0x02 => 'image',
_ => null,
};
}
void _writeSessionId(Uint8List out, int offset, String sessionId) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
for (var i = 0; i < 4; i++) {
out[offset + i] = int.parse(
sessionId.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
}
String _readSessionId(Uint8List payload, int offset) {
return payload
.sublist(offset, offset + 4)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
void _writeKey6(Uint8List out, int offset, String key6) {
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(key6)) {
throw ArgumentError.value(key6, 'key6', 'Expected 12 hex chars');
}
for (var i = 0; i < 6; i++) {
out[offset + i] = int.parse(key6.substring(i * 2, i * 2 + 2), radix: 16);
}
}
String _readKey6(Uint8List payload, int offset) {
return payload
.sublist(offset, offset + 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}

View File

@@ -3,7 +3,12 @@ import 'dart:typed_data';
import '../models/contact.dart'; import '../models/contact.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar, unreachable } enum TransmissionTargetFailure {
unknownContact,
unknownRoute,
tooFar,
unreachable,
}
class TransmissionTargetResolution { class TransmissionTargetResolution {
final Contact? target; final Contact? target;
@@ -16,7 +21,7 @@ class TransmissionTargetResolution {
required this.maxHops, required this.maxHops,
}); });
int get hops => target?.outPathLen ?? -1; int get hops => target?.routeHopCount ?? -1;
bool get isValid => target != null && failure == null; bool get isValid => target != null && failure == null;
} }
@@ -32,11 +37,17 @@ class TransmissionTargetResolver {
String? senderName, String? senderName,
}) { }) {
if (isSentByMe) { if (isSentByMe) {
final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey); final recipient = _findByRecipientKey(
contactsProvider,
recipientPublicKey,
);
if (recipient != null) return recipient; if (recipient != null) return recipient;
} }
final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope); final byEnvelope = _findByEnvelopeKey6(
contactsProvider,
senderKey6FromEnvelope,
);
if (byEnvelope != null) return byEnvelope; if (byEnvelope != null) return byEnvelope;
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix); final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
@@ -64,7 +75,9 @@ class TransmissionTargetResolver {
senderName: senderName, senderName: senderName,
); );
if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) { if (target == null ||
!target.routeHasPath ||
target.routeHopCount > maxFetchHops) {
await refreshContacts(); await refreshContacts();
target = resolveLocalTarget( target = resolveLocalTarget(
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
@@ -83,14 +96,14 @@ class TransmissionTargetResolver {
maxHops: maxFetchHops, maxHops: maxFetchHops,
); );
} }
if (target.outPathLen < 0) { if (!target.routeHasPath) {
return TransmissionTargetResolution( return TransmissionTargetResolution(
target: target, target: target,
failure: TransmissionTargetFailure.unknownRoute, failure: TransmissionTargetFailure.unknownRoute,
maxHops: maxFetchHops, maxHops: maxFetchHops,
); );
} }
if (target.outPathLen > maxFetchHops) { if (target.routeHopCount > maxFetchHops) {
return TransmissionTargetResolution( return TransmissionTargetResolution(
target: target, target: target,
failure: TransmissionTargetFailure.tooFar, failure: TransmissionTargetFailure.tooFar,

View File

@@ -2,7 +2,7 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
const int _voicePacketHeaderBytes = 8; // voice packet binary header in payload const int _voicePacketHeaderBytes = 6; // voice packet binary header in payload
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10) const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
@@ -38,7 +38,7 @@ enum VoicePacketMode {
/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2} /// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2}
/// ///
/// Binary format (direct contacts, received via pushRawData): /// Binary format (direct contacts, received via pushRawData):
/// [0x56 'V'][sessionId:4B][modeId:1B][index:1B][total:1B][codec2Data...] /// [0x56 'V'][sessionId:4B][index:1B][codec2Data...]
class VoicePacket { class VoicePacket {
final String sessionId; // 8 hex chars (4 bytes) final String sessionId; // 8 hex chars (4 bytes)
final VoicePacketMode mode; final VoicePacketMode mode;
@@ -104,8 +104,7 @@ class VoicePacket {
// ── Binary format ──────────────────────────────────────────────────────── // ── Binary format ────────────────────────────────────────────────────────
static const int _binaryMagic = 0x56; // 'V' static const int _binaryMagic = 0x56; // 'V'
static const int _binaryHeaderLen = static const int _binaryHeaderLen = 6; // magic(1)+session(4)+idx(1)
8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
static bool isVoiceBinary(Uint8List payload) => static bool isVoiceBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic; payload.isNotEmpty && payload[0] == _binaryMagic;
@@ -119,16 +118,13 @@ class VoicePacket {
final sessionId = sessionBytes final sessionId = sessionBytes
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(); .join();
final modeId = payload[5]; final index = payload[5];
final index = payload[6];
final total = payload[7];
if (total < 1) return null;
final codec2Data = payload.sublist(_binaryHeaderLen); final codec2Data = payload.sublist(_binaryHeaderLen);
return VoicePacket( return VoicePacket(
sessionId: sessionId, sessionId: sessionId,
mode: VoicePacketMode.fromId(modeId), mode: VoicePacketMode.mode1300,
index: index, index: index,
total: total, total: 0,
codec2Data: codec2Data, codec2Data: codec2Data,
); );
} catch (_) { } catch (_) {
@@ -148,9 +144,7 @@ class VoicePacket {
final out = Uint8List(_binaryHeaderLen + codec2Data.length); final out = Uint8List(_binaryHeaderLen + codec2Data.length);
out[0] = _binaryMagic; out[0] = _binaryMagic;
out.setRange(1, 5, sessionBytes); out.setRange(1, 5, sessionBytes);
out[5] = mode.id; out[5] = index;
out[6] = index;
out[7] = total;
out.setRange(_binaryHeaderLen, out.length, codec2Data); out.setRange(_binaryHeaderLen, out.length, codec2Data);
return out; return out;
} }
@@ -174,25 +168,25 @@ class VoicePacket {
} }
@override @override
String toString() => String toString() {
'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)'; final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
}
} }
/// Lightweight public/direct message envelope advertising voice availability. /// Lightweight public/direct message envelope advertising voice availability.
/// ///
/// Text format: /// Text format:
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts} /// VE3:{sid}:{mode}:{total}:{durS}
/// Example: /// Example:
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi /// VE3:00112233:1:4:4
class VoiceEnvelope { class VoiceEnvelope {
static const String _prefix = 'VE2:'; static const String _prefix = 'VE3:';
final String sessionId; final String sessionId;
final VoicePacketMode mode; final VoicePacketMode mode;
final int total; final int total;
final int durationMs; final int durationMs;
final String senderKey6;
final int timestampSec;
final int version; final int version;
const VoiceEnvelope({ const VoiceEnvelope({
@@ -200,9 +194,7 @@ class VoiceEnvelope {
required this.mode, required this.mode,
required this.total, required this.total,
required this.durationMs, required this.durationMs,
required this.senderKey6, this.version = 3,
required this.timestampSec,
this.version = 2,
}); });
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix); static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
@@ -215,14 +207,12 @@ class VoiceEnvelope {
static VoiceEnvelope? _tryParse(String body) { static VoiceEnvelope? _tryParse(String body) {
final parts = body.split(':'); final parts = body.split(':');
if (parts.length != 6) return null; if (parts.length != 4) return null;
try { try {
final sid = _decodeSessionId(parts[0]); final sid = _decodeSessionId(parts[0]);
final mode = _parseInt(parts[1], base36: true); final mode = _parseInt(parts[1], base36: true);
final total = _parseInt(parts[2], base36: true); final total = _parseInt(parts[2], base36: true);
final durS = _parseInt(parts[3], base36: true); final durS = _parseInt(parts[3], base36: true);
final senderKey6 = parts[4];
final ts = _parseInt(parts[5], base36: true);
if (sid == null) { if (sid == null) {
return null; return null;
@@ -232,19 +222,13 @@ class VoiceEnvelope {
} }
if (total == null || total < 1 || total > 255) return null; if (total == null || total < 1 || total > 255) return null;
if (durS == null || durS < 0 || durS > 10 * 60) return null; if (durS == null || durS < 0 || durS > 10 * 60) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
return null;
}
if (ts == null || ts <= 0) return null;
return VoiceEnvelope( return VoiceEnvelope(
sessionId: sid, sessionId: sid,
mode: VoicePacketMode.fromId(mode), mode: VoicePacketMode.fromId(mode),
total: total, total: total,
durationMs: durS * 1000, durationMs: durS * 1000,
senderKey6: senderKey6.toLowerCase(), version: 3,
timestampSec: ts,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -253,7 +237,7 @@ class VoiceEnvelope {
String encodeText() { String encodeText() {
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60); final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}'; return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}';
} }
} }
@@ -414,18 +398,17 @@ 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:
/// VR2:{sid}:{want}:{requesterKey6}:{ts} /// VR3:{sid}:{want}:{requesterKey6}
/// Example: /// Example:
/// VR2:00112233:a:aabbccddeeff:kf12oi /// VR3:00112233:a:aabbccddeeff
class VoiceFetchRequest { class VoiceFetchRequest {
static const String _prefix = 'VR2:'; static const String _prefix = 'VR3:';
static const int _binaryMagic = 0x72; // 'r' static const int _binaryMagic = 0x72; // 'r'
final String sessionId; final String sessionId;
final String want; final String want;
final List<int> missingIndices; final List<int> missingIndices;
final String requesterKey6; final String requesterKey6;
final int timestampSec;
final int version; final int version;
const VoiceFetchRequest({ const VoiceFetchRequest({
@@ -433,8 +416,7 @@ class VoiceFetchRequest {
this.want = 'all', this.want = 'all',
this.missingIndices = const [], this.missingIndices = const [],
required this.requesterKey6, required this.requesterKey6,
required this.timestampSec, this.version = 3,
this.version = 2,
}); });
static bool isVoiceFetchRequestText(String text) => static bool isVoiceFetchRequestText(String text) =>
@@ -450,7 +432,7 @@ class VoiceFetchRequest {
static VoiceFetchRequest? tryParseBinary(Uint8List payload) { static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
if (!isVoiceFetchRequestBinary(payload)) return null; if (!isVoiceFetchRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count if (payload.length < 13) return null; // magic+sid+flags+key6+count
try { try {
final sidBytes = payload.sublist(1, 5); final sidBytes = payload.sublist(1, 5);
final sid = sidBytes final sid = sidBytes
@@ -463,25 +445,19 @@ class VoiceFetchRequest {
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join() .join()
.toLowerCase(); .toLowerCase();
final ts = final missingCount = payload[12];
(payload[12] << 24) | if (payload.length != 13 + missingCount) return null;
(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 wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[]; final missing = <int>[];
for (var i = 0; i < missingCount; i++) { for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]); missing.add(payload[13 + i]);
} }
return VoiceFetchRequest( return VoiceFetchRequest(
sessionId: sid, sessionId: sid,
want: wantMissing ? 'missing' : 'all', want: wantMissing ? 'missing' : 'all',
missingIndices: missing, missingIndices: missing,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
timestampSec: ts, version: 3,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -490,12 +466,11 @@ class VoiceFetchRequest {
static VoiceFetchRequest? _tryParse(String body) { static VoiceFetchRequest? _tryParse(String body) {
final parts = body.split(':'); final parts = body.split(':');
if (parts.length != 4) return null; if (parts.length != 3) return null;
try { try {
final sid = _decodeSessionId(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 = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a' final normalizedWant = wantToken == 'a'
? 'all' ? 'all'
: ((wantToken.startsWith('m')) : ((wantToken.startsWith('m'))
@@ -517,15 +492,13 @@ class VoiceFetchRequest {
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
return null; return null;
} }
if (ts == null || ts <= 0) return null;
return VoiceFetchRequest( return VoiceFetchRequest(
sessionId: sid, sessionId: sid,
want: normalizedWant, want: normalizedWant,
missingIndices: missingIndices, missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(), requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts, version: 3,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -536,7 +509,7 @@ class VoiceFetchRequest {
final wantToken = want == 'missing' && missingIndices.isNotEmpty final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm${_encodeMissingIndicesCompact(missingIndices)}' ? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want); : (want == 'all' ? 'a' : want);
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}'; return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
} }
Uint8List encodeBinary() { Uint8List encodeBinary() {
@@ -555,7 +528,7 @@ class VoiceFetchRequest {
? missingIndices.where((v) => v >= 0 && v <= 254).toList() ? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[]; : <int>[];
final out = Uint8List(17 + missing.length); final out = Uint8List(13 + missing.length);
out[0] = _binaryMagic; out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) { for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
@@ -567,13 +540,9 @@ class VoiceFetchRequest {
radix: 16, radix: 16,
); );
} }
out[12] = (timestampSec >> 24) & 0xFF; out[12] = missing.length;
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++) { for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i]; out[13 + i] = missing[i];
} }
return out; return out;
} }

View File

@@ -0,0 +1,230 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
class ContactRouteDialog extends StatefulWidget {
final Contact contact;
final List<Contact> availableContacts;
const ContactRouteDialog({
super.key,
required this.contact,
required this.availableContacts,
});
static Future<ParsedContactRoute?> show(
BuildContext context, {
required Contact contact,
required List<Contact> availableContacts,
}) {
return showDialog<ParsedContactRoute>(
context: context,
builder: (context) => ContactRouteDialog(
contact: contact,
availableContacts: availableContacts,
),
);
}
@override
State<ContactRouteDialog> createState() => _ContactRouteDialogState();
}
class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller;
late int _selectedHashSize;
ParsedContactRoute? _parsedRoute;
String? _errorText;
@override
void initState() {
super.initState();
_selectedHashSize = widget.contact.routeHasPath
? widget.contact.routeHashSize
: 1;
_controller = TextEditingController(
text: widget.contact.routeCanonicalText,
);
_controller.addListener(_reparse);
_reparse();
}
@override
void dispose() {
_controller
..removeListener(_reparse)
..dispose();
super.dispose();
}
void _reparse() {
final input = _controller.text.trim();
if (input.isEmpty) {
setState(() {
_parsedRoute = null;
_errorText = null;
});
return;
}
try {
final parsed = ContactRouteCodec.parse(input);
setState(() {
_parsedRoute = parsed;
_selectedHashSize = parsed.hashSize;
_errorText = null;
});
} on ContactRouteFormatException catch (error) {
setState(() {
_parsedRoute = null;
_errorText = error.message;
});
}
}
String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2;
if (hex.length < length) {
return hex;
}
return hex.substring(0, length);
}
void _appendHop(Contact contact) {
final token = _tokenFor(contact, _selectedHashSize);
final current = _controller.text.trim();
_controller.text = current.isEmpty ? token : '$current,$token';
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
}
@override
Widget build(BuildContext context) {
final routeCandidates =
widget.availableContacts
.where((contact) => contact.isRepeater || contact.isRoom)
.toList()
..sort((a, b) => a.displayName.compareTo(b.displayName));
return AlertDialog(
title: Text('Set Route for ${widget.contact.displayName}'),
content: SizedBox(
width: 560,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Path hash size',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [1, 2, 3]
.map(
(hashSize) => ChoiceChip(
label: Text(
'$hashSize byte${hashSize == 1 ? '' : 's'}',
),
selected: _selectedHashSize == hashSize,
onSelected: (_) {
setState(() {
_selectedHashSize = hashSize;
});
},
),
)
.toList(),
),
const SizedBox(height: 16),
TextField(
controller: _controller,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Route',
hintText: _selectedHashSize == 1
? 'AA,BB,CC'
: _selectedHashSize == 2
? 'AABB,CCDD'
: 'AABBCC,DDEEFF',
helperText:
'Use comma-separated hops. Colon form like AA:BB is also accepted.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text(
_parsedRoute == null
? 'Preview: enter a route to validate it.'
: 'Preview: ${_parsedRoute!.summary}${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
style: Theme.of(context).textTheme.bodySmall,
),
if (_parsedRoute != null) ...[
const SizedBox(height: 4),
SelectableText(
_parsedRoute!.canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
const SizedBox(height: 16),
Text(
'Pick hops from contacts',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
if (routeCandidates.isEmpty)
const Text(
'No repeater or room contacts are available for route building.',
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: ListView.builder(
shrinkWrap: true,
itemCount: routeCandidates.length,
itemBuilder: (context, index) {
final candidate = routeCandidates[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(candidate.displayName),
subtitle: Text(
'1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}',
style: const TextStyle(fontFamily: 'monospace'),
),
trailing: TextButton(
onPressed: () => _appendHop(candidate),
child: Text(
'Use ${_tokenFor(candidate, _selectedHashSize)}',
),
),
);
},
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _parsedRoute == null
? null
: () => Navigator.of(context).pop(_parsedRoute),
child: const Text('Set Route'),
),
],
);
}
}

View File

@@ -9,6 +9,7 @@ import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import 'contact_route_dialog.dart';
import 'direct_message_sheet.dart'; import 'direct_message_sheet.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
@@ -52,6 +53,7 @@ class ContactTile extends StatelessWidget {
contact.telemetry != null && contact.telemetry!.isRecent; contact.telemetry != null && contact.telemetry!.isRecent;
final battery = contact.displayBattery; final battery = contact.displayBattery;
final location = contact.displayLocation; final location = contact.displayLocation;
final routeHasPath = contact.routeHasPath;
// Calculate distance if both positions are available // Calculate distance if both positions are available
String? distanceText; String? distanceText;
@@ -159,12 +161,12 @@ class ContactTile extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: contact.hasPath color: routeHasPath
? Colors.green.withValues(alpha: 0.15) ? Colors.green.withValues(alpha: 0.15)
: Colors.orange.withValues(alpha: 0.15), : Colors.orange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
border: Border.all( border: Border.all(
color: contact.hasPath ? Colors.green : Colors.orange, color: routeHasPath ? Colors.green : Colors.orange,
width: 0.5, width: 0.5,
), ),
), ),
@@ -172,19 +174,19 @@ class ContactTile extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
contact.hasPath ? Icons.route : Icons.waves, routeHasPath ? Icons.route : Icons.waves,
size: 10, size: 10,
color: contact.hasPath ? Colors.green : Colors.orange, color: routeHasPath ? Colors.green : Colors.orange,
), ),
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(
contact.hasPath routeHasPath
? AppLocalizations.of(context)!.direct ? AppLocalizations.of(context)!.direct
: AppLocalizations.of(context)!.flood, : AppLocalizations.of(context)!.flood,
style: TextStyle( style: TextStyle(
fontSize: 9, fontSize: 9,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: contact.hasPath ? Colors.green : Colors.orange, color: routeHasPath ? Colors.green : Colors.orange,
), ),
), ),
], ],
@@ -478,7 +480,7 @@ class ContactTile extends StatelessWidget {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
// Determine if we should use flooding (no path) or direct (has path) // Determine if we should use flooding (no path) or direct (has path)
final hasPath = contact.hasPath; final hasPath = contact.routeHasPath;
// Use smart ping with automatic fallback // Use smart ping with automatic fallback
final result = await connectionProvider.smartPing( final result = await connectionProvider.smartPing(
@@ -622,407 +624,485 @@ class ContactTile extends StatelessWidget {
maxChildSize: 0.9, maxChildSize: 0.9,
expand: false, expand: false,
builder: (context, scrollController) { builder: (context, scrollController) {
final contactsProvider = context.watch<ContactsProvider>();
final currentContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final isPingInProgress = context final isPingInProgress = context
.watch<ConnectionProvider>() .watch<ConnectionProvider>()
.isPingInProgress(contact.publicKey); .isPingInProgress(contact.publicKey);
return Column( return Column(
children: [ children: [
// Handle bar // Handle bar
Container( Container(
margin: const EdgeInsets.only(top: 8, bottom: 16), margin: const EdgeInsets.only(top: 8, bottom: 16),
width: 40, width: 40,
height: 4, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[300], color: Colors.grey[300],
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
),
), ),
), // Header
// Header Padding(
Padding( padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16), child: Row(
child: Row( children: [
children: [ CircleAvatar(
CircleAvatar( backgroundColor: _getTypeColor(contact.type, context),
backgroundColor: _getTypeColor(contact.type, context), child: contact.roleEmoji != null
child: contact.roleEmoji != null ? Text(
? Text( contact.roleEmoji!,
contact.roleEmoji!, style: const TextStyle(fontSize: 24),
style: const TextStyle(fontSize: 24), )
) : Icon(
: Icon(_getTypeIcon(contact.type), color: Colors.white), _getTypeIcon(contact.type),
), color: Colors.white,
const SizedBox(width: 12), ),
Expanded( ),
child: Text( const SizedBox(width: 12),
contact.displayName, Expanded(
style: const TextStyle( child: Text(
fontSize: 20, contact.displayName,
fontWeight: FontWeight.bold, style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
), ),
), ),
), IconButton(
IconButton( icon: const Icon(Icons.close),
icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(),
// Content
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
_detailRow(l10n.type, contact.type.displayName),
if (contact.isChannel) ...[
_detailRow(
l10n.channel,
contact.getLocalizedDisplayName(context),
), ),
if (!contact.isPublicChannel) ],
),
),
const Divider(),
// Content
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
_detailRow(l10n.type, contact.type.displayName),
if (contact.isChannel) ...[
_detailRow( _detailRow(
'Slot', l10n.channel,
'${l10n.channel} ${contact.publicKey.length > 1 ? contact.publicKey[1] : '-'}', contact.getLocalizedDisplayName(context),
), ),
] else if (!contact.isPublicChannel)
// Public Key with copy button _detailRow(
Padding( 'Slot',
padding: const EdgeInsets.symmetric(vertical: 4), '${l10n.channel} ${contact.publicKey.length > 1 ? contact.publicKey[1] : '-'}',
child: Row( ),
crossAxisAlignment: CrossAxisAlignment.center, ] else
children: [ // Public Key with copy button
SizedBox( Padding(
width: 100, padding: const EdgeInsets.symmetric(vertical: 4),
child: Text( child: Row(
'${l10n.publicKey}:', crossAxisAlignment: CrossAxisAlignment.center,
style: const TextStyle( children: [
fontWeight: FontWeight.w500, SizedBox(
width: 100,
child: Text(
'${l10n.publicKey}:',
style: const TextStyle(
fontWeight: FontWeight.w500,
),
), ),
), ),
Expanded(child: Text(contact.publicKeyShort)),
const SizedBox(width: 8),
InkWell(
onTap: () {
Clipboard.setData(
ClipboardData(text: contact.publicKeyHex),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.publicKeyCopied),
duration: const Duration(seconds: 2),
),
);
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.copy,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
),
_detailRow(
l10n.lastSeen,
_getLocalizedTimeSinceLastSeen(context),
),
const SizedBox(height: 16),
// Room Login Status
if (roomLoginState != null) ...[
Text(
'${AppLocalizations.of(context)!.roomStatus}:',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
_detailRow(
AppLocalizations.of(context)!.loginStatus,
roomLoginState.isLoggedIn
? AppLocalizations.of(context)!.loggedIn
: AppLocalizations.of(context)!.notLoggedIn,
),
if (roomLoginState.isLoggedIn) ...[
_detailRow(
AppLocalizations.of(context)!.adminAccess,
roomLoginState.isAdmin
? AppLocalizations.of(context)!.yes
: AppLocalizations.of(context)!.no,
),
_detailRow(
AppLocalizations.of(context)!.permissions,
roomLoginState.permissions.toString(),
),
if (roomLoginState.loginDurationFormatted != null)
_detailRow(
AppLocalizations.of(context)!.loggedIn,
roomLoginState.loginDurationFormatted!,
), ),
Expanded(child: Text(contact.publicKeyShort)), ],
const SizedBox(width: 8), _detailRow(
InkWell( AppLocalizations.of(context)!.passwordSaved,
onTap: () { roomLoginState.hasPassword
Clipboard.setData( ? AppLocalizations.of(context)!.yes
ClipboardData(text: contact.publicKeyHex), : AppLocalizations.of(context)!.no,
); ),
ScaffoldMessenger.of(context).showSnackBar( const SizedBox(height: 16),
SnackBar( ],
content: Text(l10n.publicKeyCopied), if (contact.displayLocation != null) ...[
duration: const Duration(seconds: 2), Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context)!.locationColon,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
TextButton.icon(
onPressed: () {
// Navigate to map and close modal
final mapProvider = context.read<MapProvider>();
mapProvider.navigateToLocation(
location: LatLng(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
), ),
); );
Navigator.pop(context);
// Switch to map tab using callback
onNavigateToMap?.call();
}, },
borderRadius: BorderRadius.circular(4), icon: const Icon(Icons.map, size: 18),
child: Padding( label: Text(
padding: const EdgeInsets.all(4), AppLocalizations.of(context)!.viewOnMap,
child: Icon( ),
Icons.copy, style: TextButton.styleFrom(
size: 16, padding: const EdgeInsets.symmetric(
color: Theme.of(context).colorScheme.primary, horizontal: 8,
vertical: 4,
), ),
), ),
), ),
], ],
), ),
), const SizedBox(height: 8),
_detailRow( // Decimal Degrees (DD)
l10n.lastSeen, _detailRowWithCopy(
_getLocalizedTimeSinceLastSeen(context), context,
), 'DD',
const SizedBox(height: 16), '${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
// Room Login Status
if (roomLoginState != null) ...[
Text(
'${AppLocalizations.of(context)!.roomStatus}:',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
), ),
), // Degrees Minutes Seconds (DMS)
const SizedBox(height: 8), _detailRowWithCopy(
_detailRow( context,
AppLocalizations.of(context)!.loginStatus, 'DMS',
roomLoginState.isLoggedIn _convertToDMS(
? AppLocalizations.of(context)!.loggedIn contact.displayLocation!.latitude,
: AppLocalizations.of(context)!.notLoggedIn, contact.displayLocation!.longitude,
),
if (roomLoginState.isLoggedIn) ...[
_detailRow(
AppLocalizations.of(context)!.adminAccess,
roomLoginState.isAdmin
? AppLocalizations.of(context)!.yes
: AppLocalizations.of(context)!.no,
),
_detailRow(
AppLocalizations.of(context)!.permissions,
roomLoginState.permissions.toString(),
),
if (roomLoginState.loginDurationFormatted != null)
_detailRow(
AppLocalizations.of(context)!.loggedIn,
roomLoginState.loginDurationFormatted!,
), ),
),
// Degrees Decimal Minutes (DDM)
_detailRowWithCopy(
context,
'DDM',
_convertToDDM(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
// MGRS (Military Grid Reference System)
_detailRowWithCopy(
context,
'MGRS',
_convertToMGRS(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
// Google Plus Code
_detailRowWithCopy(
context,
'Plus Code',
formatPlusCode(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
const SizedBox(height: 16),
], ],
_detailRow( if (contact.telemetry != null) ...[
AppLocalizations.of(context)!.passwordSaved, Row(
roomLoginState.hasPassword mainAxisAlignment: MainAxisAlignment.spaceBetween,
? AppLocalizations.of(context)!.yes children: [
: AppLocalizations.of(context)!.no, Text(
), '${AppLocalizations.of(context)!.telemetry}:',
const SizedBox(height: 16), style: const TextStyle(
], fontWeight: FontWeight.bold,
if (contact.displayLocation != null) ...[ fontSize: 16,
Row( ),
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context)!.locationColon,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
), ),
), TextButton.icon(
TextButton.icon( onPressed: isPingInProgress
onPressed: () { ? null
// Navigate to map and close modal : () {
final mapProvider = context.read<MapProvider>(); final connectionProvider = context
mapProvider.navigateToLocation( .read<ConnectionProvider>();
location: LatLng( connectionProvider.requestTelemetry(
contact.displayLocation!.latitude, contact.publicKey,
contact.displayLocation!.longitude, zeroHop: true,
);
},
icon: isPingInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh, size: 18),
label: Text(AppLocalizations.of(context)!.refresh),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
), ),
); ),
Navigator.pop(context); ),
],
// Switch to map tab using callback ),
onNavigateToMap?.call(); const SizedBox(height: 8),
if (contact.telemetry!.batteryMilliVolts != null)
_detailRow(
AppLocalizations.of(context)!.voltage,
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
)
else if (contact.telemetry!.batteryPercentage != null)
_detailRow(
AppLocalizations.of(context)!.battery,
'${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%',
),
if (contact.telemetry!.temperature != null)
_detailRow(
AppLocalizations.of(context)!.temperature,
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
),
if (contact.telemetry!.humidity != null)
_detailRow(
AppLocalizations.of(context)!.humidity,
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
),
if (contact.telemetry!.pressure != null)
_detailRow(
AppLocalizations.of(context)!.pressure,
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
),
if (contact.telemetry!.gpsLocation != null)
_detailRow(
AppLocalizations.of(context)!.gpsTelemetry,
'${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}',
),
_detailRow(
AppLocalizations.of(context)!.updated,
'${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})',
),
],
if (!currentContact.isChannel) ...[
const SizedBox(height: 16),
Text(
'Route',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
_detailRow('Mode', currentContact.routeSummary),
if (currentContact.routeHopCount > 0)
_detailRow('Route', currentContact.routeCanonicalText),
if (currentContact.routeHopCount > 0)
_detailRow(
'Descriptor',
'0x${currentContact.routeEncodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
_showSetRouteDialog(context, currentContact),
icon: const Icon(Icons.route),
label: const Text('Set Route'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: currentContact.isPublicChannel
? null
: () async {
contactsProvider.resetContactRouteLocal(
currentContact.publicKey,
);
try {
await connectionProvider.resetPath(
currentContact.publicKey,
);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.pathResetInfo(
currentContact.displayName,
),
),
),
);
}
} catch (_) {
contactsProvider.setContactRouteLocal(
currentContact.publicKey,
signedEncodedPathLen:
currentContact.routeSignedPathLen,
paddedPathBytes:
currentContact.outPath,
);
if (context.mounted) {
ToastLogger.error(
context,
'Failed to reset route.',
);
}
}
},
icon: const Icon(Icons.refresh),
label: Text(
AppLocalizations.of(context)!.resetPath,
),
),
),
],
),
],
// Direct Message button for chat contacts
if (contact.type == ContactType.chat) ...[
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context); // Close details first
_showDirectMessageDialog(context, contact);
}, },
icon: const Icon(Icons.map, size: 18), icon: const Icon(Icons.message),
label: Text(AppLocalizations.of(context)!.viewOnMap), label: Text(
style: TextButton.styleFrom( AppLocalizations.of(context)!.sendDirectMessage,
padding: const EdgeInsets.symmetric( ),
horizontal: 8, style: ElevatedButton.styleFrom(
vertical: 4, padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: _getTypeColor(
contact.type,
context,
), ),
foregroundColor: Colors.white,
), ),
), ),
],
),
const SizedBox(height: 8),
// Decimal Degrees (DD)
_detailRowWithCopy(
context,
'DD',
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
),
// Degrees Minutes Seconds (DMS)
_detailRowWithCopy(
context,
'DMS',
_convertToDMS(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
), ),
), ],
// Degrees Decimal Minutes (DDM) // Room Login button for room contacts (except Public Channel)
_detailRowWithCopy( if (contact.type == ContactType.room &&
context, !contact.isPublicChannel) ...[
'DDM', const SizedBox(height: 24),
_convertToDDM( SizedBox(
contact.displayLocation!.latitude, width: double.infinity,
contact.displayLocation!.longitude, child: ElevatedButton.icon(
), onPressed: () {
), Navigator.pop(context); // Close details first
// MGRS (Military Grid Reference System) _showRoomLoginDialog(context, contact);
_detailRowWithCopy( },
context, icon: const Icon(Icons.login),
'MGRS', label: Text(
_convertToMGRS( roomLoginState?.isLoggedIn == true
contact.displayLocation!.latitude, ? AppLocalizations.of(context)!.reLoginToRoom
contact.displayLocation!.longitude, : AppLocalizations.of(context)!.loginToRoom,
),
),
// Google Plus Code
_detailRowWithCopy(
context,
'Plus Code',
formatPlusCode(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
const SizedBox(height: 16),
],
if (contact.telemetry != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${AppLocalizations.of(context)!.telemetry}:',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
), ),
), style: ElevatedButton.styleFrom(
TextButton.icon( padding: const EdgeInsets.symmetric(vertical: 14),
onPressed: isPingInProgress backgroundColor: _getTypeColor(
? null contact.type,
: () { context,
final connectionProvider = context
.read<ConnectionProvider>();
connectionProvider.requestTelemetry(
contact.publicKey,
zeroHop: true,
);
},
icon: isPingInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh, size: 18),
label: Text(AppLocalizations.of(context)!.refresh),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
), ),
foregroundColor: Colors.white,
), ),
), ),
],
),
const SizedBox(height: 8),
if (contact.telemetry!.batteryMilliVolts != null)
_detailRow(
AppLocalizations.of(context)!.voltage,
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
)
else if (contact.telemetry!.batteryPercentage != null)
_detailRow(
AppLocalizations.of(context)!.battery,
'${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%',
), ),
if (contact.telemetry!.temperature != null) ],
_detailRow( // Delete Contact button (for all contact types except Public Channel)
AppLocalizations.of(context)!.temperature, if (!contact.isPublicChannel) ...[
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C', const SizedBox(height: 12),
), SizedBox(
if (contact.telemetry!.humidity != null) width: double.infinity,
_detailRow( child: OutlinedButton.icon(
AppLocalizations.of(context)!.humidity, onPressed: () =>
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%', _showDeleteConfirmation(context, contact),
), icon: const Icon(Icons.delete_outline),
if (contact.telemetry!.pressure != null) label: Text(
_detailRow( AppLocalizations.of(context)!.deleteContact,
AppLocalizations.of(context)!.pressure, ),
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa', style: OutlinedButton.styleFrom(
), padding: const EdgeInsets.symmetric(vertical: 14),
if (contact.telemetry!.gpsLocation != null) side: const BorderSide(color: Colors.red),
_detailRow( foregroundColor: Colors.red,
AppLocalizations.of(context)!.gpsTelemetry,
'${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}',
),
_detailRow(
AppLocalizations.of(context)!.updated,
'${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})',
),
],
// Direct Message button for chat contacts
if (contact.type == ContactType.chat) ...[
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context); // Close details first
_showDirectMessageDialog(context, contact);
},
icon: const Icon(Icons.message),
label: Text(
AppLocalizations.of(context)!.sendDirectMessage,
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: _getTypeColor(contact.type, context),
foregroundColor: Colors.white,
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
connectionProvider.resetPath(contact.publicKey);
},
icon: const Icon(Icons.route),
label: Text(AppLocalizations.of(context)!.resetPath),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: BorderSide(
color: _getTypeColor(contact.type, context),
), ),
foregroundColor: _getTypeColor(contact.type, context),
), ),
), ),
), ],
], ],
// Room Login button for room contacts (except Public Channel) ),
if (contact.type == ContactType.room &&
!contact.isPublicChannel) ...[
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context); // Close details first
_showRoomLoginDialog(context, contact);
},
icon: const Icon(Icons.login),
label: Text(
roomLoginState?.isLoggedIn == true
? AppLocalizations.of(context)!.reLoginToRoom
: AppLocalizations.of(context)!.loginToRoom,
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: _getTypeColor(contact.type, context),
foregroundColor: Colors.white,
),
),
),
],
// Delete Contact button (for all contact types except Public Channel)
if (!contact.isPublicChannel) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () =>
_showDeleteConfirmation(context, contact),
icon: const Icon(Icons.delete_outline),
label: Text(
AppLocalizations.of(context)!.deleteContact,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: Colors.red),
foregroundColor: Colors.red,
),
),
),
],
],
), ),
),
], ],
); );
}, },
@@ -1030,6 +1110,56 @@ class ContactTile extends StatelessWidget {
); );
} }
Future<void> _showSetRouteDialog(
BuildContext context,
Contact contact,
) async {
final contactsProvider = context.read<ContactsProvider>();
final connectionProvider = context.read<ConnectionProvider>();
final availableContacts = contactsProvider.contacts
.where((candidate) => candidate.publicKeyHex != contact.publicKeyHex)
.toList();
final parsedRoute = await ContactRouteDialog.show(
context,
contact: contact,
availableContacts: availableContacts,
);
if (parsedRoute == null || !context.mounted) {
return;
}
final previousSignedPathLen = contact.routeSignedPathLen;
final previousPathBytes = Uint8List.fromList(contact.outPath);
contactsProvider.setContactRouteLocal(
contact.publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes,
);
try {
await connectionProvider.setContactRoute(
contact,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Route set: ${parsedRoute.canonicalText}')),
);
}
} catch (error) {
contactsProvider.setContactRouteLocal(
contact.publicKey,
signedEncodedPathLen: previousSignedPathLen,
paddedPathBytes: previousPathBytes,
);
if (context.mounted) {
ToastLogger.error(context, 'Failed to set route: $error');
}
}
}
Widget _detailRow(String label, String value) { Widget _detailRow(String label, String value) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),

View File

@@ -3,11 +3,13 @@ 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/app_provider.dart'; import '../../providers/app_provider.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 '../../providers/messages_provider.dart';
import '../../utils/image_message_parser.dart'; import '../../utils/image_message_parser.dart';
import '../../utils/transmission_target_resolver.dart'; import '../../utils/transmission_target_resolver.dart';
import 'transfer_timeout.dart'; import 'transfer_timeout.dart';
@@ -33,7 +35,9 @@ class ImageMessageBubble extends StatefulWidget {
class _ImageMessageBubbleState extends State<ImageMessageBubble> { class _ImageMessageBubbleState extends State<ImageMessageBubble> {
static const int _maxFetchHops = 3; static const int _maxFetchHops = 3;
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
bool _isRequesting = false; bool _isRequesting = false;
bool _isPartialRequest = false;
String? _errorText; String? _errorText;
Timer? _requestTimeoutTimer; Timer? _requestTimeoutTimer;
@@ -59,6 +63,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return Consumer<ip.ImageProvider>( return Consumer<ip.ImageProvider>(
builder: (context, imageProvider, _) { builder: (context, imageProvider, _) {
final transferCount = context.select<MessagesProvider, int>(
(provider) => provider.transferCountForSession(
imageSessionId: envelope.sessionId,
),
);
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final session = imageProvider.session(envelope.sessionId); final session = imageProvider.session(envelope.sessionId);
final sender = TransmissionTargetResolver.resolveLocalTarget( final sender = TransmissionTargetResolver.resolveLocalTarget(
@@ -66,11 +75,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey, recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName, senderName: widget.message.senderName,
); );
final effectivePathLen = sender != null && sender.outPathLen >= 0 final effectivePathLen = sender != null && sender.routeHasPath
? sender.outPathLen ? sender.routeHopCount
: widget.message.pathLen; : widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId); final isComplete = imageProvider.isComplete(envelope.sessionId);
final eta = imageProvider.estimateRemainingTransferTime( final eta = imageProvider.estimateRemainingTransferTime(
@@ -82,6 +90,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
_errorText = null; _errorText = null;
}); });
} }
@@ -91,6 +100,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
final received = session?.receivedCount ?? 0; final received = session?.receivedCount ?? 0;
final total = session?.total ?? envelope.total; final total = session?.total ?? envelope.total;
final imageBytes = isComplete ? session?.imageBytes : null; final imageBytes = isComplete ? session?.imageBytes : null;
final fragmentPresence =
session?.fragments.map((fragment) => fragment != null).toList() ??
List<bool>.filled(total, false);
final isReceivingData =
!_isRequesting &&
!isComplete &&
_hasRecentInboundActivity(
lastReceivedAt: session?.lastFragmentAt,
received: received,
total: total,
);
return GestureDetector( return GestureDetector(
onTap: isComplete onTap: isComplete
@@ -110,8 +130,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
imageBytes: imageBytes, imageBytes: imageBytes,
isComplete: isComplete, isComplete: isComplete,
isRequesting: _isRequesting, isRequesting: _isRequesting,
isReceivingData: isReceivingData,
received: received, received: received,
total: total, total: total,
fragmentPresence: fragmentPresence,
envelope: envelope, envelope: envelope,
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
@@ -125,6 +147,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_statusText( _statusText(
isComplete: isComplete, isComplete: isComplete,
isRequesting: _isRequesting, isRequesting: _isRequesting,
isReceivingData: isReceivingData,
isPartialRequest: _isPartialRequest,
received: received, received: received,
total: total, total: total,
envelope: envelope, envelope: envelope,
@@ -135,6 +159,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
eta: eta, eta: eta,
pathLen: effectivePathLen, pathLen: effectivePathLen,
transferCount: transferCount,
), ),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
@@ -156,8 +181,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required Uint8List? imageBytes, required Uint8List? imageBytes,
required bool isComplete, required bool isComplete,
required bool isRequesting, required bool isRequesting,
required bool isReceivingData,
required int received, required int received,
required int total, required int total,
required List<bool> fragmentPresence,
required ImageEnvelope envelope, required ImageEnvelope envelope,
required int? radioBw, required int? radioBw,
required int? radioSf, required int? radioSf,
@@ -180,19 +207,28 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
if (isRequesting) ...[ if (isRequesting) ...[
// Download progress ring. Container(
SizedBox( margin: const EdgeInsets.symmetric(horizontal: 20),
width: 48, padding: const EdgeInsets.all(12),
height: 48, decoration: BoxDecoration(
child: CircularProgressIndicator( color: Colors.black.withValues(alpha: 0.25),
value: total > 0 ? received / total : null, borderRadius: BorderRadius.circular(12),
strokeWidth: 3, ),
color: Theme.of(context).colorScheme.primary, child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_PacketBlockProgress(
presence: fragmentPresence,
activeColor: Theme.of(context).colorScheme.primary,
highlightMissing: _isPartialRequest,
),
const SizedBox(height: 8),
Text(
'$received/$total',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
],
), ),
),
Text(
'$received/$total',
style: const TextStyle(color: Colors.white, fontSize: 11),
), ),
Positioned( Positioned(
top: 8, top: 8,
@@ -229,16 +265,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
] else ...[ ] else ...[
// Tap-to-load icon. // Tap-to-load icon.
IconButton( IconButton(
onPressed: () => _requestAndFetch( onPressed: isReceivingData
envelope, ? null
radioBw: radioBw, : () => _requestAndFetch(
radioSf: radioSf, envelope,
radioCr: radioCr, radioBw: radioBw,
pathLen: pathLen, radioSf: radioSf,
radioCr: radioCr,
pathLen: pathLen,
),
icon: Icon(
isReceivingData
? Icons.downloading_rounded
: Icons.download_rounded,
size: 40,
), ),
icon: const Icon(Icons.download_rounded, size: 40),
color: Colors.white70, color: Colors.white70,
tooltip: 'Load image', tooltip: isReceivingData
? 'Image is already being received'
: 'Load image',
), ),
], ],
], ],
@@ -255,10 +300,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
int pathLen = 0, int pathLen = 0,
}) async { }) async {
if (_isRequesting) return; if (_isRequesting) return;
setState(() {
_isRequesting = true;
_errorText = null;
});
final conn = context.read<ConnectionProvider>(); final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>(); final imageProvider = context.read<ip.ImageProvider>();
@@ -271,7 +312,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey, recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName, senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops, maxFetchHops: _maxFetchHops,
); );
@@ -322,7 +362,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey, recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName, senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops, maxFetchHops: _maxFetchHops,
); );
@@ -364,9 +403,18 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
} }
} }
if (sender.outPathLen >= 2) { if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) {
_showToast( _showToast(
'Image fetch over ${sender.outPathLen} hops may take a while.', 'Image fetch over ${sender.routeHopCount} hops may take a while.',
); );
} }
@@ -390,28 +438,31 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
final missing = imageProvider.missingFragmentIndices(envelope.sessionId); final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
final isPartialResume = final isPartialResume =
missing.isNotEmpty && missing.length < envelope.total; missing.isNotEmpty && missing.length < envelope.total;
setState(() {
_isRequesting = true;
_isPartialRequest = isPartialResume;
_errorText = null;
});
final request = isPartialResume final request = isPartialResume
? ImageFetchRequest( ? ImageFetchRequest(
sessionId: envelope.sessionId, sessionId: envelope.sessionId,
want: 'missing', want: 'missing',
missingIndices: missing, missingIndices: missing,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
) )
: ImageFetchRequest( : ImageFetchRequest(
sessionId: envelope.sessionId, sessionId: envelope.sessionId,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
); );
final payload = request.encodeBinary(); final payload = request.encodeBinary();
try { try {
debugPrint( debugPrint(
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}', '📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
); );
await conn.sendRawVoicePacket( await conn.sendRawVoicePacket(
contactPath: sender.outPath, contactPath: sender.outPath,
contactPathLen: sender.outPathLen, contactPathLen: sender.routeSignedPathLen,
payload: payload, payload: payload,
); );
} catch (_) { } catch (_) {
@@ -419,6 +470,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_showToast('Image fetch failed to send request'); _showToast('Image fetch failed to send request');
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
_errorText = 'Image unavailable right now'; _errorText = 'Image unavailable right now';
}); });
} }
@@ -427,8 +479,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return; if (!mounted) return;
// Timeout = 2× estimated LoRa airtime (min 30s). // Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 final effectivePathLen = sender.routeHasPath
? sender.outPathLen ? sender.routeHopCount
: pathLen; : pathLen;
final txEstimate = estimateImageTransmitDuration( final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length, fragmentCount: missing.isEmpty ? envelope.total : missing.length,
@@ -450,6 +502,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_showToast('Image fetch timed out'); _showToast('Image fetch timed out');
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
_errorText = 'Image fetch timed out'; _errorText = 'Image fetch timed out';
}); });
} }
@@ -471,6 +524,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_showToast('Image receive canceled'); _showToast('Image receive canceled');
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
_errorText = 'Image receive canceled'; _errorText = 'Image receive canceled';
}); });
} }
@@ -479,6 +533,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
}); });
} }
@@ -503,6 +558,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
static String _statusText({ static String _statusText({
required bool isComplete, required bool isComplete,
required bool isRequesting, required bool isRequesting,
required bool isReceivingData,
required bool isPartialRequest,
required int received, required int received,
required int total, required int total,
required ImageEnvelope envelope, required ImageEnvelope envelope,
@@ -513,6 +570,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required String? error, required String? error,
required bool isSentByMe, required bool isSentByMe,
required Duration? eta, required Duration? eta,
required int transferCount,
}) { }) {
final txEstimate = estimateImageTransmitDuration( final txEstimate = estimateImageTransmitDuration(
fragmentCount: envelope.total, fragmentCount: envelope.total,
@@ -527,16 +585,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (error != null) return error; if (error != null) return error;
if (isRequesting) { if (isRequesting) {
final etaLabel = _formatEta(eta); final etaLabel = _formatEta(eta);
return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel'; final actionLabel = isPartialRequest
? '📥 Fetching missing fragments…'
: '📥 Loading…';
return '$actionLabel $received/$total · $etaLabel · $txEstimateLabel';
}
if (isReceivingData) {
final etaLabel = _formatEta(eta);
return '📥 Receiving… $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}';
return isSentByMe return isSentByMe
? '$base · ${envelope.total} seg · $txEstimateLabel' ? '$base · ${envelope.total} seg · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '$base · $txEstimateLabel'; : '$base · $txEstimateLabel';
} }
return '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel'; return isSentByMe
? '🖼️ ${envelope.width}×${envelope.height} · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
} }
static String _formatTransmitEstimate(Duration value) { static String _formatTransmitEstimate(Duration value) {
@@ -554,6 +621,22 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return 'ETA ~${minutes}m ${seconds}s'; return 'ETA ~${minutes}m ${seconds}s';
} }
static String _formatTransferCount(int transferCount) {
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
}
bool _hasRecentInboundActivity({
required DateTime? lastReceivedAt,
required int received,
required int total,
}) {
if (lastReceivedAt == null || received <= 0 || received >= total) {
return false;
}
return DateTime.now().difference(lastReceivedAt) <=
_recentInboundActivityWindow;
}
void _showFullScreen(BuildContext context, Uint8List imageBytes) { void _showFullScreen(BuildContext context, Uint8List imageBytes) {
showGeneralDialog<void>( showGeneralDialog<void>(
context: context, context: context,
@@ -600,3 +683,67 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
); );
} }
} }
class _PacketBlockProgress extends StatelessWidget {
final List<bool> presence;
final Color activeColor;
final bool highlightMissing;
const _PacketBlockProgress({
required this.presence,
required this.activeColor,
this.highlightMissing = false,
});
@override
Widget build(BuildContext context) {
if (presence.isEmpty) {
return const SizedBox(width: 96, height: 12);
}
final bucketCount = presence.length <= 24 ? presence.length : 24;
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
final start = (bucketIndex * presence.length) ~/ bucketCount;
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
final safeEnd = end <= start ? start + 1 : end;
final slice = presence.sublist(start, safeEnd);
final received = slice.where((value) => value).length;
return slice.isEmpty ? 0.0 : received / slice.length;
});
final missingColor = highlightMissing
? Colors.amberAccent
: Colors.white.withValues(alpha: 0.14);
return SizedBox(
width: 120,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final fill in bucketFill)
Expanded(
child: Container(
height: 12,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: fill > 0
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
: missingColor.withValues(
alpha: highlightMissing ? 0.45 : 0.14,
),
borderRadius: BorderRadius.circular(2),
border: Border.all(
color: fill > 0
? Colors.white.withValues(alpha: 0.18)
: missingColor.withValues(
alpha: highlightMissing ? 0.7 : 0.18,
),
width: 0.5,
),
),
),
),
],
),
);
}
}

View File

@@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../../models/message_transfer_details.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 'tictactoe_message_bubble.dart';
@@ -119,20 +120,9 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
try { try {
// Create new message ID for retry
final retryMessageId = '${failedMessage.id}_retry';
// Create retry message
final retryMessage = failedMessage.copyWith(
id: retryMessageId,
deliveryStatus: MessageDeliveryStatus.sending,
);
// Add retry message to provider
Contact? roomContact; Contact? roomContact;
if (failedMessage.messageType == MessageType.contact) { if (failedMessage.messageType == MessageType.contact) {
if (failedMessage.recipientPublicKey == null) { if (failedMessage.recipientPublicKey == null) {
messagesProvider.markMessageFailed(retryMessageId);
ToastLogger.error( ToastLogger.error(
context, context,
AppLocalizations.of(context)!.cannotRetryMissingRecipient, AppLocalizations.of(context)!.cannotRetryMissingRecipient,
@@ -148,13 +138,10 @@ class _MessageBubbleState extends State<MessageBubble> {
}).firstOrNull; }).firstOrNull;
} }
messagesProvider.addSentMessage(retryMessage, contact: roomContact);
// Resend the message // Resend the message
if (failedMessage.messageType == MessageType.contact) { if (failedMessage.messageType == MessageType.contact) {
// Direct message retry (for SAR markers sent to rooms) // Direct message retry (for SAR markers sent to rooms)
if (failedMessage.recipientPublicKey == null) { if (failedMessage.recipientPublicKey == null) {
messagesProvider.markMessageFailed(retryMessageId);
ToastLogger.error( ToastLogger.error(
context, context,
AppLocalizations.of(context)!.cannotRetryMissingRecipient, AppLocalizations.of(context)!.cannotRetryMissingRecipient,
@@ -162,26 +149,39 @@ class _MessageBubbleState extends State<MessageBubble> {
return; return;
} }
// Resend to the same room final prepared = messagesProvider.prepareMessageForRetry(
failedMessage.id,
);
if (!prepared) {
return;
}
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: failedMessage.recipientPublicKey!, contactPublicKey: failedMessage.recipientPublicKey!,
text: failedMessage.text, text: failedMessage.text,
messageId: retryMessageId, messageId: failedMessage.id,
contact: roomContact, contact: roomContact,
); );
if (!context.mounted) return; if (!context.mounted) return;
if (!sentSuccessfully) { if (!sentSuccessfully) {
messagesProvider.markMessageFailed(retryMessageId); messagesProvider.markMessageFailed(failedMessage.id);
ToastLogger.error(context, 'Failed to resend message'); ToastLogger.error(context, 'Failed to resend message');
} }
} else if (failedMessage.messageType == MessageType.channel) { } else if (failedMessage.messageType == MessageType.channel) {
final prepared = messagesProvider.prepareMessageForRetry(
failedMessage.id,
);
if (!prepared) {
return;
}
// Channel message retry // Channel message retry
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
channelIdx: failedMessage.channelIdx ?? 0, channelIdx: failedMessage.channelIdx ?? 0,
text: failedMessage.text, text: failedMessage.text,
messageId: retryMessageId, messageId: failedMessage.id,
); );
if (!context.mounted) return; if (!context.mounted) return;
@@ -415,9 +415,11 @@ class _MessageBubbleState extends State<MessageBubble> {
final receptionDetails = messagesProvider.getMessageReceptionDetails( final receptionDetails = messagesProvider.getMessageReceptionDetails(
widget.message.id, widget.message.id,
); );
final transferDetails = messagesProvider.getMessageTransferDetails(
widget.message.id,
);
final envelope = VoiceEnvelope.tryParseText(widget.message.text); final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
final voiceSession = widget.message.voiceId != null final voiceSession = widget.message.voiceId != null
? voiceProvider.session(widget.message.voiceId!) ? voiceProvider.session(widget.message.voiceId!)
: null; : null;
@@ -454,16 +456,6 @@ class _MessageBubbleState extends State<MessageBubble> {
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
) )
: legacyVoicePacket != null
? estimateVoiceTransmitDuration(
mode: legacyVoicePacket.mode,
packetCount: legacyVoicePacket.total,
durationMs: legacyVoicePacket.durationMs * legacyVoicePacket.total,
pathLen: widget.message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
)
: Duration.zero; : Duration.zero;
final senderPrefixHex = widget.message.senderPublicKeyPrefix final senderPrefixHex = widget.message.senderPublicKeyPrefix
@@ -540,10 +532,17 @@ class _MessageBubbleState extends State<MessageBubble> {
'Text length: ${widget.message.text.length}', 'Text length: ${widget.message.text.length}',
]; ];
if (transferDetails != null) {
rawLines.add('Transfers served: ${transferDetails.totalTransfers}');
rawLines.add(
'Downloaded by: ${_formatDownloaderSummary(transferDetails)}',
);
}
if (widget.message.isVoice) { if (widget.message.isVoice) {
rawLines.add('--- Voice Technical ---'); rawLines.add('--- Voice Technical ---');
if (envelope != null) { if (envelope != null) {
rawLines.add('Envelope format: VE1 compact'); rawLines.add('Envelope format: VE3 compact');
rawLines.add( rawLines.add(
'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})', 'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})',
); );
@@ -551,17 +550,7 @@ class _MessageBubbleState extends State<MessageBubble> {
rawLines.add( rawLines.add(
'Estimated duration ms (envelope): ${envelope.durationMs}', 'Estimated duration ms (envelope): ${envelope.durationMs}',
); );
rawLines.add('Envelope senderKey6: ${envelope.senderKey6}');
rawLines.add('Envelope ts: ${envelope.timestampSec}');
rawLines.add('Envelope ver: ${envelope.version}'); rawLines.add('Envelope ver: ${envelope.version}');
} else if (legacyVoicePacket != null) {
rawLines.add('Envelope format: legacy V packet');
rawLines.add(
'Legacy segment index/total: ${legacyVoicePacket.index + 1}/${legacyVoicePacket.total}',
);
rawLines.add(
'Legacy codec mode: ${legacyVoicePacket.mode.label} (id=${legacyVoicePacket.mode.id})',
);
} else { } else {
rawLines.add('Envelope format: unknown'); rawLines.add('Envelope format: unknown');
} }
@@ -604,8 +593,6 @@ class _MessageBubbleState extends State<MessageBubble> {
rawLines.add( rawLines.add(
'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)', 'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)',
); );
rawLines.add('Envelope senderKey6: ${imageEnvelope.senderKey6}');
rawLines.add('Envelope ts: ${imageEnvelope.timestampSec}');
rawLines.add('Envelope ver: ${imageEnvelope.version}'); rawLines.add('Envelope ver: ${imageEnvelope.version}');
if (imageSession != null) { if (imageSession != null) {
@@ -916,11 +903,7 @@ class _MessageBubbleState extends State<MessageBubble> {
_detailRow( _detailRow(
context, context,
label: l10n.envelope, label: l10n.envelope,
value: envelope != null value: envelope != null ? 'VE3 compact' : l10n.unknown,
? 'VE1 compact'
: legacyVoicePacket != null
? 'Legacy V packet'
: l10n.unknown,
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
@@ -937,6 +920,21 @@ class _MessageBubbleState extends State<MessageBubble> {
? l10n.yes ? l10n.yes
: l10n.no, : l10n.no,
), ),
if (transferDetails != null)
_detailRow(
context,
label: 'Transfers',
value: '${transferDetails.totalTransfers}',
),
if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty)
_detailRow(
context,
label: 'Downloaded by',
value: _formatDownloaderSummary(
transferDetails,
),
),
if (voiceTxEstimate > Duration.zero) if (voiceTxEstimate > Duration.zero)
_detailRow( _detailRow(
context, context,
@@ -988,6 +986,21 @@ class _MessageBubbleState extends State<MessageBubble> {
? l10n.yes ? l10n.yes
: l10n.no, : l10n.no,
), ),
if (transferDetails != null)
_detailRow(
context,
label: 'Transfers',
value: '${transferDetails.totalTransfers}',
),
if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty)
_detailRow(
context,
label: 'Downloaded by',
value: _formatDownloaderSummary(
transferDetails,
),
),
if (imageTxEstimate > Duration.zero) if (imageTxEstimate > Duration.zero)
_detailRow( _detailRow(
context, context,
@@ -1147,6 +1160,20 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
String _formatDownloaderSummary(MessageTransferDetails transferDetails) {
return transferDetails.downloaders.map(_formatDownloaderLabel).join(', ');
}
String _formatDownloaderLabel(MessageTransferDownloader downloader) {
final name = downloader.requesterName?.trim();
final base = name != null && name.isNotEmpty
? '$name (${downloader.requesterKey6})'
: downloader.requesterKey6;
return downloader.transferCount > 1
? '$base ×${downloader.transferCount}'
: base;
}
Widget _signalRow( Widget _signalRow(
BuildContext context, { BuildContext context, {
required String label, required String label,

View File

@@ -65,8 +65,28 @@ Widget buildBubbleMetaFooter(
).textTheme.labelSmall?.color?.withValues(alpha: 0.68); ).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
final items = <Widget>[]; final items = <Widget>[];
final sentEchoLabel = message.isSentMessage && message.echoCount > 0
? '${message.echoCount} echo${message.echoCount == 1 ? '' : 'es'}'
: null;
if (!isSarMarker && message.pathLen < 255) { if (!isSarMarker && sentEchoLabel != null) {
items.addAll([
Icon(Icons.hub_outlined, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
sentEchoLabel,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
Text(
'',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
]);
} else if (!isSarMarker && message.pathLen < 255) {
items.addAll([ items.addAll([
Icon(Icons.alt_route, size: 11, color: metaColor), Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3), const SizedBox(width: 3),

View File

@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import '../../models/ble_packet_log.dart'; import '../../models/ble_packet_log.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 '../../services/mesh_map_nodes_service.dart'; import '../../services/mesh_map_nodes_service.dart';
class MessageTraceSheet extends StatefulWidget { class MessageTraceSheet extends StatefulWidget {
@@ -30,9 +31,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async { Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final nodes = await MeshMapNodesService.fetchNodes( final contactsProvider = context.read<ContactsProvider>();
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
final packetPath = _extractPathFromPacketLogs( final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs, logs: connectionProvider.bleService.packetLogs,
message: widget.message, message: widget.message,
@@ -43,45 +42,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
? _toPrefixHex(widget.message.recipientPublicKey) ? _toPrefixHex(widget.message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey); : _toPrefixHex(connectionProvider.deviceInfo.publicKey);
final senderNode = _bestNodeForPrefix(nodes, senderPrefix); final localNodes = _localNodesFromContacts(contactsProvider);
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix); var trace = _buildTraceResult(
nodes: localNodes,
if (packetPath != null && packetPath.isNotEmpty) { packetPath: packetPath,
final matched = _matchNodesFromPathHashes( senderPrefix: senderPrefix,
nodes: nodes, recipientPrefix: recipientPrefix,
pathHashes: packetPath, );
senderPrefix: senderPrefix, if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) {
recipientPrefix: recipientPrefix, return trace;
);
return _TraceResult(
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: packetPath,
matchedPathNodes: matched,
);
} }
// Fallback when packet path is unavailable. final remoteNodes = await MeshMapNodesService.fetchNodes(
final inferred = _inferRelaysFromHopCount( cacheTtl: MeshMapNodesService.traceCacheTtl,
nodes: nodes,
sender: senderNode,
recipient: recipientNode,
relayCount: math.max(0, widget.message.pathLen),
); );
final matchedPathNodes = <MeshMapNode?>[ trace = _buildTraceResult(
if (senderNode != null) senderNode, nodes: _mergeNodes(localNodes, remoteNodes),
...inferred, packetPath: packetPath,
if (recipientNode != null) recipientNode, senderPrefix: senderPrefix,
]; recipientPrefix: recipientPrefix,
return _TraceResult(
mode: TraceMode.hopCountInference,
sender: senderNode,
recipient: recipientNode,
pathHashes: const [],
matchedPathNodes: matchedPathNodes,
); );
return trace;
} }
@override @override
@@ -109,12 +90,16 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
final trace = snapshot.data!; final trace = snapshot.data!;
final mapPoints = trace.matchedPathNodes final routeEntries = _displayRouteEntries(trace);
.whereType<MeshMapNode>() final concretePathNodes = routeEntries
.where((entry) => entry.node != null)
.map((entry) => entry.node!)
.toList();
final mapPoints = concretePathNodes
.map((n) => LatLng(n.latitude, n.longitude)) .map((n) => LatLng(n.latitude, n.longitude))
.toList(); .toList();
final hasMapPath = mapPoints.length >= 2; final hasMapPath = mapPoints.length >= 2;
final relayNodes = _relayNodes(trace.matchedPathNodes); final relayNodes = _relayNodes(trace);
return SizedBox( return SizedBox(
height: MediaQuery.of(context).size.height * 0.75, height: MediaQuery.of(context).size.height * 0.75,
@@ -197,9 +182,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
], ],
), ),
flutter_map.MarkerLayer( flutter_map.MarkerLayer(
markers: trace.matchedPathNodes markers: concretePathNodes
.whereType<MeshMapNode>()
.toList()
.asMap() .asMap()
.entries .entries
.map( .map(
@@ -216,10 +199,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
entry.key == 0 entry.key == 0
? Colors.green ? Colors.green
: (entry.key == : (entry.key ==
trace.matchedPathNodes concretePathNodes
.whereType<
MeshMapNode
>()
.length - .length -
1 1
? Colors.red ? Colors.red
@@ -250,6 +230,48 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Route',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (routeEntries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No named nodes could be matched for this trace.',
),
),
...routeEntries.asMap().entries.map(
(entry) => ListTile(
leading: CircleAvatar(
radius: 14,
backgroundColor: entry.key == 0
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
title: Text(entry.value.label),
subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}',
),
),
),
const SizedBox(height: 12),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
@@ -288,12 +310,71 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
} }
List<MeshMapNode> _relayNodes(List<MeshMapNode?> path) { List<MeshMapNode> _relayNodes(_TraceResult trace) {
final concrete = path.whereType<MeshMapNode>().toList(); final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
if (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) {
if (concrete.length <= 1) return const [];
return concrete.sublist(1);
}
if (concrete.length <= 2) return const []; if (concrete.length <= 2) return const [];
return concrete.sublist(1, concrete.length - 1); return concrete.sublist(1, concrete.length - 1);
} }
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
if (pathNodes.isEmpty) {
return [
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!),
if (trace.recipient != null &&
trace.recipient!.publicKey != trace.sender?.publicKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
];
}
if (trace.mode == TraceMode.packetPath) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key]
.toRadixString(16)
.padLeft(2, '0');
return _RouteDisplayEntry(
node: entry.value,
label: entry.value?.name ?? 'Unknown',
keyLabel: entry.value != null
? _prefixKeyLabel(entry.value!.publicKey)
: hashHex,
);
}).toList();
final lastKey = pathNodes.last.publicKey;
return [
...entries,
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
];
}
final firstKey = pathNodes.first.publicKey;
final lastKey = pathNodes.last.publicKey;
return [
if (trace.sender != null && trace.sender!.publicKey != firstKey)
_RouteDisplayEntry.fromNode(trace.sender!),
...pathNodes.map(_RouteDisplayEntry.fromNode),
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
];
}
String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length));
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
}
String? _toPrefixHex(List<int>? key) { String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null; if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6; final take = key.length < 6 ? key.length : 6;
@@ -312,6 +393,98 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
return matches.isEmpty ? null : matches.first; return matches.isEmpty ? null : matches.first;
} }
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
return contactsProvider.contactsWithLocation
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return MeshMapNode(
type: contact.type.index,
name: contact.displayName,
publicKey: contact.publicKeyHex.toLowerCase(),
latitude: location.latitude,
longitude: location.longitude,
updatedAtMs: contact.lastAdvert * 1000,
);
})
.whereType<MeshMapNode>()
.toList();
}
List<MeshMapNode> _mergeNodes(
List<MeshMapNode> preferred,
List<MeshMapNode> fallback,
) {
final merged = <String, MeshMapNode>{};
for (final node in fallback) {
merged[node.publicKey] = node;
}
for (final node in preferred) {
merged[node.publicKey] = node;
}
return merged.values.toList();
}
_TraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required List<int>? packetPath,
required String? senderPrefix,
required String? recipientPrefix,
}) {
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
if (packetPath != null && packetPath.isNotEmpty) {
final matched = _matchNodesFromPathHashes(
nodes: nodes,
pathHashes: packetPath,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
return _TraceResult(
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: packetPath,
matchedPathNodes: matched,
);
}
final inferred = _inferRelaysFromHopCount(
nodes: nodes,
sender: senderNode,
recipient: recipientNode,
relayCount: math.max(0, widget.message.pathLen),
);
final matchedPathNodes = <MeshMapNode?>[
if (senderNode != null) senderNode,
...inferred,
if (recipientNode != null) recipientNode,
];
return _TraceResult(
mode: TraceMode.hopCountInference,
sender: senderNode,
recipient: recipientNode,
pathHashes: const [],
matchedPathNodes: matchedPathNodes,
);
}
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
if (trace.sender == null || trace.recipient == null) {
return false;
}
if (trace.mode == TraceMode.packetPath) {
return trace.matchedPathNodes.length == trace.pathHashes.length &&
trace.matchedPathNodes.every((node) => node != null);
}
final concreteCount = trace.matchedPathNodes.whereType<MeshMapNode>().length;
return concreteCount >= expectedRelayCount + 2;
}
List<int>? _extractPathFromPacketLogs({ List<int>? _extractPathFromPacketLogs({
required List<BlePacketLog> logs, required List<BlePacketLog> logs,
required Message message, required Message message,
@@ -371,11 +544,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
.where((n) => n.publicKey.startsWith(senderPrefix)) .where((n) => n.publicKey.startsWith(senderPrefix))
.toList(); .toList();
if (senderMatches.isNotEmpty) filtered = senderMatches; if (senderMatches.isNotEmpty) filtered = senderMatches;
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
final recipientMatches = filtered
.where((n) => n.publicKey.startsWith(recipientPrefix))
.toList();
if (recipientMatches.isNotEmpty) filtered = recipientMatches;
} }
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
@@ -461,3 +629,23 @@ class _TraceResult {
required this.matchedPathNodes, required this.matchedPathNodes,
}); });
} }
class _RouteDisplayEntry {
final MeshMapNode? node;
final String label;
final String? keyLabel;
const _RouteDisplayEntry({
required this.node,
required this.label,
required this.keyLabel,
});
factory _RouteDisplayEntry.fromNode(MeshMapNode node) {
return _RouteDisplayEntry(
node: node,
label: node.name,
keyLabel: node.publicKey.substring(0, math.min(12, node.publicKey.length)),
);
}
}

View File

@@ -2,10 +2,12 @@ import 'dart:async';
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/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../providers/voice_provider.dart'; import '../../providers/voice_provider.dart';
import '../../utils/transmission_target_resolver.dart'; import '../../utils/transmission_target_resolver.dart';
import '../../utils/voice_message_parser.dart'; import '../../utils/voice_message_parser.dart';
@@ -28,7 +30,9 @@ class VoiceMessageBubble extends StatefulWidget {
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> { class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
static const int _maxFetchHops = 3; static const int _maxFetchHops = 3;
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
bool _isRequesting = false; bool _isRequesting = false;
bool _isPartialRequest = false;
bool _autoPlayWhenReady = false; bool _autoPlayWhenReady = false;
String? _errorText; String? _errorText;
Timer? _requestTimeoutTimer; Timer? _requestTimeoutTimer;
@@ -55,6 +59,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return Consumer<VoiceProvider>( return Consumer<VoiceProvider>(
builder: (context, voiceProvider, _) { builder: (context, voiceProvider, _) {
final transferCount = context.select<MessagesProvider, int>(
(provider) =>
provider.transferCountForSession(voiceSessionId: voiceId),
);
final contactsProvider = context.read<ContactsProvider>(); 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);
@@ -63,11 +71,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey, recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName, senderName: widget.message.senderName,
); );
final effectivePathLen = sender != null && sender.outPathLen >= 0 final effectivePathLen = sender != null && sender.routeHasPath
? sender.outPathLen ? sender.routeHopCount
: widget.message.pathLen; : widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId); final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId); final isComplete = voiceProvider.isComplete(voiceId);
@@ -77,6 +84,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
_errorText = null; _errorText = null;
}); });
}); });
@@ -93,9 +101,17 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final received = session?.receivedCount ?? 0; final received = session?.receivedCount ?? 0;
final total = session?.total ?? envelope?.total ?? 0; final total = session?.total ?? envelope?.total ?? 0;
final playbackProgress = voiceProvider.playbackProgress(voiceId); final playbackProgress = voiceProvider.playbackProgress(voiceId);
final requestProgress = total > 0 final packetPresence =
? (received / total).clamp(0.0, 1.0) session?.packets.map((packet) => packet != null).toList() ??
: null; List<bool>.filled(total, false);
final isReceivingData =
!_isRequesting &&
!isComplete &&
_hasRecentInboundActivity(
lastReceivedAt: session?.lastPacketAt,
received: received,
total: total,
);
final durationSec = final durationSec =
session?.estimatedDurationSeconds ?? session?.estimatedDurationSeconds ??
((envelope?.durationMs ?? 0) / 1000.0); ((envelope?.durationMs ?? 0) / 1000.0);
@@ -117,32 +133,37 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final txEstimateLabel = _formatTransmitEstimate(txEstimate); final txEstimateLabel = _formatTransmitEstimate(txEstimate);
final eta = voiceProvider.estimateRemainingTransferTime(voiceId); final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
Future<void> handlePrimaryTap() async {
if (isPlaying) {
await voiceProvider.stop();
return;
}
if (_isRequesting) {
_cancelReceive(voiceId);
return;
}
if (isComplete) {
await voiceProvider.play(voiceId);
return;
}
if (isReceivingData) {
return;
}
await _requestAndPlayVoice(
voiceId,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
);
}
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
InkWell( InkWell(
onTap: () async { onTap: handlePrimaryTap,
if (isPlaying) {
await voiceProvider.stop();
return;
}
if (_isRequesting) {
_cancelReceive(voiceId);
return;
}
if (isComplete) {
await voiceProvider.play(voiceId);
return;
}
await _requestAndPlayVoice(
voiceId,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
);
},
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: Container( child: Container(
width: 48, width: 48,
@@ -156,11 +177,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
child: Icon( child: Icon(
isPlaying isPlaying
? Icons.stop ? Icons.stop
: (_isRequesting ? Icons.close : Icons.play_arrow), : (_isRequesting
? Icons.close
: (isReceivingData
? Icons.downloading_rounded
: Icons.play_arrow)),
size: 28, size: 28,
color: widget.isSentByMe color: widget.isSentByMe
? Theme.of(context).colorScheme.onPrimaryContainer ? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer, : Theme.of(context).colorScheme.onSecondaryContainer
.withValues(alpha: isReceivingData ? 0.6 : 1.0),
), ),
), ),
), ),
@@ -169,14 +195,22 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isPlaying || _isRequesting) if (isPlaying)
SizedBox( SizedBox(
width: 100, width: 100,
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: isPlaying ? playbackProgress : requestProgress, value: playbackProgress,
backgroundColor: Colors.grey.withValues(alpha: 0.3), backgroundColor: Colors.grey.withValues(alpha: 0.3),
), ),
) )
else if ((_isRequesting || isReceivingData) && total > 0)
_PacketBlockProgress(
presence: packetPresence,
activeColor: widget.isSentByMe
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary,
highlightMissing: _isPartialRequest,
)
else else
_WaveformBar(isComplete: isComplete, bars: waveformBars), _WaveformBar(isComplete: isComplete, bars: waveformBars),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -189,11 +223,15 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
total: total, total: total,
isComplete: isComplete, isComplete: isComplete,
isRequesting: _isRequesting, isRequesting: _isRequesting,
isReceivingData: isReceivingData,
isPartialRequest: _isPartialRequest,
errorText: _errorText, errorText: _errorText,
requestingLabel: AppLocalizations.of( requestingLabel: AppLocalizations.of(
context, context,
)!.requestingVoice, )!.requestingVoice,
eta: eta, eta: eta,
isSentByMe: widget.isSentByMe,
transferCount: transferCount,
), ),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
@@ -221,6 +259,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (_isRequesting) return; if (_isRequesting) return;
setState(() { setState(() {
_isRequesting = true; _isRequesting = true;
_isPartialRequest = false;
_autoPlayWhenReady = true; _autoPlayWhenReady = true;
_errorText = null; _errorText = null;
}); });
@@ -236,7 +275,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey, recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName, senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops, maxFetchHops: _maxFetchHops,
); );
@@ -287,7 +325,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey, recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName, senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops, maxFetchHops: _maxFetchHops,
); );
@@ -329,9 +366,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
} }
} }
if (sender.outPathLen >= 2) { if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) {
_showToast( _showToast(
'Voice fetch over ${sender.outPathLen} hops may take a while.', 'Voice fetch over ${sender.routeHopCount} hops may take a while.',
); );
} }
@@ -357,29 +403,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
); );
final isPartialResume = final isPartialResume =
missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets; missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets;
if (_isPartialRequest != isPartialResume && mounted) {
setState(() {
_isPartialRequest = isPartialResume;
});
}
final request = isPartialResume final request = isPartialResume
? VoiceFetchRequest( ? VoiceFetchRequest(
sessionId: sessionId, sessionId: sessionId,
want: 'missing', want: 'missing',
missingIndices: missing, missingIndices: missing,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 2,
) )
: VoiceFetchRequest( : VoiceFetchRequest(
sessionId: sessionId, sessionId: sessionId,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 2,
); );
try { try {
debugPrint( debugPrint(
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}', '🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
); );
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath, contactPath: sender.outPath,
contactPathLen: sender.outPathLen, contactPathLen: sender.routeSignedPathLen,
payload: request.encodeBinary(), payload: request.encodeBinary(),
); );
} catch (_) { } catch (_) {
@@ -388,8 +435,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
} }
// Timeout = 2× estimated LoRa airtime (min 30s). // Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 final effectivePathLen = sender.routeHasPath
? sender.outPathLen ? sender.routeHopCount
: pathLen; : pathLen;
final estimatedDurationMs = final estimatedDurationMs =
envelope != null && envelope != null &&
@@ -433,6 +480,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
_showToast(AppLocalizations.of(context)!.voiceUnavailable); _showToast(AppLocalizations.of(context)!.voiceUnavailable);
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false; _autoPlayWhenReady = false;
_errorText = AppLocalizations.of(context)!.voiceUnavailable; _errorText = AppLocalizations.of(context)!.voiceUnavailable;
}); });
@@ -442,6 +490,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false; _autoPlayWhenReady = false;
}); });
} }
@@ -453,6 +502,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
_showToast('Voice receive canceled'); _showToast('Voice receive canceled');
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false; _autoPlayWhenReady = false;
_errorText = 'Voice receive canceled'; _errorText = 'Voice receive canceled';
}); });
@@ -497,19 +547,33 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
required int total, required int total,
required bool isComplete, required bool isComplete,
required bool isRequesting, required bool isRequesting,
required bool isReceivingData,
required bool isPartialRequest,
required String? errorText, required String? errorText,
required String requestingLabel, required String requestingLabel,
required Duration? eta, required Duration? eta,
required bool isSentByMe,
required int transferCount,
}) { }) {
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 · ${_formatEta(eta)} · $txEstimateLabel'; final actionLabel = isPartialRequest
? 'Fetching missing voice fragments'
: requestingLabel;
return '$actionLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
}
if (isReceivingData) {
return 'Receiving voice$progress · ${_formatEta(eta)} · $txEstimateLabel';
} }
if (!isComplete && total > 0) { if (!isComplete && total > 0) {
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel'; return isSentByMe
? '🎙️ $durationLabel · $modeLabel$progress · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
} }
return '🎙️ $durationLabel · $modeLabel · $txEstimateLabel'; return isSentByMe
? '🎙️ $durationLabel · $modeLabel · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
} }
List<double> _resolveWaveformBars({ List<double> _resolveWaveformBars({
@@ -592,6 +656,86 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final seconds = eta.inSeconds % 60; final seconds = eta.inSeconds % 60;
return 'ETA ~${minutes}m ${seconds}s'; return 'ETA ~${minutes}m ${seconds}s';
} }
static String _formatTransferCount(int transferCount) {
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
}
bool _hasRecentInboundActivity({
required DateTime? lastReceivedAt,
required int received,
required int total,
}) {
if (lastReceivedAt == null || received <= 0 || received >= total) {
return false;
}
return DateTime.now().difference(lastReceivedAt) <=
_recentInboundActivityWindow;
}
}
class _PacketBlockProgress extends StatelessWidget {
final List<bool> presence;
final Color activeColor;
final bool highlightMissing;
const _PacketBlockProgress({
required this.presence,
required this.activeColor,
this.highlightMissing = false,
});
@override
Widget build(BuildContext context) {
if (presence.isEmpty) {
return const SizedBox(width: 100, height: 16);
}
final bucketCount = presence.length <= 20 ? presence.length : 20;
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
final start = (bucketIndex * presence.length) ~/ bucketCount;
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
final safeEnd = end <= start ? start + 1 : end;
final slice = presence.sublist(start, safeEnd);
final received = slice.where((value) => value).length;
return slice.isEmpty ? 0.0 : received / slice.length;
});
final missingColor = highlightMissing
? Colors.amberAccent
: Colors.white.withValues(alpha: 0.14);
return SizedBox(
width: 100,
height: 16,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final fill in bucketFill)
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: fill > 0
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
: missingColor.withValues(
alpha: highlightMissing ? 0.45 : 0.14,
),
borderRadius: BorderRadius.circular(2),
border: Border.all(
color: fill > 0
? Colors.white.withValues(alpha: 0.18)
: missingColor.withValues(
alpha: highlightMissing ? 0.7 : 0.18,
),
width: 0.5,
),
),
),
),
],
),
);
}
} }
/// Voice waveform rendered as a row of bars. /// Voice waveform rendered as a row of bars.

View File

@@ -0,0 +1,110 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
void main() {
Contact buildContact({
required int signedPathLen,
required Uint8List outPath,
}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (index) => index)),
type: ContactType.chat,
flags: 0,
outPathLen: signedPathLen,
outPath: outPath,
advName: 'Route Contact',
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
group('ContactRouteCodec.parse', () {
test('parses 1-byte hop routes', () {
final route = ContactRouteCodec.parse('AA,BB,CC');
expect(route.hashSize, 1);
expect(route.hopCount, 3);
expect(route.encodedPathLen, 0x03);
expect(route.canonicalText, 'AA,BB,CC');
expect(route.pathBytes, [0xAA, 0xBB, 0xCC]);
});
test('parses 2-byte hop routes', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
expect(route.hashSize, 2);
expect(route.hopCount, 2);
expect(route.encodedPathLen, 0x42);
expect(route.canonicalText, 'AABB,CCDD');
expect(route.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
});
test('parses 3-byte hop routes', () {
final route = ContactRouteCodec.parse('AABBCC,DDEEFF');
expect(route.hashSize, 3);
expect(route.hopCount, 2);
expect(route.encodedPathLen, 0x82);
expect(route.signedEncodedPathLen, -126);
expect(route.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
});
test('accepts colon-separated hops and normalizes output', () {
final route = ContactRouteCodec.parse('AA:BB,CC:DD');
expect(route.hashSize, 2);
expect(route.canonicalText, 'AABB,CCDD');
});
test('rejects mixed hop widths', () {
expect(
() => ContactRouteCodec.parse('AA,AABB'),
throwsA(isA<ContactRouteFormatException>()),
);
});
test('rejects invalid tokens', () {
expect(
() => ContactRouteCodec.parse('AA,XYZ'),
throwsA(isA<ContactRouteFormatException>()),
);
expect(
() => ContactRouteCodec.parse('AAA'),
throwsA(isA<ContactRouteFormatException>()),
);
});
test('rejects routes over 64 bytes', () {
final tooLong = List.filled(22, 'AABBCC').join(',');
expect(
() => ContactRouteCodec.parse(tooLong),
throwsA(isA<ContactRouteFormatException>()),
);
});
});
group('Contact route helpers', () {
test('interprets signed 3-byte descriptors as valid routes', () {
final outPath = Uint8List(64)
..setRange(0, 6, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
final contact = buildContact(signedPathLen: -126, outPath: outPath);
expect(contact.routeHasPath, isTrue);
expect(contact.routeHashSize, 3);
expect(contact.routeHopCount, 2);
expect(contact.routeCanonicalText, 'AABBCC,DDEEFF');
expect(contact.routeSupportsLegacyRawTransport, isFalse);
});
test('treats -1 as unknown route', () {
final contact = buildContact(signedPathLen: -1, outPath: Uint8List(0));
expect(contact.routeHasPath, isFalse);
expect(contact.routeSummary, 'Flood/Unknown');
});
});
}

View File

@@ -233,4 +233,49 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
}); });
}); });
group('ContactsProvider route updates', () {
late ContactsProvider provider;
late Uint8List publicKey;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
publicKey = createPublicKey(64);
provider.addOrUpdateContact(
createContact(key: publicKey, type: ContactType.chat, name: 'Routey'),
);
});
test('optimistically stores a multi-byte route locally', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeHashSize, 2);
expect(updated.routeHopCount, 2);
expect(updated.routeCanonicalText, 'AABB,CCDD');
});
test('resetContactRouteLocal clears route state', () {
final route = ContactRouteCodec.parse('AA,BB,CC');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
provider.resetContactRouteLocal(publicKey);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isFalse);
expect(updated.routeSummary, 'Flood/Unknown');
});
});
} }

View File

@@ -1,4 +1,7 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/helpers/session_metadata_restore.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/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
@@ -13,8 +16,6 @@ void main() {
mode: VoicePacketMode.mode1200, mode: VoicePacketMode.mode1200,
total: 4, total: 4,
durationMs: 4000, durationMs: 4000,
senderKey6: 'AABBCCDDEEFF',
timestampSec: 123456,
); );
final imageEnvelope = ImageEnvelope( final imageEnvelope = ImageEnvelope(
sessionId: '195cb2fb', sessionId: '195cb2fb',
@@ -23,24 +24,62 @@ void main() {
width: 118, width: 118,
height: 256, height: 256,
sizeBytes: 1069, sizeBytes: 1069,
senderKey6: 'FE8B30EE05FC',
timestampSec: 123457,
); );
final restored = restoreSessionMetadataFromMessages([ final restored = restoreSessionMetadataFromMessages([
'plain text', Message(
voiceEnvelope.encodeText(), id: 'plain',
imageEnvelope.encode(), messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1,
text: 'plain text',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sent,
),
Message(
id: 'voice',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 2,
text: voiceEnvelope.encodeText(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList(
[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff],
),
deliveryStatus: MessageDeliveryStatus.sent,
),
Message(
id: 'image',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 3,
text: imageEnvelope.encode(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList(
[0xfe, 0x8b, 0x30, 0xee, 0x05, 0xfc],
),
deliveryStatus: MessageDeliveryStatus.sent,
),
]); ]);
expect( expect(
restored.voiceSenderKeyBySession, restored.voiceSenderKeyBySession,
equals({'00112233': 'aabbccddeeff'}), equals({'00112233': 'aabbccddeeff'}),
); );
expect(
restored.imageSenderKeyBySession,
equals({'195cb2fb': 'fe8b30ee05fc'}),
);
expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'})); expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'}));
expect( expect(
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6, restored.imageEnvelopeBySession['195cb2fb']?.sessionId,
equals('fe8b30ee05fc'), equals('195cb2fb'),
); );
}, },
); );
@@ -53,8 +92,6 @@ void main() {
width: 100, width: 100,
height: 100, height: 100,
sizeBytes: 900, sizeBytes: 900,
senderKey6: '001122334455',
timestampSec: 100,
); );
final second = ImageEnvelope( final second = ImageEnvelope(
sessionId: '195cb2fb', sessionId: '195cb2fb',
@@ -63,18 +100,40 @@ void main() {
width: 118, width: 118,
height: 256, height: 256,
sizeBytes: 1069, sizeBytes: 1069,
senderKey6: 'AABBCCDDEEFF',
timestampSec: 101,
); );
final restored = restoreSessionMetadataFromMessages([ final restored = restoreSessionMetadataFromMessages([
first.encode(), Message(
second.encode(), id: 'first',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1,
text: first.encode(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
deliveryStatus: MessageDeliveryStatus.sent,
),
Message(
id: 'second',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 2,
text: second.encode(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList(
[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff],
),
deliveryStatus: MessageDeliveryStatus.sent,
),
]); ]);
expect(restored.imageEnvelopeBySession.length, equals(1)); expect(restored.imageEnvelopeBySession.length, equals(1));
expect( expect(
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6, restored.imageSenderKeyBySession['195cb2fb'],
equals('aabbccddeeff'), equals('aabbccddeeff'),
); );
expect( expect(

View File

@@ -20,8 +20,6 @@ void main() {
width: 32, width: 32,
height: 32, height: 32,
sizeBytes: 4, sizeBytes: 4,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
); );
final fragment = ImagePacket( final fragment = ImagePacket(
sessionId: sessionId, sessionId: sessionId,

View File

@@ -0,0 +1,90 @@
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/image_provider.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:shared_preferences/shared_preferences.dart';
Contact _buildRequester() {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: 1,
outPath: Uint8List.fromList([1, 2, 3, 4]),
advName: 'Requester',
lastAdvert: 1700000000,
advLat: 0,
advLon: 0,
lastMod: 1700000000,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ImageProvider swarm serving', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('serves requested fragments from received session cache', () async {
final provider = ImageProvider();
provider.registerEnvelope(
const ImageEnvelope(
sessionId: 'deadbeef',
format: ImageFormat.avif,
total: 3,
width: 64,
height: 64,
sizeBytes: 300,
),
);
provider.addFragment(
ImagePacket(
sessionId: 'deadbeef',
format: ImageFormat.avif,
index: 0,
total: 3,
data: Uint8List.fromList([1, 2]),
),
width: 64,
height: 64,
);
provider.addFragment(
ImagePacket(
sessionId: 'deadbeef',
format: ImageFormat.avif,
index: 2,
total: 3,
data: Uint8List.fromList([7, 8]),
),
width: 64,
height: 64,
);
final sent = <Uint8List>[];
provider.sendRawPacketCallback =
({
required contactPath,
required contactPathLen,
required payload,
}) async {
sent.add(payload);
};
final ok = await provider.serveSessionTo(
sessionId: 'deadbeef',
requester: _buildRequester(),
requestedIndices: {2},
);
expect(ok, isTrue);
expect(provider.availableFragmentIndices('deadbeef'), [0, 2]);
expect(sent, hasLength(1));
expect(ImagePacket.tryParseBinary(sent.single)?.index, 2);
});
});
}

View File

@@ -64,22 +64,54 @@ void main() {
expect(provider.messages.single.roundTripTimeMs, 180); expect(provider.messages.single.roundTripTimeMs, 180);
}); });
test('direct messages stay sent after device accept until confirm arrives', () { test(
'direct messages stay sent after device accept until confirm arrives',
() {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m1b'),
contact: _buildContact(),
);
provider.markMessageSent('m1b', 78, 250);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent,
);
expect(provider.messages.single.expectedAckTag, 78);
expect(provider.messages.single.roundTripTimeMs, isNull);
expect(provider.messages.single.deliveredAt, isNull);
},
);
test('fallback sent state can later upgrade to ACK-tracked delivery', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.addSentMessage( provider.addSentMessage(
_buildDirectMessage('m1b'), _buildDirectMessage('m1c'),
contact: _buildContact(), contact: _buildContact(),
); );
provider.markMessageSent('m1b', 78, 250); provider.markMessageSent('m1c', 0, 0);
expect( expect(
provider.messages.single.deliveryStatus, provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent, MessageDeliveryStatus.sent,
); );
expect(provider.messages.single.expectedAckTag, 78); expect(provider.messages.single.expectedAckTag, isNull);
expect(provider.messages.single.roundTripTimeMs, isNull);
expect(provider.messages.single.deliveredAt, isNull); provider.markMessageSent('m1c', 79, 250);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent,
);
expect(provider.messages.single.expectedAckTag, 79);
provider.markMessageDelivered(79, 190);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.delivered,
);
expect(provider.messages.single.roundTripTimeMs, 190);
}); });
test('channel messages are marked sent immediately', () { test('channel messages are marked sent immediately', () {
@@ -180,6 +212,31 @@ void main() {
expect(provider.messages.single.roundTripTimeMs, 220); expect(provider.messages.single.roundTripTimeMs, 220);
}); });
test('manual retry reuses the same message record', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildDirectMessage('m4b'),
contact: _buildContact(),
);
provider.markMessageSent('m4b', 113, 10);
provider.markMessageDelivered(113, 220);
final prepared = provider.prepareMessageForRetry('m4b');
expect(prepared, isTrue);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, 'm4b');
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending,
);
expect(provider.messages.single.expectedAckTag, isNull);
expect(provider.messages.single.roundTripTimeMs, isNull);
expect(provider.messages.single.deliveredAt, isNull);
expect(provider.messages.single.retryAttempt, 0);
});
test('repeated max-retry failures request path reset', () async { test('repeated max-retry failures request path reset', () async {
final provider = MessagesProvider(); final provider = MessagesProvider();
final contact = _buildContact(); final contact = _buildContact();
@@ -190,19 +247,17 @@ void main() {
}; };
provider.addSentMessage( provider.addSentMessage(
_buildDirectMessage('m5').copyWith( _buildDirectMessage(
retryAttempt: 3, 'm5',
usedFloodFallback: true, ).copyWith(retryAttempt: 3, usedFloodFallback: true),
),
contact: contact, contact: contact,
); );
provider.markMessageFailed('m5'); provider.markMessageFailed('m5');
provider.addSentMessage( provider.addSentMessage(
_buildDirectMessage('m6').copyWith( _buildDirectMessage(
retryAttempt: 3, 'm6',
usedFloodFallback: true, ).copyWith(retryAttempt: 3, usedFloodFallback: true),
),
contact: contact, contact: contact,
); );
provider.markMessageFailed('m6'); provider.markMessageFailed('m6');
@@ -222,26 +277,21 @@ void main() {
}; };
provider.addSentMessage( provider.addSentMessage(
_buildDirectMessage('m7').copyWith( _buildDirectMessage(
retryAttempt: 3, 'm7',
usedFloodFallback: true, ).copyWith(retryAttempt: 3, usedFloodFallback: true),
),
contact: contact, contact: contact,
); );
provider.markMessageFailed('m7'); provider.markMessageFailed('m7');
provider.addSentMessage( provider.addSentMessage(_buildDirectMessage('m8'), contact: contact);
_buildDirectMessage('m8'),
contact: contact,
);
provider.markMessageSent('m8', 123, 10); provider.markMessageSent('m8', 123, 10);
provider.markMessageDelivered(123, 150); provider.markMessageDelivered(123, 150);
provider.addSentMessage( provider.addSentMessage(
_buildDirectMessage('m9').copyWith( _buildDirectMessage(
retryAttempt: 3, 'm9',
usedFloodFallback: true, ).copyWith(retryAttempt: 3, usedFloodFallback: true),
),
contact: contact, contact: contact,
); );
provider.markMessageFailed('m9'); provider.markMessageFailed('m9');

View File

@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart'; import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -15,15 +16,13 @@ void main() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
test('marks VE2 envelope messages as voice', () { test('marks VE3 envelope messages as voice', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final envelope = VoiceEnvelope( final envelope = VoiceEnvelope(
sessionId: 'deafbead', sessionId: 'deafbead',
mode: VoicePacketMode.mode1200, mode: VoicePacketMode.mode1200,
total: 3, total: 3,
durationMs: 2400, durationMs: 2400,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
); );
final message = Message( final message = Message(
@@ -45,7 +44,7 @@ void main() {
expect(stored.voiceId, equals('deafbead')); expect(stored.voiceId, equals('deafbead'));
}); });
test('marks legacy V text packets as voice', () { test('does not mark legacy V text packets as voice', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final packet = VoicePacket( final packet = VoicePacket(
sessionId: '00112233', sessionId: '00112233',
@@ -69,8 +68,8 @@ void main() {
provider.addMessage(message); provider.addMessage(message);
final stored = provider.messages.single; final stored = provider.messages.single;
expect(stored.isVoice, isTrue); expect(stored.isVoice, isFalse);
expect(stored.voiceId, equals('00112233')); expect(stored.voiceId, isNull);
}); });
test('persists received contact location snapshots', () async { test('persists received contact location snapshots', () async {
@@ -106,5 +105,90 @@ void main() {
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001)); expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
}); });
test('tracks and persists media transfer counts and downloaders', () async {
final provider = MessagesProvider();
final voiceEnvelope = VoiceEnvelope(
sessionId: 'deafbead',
mode: VoicePacketMode.mode1200,
total: 3,
durationMs: 2400,
);
const imageEnvelope = ImageEnvelope(
sessionId: '01020304',
format: ImageFormat.avif,
total: 2,
width: 64,
height: 64,
sizeBytes: 2048,
);
provider.addMessage(
Message(
id: 'voice1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000010,
text: voiceEnvelope.encodeText(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
),
);
provider.addMessage(
Message(
id: 'image1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000011,
text: imageEnvelope.encode(),
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
),
);
provider.recordMediaTransfer(
sessionId: 'deafbead',
mediaType: 'voice',
requesterKey6: '112233445566',
requesterName: 'Alice',
);
provider.recordMediaTransfer(
sessionId: 'deafbead',
mediaType: 'voice',
requesterKey6: '112233445566',
requesterName: 'Alice',
);
provider.recordMediaTransfer(
sessionId: '01020304',
mediaType: 'image',
requesterKey6: 'a1b2c3d4e5f6',
requesterName: 'Bob',
);
await Future<void>.delayed(const Duration(milliseconds: 50));
final voiceDetails = provider.getMessageTransferDetails('voice1');
final imageDetails = provider.getMessageTransferDetails('image1');
expect(voiceDetails, isNotNull);
expect(voiceDetails!.totalTransfers, equals(2));
expect(voiceDetails.downloaders.single.requesterName, equals('Alice'));
expect(voiceDetails.downloaders.single.transferCount, equals(2));
expect(provider.transferCountForSession(voiceSessionId: 'deafbead'), 2);
expect(imageDetails?.totalTransfers, equals(1));
expect(provider.transferCountForSession(imageSessionId: '01020304'), 1);
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
final restoredVoice = restoredProvider.getMessageTransferDetails(
'voice1',
);
final restoredImage = restoredProvider.getMessageTransferDetails(
'image1',
);
expect(restoredVoice?.totalTransfers, equals(2));
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
expect(restoredImage?.downloaders.single.requesterName, equals('Bob'));
});
}); });
} }

View File

@@ -3,7 +3,7 @@ import 'package:meshcore_sar_app/utils/image_message_parser.dart';
void main() { void main() {
group('ImageEnvelope', () { group('ImageEnvelope', () {
test('encodes and parses IE2 with compressed session id', () { test('encodes and parses IE4 with compressed session id', () {
final env = ImageEnvelope( final env = ImageEnvelope(
sessionId: '0000000a', sessionId: '0000000a',
format: ImageFormat.avif, format: ImageFormat.avif,
@@ -11,12 +11,10 @@ void main() {
width: 256, width: 256,
height: 171, height: 171,
sizeBytes: 2100, sizeBytes: 2100,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
); );
final text = env.encode(); final text = env.encode();
expect(text.startsWith('IE2:'), isTrue); expect(text.startsWith('IE4:'), isTrue);
expect(text.split(':')[1], equals('a')); expect(text.split(':')[1], equals('a'));
final parsed = ImageEnvelope.tryParse(text); final parsed = ImageEnvelope.tryParse(text);
@@ -27,26 +25,25 @@ void main() {
expect(parsed.width, equals(256)); expect(parsed.width, equals(256));
expect(parsed.height, equals(171)); expect(parsed.height, equals(171));
expect(parsed.sizeBytes, equals(2100)); expect(parsed.sizeBytes, equals(2100));
expect(parsed.senderKey6, equals('aabbccddeeff')); expect(parsed.version, equals(4));
expect(parsed.version, equals(2));
}); });
test('rejects IE1 legacy prefix', () { test('rejects IE1 legacy prefix', () {
const legacy = 'IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1'; const legacy = 'IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1';
expect(ImageEnvelope.tryParse(legacy), isNull); expect(ImageEnvelope.tryParse(legacy), isNull);
}); });
}); });
group('ImageFetchRequest', () { group('ImageFetchRequest', () {
test('encodes and parses IR2 with compressed sid', () { test('encodes and parses IR4 with compressed sid', () {
final req = ImageFetchRequest( final req = ImageFetchRequest(
sessionId: '0000000a', sessionId: '0000000a',
requesterKey6: 'ffeeddccbbaa', requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
); );
final text = req.encode(); final text = req.encode();
expect(text.startsWith('IR2:'), isTrue); expect(text.startsWith('IR4:'), isTrue);
expect(text.split(':')[1], equals('a')); expect(text.split(':')[1], equals('a'));
final parsed = ImageFetchRequest.tryParse(text); final parsed = ImageFetchRequest.tryParse(text);
@@ -54,7 +51,7 @@ void main() {
expect(parsed!.sessionId, equals('0000000a')); 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(2)); expect(parsed.version, equals(4));
}); });
test('encodes and parses compact missing index ranges', () { test('encodes and parses compact missing index ranges', () {
@@ -63,7 +60,6 @@ void main() {
want: 'missing', want: 'missing',
missingIndices: const [0, 1, 2, 5, 6, 8], missingIndices: const [0, 1, 2, 5, 6, 8],
requesterKey6: 'ffeeddccbbaa', requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
); );
final text = req.encode(); final text = req.encode();
@@ -86,7 +82,6 @@ void main() {
want: 'missing', want: 'missing',
missingIndices: const [0, 2, 5], missingIndices: const [0, 2, 5],
requesterKey6: 'ffeeddccbbaa', requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
); );
final payload = req.encodeBinary(); final payload = req.encodeBinary();
@@ -98,7 +93,7 @@ void main() {
expect(parsed.want, equals('missing')); expect(parsed.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 2, 5])); expect(parsed.missingIndices, equals([0, 2, 5]));
expect(parsed.requesterKey6, equals('ffeeddccbbaa')); expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(2)); expect(parsed.version, equals(4));
}); });
}); });

View File

@@ -0,0 +1,72 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/media_swarm_protocol.dart';
void main() {
group('MediaSwarmProtocol', () {
test('encodes and decodes binary missing-fragment requests', () {
const request = MediaSwarmRequest(
mediaType: 'image',
sessionId: 'deadbeef',
requesterKey6: 'aabbccddeeff',
missingIndices: [9, 2, 9, 0],
);
final decoded = MediaSwarmRequest.tryParseBinary(request.encodeBinary());
expect(decoded, isNotNull);
expect(decoded!.mediaType, 'image');
expect(decoded.sessionId, 'deadbeef');
expect(decoded.requesterKey6, 'aabbccddeeff');
expect(decoded.missingIndices, [0, 2, 9]);
expect(decoded.requestsAll, isFalse);
});
test('encodes and decodes binary availability advertisements', () {
const availability = MediaSwarmAvailability(
mediaType: 'voice',
sessionId: '01020304',
requesterKey6: 'aabbccddeeff',
responderKey6: '112233445566',
availableIndices: [7, 1],
);
final decoded = MediaSwarmAvailability.tryParseBinary(
availability.encodeBinary(),
);
expect(decoded, isNotNull);
expect(decoded!.mediaType, 'voice');
expect(decoded.sessionId, '01020304');
expect(decoded.requesterKey6, 'aabbccddeeff');
expect(decoded.responderKey6, '112233445566');
expect(decoded.availableIndices, [1, 7]);
expect(decoded.servesAll, isFalse);
});
test('uses zero-count semantics when no indices are provided', () {
const request = MediaSwarmRequest(
mediaType: 'voice',
sessionId: '01020304',
requesterKey6: 'aabbccddeeff',
);
const availability = MediaSwarmAvailability(
mediaType: 'image',
sessionId: 'deadbeef',
requesterKey6: 'aabbccddeeff',
responderKey6: '112233445566',
availableIndices: [],
);
expect(
MediaSwarmRequest.tryParseBinary(request.encodeBinary())?.requestsAll,
isTrue,
);
expect(
MediaSwarmAvailability.tryParseBinary(
availability.encodeBinary(),
)?.servesAll,
isTrue,
);
});
});
}

View File

@@ -10,13 +10,11 @@ void main() {
mode: VoicePacketMode.mode1200, mode: VoicePacketMode.mode1200,
total: 4, total: 4,
durationMs: 3000, durationMs: 3000,
senderKey6: 'aabbccddeeff',
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.startsWith('VE3:'), isTrue);
expect(text.split(':')[1], equals('a')); expect(text.split(':')[1], equals('a'));
final parsed = VoiceEnvelope.tryParseText(text); final parsed = VoiceEnvelope.tryParseText(text);
@@ -25,12 +23,11 @@ void main() {
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(3000)); expect(parsed.durationMs, equals(3000));
expect(parsed.senderKey6, equals('aabbccddeeff')); expect(parsed.version, equals(3));
expect(parsed.version, equals(2));
}); });
test('rejects invalid envelope payload', () { test('rejects invalid envelope payload', () {
final text = 'VE2:bad_sid:1:2:1000:aabbccddeeff:s44we8'; final text = 'VE3:bad_sid:1:2:1000';
expect(VoiceEnvelope.tryParseText(text), isNull); expect(VoiceEnvelope.tryParseText(text), isNull);
}); });
@@ -45,11 +42,10 @@ void main() {
final req = VoiceFetchRequest( final req = VoiceFetchRequest(
sessionId: '0000000a', sessionId: '0000000a',
requesterKey6: 'ffeeddccbbaa', requesterKey6: 'ffeeddccbbaa',
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.startsWith('VR3:'), isTrue);
expect(text.split(':')[1], equals('a')); expect(text.split(':')[1], equals('a'));
final parsed = VoiceFetchRequest.tryParseText(text); final parsed = VoiceFetchRequest.tryParseText(text);
@@ -57,13 +53,13 @@ void main() {
expect(parsed!.sessionId, equals('0000000a')); 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(2)); expect(parsed.version, equals(3));
}); });
test('rejects invalid request payload', () { test('rejects invalid request payload', () {
expect( expect(
VoiceFetchRequest.tryParseText( VoiceFetchRequest.tryParseText(
'VR2:a:chunk:ffeeddccbbaa:s44we9', 'VR3:a:chunk:ffeeddccbbaa',
), ),
isNull, isNull,
); );
@@ -80,7 +76,6 @@ void main() {
want: 'missing', want: 'missing',
missingIndices: const [0, 1, 2, 3, 7], missingIndices: const [0, 1, 2, 3, 7],
requesterKey6: 'ffeeddccbbaa', requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
); );
final text = req.encodeText(); final text = req.encodeText();
expect(text, contains(':m0-3.7:')); expect(text, contains(':m0-3.7:'));
@@ -97,7 +92,6 @@ void main() {
want: 'missing', want: 'missing',
missingIndices: const [1, 4], missingIndices: const [1, 4],
requesterKey6: 'ffeeddccbbaa', requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
); );
final payload = req.encodeBinary(); final payload = req.encodeBinary();
@@ -109,7 +103,7 @@ void main() {
expect(parsed.want, equals('missing')); expect(parsed.want, equals('missing'));
expect(parsed.missingIndices, equals([1, 4])); expect(parsed.missingIndices, equals([1, 4]));
expect(parsed.requesterKey6, equals('ffeeddccbbaa')); expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(2)); expect(parsed.version, equals(3));
}); });
}); });
@@ -125,23 +119,7 @@ void main() {
}); });
}); });
group('VoicePacket backward compatibility', () { group('VoicePacket binary format', () {
test('parses legacy V: text format', () {
final pkt = VoicePacket(
sessionId: 'a1b2c3d4',
mode: VoicePacketMode.mode700c,
index: 0,
total: 1,
codec2Data: Uint8List.fromList([1, 2, 3, 4]),
);
final encoded = pkt.encodeText();
final parsed = VoicePacket.tryParseText(encoded);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('a1b2c3d4'));
expect(parsed.total, equals(1));
expect(parsed.codec2Data, equals(Uint8List.fromList([1, 2, 3, 4])));
});
test('constructs binary datagram from actual packet data', () { test('constructs binary datagram from actual packet data', () {
final actualCodec2 = Uint8List.fromList([ final actualCodec2 = Uint8List.fromList([
0xD3, 0xD3,
@@ -166,17 +144,15 @@ void main() {
final datagram = pkt.encodeBinary(); final datagram = pkt.encodeBinary();
expect(datagram[0], equals(0x56)); // magic 'V' expect(datagram[0], equals(0x56)); // magic 'V'
expect(datagram.sublist(1, 5), equals(Uint8List.fromList([1, 2, 3, 4]))); expect(datagram.sublist(1, 5), equals(Uint8List.fromList([1, 2, 3, 4])));
expect(datagram[5], equals(VoicePacketMode.mode1300.id)); expect(datagram[5], equals(2));
expect(datagram[6], equals(2)); expect(datagram.sublist(6), equals(actualCodec2));
expect(datagram[7], equals(5));
expect(datagram.sublist(8), equals(actualCodec2));
final parsed = VoicePacket.tryParseBinary(datagram); final parsed = VoicePacket.tryParseBinary(datagram);
expect(parsed, isNotNull); expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304')); expect(parsed!.sessionId, equals('01020304'));
expect(parsed.mode, equals(VoicePacketMode.mode1300)); expect(parsed.mode, equals(VoicePacketMode.mode1300));
expect(parsed.index, equals(2)); expect(parsed.index, equals(2));
expect(parsed.total, equals(5)); expect(parsed.total, equals(0));
expect(parsed.codec2Data, equals(actualCodec2)); expect(parsed.codec2Data, equals(actualCodec2));
}); });
}); });