Merge remote-tracking branch 'origin/main'

This commit is contained in:
MGJ
2026-03-07 22:18:06 +08:00
75 changed files with 8390 additions and 3252 deletions

View File

@@ -23,6 +23,7 @@
<!-- Microphone for voice messages --> <!-- Microphone for voice messages -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Notifications --> <!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

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

@@ -171,7 +171,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/vibration/ios" :path: ".symlinks/plugins/vibration/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c

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 = 94; CURRENT_PROJECT_VERSION = 98;
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 = 94; CURRENT_PROJECT_VERSION = 98;
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 = 94; CURRENT_PROJECT_VERSION = 98;
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 = 94; CURRENT_PROJECT_VERSION = 98;
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 = 94; CURRENT_PROJECT_VERSION = 98;
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 = 94; CURRENT_PROJECT_VERSION = 98;
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>94</string> <string>98</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.000555"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.42343"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.489082">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="91.987634"> <testcase classname="fastlane.lanes" name="2: build_app" time="115.233655">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="184.536223"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="296.0895">
</testcase> </testcase>

View File

@@ -14,6 +14,7 @@ import 'providers/channels_provider.dart';
import 'providers/voice_provider.dart'; import 'providers/voice_provider.dart';
import 'providers/image_provider.dart' as ip; import 'providers/image_provider.dart' as ip;
import 'providers/app_provider.dart'; import 'providers/app_provider.dart';
import 'providers/sensors_provider.dart';
import 'services/voice_codec_service.dart'; import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart'; import 'services/voice_player_service.dart';
import 'services/tile_cache_service.dart'; import 'services/tile_cache_service.dart';
@@ -234,6 +235,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
}, },
), ),
ChangeNotifierProvider(create: (_) => ChannelsProvider()), ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
// Voice provider (packet reassembly + playback) // Voice provider (packet reassembly + playback)
ChangeNotifierProvider( ChangeNotifierProvider(

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,86 @@
const int _transmitEstimateToleranceMs = 1500;
int? sanitizeEstimatedTransmitMs({
required int? estimatedTransmitMs,
required int? senderToReceiptMs,
}) {
if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) {
return null;
}
if (senderToReceiptMs == null || senderToReceiptMs <= 0) {
return estimatedTransmitMs;
}
// Sender timestamps are second-granularity, so allow a small cushion before
// treating the estimate as impossible for the observed delivery time.
if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) {
return null;
}
return estimatedTransmitMs;
}
class MessageReceptionDetails {
final DateTime capturedAt;
final DateTime? packetLoggedAt;
final int? rssiDbm;
final double? snrDb;
final List<int>? pathBytes;
final int? senderToReceiptMs;
final int? estimatedTransmitMs;
final int? postTransmitDelayMs;
const MessageReceptionDetails({
required this.capturedAt,
this.packetLoggedAt,
this.rssiDbm,
this.snrDb,
this.pathBytes,
this.senderToReceiptMs,
this.estimatedTransmitMs,
this.postTransmitDelayMs,
});
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
? null
: pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
Map<String, dynamic> toJson() {
return {
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch,
'rssiDbm': rssiDbm,
'snrDb': snrDb,
'pathBytes': pathBytes,
'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs,
};
}
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
final capturedAtMillis = json['capturedAtMillis'];
if (capturedAtMillis is! int) {
return null;
}
final pathBytes = json['pathBytes'];
return MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
packetLoggedAt: json['packetLoggedAtMillis'] is int
? DateTime.fromMillisecondsSinceEpoch(
json['packetLoggedAtMillis'] as int,
)
: null,
rssiDbm: json['rssiDbm'] as int?,
snrDb: (json['snrDb'] as num?)?.toDouble(),
pathBytes: pathBytes is List
? pathBytes.whereType<num>().map((b) => b.toInt()).toList()
: null,
senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
);
}
}

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,
);
}
}

File diff suppressed because it is too large Load Diff

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';
@@ -146,11 +147,15 @@ class ConnectionProvider with ChangeNotifier {
final MessageDeliveryTracker _messageDeliveryTracker = final MessageDeliveryTracker _messageDeliveryTracker =
MessageDeliveryTracker(); MessageDeliveryTracker();
final PingTracker _pingTracker = PingTracker(); final PingTracker _pingTracker = PingTracker();
final Map<String, Future<PingResult>> _pendingSmartPings = {};
// Expose room login states // Expose room login states
Map<String, RoomLoginState> get roomLoginStates => Map<String, RoomLoginState> get roomLoginStates =>
_roomLoginManager.roomLoginStates; _roomLoginManager.roomLoginStates;
bool isPingInProgress(Uint8List publicKey) =>
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
// Callbacks for other providers // Callbacks for other providers
Function(Contact)? onContactReceived; Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete; Function(List<Contact>)? onContactsComplete;
@@ -1117,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');
} }
@@ -1169,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) {
@@ -1316,6 +1336,34 @@ class ConnectionProvider with ChangeNotifier {
required Uint8List contactPublicKey, required Uint8List contactPublicKey,
required bool hasPath, required bool hasPath,
Function()? onRetryWithFlooding, Function()? onRetryWithFlooding,
}) async {
final pingKey = _publicKeyToHex(contactPublicKey);
final pendingPing = _pendingSmartPings[pingKey];
if (pendingPing != null) {
debugPrint(' [Provider] Joining in-flight ping for $pingKey');
return pendingPing;
}
final future = _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future;
notifyListeners();
try {
return await future;
} finally {
_pendingSmartPings.remove(pingKey);
notifyListeners();
}
}
Future<PingResult> _runSmartPing({
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async { }) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -1334,7 +1382,10 @@ class ConnectionProvider with ChangeNotifier {
); );
// Send the ping // Send the ping
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); await _activeService.requestTelemetry(
contactPublicKey,
zeroHop: firstAttemptDirect,
);
// Wait for response or timeout // Wait for response or timeout
final bool gotResponse = await pingFuture; final bool gotResponse = await pingFuture;
@@ -1361,8 +1412,8 @@ class ConnectionProvider with ChangeNotifier {
wasDirectAttempt: false, wasDirectAttempt: false,
); );
// Retry with flooding (zeroHop=true acts as broadcast to neighbors) // Retry with flooding.
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
// Wait for response or timeout // Wait for response or timeout
final bool gotRetryResponse = await retryFuture; final bool gotRetryResponse = await retryFuture;
@@ -1384,6 +1435,10 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Send binary request to contact (modern replacement for requestTelemetry) /// Send binary request to contact (modern replacement for requestTelemetry)
/// ///
/// Supports multiple request types: /// Supports multiple request types:
@@ -1967,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

@@ -423,9 +423,16 @@ class ContactsProvider with ChangeNotifier {
debugPrint(' Old lastAdvert: ${contact.lastAdvert}'); debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
debugPrint(' New lastAdvert: $currentTimestamp'); debugPrint(' New lastAdvert: $currentTimestamp');
final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation);
final updatedContact = contact.copyWith( final updatedContact = contact.copyWith(
telemetry: telemetry, telemetry: telemetry,
lastAdvert: currentTimestamp, // Update last seen time lastAdvert: currentTimestamp, // Update last seen time
advLat: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.latitude)
: contact.advLat,
advLon: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.longitude)
: contact.advLon,
); );
_contacts[contact.publicKeyHex] = updatedContact; _contacts[contact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Updated contact in map (with new lastAdvert)'); debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
@@ -476,6 +483,10 @@ class ContactsProvider with ChangeNotifier {
return incomingGps == null; return incomingGps == null;
} }
int _coordinateToAdvertMicrodegrees(double coordinate) {
return (coordinate * 1e6).round();
}
/// Find contact by public key prefix (6 bytes) /// Find contact by public key prefix (6 bytes)
Contact? _findContactByPrefix(Uint8List prefix) { Contact? _findContactByPrefix(Uint8List prefix) {
if (prefix.length < 6) return null; if (prefix.length < 6) return null;
@@ -521,7 +532,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

@@ -46,8 +46,8 @@ class PingTracker {
/// Mark a ping as successful (response received) /// Mark a ping as successful (response received)
/// Should be called when telemetry response arrives /// Should be called when telemetry response arrives
void markPingSuccessful(Uint8List publicKey) { void markPingSuccessful(Uint8List publicKey) {
final String keyHex = _publicKeyToHex(publicKey); final requestKey = _findMatchingPendingPingKey(publicKey);
final request = _pendingPings.remove(keyHex); final request = requestKey != null ? _pendingPings.remove(requestKey) : null;
if (request != null) { if (request != null) {
request.cancel(); request.cancel();
@@ -81,6 +81,23 @@ class PingTracker {
String _publicKeyToHex(Uint8List publicKey) { String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
} }
String? _findMatchingPendingPingKey(Uint8List responseKey) {
final responseHex = _publicKeyToHex(responseKey);
if (_pendingPings.containsKey(responseHex)) {
return responseHex;
}
for (final entry in _pendingPings.entries) {
final pendingHex = entry.key;
if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) {
return pendingHex;
}
}
return null;
}
} }
/// Internal class to track a single ping request /// Internal class to track a single ping request

View File

@@ -9,13 +9,6 @@ typedef RawPacketSender =
required Uint8List payload, required Uint8List payload,
}); });
typedef FragmentAckWaiter =
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
});
Future<bool> serveCachedSessionFragments<T>({ Future<bool> serveCachedSessionFragments<T>({
required String providerLabel, required String providerLabel,
required String sessionId, required String sessionId,
@@ -25,9 +18,7 @@ Future<bool> serveCachedSessionFragments<T>({
required int Function(T fragment) indexOf, required int Function(T fragment) indexOf,
required Uint8List Function(T fragment) encodeBinary, required Uint8List Function(T fragment) encodeBinary,
required RawPacketSender? sendRawPacket, required RawPacketSender? sendRawPacket,
FragmentAckWaiter? waitForFragmentAck,
Set<int>? requestedIndices, Set<int>? requestedIndices,
Duration ackTimeout = const Duration(seconds: 8),
}) async { }) async {
if (fragments.isEmpty) { if (fragments.isEmpty) {
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId'); debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
@@ -37,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;
} }
@@ -65,24 +62,12 @@ Future<bool> serveCachedSessionFragments<T>({
continue; continue;
} }
try { try {
final ackFuture = waitForFragmentAck?.call(
sessionId: sessionId,
index: index,
timeout: ackTimeout,
);
await sendRawPacket( await sendRawPacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.outPathLen, contactPathLen: requester.routeSignedPathLen,
payload: encodeBinary(fragment), payload: encodeBinary(fragment),
); );
servedCount++; servedCount++;
if (ackFuture != null) {
final acked = await ackFuture;
if (!acked) {
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
return false;
}
}
} catch (e, st) { } catch (e, st) {
debugPrint( debugPrint(
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st', '❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',

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

@@ -70,12 +70,6 @@ class ImageProvider with ChangeNotifier {
required Uint8List payload, required Uint8List payload,
})? })?
sendRawPacketCallback; sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
ImageProvider() { ImageProvider() {
_restore(); _restore();
@@ -102,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}) {
@@ -116,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) {
@@ -250,21 +265,26 @@ 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(),
sendRawPacket: sendRawPacketCallback, sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices, requestedIndices: requestedIndices,
); );
} }

View File

@@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart';
import '../models/message.dart'; 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_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';
@@ -10,6 +12,7 @@ import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import '../utils/drawing_message_parser.dart'; import '../utils/drawing_message_parser.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart'; import 'helpers/message_retry_manager.dart';
@@ -22,6 +25,8 @@ class MessagesProvider with ChangeNotifier {
bool _isInitialized = false; bool _isInitialized = false;
AppLocalizations? _localizations; AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {}; final Map<String, MessageContactLocation> _messageContactLocations = {};
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 = {};
@@ -74,12 +79,12 @@ class MessagesProvider with ChangeNotifier {
})? })?
sendMessageCallback; sendMessageCallback;
Future<void> Function({ Future<void> Function({required Contact contact, required int failureStreak})?
required Contact contact,
required int failureStreak,
})?
onDirectPathFailedCallback; onDirectPathFailedCallback;
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
String Function(int channelIdx)? resolveChannelNameCallback;
List<Message> get messages => List.unmodifiable(_messages); List<Message> get messages => List.unmodifiable(_messages);
List<Message> get contactMessages => List<Message> get contactMessages =>
@@ -115,6 +120,12 @@ class MessagesProvider with ChangeNotifier {
MessageContactLocation? getMessageContactLocation(String messageId) => MessageContactLocation? getMessageContactLocation(String messageId) =>
_messageContactLocations[messageId]; _messageContactLocations[messageId];
MessageReceptionDetails? getMessageReceptionDetails(String 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;
@@ -145,9 +156,19 @@ class MessagesProvider with ChangeNotifier {
final storedMessages = await _storageService.loadMessages(); final storedMessages = await _storageService.loadMessages();
final storedContactLocations = await _storageService final storedContactLocations = await _storageService
.loadMessageContactLocations(); .loadMessageContactLocations();
final storedReceptionDetails = await _storageService
.loadMessageReceptionDetails();
final storedTransferDetails = await _storageService
.loadMessageTransferDetails();
_messageContactLocations _messageContactLocations
..clear() ..clear()
..addAll(storedContactLocations); ..addAll(storedContactLocations);
_messageReceptionDetails
..clear()
..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) {
@@ -187,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,
);
}
} }
} }
@@ -311,6 +324,7 @@ class MessagesProvider with ChangeNotifier {
Message message, { Message message, {
String Function(String name)? contactLookup, String Function(String name)? contactLookup,
MessageContactLocation? contactLocationSnapshot, MessageContactLocation? contactLocationSnapshot,
MessageReceptionDetails? receptionDetailsSnapshot,
}) { }) {
// Always enhance message with SAR parser to detect SAR markers // Always enhance message with SAR parser to detect SAR markers
var enhancedMessage = SarMessageParser.enhanceMessage(message); var enhancedMessage = SarMessageParser.enhanceMessage(message);
@@ -341,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,
);
}
} }
} }
@@ -397,6 +403,22 @@ class MessagesProvider with ChangeNotifier {
debugPrint( debugPrint(
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', ' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
); );
final existingIndex = _messages.indexWhere(
(existing) =>
existing.messageType == finalMessage.messageType &&
existing.senderTimestamp == finalMessage.senderTimestamp &&
existing.text == finalMessage.text,
);
if (existingIndex != -1) {
final existingId = _messages[existingIndex].id;
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
}
_persistMessages();
}
return; // Skip duplicate return; // Skip duplicate
} }
@@ -404,6 +426,9 @@ class MessagesProvider with ChangeNotifier {
if (contactLocationSnapshot != null) { if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot; _messageContactLocations[finalMessage.id] = contactLocationSnapshot;
} }
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot;
}
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (finalMessage.isSarMarker) { if (finalMessage.isSarMarker) {
@@ -549,33 +574,31 @@ class MessagesProvider with ChangeNotifier {
/// Trigger notification for regular message /// Trigger notification for regular message
Future<void> _triggerMessageNotification(Message message) async { Future<void> _triggerMessageNotification(Message message) async {
try { try {
// Get sender name from message final senderName = _resolveParticipantName(
final senderName = publicKey: message.senderPublicKeyPrefix,
message.senderName ?? message.senderKeyShort ?? 'Unknown'; fallback: message.senderName ?? message.senderKeyShort,
);
// Determine if it's a channel message
final isChannelMessage = message.isChannelMessage; final isChannelMessage = message.isChannelMessage;
final channelName = isChannelMessage
// Get channel name if available ? _resolveChannelName(message.channelIdx)
String? channelName; : null;
if (isChannelMessage) { final messageText = _buildNotificationMessageText(
// You could map channelIdx to channel name here if needed message,
// For now, use "Public" for channel 0 senderName: senderName,
channelName = message.channelIdx == 0 isChannelMessage: isChannelMessage,
? 'Public' channelName: channelName,
: 'Channel ${message.channelIdx}'; );
}
debugPrint('🔔 [MessagesProvider] Triggering message notification'); debugPrint('🔔 [MessagesProvider] Triggering message notification');
debugPrint(' Sender: $senderName'); debugPrint(' Sender: $senderName');
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}'); debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
debugPrint( debugPrint(
' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...', ' Message: ${messageText.substring(0, messageText.length > 50 ? 50 : messageText.length)}...',
); );
await _notificationService.showMessageNotification( await _notificationService.showMessageNotification(
senderName: senderName, senderName: senderName,
messageText: message.text, messageText: messageText,
isChannelMessage: isChannelMessage, isChannelMessage: isChannelMessage,
channelName: channelName, channelName: channelName,
localizations: _localizations, localizations: _localizations,
@@ -587,12 +610,84 @@ class MessagesProvider with ChangeNotifier {
} }
} }
String _resolveParticipantName({
required Uint8List? publicKey,
String? fallback,
}) {
final resolved = resolveContactNameCallback?.call(publicKey)?.trim();
if (resolved != null && resolved.isNotEmpty) {
return resolved;
}
final normalizedFallback = fallback?.trim();
if (normalizedFallback != null && normalizedFallback.isNotEmpty) {
return normalizedFallback;
}
return 'Unknown';
}
String _resolveChannelName(int? channelIdx) {
final idx = channelIdx ?? 0;
final resolved = resolveChannelNameCallback?.call(idx).trim();
if (resolved != null && resolved.isNotEmpty) {
return resolved;
}
return idx == 0 ? 'Public' : 'Channel $idx';
}
String _buildNotificationMessageText(
Message message, {
required String senderName,
required bool isChannelMessage,
String? channelName,
}) {
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
if (voiceEnvelope != null) {
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
final summary =
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
return isChannelMessage ? '$senderName\n$summary' : summary;
}
final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) {
final summary =
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
return isChannelMessage ? '$senderName\n$summary' : summary;
}
if (!isChannelMessage && message.recipientPublicKey != null) {
final recipientName = _resolveParticipantName(
publicKey: message.recipientPublicKey,
fallback: null,
);
if (recipientName != 'Unknown') {
return 'To: $recipientName\n${message.text}';
}
}
if (isChannelMessage) {
return '$senderName\n${message.text}';
}
return message.text;
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
final kib = bytes / 1024;
if (kib < 1024) return '${kib.toStringAsFixed(kib >= 10 ? 0 : 1)} KB';
final mib = kib / 1024;
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB';
}
/// Persist messages to storage (async, non-blocking) /// Persist messages to storage (async, non-blocking)
Future<void> _persistMessages() async { Future<void> _persistMessages() async {
try { try {
await _storageService.saveMessages( await _storageService.saveMessages(
_messages, _messages,
messageContactLocations: _messageContactLocations, messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
); );
} catch (e) { } catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
@@ -709,6 +804,8 @@ class MessagesProvider with ChangeNotifier {
_messageContactMap.remove(messageId); _messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId); _groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId); _messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId);
_messageTransferDetails.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -738,6 +835,8 @@ class MessagesProvider with ChangeNotifier {
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear(); _messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
@@ -753,10 +852,70 @@ class MessagesProvider with ChangeNotifier {
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear(); _messageContactLocations.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();
@@ -852,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,
);
}
} }
} }
@@ -1062,10 +1213,11 @@ class MessagesProvider with ChangeNotifier {
' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', ' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
); );
// Once the device accepts a direct message and returns an ACK tag, the
// send itself succeeded locally even if end-to-end delivery confirmation
// may still arrive later. Keep ACK tracking, but stop showing "waiting".
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
deliveryStatus: expectedAckTag > 0 deliveryStatus: MessageDeliveryStatus.sent,
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null, expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null, suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
); );
@@ -1522,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
@@ -1678,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',
@@ -1698,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(
@@ -1709,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',
); );
@@ -1720,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

@@ -0,0 +1,233 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import 'connection_provider.dart';
import 'contacts_provider.dart';
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
enum SensorMetric {
lastSeen,
voltage,
battery,
temperature,
humidity,
pressure,
gps,
updated,
}
class SensorsProvider with ChangeNotifier {
static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const Set<SensorMetric> _defaultVisibleMetrics = <SensorMetric>{
SensorMetric.lastSeen,
SensorMetric.voltage,
SensorMetric.battery,
SensorMetric.temperature,
SensorMetric.humidity,
SensorMetric.pressure,
SensorMetric.gps,
SensorMetric.updated,
};
final List<String> _watchedSensorKeys = <String>[];
final Map<String, SensorRefreshState> _refreshStates =
<String, SensorRefreshState>{};
final Map<String, Set<SensorMetric>> _visibleMetricsBySensor =
<String, Set<SensorMetric>>{};
bool _isLoaded = false;
bool _isRefreshingAll = false;
SensorsProvider() {
unawaited(_loadWatchedSensors());
}
List<String> get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys);
bool get isLoaded => _isLoaded;
bool get isRefreshingAll => _isRefreshingAll;
SensorRefreshState stateFor(String publicKeyHex) =>
_refreshStates[publicKeyHex] ?? SensorRefreshState.idle;
Future<void> _loadWatchedSensors() async {
try {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
_watchedSensorKeys
..clear()
..addAll(stored);
_visibleMetricsBySensor.clear();
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
final metricNames = (entry.value as List<dynamic>).cast<String>();
_visibleMetricsBySensor[entry.key] = metricNames
.map(_metricFromName)
.whereType<SensorMetric>()
.toSet();
}
}
for (final key in _watchedSensorKeys) {
_visibleMetricsBySensor.putIfAbsent(
key,
() => Set<SensorMetric>.from(_defaultVisibleMetrics),
);
}
} catch (e) {
debugPrint('Error loading watched sensors: $e');
} finally {
_isLoaded = true;
notifyListeners();
}
}
Future<void> _persistWatchedSensors() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_watchedSensorsKey, _watchedSensorKeys);
} catch (e) {
debugPrint('Error saving watched sensors: $e');
}
}
Future<void> _persistVisibleMetrics() async {
try {
final prefs = await SharedPreferences.getInstance();
final encoded = <String, List<String>>{};
for (final entry in _visibleMetricsBySensor.entries) {
encoded[entry.key] = entry.value.map((metric) => metric.name).toList();
}
await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded));
} catch (e) {
debugPrint('Error saving visible sensor metrics: $e');
}
}
Set<SensorMetric> visibleMetricsFor(String publicKeyHex) =>
Set<SensorMetric>.unmodifiable(
_visibleMetricsBySensor[publicKeyHex] ?? _defaultVisibleMetrics,
);
bool showsMetric(String publicKeyHex, SensorMetric metric) =>
visibleMetricsFor(publicKeyHex).contains(metric);
Future<void> toggleMetric(
String publicKeyHex,
SensorMetric metric,
bool visible,
) async {
final visibleMetrics = _visibleMetricsBySensor.putIfAbsent(
publicKeyHex,
() => Set<SensorMetric>.from(_defaultVisibleMetrics),
);
if (visible) {
visibleMetrics.add(metric);
} else {
if (visibleMetrics.length == 1 && visibleMetrics.contains(metric)) {
return;
}
visibleMetrics.remove(metric);
}
await _persistVisibleMetrics();
notifyListeners();
}
bool isWatched(String publicKeyHex) =>
_watchedSensorKeys.contains(publicKeyHex);
Future<void> addSensor(Contact contact) async {
if (!contact.isChat && !contact.isRepeater) {
return;
}
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
return;
}
_watchedSensorKeys.add(contact.publicKeyHex);
await _persistWatchedSensors();
_visibleMetricsBySensor[contact.publicKeyHex] = Set<SensorMetric>.from(
_defaultVisibleMetrics,
);
await _persistVisibleMetrics();
notifyListeners();
}
Future<void> removeSensor(String publicKeyHex) async {
_watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex);
_visibleMetricsBySensor.remove(publicKeyHex);
await _persistWatchedSensors();
await _persistVisibleMetrics();
notifyListeners();
}
List<Contact> availableCandidates(ContactsProvider contactsProvider) {
final candidates = <Contact>[
...contactsProvider.chatContacts,
...contactsProvider.repeaters,
];
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
return candidates;
}
Future<void> refreshAll({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
if (_isRefreshingAll || _watchedSensorKeys.isEmpty) {
return;
}
_isRefreshingAll = true;
notifyListeners();
try {
for (final key in _watchedSensorKeys) {
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == key) {
contact = entry;
break;
}
}
if (contact == null) {
_refreshStates[key] = SensorRefreshState.unavailable;
notifyListeners();
continue;
}
_refreshStates[key] = SensorRefreshState.refreshing;
notifyListeners();
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.hasPath,
);
_refreshStates[key] = result.success
? SensorRefreshState.success
: SensorRefreshState.timeout;
notifyListeners();
}
} finally {
_isRefreshingAll = false;
notifyListeners();
}
}
SensorMetric? _metricFromName(String name) {
for (final metric in SensorMetric.values) {
if (metric.name == name) {
return metric;
}
}
return null;
}
}

View File

@@ -71,12 +71,6 @@ class VoiceProvider with ChangeNotifier {
required Uint8List payload, required Uint8List payload,
})? })?
sendRawPacketCallback; sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {}; final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
@@ -131,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.
@@ -142,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) {
@@ -185,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;
@@ -201,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;
} }
@@ -212,12 +272,11 @@ 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(),
sendRawPacket: sendRawPacketCallback, sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices, requestedIndices: requestedIndices,
); );
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
@@ -119,6 +120,43 @@ class _ContactsTabState extends State<ContactsTab> {
return l10n.daysAgo(diff.inDays); return l10n.daysAgo(diff.inDays);
} }
List<Contact> _sortContactsByDistance(List<Contact> contacts) {
final sorted = List<Contact>.from(contacts);
sorted.sort((a, b) {
final distanceA = _distanceFromCurrentPosition(a);
final distanceB = _distanceFromCurrentPosition(b);
if (distanceA != null && distanceB != null) {
final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) return distanceCompare;
} else if (distanceA != null) {
return -1;
} else if (distanceB != null) {
return 1;
}
return b.lastSeenTime.compareTo(a.lastSeenTime);
});
return sorted;
}
double? _distanceFromCurrentPosition(Contact contact) {
final currentPosition = _currentPosition;
final contactLocation = contact.displayLocation;
if (currentPosition == null || contactLocation == null) {
return null;
}
return _calculateDistanceInMeters(
currentPosition.latitude,
currentPosition.longitude,
contactLocation.latitude,
contactLocation.longitude,
);
}
/// Show the add channel dialog /// Show the add channel dialog
Future<void> _showAddChannelDialog(BuildContext context) async { Future<void> _showAddChannelDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@@ -165,10 +203,12 @@ class _ContactsTabState extends State<ContactsTab> {
return Scaffold( return Scaffold(
body: Consumer<ContactsProvider>( body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) { builder: (context, contactsProvider, child) {
final chatContacts = contactsProvider.chatContacts; final chatContacts = _sortContactsByDistance(
final repeaters = contactsProvider.repeaters; contactsProvider.chatContacts,
final rooms = contactsProvider.rooms; );
final channels = contactsProvider.channels; final repeaters = _sortContactsByDistance(contactsProvider.repeaters);
final rooms = _sortContactsByDistance(contactsProvider.rooms);
final channels = _sortContactsByDistance(contactsProvider.channels);
final pendingAdverts = contactsProvider.pendingAdverts; final pendingAdverts = contactsProvider.pendingAdverts;
// Check if there are any displayable contacts (excluding channels) // Check if there are any displayable contacts (excluding channels)

View File

@@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'messages_tab.dart'; import 'messages_tab.dart';
import 'contacts_tab.dart'; import 'contacts_tab.dart';
import 'sensors_tab.dart';
import 'map_tab.dart'; import 'map_tab.dart';
import 'map_management_screen.dart'; import 'map_management_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
@@ -24,7 +25,7 @@ import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart'; import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
enum _HomeTab { messages, contacts, map } enum _HomeTab { messages, contacts, sensors, map }
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged; final Function(AppThemeMode) onThemeChanged;
@@ -54,11 +55,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
bool _isMapEnabled = true; bool _isMapEnabled = true;
bool _isContactsEnabled = true; bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
List<_HomeTab> get _enabledTabs { List<_HomeTab> get _enabledTabs {
return [ return [
_HomeTab.messages, _HomeTab.messages,
if (_isContactsEnabled) _HomeTab.contacts, if (_isContactsEnabled) _HomeTab.contacts,
if (_isSensorsEnabled) _HomeTab.sensors,
if (_isMapEnabled) _HomeTab.map, if (_isMapEnabled) _HomeTab.map,
]; ];
} }
@@ -77,6 +80,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_appProvider = context.read<AppProvider>(); _appProvider = context.read<AppProvider>();
_isMapEnabled = _appProvider.isMapEnabled; _isMapEnabled = _appProvider.isMapEnabled;
_isContactsEnabled = _appProvider.isContactsEnabled; _isContactsEnabled = _appProvider.isContactsEnabled;
_isSensorsEnabled = _appProvider.isSensorsEnabled;
_appProvider.addListener(_handleAppProviderChanged); _appProvider.addListener(_handleAppProviderChanged);
// Initialize synchronously so first build always has a valid controller. // Initialize synchronously so first build always has a valid controller.
@@ -106,6 +110,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_updateTabController( _updateTabController(
mapEnabled: _appProvider.isMapEnabled, mapEnabled: _appProvider.isMapEnabled,
contactsEnabled: _appProvider.isContactsEnabled, contactsEnabled: _appProvider.isContactsEnabled,
sensorsEnabled: _appProvider.isSensorsEnabled,
); );
} }
@@ -129,8 +134,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
void _updateTabController({ void _updateTabController({
required bool mapEnabled, required bool mapEnabled,
required bool contactsEnabled, required bool contactsEnabled,
required bool sensorsEnabled,
}) { }) {
if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) { if (_isMapEnabled == mapEnabled &&
_isContactsEnabled == contactsEnabled &&
_isSensorsEnabled == sensorsEnabled) {
return; return;
} }
@@ -147,6 +155,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Update state // Update state
_isMapEnabled = mapEnabled; _isMapEnabled = mapEnabled;
_isContactsEnabled = contactsEnabled; _isContactsEnabled = contactsEnabled;
_isSensorsEnabled = sensorsEnabled;
if (!_isMapEnabled) { if (!_isMapEnabled) {
_isMapFullscreen = false; _isMapFullscreen = false;
} }
@@ -183,6 +192,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
case _HomeTab.contacts: case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed(); context.read<ContactsProvider>().markAllAsViewed();
break; break;
case _HomeTab.sensors:
break;
case _HomeTab.map: case _HomeTab.map:
break; break;
} }
@@ -471,6 +482,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
? () => _navigateToTab(_HomeTab.map) ? () => _navigateToTab(_HomeTab.map)
: null, : null,
); );
case _HomeTab.sensors:
return const SensorsTab();
case _HomeTab.map: case _HomeTab.map:
return MapTab( return MapTab(
onFullscreenChanged: (isFullscreen) { onFullscreenChanged: (isFullscreen) {
@@ -502,6 +515,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:
@@ -525,6 +545,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
icon: const Icon(Icons.map), icon: const Icon(Icons.map),
text: AppLocalizations.of(context)!.map, text: AppLocalizations.of(context)!.map,
); );
case _HomeTab.sensors:
return const Tab(
icon: Icon(Icons.sensors),
text: 'Sensors',
);
} }
}).toList(), }).toList(),
), ),

View File

@@ -76,6 +76,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
final BackgroundLocationService _backgroundLocationService = final BackgroundLocationService _backgroundLocationService =
BackgroundLocationService(); BackgroundLocationService();
bool _isDisposing = false; // Flag to prevent updates during disposal bool _isDisposing = false; // Flag to prevent updates during disposal
MapProvider? _mapProvider;
// MBTiles layers // MBTiles layers
List<MapLayer> _mbtilesLayers = []; List<MapLayer> _mbtilesLayers = [];
@@ -129,6 +130,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; // Check if widget is still mounted if (!mounted) return; // Check if widget is still mounted
final mapProvider = context.read<MapProvider>(); final mapProvider = context.read<MapProvider>();
_mapProvider = mapProvider;
mapProvider.addListener(_handleMapNavigation); mapProvider.addListener(_handleMapNavigation);
// Load WMS overlay state // Load WMS overlay state
mapProvider.loadOverlayState(); mapProvider.loadOverlayState();
@@ -438,8 +440,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
// Save map position before disposing // Save map position before disposing
_saveMapPosition(); _saveMapPosition();
final mapProvider = context.read<MapProvider>(); _mapProvider?.removeListener(_handleMapNavigation);
mapProvider.removeListener(_handleMapNavigation);
// DO NOT stop location tracking - it's managed by AppProvider // DO NOT stop location tracking - it's managed by AppProvider
// Restore the original callback instead of setting to null // Restore the original callback instead of setting to null

View File

@@ -19,7 +19,9 @@ import '../models/message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../widgets/messages/sar_update_sheet.dart'; import '../widgets/messages/sar_update_sheet.dart';
import '../widgets/messages/recipient_selector_sheet.dart'; import '../widgets/messages/recipient_selector_sheet.dart';
import '../widgets/messages/message_bubble.dart'; import '../widgets/messages/messages_composer.dart';
import '../widgets/messages/messages_content.dart';
import '../widgets/common/contact_avatar.dart';
import '../services/message_destination_preferences.dart'; import '../services/message_destination_preferences.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/voice_recorder_service.dart'; import '../services/voice_recorder_service.dart';
@@ -47,6 +49,7 @@ class MessagesTab extends StatefulWidget {
class _MessagesTabState extends State<MessagesTab> { class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156; static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127; static const int _maxChannelMessageBytes = 127;
static const double _composerOverlayHeight = 148;
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
@@ -314,20 +317,6 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
/// Get tooltip for destination button
String _getDestinationTooltip() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) {
final channelName = _selectedRecipient!.getLocalizedDisplayName(context);
return '$channelName (tap to change)';
} else if (_selectedRecipient != null) {
final recipientName = _selectedRecipient!.displayName;
return '$recipientName (tap to change)';
}
return 'Select recipient';
}
String _getDestinationLabel() { String _getDestinationLabel() {
if (_destinationType == if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel && MessageDestinationPreferences.destinationTypeChannel &&
@@ -466,10 +455,7 @@ class _MessagesTabState extends State<MessagesTab> {
); );
// Add to messages list with "sending" status // Add to messages list with "sending" status
messagesProvider.addSentMessage( messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
sentMessage,
contact: _selectedRecipient,
);
// Send message to selected recipient // Send message to selected recipient
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
@@ -589,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,
); );
} }
@@ -615,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,
@@ -627,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;
@@ -929,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,
@@ -941,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();
@@ -1115,6 +1089,16 @@ class _MessagesTabState extends State<MessagesTab> {
); );
} }
Future<void> _runAfterSheetDismissal(
BuildContext sheetContext,
Future<void> Function() action,
) async {
Navigator.pop(sheetContext);
await Future<void>.delayed(const Duration(milliseconds: 180));
if (!mounted) return;
await action();
}
void _showComposerActions() { void _showComposerActions() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -1126,9 +1110,10 @@ class _MessagesTabState extends State<MessagesTab> {
ListTile( ListTile(
leading: const Icon(Icons.add_location_alt), leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker), title: Text(AppLocalizations.of(context)!.sendSarMarker),
onTap: () { onTap: () async {
Navigator.pop(sheetContext); await _runAfterSheetDismissal(sheetContext, () async {
_showSarDialog(); _showSarDialog();
});
}, },
), ),
if (_voiceSupported) if (_voiceSupported)
@@ -1138,13 +1123,14 @@ class _MessagesTabState extends State<MessagesTab> {
title: Text(_isRecording ? 'Stop recording' : 'Record voice'), title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
onTap: _isSendingVoice onTap: _isSendingVoice
? null ? null
: () { : () async {
Navigator.pop(sheetContext); await _runAfterSheetDismissal(sheetContext, () async {
if (_isRecording) { if (_isRecording) {
_stopAndSendVoice(); await _stopAndSendVoice();
} else { } else {
_startVoiceRecording(); await _startVoiceRecording();
} }
});
}, },
), ),
ListTile( ListTile(
@@ -1153,9 +1139,10 @@ class _MessagesTabState extends State<MessagesTab> {
title: const Text('Send image from gallery'), title: const Text('Send image from gallery'),
onTap: _isSendingImage onTap: _isSendingImage
? null ? null
: () { : () async {
Navigator.pop(sheetContext); await _runAfterSheetDismissal(sheetContext, () async {
_pickAndSendImage(source: ImageSource.gallery); await _pickAndSendImage(source: ImageSource.gallery);
});
}, },
), ),
ListTile( ListTile(
@@ -1164,18 +1151,20 @@ class _MessagesTabState extends State<MessagesTab> {
title: const Text('Take photo'), title: const Text('Take photo'),
onTap: _isSendingImage onTap: _isSendingImage
? null ? null
: () { : () async {
Navigator.pop(sheetContext); await _runAfterSheetDismissal(sheetContext, () async {
_pickAndSendImage(source: ImageSource.camera); await _pickAndSendImage(source: ImageSource.camera);
});
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.grid_3x3), leading: const Icon(Icons.grid_3x3),
title: const Text('Start Tic-Tac-Toe'), title: const Text('Start Tic-Tac-Toe'),
subtitle: const Text('DM only'), subtitle: const Text('DM only'),
onTap: () { onTap: () async {
Navigator.pop(sheetContext); await _runAfterSheetDismissal(sheetContext, () async {
_startTicTacToeGame(); await _startTicTacToeGame();
});
}, },
), ),
], ],
@@ -1185,6 +1174,23 @@ class _MessagesTabState extends State<MessagesTab> {
); );
} }
Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient;
if (recipient != null) {
return ContactAvatar(
contact: recipient,
radius: 14,
displayName: _getDestinationLabel(),
);
}
return Icon(
_getDestinationIcon(),
size: 17,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
Future<void> _sendSarMessage( Future<void> _sendSarMessage(
String emoji, String emoji,
String name, String name,
@@ -1496,6 +1502,28 @@ class _MessagesTabState extends State<MessagesTab> {
return filteredMessages; return filteredMessages;
} }
void _handleMessageTap(Message message) {
if (widget.onNavigateToMap == null) return;
if (message.isSarMarker && message.sarGpsCoordinates != null) {
final mapProvider = context.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap?.call();
return;
}
if (message.isDrawing && message.drawingId != null) {
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
final mapProvider = context.read<MapProvider>();
final drawingProvider = context.read<DrawingProvider>();
mapProvider.navigateToDrawing(message.drawingId!, drawingProvider);
widget.onNavigateToMap?.call();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MessagesProvider>( return Consumer<MessagesProvider>(
@@ -1507,552 +1535,41 @@ class _MessagesTabState extends State<MessagesTab> {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(), onTap: () => FocusScope.of(context).unfocus(),
child: Column( child: Stack(
children: [ children: [
// Messages list with pull-to-refresh Positioned.fill(
Expanded( child: MessagesContent(
child: RefreshIndicator( messages: messages,
scrollController: _scrollController,
highlightedMessageId: _highlightedMessageId,
bottomContentPadding:
_composerOverlayHeight + composerBottomPadding,
onRefresh: _handleRefresh, onRefresh: _handleRefresh,
child: messages.isEmpty onNavigateToMap: widget.onNavigateToMap,
? LayoutBuilder( onMessageTap: _handleMessageTap,
builder: (context, constraints) =>
SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics:
const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: Center(
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(
context,
).disabledColor,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(
context,
)!.noMessagesYet,
style: Theme.of(
context,
).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(
context,
)!.pullDownToSync,
style: Theme.of(
context,
).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: ListView.builder(
controller: _scrollController,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
reverse: true,
padding: const EdgeInsets.all(8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final isHighlighted =
message.id == _highlightedMessageId;
return MessageBubble(
key: ValueKey(message.id),
message: message,
isHighlighted: isHighlighted,
onNavigateToMap: widget.onNavigateToMap,
onTap:
widget.onNavigateToMap != null &&
message.isSarMarker &&
message.sarGpsCoordinates != null
? () {
final mapProvider = context
.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap?.call();
}
: widget.onNavigateToMap != null &&
message.isDrawing &&
message.drawingId != null
? () {
debugPrint(
'🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}',
);
final mapProvider = context
.read<MapProvider>();
final drawingProvider = context
.read<DrawingProvider>();
mapProvider.navigateToDrawing(
message.drawingId!,
drawingProvider,
);
widget.onNavigateToMap?.call();
}
: null,
);
},
),
), ),
), ),
Positioned(
// Message input area left: 0,
Container( right: 0,
decoration: BoxDecoration( bottom: 0,
color: Theme.of(context).colorScheme.surface, child: MessagesComposer(
), textController: _textController,
child: Column( focusNode: _focusNode,
mainAxisSize: MainAxisSize.min, messageByteLimiter: _messageByteLimiter,
children: [ messageByteCount: _messageByteCount,
SafeArea( maxMessageBytes: _maxMessageBytes,
top: false, isRecording: _isRecording,
child: Padding( isSendingVoice: _isSendingVoice,
padding: EdgeInsets.fromLTRB( voiceSupported: _voiceSupported,
10, bottomPadding: composerBottomPadding,
10, destinationLabel: _getDestinationLabel(),
10, destinationAvatar: _buildDestinationAvatar(context),
composerBottomPadding, onShowComposerActions: _showComposerActions,
), onShowRecipientSelector: _showRecipientSelector,
child: Container( onStartVoiceRecording: _startVoiceRecording,
decoration: BoxDecoration( onStopAndSendVoice: _stopAndSendVoice,
color: Theme.of( onSendMessage: _sendMessage,
context,
).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: Icon(
_isRecording ? Icons.stop : Icons.add,
size: 22,
),
tooltip: _isRecording
? 'Stop recording'
: 'More actions',
onPressed: _isRecording
? _stopAndSendVoice
: _showComposerActions,
color: _isRecording
? Colors.red
: Theme.of(
context,
).colorScheme.primary,
),
),
const SizedBox(width: 8),
Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: _showRecipientSelector,
child: Ink(
height: 42,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface,
borderRadius: BorderRadius.circular(
20,
),
border: Border.all(
color: Theme.of(context)
.dividerColor
.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
),
child: Row(
children: [
Icon(
_getDestinationIcon(),
size: 17,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(
child: Text(
_getDestinationLabel(),
overflow:
TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight:
FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more_rounded,
size: 20,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
],
),
),
),
),
),
),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: AnimatedContainer(
duration: const Duration(
milliseconds: 180,
),
constraints: const BoxConstraints(
minHeight: 46,
maxHeight: 132,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: _focusNode.hasFocus
? Theme.of(
context,
).colorScheme.primary
: Theme.of(context).dividerColor
.withValues(alpha: 0.35),
width: _focusNode.hasFocus ? 1.4 : 1,
),
boxShadow: _focusNode.hasFocus
? [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: TextField(
controller: _textController,
focusNode: _focusNode,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.multiline,
inputFormatters: [
_messageByteLimiter,
],
style: const TextStyle(fontSize: 15),
textAlignVertical:
TextAlignVertical.center,
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
)!.typeYourMessage,
hintStyle: TextStyle(
fontSize: 15,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.9),
),
filled: false,
fillColor: Colors.transparent,
border: InputBorder.none,
isCollapsed: true,
),
textInputAction:
TextInputAction.newline,
),
),
),
),
const SizedBox(width: 8),
Builder(
builder: (context) {
final canSendText =
!_isRecording &&
!_isSendingVoice &&
_textController.text
.trim()
.isNotEmpty;
final semanticsLabel = _isRecording
? 'Recording... release to send voice'
: (_isSendingVoice
? 'Sending voice...'
: _voiceSupported
? 'Send (long press to record voice)'
: 'Send');
return Semantics(
button: true,
enabled:
canSendText ||
(_voiceSupported &&
!_isSendingVoice),
label: semanticsLabel,
onTap: canSendText
? _sendMessage
: null,
onLongPress:
(_voiceSupported &&
!_isSendingVoice)
? () {
if (_isRecording) {
_stopAndSendVoice();
return;
}
_startVoiceRecording();
}
: null,
child: Tooltip(
message: semanticsLabel,
excludeFromSemantics: true,
child: GestureDetector(
excludeFromSemantics: true,
onTap: canSendText
? () {
debugPrint(
'👆 [MessagesTab] Send button tapped '
'(canSendText=$canSendText, '
'textLength=${_textController.text.trim().length}, '
'recording=$_isRecording, '
'sendingVoice=$_isSendingVoice)',
);
_sendMessage();
}
: null,
onLongPressStart:
(_voiceSupported &&
!_isSendingVoice)
? (_) {
debugPrint(
'🎙️ [MessagesTab] Send button long-press start '
'(voiceSupported=$_voiceSupported, '
'sendingVoice=$_isSendingVoice, '
'recording=$_isRecording)',
);
_startVoiceRecording();
}
: null,
onLongPressEnd:
(_voiceSupported &&
_isRecording)
? (_) {
debugPrint(
'🎙️ [MessagesTab] Send button long-press end '
'(recording=$_isRecording)',
);
_stopAndSendVoice();
}
: null,
onLongPressCancel:
(_voiceSupported &&
_isRecording)
? () {
debugPrint(
'🎙️ [MessagesTab] Send button long-press cancel '
'(recording=$_isRecording)',
);
_stopAndSendVoice();
}
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(
milliseconds: 180,
),
width: 46,
height: 46,
decoration: BoxDecoration(
color:
canSendText ||
_isRecording
? Theme.of(
context,
).colorScheme.primary
: Theme.of(
context,
).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color:
canSendText ||
_isRecording
? Colors.transparent
: Theme.of(context)
.dividerColor
.withValues(
alpha: 0.35,
),
),
boxShadow:
canSendText ||
_isRecording
? [
BoxShadow(
color:
Theme.of(
context,
)
.colorScheme
.primary
.withValues(
alpha:
0.22,
),
blurRadius: 14,
offset:
const Offset(
0,
6,
),
),
]
: null,
),
child: _isSendingVoice
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color:
Theme.of(
context,
)
.colorScheme
.onPrimary,
),
)
: Icon(
_isRecording
? Icons
.mic_rounded
: Icons
.send_rounded,
size: 22,
color:
canSendText ||
_isRecording
? Theme.of(
context,
)
.colorScheme
.onPrimary
: Theme.of(
context,
)
.colorScheme
.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'$_messageByteCount/$_maxMessageBytes',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color:
_messageByteCount >
_maxMessageBytes *
0.9
? Colors.orange.shade800
: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(
alpha: 0.9,
),
),
),
],
),
),
),
);
},
),
],
),
],
),
),
),
),
),
],
), ),
), ),
], ],

View File

@@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'dart:io'; import 'dart:io';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../utils/log_rx_route_decoder.dart';
class PacketLogScreen extends StatefulWidget { class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService; final MeshCoreBleService bleService;
@@ -456,6 +460,26 @@ class _PacketLogCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx; final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue; final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo; final rxInfo = log.logRxDataInfo;
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
final resolvedPath = decodedRoute?.pathHashes
.map(
(hash) => LogRxRouteDecoder.resolveHash(
hash,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
? resolvedPath.first
: null;
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -580,6 +604,14 @@ class _PacketLogCard extends StatelessWidget {
), ),
), ),
], ],
if (isRx && decodedRoute != null) ...[
const SizedBox(height: 12),
_RouteSection(
route: decodedRoute,
path: resolvedPath ?? const [],
originalSender: originalSender,
),
],
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -723,6 +755,162 @@ class _PacketLogCard extends StatelessWidget {
} }
} }
class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path;
final ResolvedNodeHash? originalSender;
const _RouteSection({
required this.route,
required this.path,
required this.originalSender,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.alt_route, size: 16),
SizedBox(width: 6),
Text(
'Mesh Route',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
_FactCard(
icon: Icons.route,
label: 'Payload',
value: _payloadTypeLabel(route.payloadType),
),
_FactCard(
icon: Icons.hub,
label: 'Hops',
value: '${route.pathHashes.length}',
),
if (originalSender != null)
_FactCard(
icon: Icons.person_pin_circle,
label: 'Original sender',
value: _nodeLabel(originalSender!),
),
],
),
const SizedBox(height: 12),
if (path.isEmpty)
Text(
'Direct packet, no hop path attached.',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (var i = 0; i < path.length; i++) ...[
_RouteHopChip(index: i + 1, node: path[i]),
if (i < path.length - 1)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.arrow_right_alt, size: 16),
),
],
],
),
],
),
);
}
static String _payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'REQ';
case 0x01:
return 'RESP';
case 0x02:
return 'TXT';
case 0x03:
return 'ACK';
case 0x04:
return 'ADVERT';
case 0x05:
return 'GRP_TXT';
case 0x06:
return 'GRP_DATA';
case 0x07:
return 'ANON_REQ';
case 0x08:
return 'PATH';
case 0x09:
return 'TRACE';
case 0x0A:
return 'MULTIPART';
case 0x0B:
return 'CONTROL';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String _nodeLabel(ResolvedNodeHash node) {
if (node.isOwnNode) {
return '${node.label} (${node.hexLabel})';
}
if (node.matchCount == 0) {
return node.hexLabel;
}
if (node.isUniqueMatch) {
return '${node.label} (${node.hexLabel})';
}
return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)';
}
}
class _RouteHopChip extends StatelessWidget {
final int index;
final ResolvedNodeHash node;
const _RouteHopChip({required this.index, required this.node});
@override
Widget build(BuildContext context) {
final color = node.isOwnNode
? Colors.blue
: node.isUniqueMatch
? Colors.green
: Colors.orange;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Text(
'$index. ${_RouteSection._nodeLabel(node)}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
}
}
class _FactCard extends StatelessWidget { class _FactCard extends StatelessWidget {
final IconData icon; final IconData icon;
final String label; final String label;

View File

@@ -0,0 +1,735 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/sensors_provider.dart';
class SensorsTab extends StatelessWidget {
const SensorsTab({super.key});
Future<void> _showAddSensorSheet(BuildContext context) async {
final sensorsProvider = context.read<SensorsProvider>();
final contactsProvider = context.read<ContactsProvider>();
final candidates = sensorsProvider.availableCandidates(contactsProvider);
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
if (candidates.isEmpty) {
return const SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No eligible nodes available. Discover a relay or node first.',
),
),
);
}
return SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 20),
children: [
const ListTile(
title: Text(
'Add sensor node',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text('Pick a relay or node to watch in Sensors.'),
),
...candidates.map(
(contact) => ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: CircleAvatar(
radius: 24,
backgroundColor: const Color(0xFFDDEAF8),
child: Icon(
_typeIcon(contact),
color: const Color(0xFF1E4F7A),
),
),
title: Text(contact.displayName),
subtitle: _SensorCandidatePreview(contact: contact),
isThreeLine: true,
onTap: () async {
await sensorsProvider.addSensor(contact);
if (!sheetContext.mounted) return;
Navigator.of(sheetContext).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${contact.displayName} added to Sensors',
),
),
);
},
),
),
],
),
);
},
);
}
Future<void> _showMetricSelector(
BuildContext context,
String publicKeyHex,
) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => Consumer<SensorsProvider>(
builder: (context, sensorsProvider, child) {
final visibleMetrics = sensorsProvider.visibleMetricsFor(
publicKeyHex,
);
return SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
children: [
Text(
'Visible fields',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Choose which values appear on sensor cards.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 20),
Wrap(
spacing: 10,
runSpacing: 10,
children: SensorMetric.values.map((metric) {
final visible = visibleMetrics.contains(metric);
return FilterChip(
selected: visible,
label: Text(_metricLabel(metric)),
onSelected: (value) {
sensorsProvider.toggleMetric(
publicKeyHex,
metric,
value,
);
},
);
}).toList(),
),
],
),
);
},
),
);
}
Future<void> _refreshAll(BuildContext context) async {
await context.read<SensorsProvider>().refreshAll(
contactsProvider: context.read<ContactsProvider>(),
connectionProvider: context.read<ConnectionProvider>(),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddSensorSheet(context),
child: const Icon(Icons.add),
),
body: Consumer2<SensorsProvider, ContactsProvider>(
builder: (context, sensorsProvider, contactsProvider, child) {
final watchedKeys = sensorsProvider.watchedSensorKeys;
return RefreshIndicator(
onRefresh: () => _refreshAll(context),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: [
if (watchedKeys.isEmpty)
const _EmptySensorsState()
else
...watchedKeys.map((key) {
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == key) {
contact = entry;
break;
}
}
return _SensorCard(
contact: contact,
state: sensorsProvider.stateFor(key),
visibleMetrics: sensorsProvider.visibleMetricsFor(key),
onRemove: () async {
await sensorsProvider.removeSensor(key);
},
onCustomize: () => _showMetricSelector(context, key),
);
}),
],
),
);
},
),
);
}
}
class _SensorCandidatePreview extends StatelessWidget {
final Contact contact;
const _SensorCandidatePreview({required this.contact});
@override
Widget build(BuildContext context) {
final telemetry = contact.telemetry;
final previewLines = <String>[
'${contact.type.displayName}${contact.publicKeyShort}',
if (telemetry?.batteryPercentage != null)
'Battery ${telemetry!.batteryPercentage!.toStringAsFixed(0)}% • '
'Temp ${telemetry.temperature?.toStringAsFixed(1) ?? '--'}°C',
if (telemetry?.gpsLocation != null)
'GPS ${telemetry!.gpsLocation!.latitude.toStringAsFixed(3)}, '
'${telemetry.gpsLocation!.longitude.toStringAsFixed(3)}',
];
if (previewLines.length == 1) {
previewLines.add('No telemetry preview available yet');
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: previewLines
.take(3)
.map(
(line) => Text(line, maxLines: 1, overflow: TextOverflow.ellipsis),
)
.toList(),
);
}
}
class _EmptySensorsState extends StatelessWidget {
const _EmptySensorsState();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 64),
child: Column(
children: [
Container(
width: 84,
height: 84,
decoration: const BoxDecoration(
color: Color(0xFFDDEAF8),
shape: BoxShape.circle,
),
child: const Icon(
Icons.sensors_outlined,
size: 40,
color: Color(0xFF1E4F7A),
),
),
const SizedBox(height: 20),
Text(
'No sensor nodes added',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 10),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text(
'Use + to add discovered relays or nodes. Pull down to refresh telemetry after adding them.',
textAlign: TextAlign.center,
),
),
],
),
);
}
}
class _SensorCard extends StatelessWidget {
final Contact? contact;
final SensorRefreshState state;
final Set<SensorMetric> visibleMetrics;
final Future<void> Function() onRemove;
final VoidCallback onCustomize;
const _SensorCard({
required this.contact,
required this.state,
required this.visibleMetrics,
required this.onRemove,
required this.onCustomize,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final telemetry = contact?.telemetry;
final theme = Theme.of(context);
final metrics = contact == null || telemetry == null
? const <_MetricCardData>[]
: _buildMetricCards(l10n, telemetry, contact!);
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.14),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
contact?.displayName ?? 'Unavailable node',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (state == SensorRefreshState.timeout)
const _InlineAlertBadge(label: 'No response'),
],
),
if (telemetry != null) ...[
const SizedBox(height: 2),
Text(
'${_formatTelemetryDateTime(telemetry.timestamp)}${_formatTelemetryTime(telemetry.timestamp)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'remove') {
await onRemove();
} else if (value == 'customize') {
onCustomize();
}
},
itemBuilder: (context) => const [
PopupMenuItem<String>(
value: 'customize',
child: Text('Customize fields'),
),
PopupMenuItem<String>(
value: 'remove',
child: Text('Remove'),
),
],
),
],
),
const SizedBox(height: 12),
if (((state != SensorRefreshState.idle &&
state != SensorRefreshState.timeout) ||
state == SensorRefreshState.unavailable) ||
(contact != null &&
visibleMetrics.contains(SensorMetric.lastSeen)))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Wrap(
spacing: 10,
runSpacing: 10,
children: [
if (state != SensorRefreshState.idle &&
state != SensorRefreshState.timeout)
_StatusPill(state: state),
if (contact != null &&
visibleMetrics.contains(SensorMetric.lastSeen))
_InfoPill(
icon: Icons.schedule,
label:
'${l10n.lastSeen}: ${contact!.timeSinceLastSeen}',
),
],
),
),
if (contact == null)
const Text(
'This node is no longer available in the contact list.',
)
else if (telemetry == null)
const Text('No telemetry received yet. Pull down to fetch it.')
else if (metrics.isEmpty)
const Text(
'All fields are hidden. Use Visible fields to choose what to show.',
)
else
Wrap(
spacing: 12,
runSpacing: 12,
children: metrics
.map((metric) => _MetricTile(data: metric))
.toList(),
),
],
),
),
);
}
List<_MetricCardData> _buildMetricCards(
AppLocalizations l10n,
dynamic telemetry,
Contact contact,
) {
final items = <_MetricCardData>[];
if (visibleMetrics.contains(SensorMetric.voltage) &&
telemetry.batteryMilliVolts != null) {
items.add(
_MetricCardData(
icon: Icons.bolt,
label: l10n.voltage,
value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V',
accent: const Color(0xFF0A7D61),
),
);
}
if (visibleMetrics.contains(SensorMetric.battery) &&
telemetry.batteryPercentage != null) {
items.add(
_MetricCardData(
icon: Icons.battery_5_bar,
label: l10n.battery,
value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%',
accent: const Color(0xFF4B8E2F),
),
);
}
if (visibleMetrics.contains(SensorMetric.temperature) &&
telemetry.temperature != null) {
items.add(
_MetricCardData(
icon: Icons.thermostat,
label: l10n.temperature,
value: '${telemetry.temperature!.toStringAsFixed(1)}°C',
accent: const Color(0xFFC76821),
),
);
}
if (visibleMetrics.contains(SensorMetric.humidity) &&
telemetry.humidity != null) {
items.add(
_MetricCardData(
icon: Icons.water_drop,
label: l10n.humidity,
value: '${telemetry.humidity!.toStringAsFixed(1)}%',
accent: const Color(0xFF246BB2),
),
);
}
if (visibleMetrics.contains(SensorMetric.pressure) &&
telemetry.pressure != null) {
items.add(
_MetricCardData(
icon: Icons.compress,
label: l10n.pressure,
value: '${telemetry.pressure!.toStringAsFixed(1)} hPa',
accent: const Color(0xFF6B4BAE),
),
);
}
if (visibleMetrics.contains(SensorMetric.gps) &&
telemetry.gpsLocation != null) {
items.add(
_MetricCardData(
icon: Icons.place,
label: l10n.gpsTelemetry,
value:
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
accent: const Color(0xFFAA3F57),
wide: true,
),
);
}
if (visibleMetrics.contains(SensorMetric.updated)) {
items.add(
_MetricCardData(
icon: Icons.update,
label: l10n.updated,
value: _formatTelemetryTime(telemetry.timestamp),
accent: const Color(0xFF6C727F),
),
);
}
if (telemetry.extraSensorData != null) {
for (final entry in telemetry.extraSensorData!.entries) {
items.add(
_MetricCardData(
icon: Icons.sensors,
label: entry.key,
value: '${entry.value}',
accent: const Color(0xFF3E657C),
),
);
}
}
return items;
}
String _formatTelemetryTime(DateTime timestamp) {
final diff = DateTime.now().difference(timestamp);
if (diff.inMinutes < 1) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
String _formatTelemetryDateTime(DateTime timestamp) {
final local = timestamp.toLocal();
final year = local.year.toString().padLeft(4, '0');
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '$year-$month-$day $hour:$minute';
}
}
class _StatusPill extends StatelessWidget {
final SensorRefreshState state;
const _StatusPill({required this.state});
@override
Widget build(BuildContext context) {
final (label, color, icon) = switch (state) {
SensorRefreshState.idle => (
'Idle',
const Color(0xFF5D7185),
Icons.sensors,
),
SensorRefreshState.refreshing => (
'Refreshing',
const Color(0xFF266AC2),
Icons.sync,
),
SensorRefreshState.success => (
'Updated',
const Color(0xFF218B63),
Icons.check_circle,
),
SensorRefreshState.timeout => (
'No response',
const Color(0xFFC17B1D),
Icons.schedule,
),
SensorRefreshState.unavailable => (
'Unavailable',
const Color(0xFFB13B55),
Icons.error_outline,
),
};
return _InfoPill(
icon: icon,
label: label,
foreground: color,
background: color.withValues(alpha: 0.10),
);
}
}
class _InfoPill extends StatelessWidget {
final IconData icon;
final String label;
final Color? foreground;
final Color? background;
const _InfoPill({
required this.icon,
required this.label,
this.foreground,
this.background,
});
@override
Widget build(BuildContext context) {
final color = foreground ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: BoxDecoration(
color:
background ?? Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
],
),
);
}
}
class _InlineAlertBadge extends StatelessWidget {
final String label;
const _InlineAlertBadge({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFC17B1D).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: const Color(0xFFC17B1D),
fontWeight: FontWeight.w700,
),
),
);
}
}
class _MetricTile extends StatelessWidget {
final _MetricCardData data;
const _MetricTile({required this.data});
@override
Widget build(BuildContext context) {
final width = data.wide ? 320.0 : 168.0;
return Container(
width: width,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: data.accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: data.accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Icon(data.icon, color: data.accent, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: data.accent,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
data.value,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
height: 1.1,
),
),
],
),
),
],
),
);
}
}
class _MetricCardData {
final IconData icon;
final String label;
final String value;
final Color accent;
final bool wide;
const _MetricCardData({
required this.icon,
required this.label,
required this.value,
required this.accent,
this.wide = false,
});
}
String _metricLabel(SensorMetric metric) {
return switch (metric) {
SensorMetric.lastSeen => 'Last seen',
SensorMetric.voltage => 'Voltage',
SensorMetric.battery => 'Battery',
SensorMetric.temperature => 'Temperature',
SensorMetric.humidity => 'Humidity',
SensorMetric.pressure => 'Pressure',
SensorMetric.gps => 'GPS',
SensorMetric.updated => 'Updated',
};
}
IconData _typeIcon(Contact contact) {
if (contact.isRepeater) {
return Icons.router;
}
if (contact.isChat) {
return Icons.sensors;
}
return Icons.device_hub;
}

View File

@@ -737,6 +737,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.sensors),
title: const Text('Enable Sensors tab'),
subtitle: const Text(
'Show a dedicated tab for watched relay and node telemetry',
),
value: appProvider.isSensorsEnabled,
onChanged: (value) async {
await appProvider.toggleSensorsEnabled(value);
},
),
),
ListTile( ListTile(
leading: const Icon(Icons.language), leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.language), title: Text(AppLocalizations.of(context)!.language),

View File

@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/map/location_pointer.dart'; import '../widgets/map/location_pointer.dart';
/// Centralized service for map marker management. /// Centralized service for map marker management.
@@ -77,9 +78,15 @@ class MapMarkerService {
// Marker icon // Marker icon
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: getContactMarkerColor(contact, context), color: Colors.white,
shape: BoxShape.circle, shape: contact.type == ContactType.channel ||
border: Border.all(color: Colors.white, width: 2), contact.type == ContactType.room
? BoxShape.rectangle
: BoxShape.circle,
borderRadius: contact.type == ContactType.channel ||
contact.type == ContactType.room
? BorderRadius.circular(14)
: null,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.3), color: Colors.black.withValues(alpha: 0.3),
@@ -88,17 +95,8 @@ class MapMarkerService {
), ),
], ],
), ),
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(2),
child: contact.roleEmoji != null child: ContactAvatar(contact: contact, radius: 16),
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
)
: Icon(
getContactMarkerIcon(contact),
color: Colors.white,
size: 18,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
// Name label (without emoji) // Name label (without emoji)

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

@@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; 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_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
@@ -10,12 +12,18 @@ class MessageStorageService {
static const String _messagesKey = 'stored_messages'; static const String _messagesKey = 'stored_messages';
static const String _messageContactLocationsKey = static const String _messageContactLocationsKey =
'stored_message_contact_locations'; 'stored_message_contact_locations';
static const String _messageReceptionDetailsKey =
'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
Future<void> saveMessages( Future<void> saveMessages(
List<Message> messages, { List<Message> messages, {
Map<String, MessageContactLocation> messageContactLocations = const {}, Map<String, MessageContactLocation> messageContactLocations = 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();
@@ -34,15 +42,35 @@ class MessageStorageService {
.map((entry) => entry['id'] as String) .map((entry) => entry['id'] as String)
.toSet(); .toSet();
final locationJson = <String, dynamic>{}; final locationJson = <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();
} }
} }
for (final entry in messageReceptionDetails.entries) {
if (retainedMessageIds.contains(entry.key)) {
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),
); );
await prefs.setString(
_messageReceptionDetailsKey,
jsonEncode(receptionJson),
);
await prefs.setString(
_messageTransferDetailsKey,
jsonEncode(transferJson),
);
debugPrint( debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage', '✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -52,8 +80,8 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageContactLocation>> loadMessageContactLocations() Future<Map<String, MessageContactLocation>>
async { loadMessageContactLocations() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageContactLocationsKey); final jsonString = prefs.getString(_messageContactLocationsKey);
@@ -82,6 +110,66 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageReceptionDetails>>
loadMessageReceptionDetails() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
return const {};
}
}
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 {
@@ -116,6 +204,8 @@ class MessageStorageService {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey); await prefs.remove(_messagesKey);
await prefs.remove(_messageContactLocationsKey); await prefs.remove(_messageContactLocationsKey);
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

@@ -18,7 +18,7 @@ class VoiceRecorderService {
/// Request microphone permission. Returns true if granted. /// Request microphone permission. Returns true if granted.
Future<bool> requestPermission() async { Future<bool> requestPermission() async {
return _recorder.hasPermission(); return _recorder.hasPermission(request: true);
} }
/// Start capturing PCM audio. /// Start capturing PCM audio.
@@ -38,9 +38,7 @@ class VoiceRecorderService {
throw StateError('VoiceRecorderService: already recording'); throw StateError('VoiceRecorderService: already recording');
} }
_controller = StreamController<Int16List>( _controller = StreamController<Int16List>(onCancel: () => _stopInternal());
onCancel: () => _stopInternal(),
);
_isRecording = true; _isRecording = true;
_startRecording( _startRecording(
@@ -167,17 +165,17 @@ class _VoiceDynamicsProcessor {
required int sampleRate, required int sampleRate,
required bool enableCompressor, required bool enableCompressor,
required bool enableLimiter, required bool enableLimiter,
}) : _enableCompressor = enableCompressor, }) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter, _enableLimiter = enableLimiter,
_compressor = _SimpleCompressor( _compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(), sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0, thresholdDb: -18.0,
ratio: 2.5, ratio: 2.5,
attackMs: 8.0, attackMs: 8.0,
releaseMs: 120.0, releaseMs: 120.0,
makeupGainDb: 4.0, makeupGainDb: 4.0,
), ),
_limiter = _PeakLimiter(ceilingDb: -1.0); _limiter = _PeakLimiter(ceilingDb: -1.0);
Int16List process(Int16List input) { Int16List process(Int16List input) {
final output = Int16List(input.length); final output = Int16List(input.length);
@@ -214,11 +212,11 @@ class _SimpleCompressor {
required double attackMs, required double attackMs,
required double releaseMs, required double releaseMs,
required double makeupGainDb, required double makeupGainDb,
}) : _thresholdDb = thresholdDb, }) : _thresholdDb = thresholdDb,
_ratio = ratio, _ratio = ratio,
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(), _makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))), _attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0))); _releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
double process(double x) { double process(double x) {
final absX = x.abs(); final absX = x.abs();
@@ -266,14 +264,14 @@ class _VoiceBandPassFilter {
required int sampleRate, required int sampleRate,
required double lowCutHz, required double lowCutHz,
required double highCutHz, required double highCutHz,
}) : _highPass = _BiquadFilter.highPass( }) : _highPass = _BiquadFilter.highPass(
sampleRate: sampleRate.toDouble(), sampleRate: sampleRate.toDouble(),
cutoffHz: lowCutHz, cutoffHz: lowCutHz,
), ),
_lowPass = _BiquadFilter.lowPass( _lowPass = _BiquadFilter.lowPass(
sampleRate: sampleRate.toDouble(), sampleRate: sampleRate.toDouble(),
cutoffHz: highCutHz, cutoffHz: highCutHz,
); );
Int16List process(Int16List input) { Int16List process(Int16List input) {
final output = Int16List(input.length); final output = Int16List(input.length);
@@ -306,11 +304,11 @@ class _BiquadFilter {
required double b2, required double b2,
required double a1, required double a1,
required double a2, required double a2,
}) : _b0 = b0, }) : _b0 = b0,
_b1 = b1, _b1 = b1,
_b2 = b2, _b2 = b2,
_a1 = a1, _a1 = a1,
_a2 = a2; _a2 = a2;
factory _BiquadFilter.lowPass({ factory _BiquadFilter.lowPass({
required double sampleRate, required double sampleRate,

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,25 @@
String formatPlusCode(double lat, double lon) {
const base = '23456789CFGHJMPQRVWX';
var normalizedLat = (lat + 90) / 180;
var normalizedLon = (lon + 180) / 360;
final buffer = StringBuffer();
for (var i = 0; i < 8; i++) {
if (i == 4) {
buffer.write('+');
}
final latDigit = (normalizedLat * 20).floor() % 20;
final lonDigit = (normalizedLon * 20).floor() % 20;
buffer
..write(base[latDigit])
..write(base[lonDigit]);
normalizedLat = (normalizedLat * 20) % 1;
normalizedLon = (normalizedLon * 20) % 1;
}
return buffer.toString();
}

View File

@@ -0,0 +1,129 @@
import 'dart:typed_data';
import '../models/contact.dart';
class DecodedLogRxRoute {
final int payloadType;
final List<int> pathHashes;
const DecodedLogRxRoute({
required this.payloadType,
required this.pathHashes,
});
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
}
class ResolvedNodeHash {
final int hash;
final String label;
final bool isOwnNode;
final bool isUniqueMatch;
final int matchCount;
const ResolvedNodeHash({
required this.hash,
required this.label,
required this.isOwnNode,
required this.isUniqueMatch,
required this.matchCount,
});
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
}
class LogRxRouteDecoder {
const LogRxRouteDecoder._();
static DecodedLogRxRoute? decode(Uint8List rawData) {
if (rawData.length < 5 || rawData[0] != 0x88) return null;
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) return null;
final header = rawPacketData[0];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) return null;
index += 4;
}
if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null;
return DecodedLogRxRoute(
payloadType: payloadType,
pathHashes: rawPacketData.sublist(index, index + pathLen),
);
}
static ResolvedNodeHash resolveHash(
int hash, {
required Iterable<Contact> contacts,
Uint8List? ownPublicKey,
String? ownName,
}) {
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
? ownPublicKey.first
: null;
if (ownHash == hash) {
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
? '$ownName (you)'
: 'You';
return ResolvedNodeHash(
hash: hash,
label: ownLabel,
isOwnNode: true,
isUniqueMatch: true,
matchCount: 1,
);
}
final matches = contacts.where((contact) {
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
}).toList();
if (matches.isEmpty) {
return ResolvedNodeHash(
hash: hash,
label: 'Unknown',
isOwnNode: false,
isUniqueMatch: false,
matchCount: 0,
);
}
if (matches.length == 1) {
return ResolvedNodeHash(
hash: hash,
label: matches.first.displayName,
isOwnNode: false,
isUniqueMatch: true,
matchCount: 1,
);
}
final candidateNames = matches
.map((contact) => contact.displayName)
.where((name) => name.trim().isNotEmpty)
.take(2)
.join(', ');
final extraCount = matches.length - 2;
final label = candidateNames.isEmpty
? '${matches.length} contacts'
: extraCount > 0
? '$candidateNames +$extraCount'
: candidateNames;
return ResolvedNodeHash(
hash: hash,
label: label,
isOwnNode: false,
isUniqueMatch: false,
matchCount: matches.length,
);
}
}

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

@@ -0,0 +1,149 @@
import '../models/message.dart';
import 'image_message_parser.dart';
import 'voice_message_parser.dart';
const int _defaultLoRaSf = 10;
const int _defaultLoRaCr = 5;
const int _defaultLoRaBwHz = 250000;
const int _defaultLoRaPreambleSymbols = 8;
const int _defaultLoRaCrcEnabled = 1;
const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0;
const int _meshPacketHeaderBytes = 2;
const int _textFrameBaseBytes = 10;
Duration estimateMessageTransmitDuration(
Message message, {
int? radioBw,
int? radioSf,
int? radioCr,
}) {
final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) {
return estimateImageTransmitDuration(
fragmentCount: imageEnvelope.total,
sizeBytes: imageEnvelope.sizeBytes,
pathLen: message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
if (voiceEnvelope != null) {
return estimateVoiceTransmitDuration(
mode: voiceEnvelope.mode,
packetCount: voiceEnvelope.total,
durationMs: voiceEnvelope.durationMs,
pathLen: message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final voicePacket = VoicePacket.tryParseText(message.text);
if (voicePacket != null) {
return estimateVoiceTransmitDuration(
mode: voicePacket.mode,
packetCount: voicePacket.total,
durationMs: voicePacket.durationMs * voicePacket.total,
pathLen: message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final normalizedPathLen = _normalizedPathLen(message.pathLen);
final payloadBytes = _textFrameBaseBytes + message.text.length;
final hops = normalizedPathLen + 1;
final airtimeMs = _estimateLoRaAirtimeMs(
_meshPacketHeaderBytes + normalizedPathLen + payloadBytes,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
return Duration(
milliseconds: (airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops)
.round(),
);
}
int _normalizedPathLen(int pathLen) {
if (pathLen < 0 || pathLen >= 255) return 0;
return pathLen.clamp(0, 64).toInt();
}
double _estimateLoRaAirtimeMs(
int payloadLenBytes, {
int? radioBw,
int? radioSf,
int? radioCr,
}) {
final sf = _normalizeSf(radioSf);
final bw = _resolveBandwidthHz(radioBw).toDouble();
final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4);
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
final symbolMs = ((1 << sf) / bw) * 1000.0;
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
final num =
(8 * payloadLenBytes) -
(4 * sf) +
28 +
(16 * _defaultLoRaCrcEnabled) -
(20 * ih);
final den = 4 * (sf - (2 * de));
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
final payloadSymbols =
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
final payloadMs = payloadSymbols * symbolMs;
return preambleMs + payloadMs;
}
int _normalizeSf(int? value) {
if (value == null) return _defaultLoRaSf;
if (value >= 5 && value <= 12) return value;
return _defaultLoRaSf;
}
int _normalizeCr(int? value) {
if (value == null) return _defaultLoRaCr;
if (value >= 5 && value <= 8) return value;
return _defaultLoRaCr;
}
int _resolveBandwidthHz(int? rawBw) {
if (rawBw == null) return _defaultLoRaBwHz;
if (rawBw > 1000) return rawBw;
switch (rawBw) {
case 0:
return 7800;
case 1:
return 10400;
case 2:
return 15600;
case 3:
return 20800;
case 4:
return 31250;
case 5:
return 41700;
case 6:
return 62500;
case 7:
return 125000;
case 8:
return 250000;
case 9:
return 500000;
default:
return _defaultLoRaBwHz;
}
}

View File

@@ -0,0 +1,87 @@
import 'dart:typed_data';
class RawRouteProbeRequest {
static const int _binaryMagic = 0x70; // 'p'
final int nonce;
final String requesterKey6;
const RawRouteProbeRequest({
required this.nonce,
required this.requesterKey6,
});
static RawRouteProbeRequest? tryParseBinary(Uint8List payload) {
if (payload.length != 11 || payload[0] != _binaryMagic) return null;
try {
final nonce =
(payload[1] << 24) |
(payload[2] << 16) |
(payload[3] << 8) |
payload[4];
final requesterKey6 = payload
.sublist(5, 11)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
return RawRouteProbeRequest(nonce: nonce, requesterKey6: requesterKey6);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
throw ArgumentError.value(
requesterKey6,
'requesterKey6',
'Expected 12 hex chars',
);
}
final out = Uint8List(11);
out[0] = _binaryMagic;
out[1] = (nonce >> 24) & 0xFF;
out[2] = (nonce >> 16) & 0xFF;
out[3] = (nonce >> 8) & 0xFF;
out[4] = nonce & 0xFF;
for (var i = 0; i < 6; i++) {
out[5 + i] = int.parse(
requesterKey6.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
return out;
}
}
class RawRouteProbeAck {
static const int _binaryMagic = 0x71; // 'q'
final int nonce;
const RawRouteProbeAck({required this.nonce});
static RawRouteProbeAck? tryParseBinary(Uint8List payload) {
if (payload.length != 5 || payload[0] != _binaryMagic) return null;
try {
final nonce =
(payload[1] << 24) |
(payload[2] << 16) |
(payload[3] << 8) |
payload[4];
return RawRouteProbeAck(nonce: nonce);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
final out = Uint8List(5);
out[0] = _binaryMagic;
out[1] = (nonce >> 24) & 0xFF;
out[2] = (nonce >> 16) & 0xFF;
out[3] = (nonce >> 8) & 0xFF;
out[4] = nonce & 0xFF;
return out;
}
}

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 } 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

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/location_formats.dart';
/// Reusable location display widget with tap-to-show modal /// Reusable location display widget with tap-to-show modal
/// Shows coordinates in a compact format with ability to view all formats /// Shows coordinates in a compact format with ability to view all formats
@@ -35,9 +36,9 @@ class LocationDisplay extends StatelessWidget {
Text( Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', '${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontFamily: 'monospace', fontFamily: 'monospace',
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Icon( Icon(
@@ -54,9 +55,9 @@ class LocationDisplay extends StatelessWidget {
// Non-compact version (just text) // Non-compact version (just text)
return Text( return Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', '${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
fontFamily: 'monospace', context,
), ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
); );
} }
@@ -79,10 +80,7 @@ class LocationDisplay extends StatelessWidget {
children: [ children: [
const Text( const Text(
'Location Formats', 'Location Formats',
style: TextStyle( style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
fontSize: 20,
fontWeight: FontWeight.bold,
),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
@@ -121,7 +119,7 @@ class LocationDisplay extends StatelessWidget {
_buildFormatRow( _buildFormatRow(
context, context,
'Plus Code', 'Plus Code',
_convertToPlusCode(location.latitude, location.longitude), formatPlusCode(location.latitude, location.longitude),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
@@ -140,9 +138,9 @@ class LocationDisplay extends StatelessWidget {
Text( Text(
label, label,
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey, color: Colors.grey,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
InkWell( InkWell(
@@ -150,7 +148,9 @@ class LocationDisplay extends StatelessWidget {
Clipboard.setData(ClipboardData(text: value)); Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)), content: Text(
AppLocalizations.of(context)!.copiedToClipboard(label),
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -168,9 +168,9 @@ class LocationDisplay extends StatelessWidget {
child: Text( child: Text(
value, value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace', fontFamily: 'monospace',
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
), ),
Icon( Icon(
@@ -242,31 +242,4 @@ class LocationDisplay extends StatelessWidget {
// Full MGRS would require UTM conversion library // Full MGRS would require UTM conversion library
return '$zone$letter (approximate)'; return '$zone$letter (approximate)';
} }
/// Convert to Google Plus Code format
/// Simplified implementation - returns approximate code
String _convertToPlusCode(double lat, double lon) {
// This is a simplified version - full Plus Code requires the open_location_code package
const base = '23456789CFGHJMPQRVWX';
// Normalize coordinates
lat = (lat + 90) / 180; // 0 to 1
lon = (lon + 180) / 360; // 0 to 1
String code = '';
for (int i = 0; i < 8; i++) {
if (i == 4) code += '+';
int latDigit = (lat * 20).floor() % 20;
int lonDigit = (lon * 20).floor() % 20;
code += base[latDigit];
code += base[lonDigit];
lat = (lat * 20) % 1;
lon = (lon * 20) % 1;
}
return code;
}
} }

View File

@@ -21,10 +21,19 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final List<DiscoveredServer> _discoveredServers = []; final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
String? _connectingToServerKey; // Track which server is being connected to (ip:port) int _lastTabIndex = 0;
String?
_connectingToServerKey; // Track which server is being connected to (ip:port)
// Named listener method for proper cleanup // Named listener method for proper cleanup
void _onTabChanged() { void _onTabChanged() {
if (_tabController.index == _lastTabIndex) return;
_lastTabIndex = _tabController.index;
if (_tabController.index == 0) {
_refreshBleDevices();
}
if (_tabController.index == 1) { if (_tabController.index == 1) {
// Switched to network tab // Switched to network tab
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
@@ -47,13 +56,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_connectionProvider = Provider.of<ConnectionProvider>(context, listen: false); _connectionProvider = Provider.of<ConnectionProvider>(
context,
listen: false,
);
// Defer scan startup until after the first frame so Provider listeners // Defer scan startup until after the first frame so Provider listeners
// are not notified while this dialog is still being built. // are not notified while this dialog is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_connectionProvider.startScan(); _refreshBleDevices();
}); });
// Set up network scanner callbacks // Set up network scanner callbacks
@@ -101,6 +113,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
_networkScanner.scan(); _networkScanner.scan();
} }
Future<void> _refreshBleDevices() async {
await _connectionProvider.stopScan();
if (!mounted) return;
await _connectionProvider.startScan();
}
Color _getSignalColor(int rssi) { Color _getSignalColor(int rssi) {
if (rssi >= -60) return Colors.green; if (rssi >= -60) return Colors.green;
if (rssi >= -75) return Colors.orange; if (rssi >= -75) return Colors.orange;
@@ -218,10 +236,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Icons.refresh, Icons.refresh,
color: Theme.of(context).colorScheme.onPrimaryContainer, color: Theme.of(context).colorScheme.onPrimaryContainer,
), ),
onPressed: () { onPressed: _refreshBleDevices,
connectionProvider.stopScan();
connectionProvider.startScan();
},
), ),
], ],
), ),
@@ -255,10 +270,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: _refreshBleDevices,
connectionProvider.stopScan();
connectionProvider.startScan();
},
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context)!.scanAgain), label: Text(AppLocalizations.of(context)!.scanAgain),
), ),

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'),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,452 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/messages_provider.dart';
import '../../providers/app_provider.dart';
import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart';
class DirectMessageSheet extends StatefulWidget {
final Contact contact;
const DirectMessageSheet({super.key, required this.contact});
@override
State<DirectMessageSheet> createState() => _DirectMessageSheetState();
}
class _DirectMessageSheetState extends State<DirectMessageSheet> {
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
int _characterCount = 0;
static const int _maxCharacters = 160;
@override
void initState() {
super.initState();
_textController.addListener(_updateCharacterCount);
}
@override
void dispose() {
_textController.dispose();
_focusNode.dispose();
super.dispose();
}
void _updateCharacterCount() {
if (!mounted) return;
setState(() {
_characterCount = _textController.text.length;
});
}
/// Insert current GPS location at cursor position
Future<void> _insertCurrentLocation() async {
try {
// Check location permission
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (!mounted) return;
ToastLogger.error(context, 'Location permission denied');
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (!mounted) return;
ToastLogger.error(context, 'Location permission permanently denied');
return;
}
// Get current position
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
),
);
// Format location text
final locationText =
'📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}';
// Check if adding location would exceed limit
final currentText = _textController.text;
if (currentText.length + locationText.length > _maxCharacters) {
if (!mounted) return;
ToastLogger.error(
context,
'Adding location would exceed 160 character limit',
);
return;
}
// Insert at cursor position or append
final selection = _textController.selection;
final newText = currentText.replaceRange(
selection.start >= 0 ? selection.start : currentText.length,
selection.end >= 0 ? selection.end : currentText.length,
locationText,
);
_textController.text = newText;
// Move cursor to end of inserted text
final newCursorPosition =
(selection.start >= 0 ? selection.start : currentText.length) +
locationText.length;
_textController.selection = TextSelection.fromPosition(
TextPosition(offset: newCursorPosition),
);
if (!mounted) return;
} catch (e) {
if (!mounted) return;
ToastLogger.error(context, 'Failed to get location: $e');
}
}
Future<void> _sendDirectMessage() async {
final text = _textController.text.trim();
if (text.isEmpty) return;
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
try {
// Create message ID
final messageId = '${DateTime.now().millisecondsSinceEpoch}_dm_sent';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Get current device's public key (first 6 bytes)
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create sent message object with recipient public key for retry support
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey:
widget.contact.publicKey, // Store recipient for retry
);
// Add to messages list with "sending" status
// Pass contact for retry logic
messagesProvider.addSentMessage(sentMessage, contact: widget.contact);
// Send direct message to contact (include contact for path logging)
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: widget.contact.publicKey,
text: text,
messageId: messageId, // Pass message ID for tracking
contact: widget.contact,
);
if (!sentSuccessfully) {
// Mark message as failed if sending failed
messagesProvider.markMessageFailed(messageId);
}
_textController.clear();
_focusNode.unfocus();
if (!mounted) return;
Navigator.pop(context); // Close the dialog
} catch (e) {
if (!mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToSend(e.toString()),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final appProvider = context.watch<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
final contactLocation = widget.contact.displayLocation;
return Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
children: [
Text(
AppLocalizations.of(context)!.directMessage,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
widget.contact.displayName,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 48), // Spacer to keep title centered
],
),
),
// Mini map in simple mode (scrollable content)
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 16),
if (isSimpleMode && contactLocation != null) ...[
GestureDetector(
onTap: () {
// Hide keyboard when tapping on map
_focusNode.unfocus();
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.outline),
),
clipBehavior: Clip.antiAlias,
child: FlutterMap(
options: MapOptions(
initialCenter: LatLng(
contactLocation.latitude,
contactLocation.longitude,
),
initialZoom: 13.0,
interactionOptions: const InteractionOptions(
flags:
InteractiveFlag.pinchZoom |
InteractiveFlag.drag,
),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
MarkerLayer(
markers: [
Marker(
point: LatLng(
contactLocation.latitude,
contactLocation.longitude,
),
width: 40,
height: 40,
child: Icon(
Icons.location_on,
color: colorScheme.primary,
size: 40,
),
),
],
),
],
),
),
),
const SizedBox(height: 8),
// Location coordinates
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.gps_fixed,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(height: 16),
],
],
),
),
),
// Message input
Container(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
),
child: Column(
children: [
TextField(
controller: _textController,
focusNode: _focusNode,
maxLength: _maxCharacters,
maxLines: 3,
autofocus: true,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.all(16),
counterText: '', // Hide default counter
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendDirectMessage(),
),
// Always-visible character counter
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'$_characterCount / $_maxCharacters',
style: TextStyle(
fontSize: 12,
color: _characterCount > 155
? Colors.red
: (_characterCount > 140
? Colors.orange
: colorScheme.onSurfaceVariant),
fontWeight: _characterCount > 140
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
const SizedBox(height: 8),
// Location and Send buttons
Row(
children: [
OutlinedButton.icon(
onPressed: _insertCurrentLocation,
icon: const Icon(Icons.my_location, size: 18),
label: Text(AppLocalizations.of(context)!.myLocation),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: BorderSide(color: colorScheme.outline),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _textController.text.trim().isEmpty
? null
: _sendDirectMessage,
icon: const Icon(Icons.send),
label: Text(
AppLocalizations.of(context)!.sendDirectMessage,
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor:
colorScheme.surfaceContainerHighest,
disabledForegroundColor: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
],
),
);
}
}

View File

@@ -3,10 +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/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';
@@ -32,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;
@@ -58,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(
@@ -65,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(
@@ -81,6 +90,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isRequesting = false; _isRequesting = false;
_isPartialRequest = false;
_errorText = null; _errorText = null;
}); });
} }
@@ -90,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
@@ -109,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,
@@ -124,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,
@@ -134,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,
@@ -155,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,
@@ -179,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,
@@ -228,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',
), ),
], ],
], ],
@@ -254,23 +300,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
int pathLen = 0, int pathLen = 0,
}) async { }) async {
if (_isRequesting) return; if (_isRequesting) return;
final conn = context.read<ConnectionProvider>(); final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>(); final imageProvider = context.read<ip.ImageProvider>();
imageProvider.resumeIncomingSession(envelope.sessionId); imageProvider.resumeIncomingSession(envelope.sessionId);
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget( final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
refreshContacts: conn.getContacts, refreshContacts: conn.getContacts,
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,
); );
if (!mounted) return; if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) { if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch image', 'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.', 'Sender contact is unknown. Sync contacts first.',
@@ -278,6 +326,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.unknownRoute) { if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch image', 'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.', 'Sender route is unknown. Sync contacts/path first.',
@@ -285,23 +334,94 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.tooFar) { if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch image', 'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
); );
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route did not respond to a path check. Sync contacts/path and try again.',
);
return;
}
final sender = resolution.target!; var sender = resolution.target!;
if (sender.outPathLen >= 2) { var routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
await conn.getContacts();
if (!mounted) return;
resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: conn.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
sender = resolution.target!;
routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route did not respond on the raw transport path.',
);
return;
}
}
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.',
); );
} }
setState(() => _errorText = null); setState(() => _errorText = null);
final deviceKey = conn.deviceInfo.publicKey; final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) { if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch image', 'Cannot fetch image',
'Device key is unavailable.', 'Device key is unavailable.',
@@ -318,30 +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,
); );
setState(() {
_isRequesting = true;
_errorText = null;
});
final payload = request.encodeBinary(); final payload = request.encodeBinary();
try { try {
debugPrint(
'📷 [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 (_) {
@@ -349,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';
}); });
} }
@@ -357,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,
@@ -380,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';
}); });
} }
@@ -401,10 +524,19 @@ 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';
}); });
} }
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_isPartialRequest = false;
});
}
Future<void> _showBlockingAlert(String title, String message) async { Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return; if (!mounted) return;
_showToast('$title: $message'); _showToast('$title: $message');
@@ -426,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,
@@ -436,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,
@@ -450,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) {
@@ -477,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,
@@ -523,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,
),
),
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../utils/avatar_label_helper.dart';
import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart';
Widget buildMessageHeaderAvatar(
BuildContext context, {
required bool isOwnMessage,
required bool isChannelMessage,
required dynamic senderContact,
required String displayName,
}) {
if (isOwnMessage) {
return CircleAvatar(
radius: 10.5,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.account_circle,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
);
}
if (senderContact is Contact) {
return ContactAvatar(
contact: senderContact,
radius: 10.5,
displayName: displayName,
);
}
final background = isChannelMessage
? Colors.teal.withValues(alpha: 0.16)
: Theme.of(context).colorScheme.surfaceContainerHighest;
final foreground = isChannelMessage
? Colors.teal.shade800
: Theme.of(context).colorScheme.onSurfaceVariant;
return CircleAvatar(
radius: 10.5,
backgroundColor: background,
child: Text(
AvatarLabelHelper.buildLabel(displayName),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: foreground,
letterSpacing: -0.2,
),
),
);
}
Widget buildBubbleMetaFooter(
BuildContext context, {
required Message message,
required bool isSarMarker,
}) {
final metaColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
final items = <Widget>[];
final sentEchoLabel = message.isSentMessage && message.echoCount > 0
? '${message.echoCount} echo${message.echoCount == 1 ? '' : 'es'}'
: null;
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([
Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
Text(
'',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
]);
}
items.add(
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: metaColor,
fontWeight: FontWeight.w500,
),
),
);
return Padding(
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
child: Align(
alignment: Alignment.centerRight,
child: Row(mainAxisSize: MainAxisSize.min, children: items),
),
);
}
Widget buildChannelHeaderPill(
BuildContext context, {
required String label,
IconData icon = Icons.campaign_outlined,
}) {
final labelColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 11,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
),
const SizedBox(width: 5),
Flexible(
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: labelColor,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Widget buildDirectHeaderCounterpart(
BuildContext context, {
required String label,
}) {
return buildChannelHeaderPill(
context,
label: label,
icon: Icons.alternate_email,
);
}

View File

@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import '../../models/message.dart';
import '../../models/message_reception_details.dart';
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Icons.schedule;
case MessageDeliveryStatus.sent:
return Icons.done;
case MessageDeliveryStatus.delivered:
return Icons.done_all;
case MessageDeliveryStatus.failed:
return Icons.error_outline;
case MessageDeliveryStatus.received:
return Icons.inbox;
}
}
Color getDeliveryStatusColor(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Colors.orange;
case MessageDeliveryStatus.sent:
return Colors.blue;
case MessageDeliveryStatus.delivered:
return Colors.green;
case MessageDeliveryStatus.failed:
return Colors.red;
case MessageDeliveryStatus.received:
return Colors.grey;
}
}
Widget buildChannelEchoStatus(BuildContext context, Message message) {
final hasEcho = message.echoCount > 0;
if (!hasEcho) {
return const SizedBox.shrink();
}
final statusColor = getDeliveryStatusColor(message.deliveryStatus);
final rssi = message.lastEchoRssiDbm;
final snr = message.lastEchoSnrRaw != null
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
: null;
final quality = linkQualityLabel(rssi, snr);
final qualityColor = linkQualityColor(quality);
return Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.hub_outlined,
label: 'x${message.echoCount}',
color: statusColor,
),
if (message.expectedAckTag != null)
_techChip(
context,
icon: Icons.tag,
label:
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
color: Colors.indigo,
),
_techChip(context, icon: Icons.bolt, label: quality, color: qualityColor),
if (message.lastEchoRssiDbm != null)
_signalCapsule(
context,
icon: Icons.network_cell,
label: message.lastEchoRssiDbm!.toString(),
filled: rssiScore(message.lastEchoRssiDbm!),
color: Colors.blueGrey,
),
if (message.lastEchoSnrRaw != null)
_signalCapsule(
context,
icon: Icons.graphic_eq,
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1),
filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
color: Colors.teal,
),
],
);
}
bool shouldShowSentChannelStats(
Message message, {
required bool showReceivedStats,
}) {
if (!message.isSentMessage || !message.isChannelMessage) {
return false;
}
final hasSignalData =
message.echoCount > 0 ||
message.lastEchoRssiDbm != null ||
message.lastEchoSnrRaw != null ||
message.expectedAckTag != null;
return showReceivedStats && hasSignalData;
}
Widget buildReceivedSignalStatus(
BuildContext context,
Message message, {
MessageReceptionDetails? receptionDetails,
required int? rssiDbm,
required double? snrDb,
}) {
final hopLabel = hopDisplayLabel(message);
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.alt_route,
label: hopLabel,
color: Colors.indigo,
),
if (receptionDetails?.senderToReceiptMs != null)
_techChip(
context,
icon: Icons.schedule,
label: _formatMs(receptionDetails!.senderToReceiptMs!),
color: Colors.deepPurple,
),
if (receptionDetails?.estimatedTransmitMs != null)
_techChip(
context,
icon: Icons.timelapse,
label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx',
color: Colors.blue,
),
if (receptionDetails?.postTransmitDelayMs != null)
_techChip(
context,
icon: Icons.hourglass_bottom,
label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag',
color: Colors.orange,
),
if (receptionDetails?.pathBytesHex != null)
_techChip(
context,
icon: Icons.route,
label: receptionDetails!.pathBytesHex!,
color: Colors.brown,
),
if (rssiDbm != null || snrDb != null) ...[
_techChip(
context,
icon: Icons.bolt,
label: linkQualityLabel(rssiDbm, snrDb),
color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)),
),
if (rssiDbm != null)
_signalCapsule(
context,
icon: Icons.network_cell,
label: '$rssiDbm',
filled: rssiScore(rssiDbm),
color: Colors.blueGrey,
),
if (snrDb != null)
_signalCapsule(
context,
icon: Icons.graphic_eq,
label: snrDb.toStringAsFixed(1),
filled: snrScore(snrDb),
color: Colors.teal,
),
],
],
);
}
Widget buildSentDirectSignalStatus(
BuildContext context,
Message message, {
required int roundTripTimeMs,
required Duration txEstimate,
}) {
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
estimatedTransmitMs: txEstimate > Duration.zero
? txEstimate.inMilliseconds
: null,
senderToReceiptMs: roundTripTimeMs,
);
final postTransmitDelayMs = estimatedTransmitMs != null
? (roundTripTimeMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
: null;
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.alt_route,
label: hopDisplayLabel(message),
color: Colors.indigo,
),
_techChip(
context,
icon: Icons.schedule,
label: _formatMs(roundTripTimeMs),
color: Colors.deepPurple,
),
if (estimatedTransmitMs != null)
_techChip(
context,
icon: Icons.timelapse,
label: '~${_formatMs(estimatedTransmitMs)} tx',
color: Colors.blue,
),
if (postTransmitDelayMs != null)
_techChip(
context,
icon: Icons.hourglass_bottom,
label: '+${_formatMs(postTransmitDelayMs)} lag',
color: Colors.orange,
),
if (message.retryAttempt > 0)
_techChip(
context,
icon: Icons.refresh,
label: 'retry ${message.retryAttempt}/3',
color: Colors.redAccent,
),
if (message.suggestedTimeoutMs != null)
_techChip(
context,
icon: Icons.timer_outlined,
label: 'timeout ${_formatMs(message.suggestedTimeoutMs!)}',
color: Colors.blueGrey,
),
if (message.usedFloodFallback)
_techChip(
context,
icon: Icons.waves,
label: 'flood fallback',
color: Colors.teal,
)
else if (message.expectedAckTag != null)
_techChip(
context,
icon: Icons.route,
label: 'direct ACK',
color: Colors.indigo,
),
],
);
}
String _formatMs(int value) {
if (value >= 60000) {
final minutes = value ~/ 60000;
final seconds = (value % 60000) ~/ 1000;
return '${minutes}m ${seconds}s';
}
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s';
}
return '${value}ms';
}
String hopDisplayLabel(Message message) {
if (message.pathLen == 0) return 'Direct';
if (message.pathLen >= 255 && message.isContactMessage) return 'Direct';
if (message.pathLen >= 255) return 'Unknown';
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
}
Widget _techChip(
BuildContext context, {
required IconData icon,
required String label,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}
Widget _signalCapsule(
BuildContext context, {
required IconData icon,
required String label,
required int filled,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final active = i < filled;
return Container(
width: 3,
height: (4 + i).toDouble(),
margin: const EdgeInsets.symmetric(horizontal: 0.5),
decoration: BoxDecoration(
color: active ? color : color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(1),
),
);
}),
),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}
int rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5);
int snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
String linkQualityLabel(int? rssiDbm, double? snrDb) {
var score = 0;
if (rssiDbm != null) score += rssiScore(rssiDbm);
if (snrDb != null) score += snrScore(snrDb);
if (score >= 8) return 'Excellent';
if (score >= 6) return 'Good';
if (score >= 4) return 'Fair';
return 'Weak';
}
Color linkQualityColor(String quality) {
switch (quality) {
case 'Excellent':
return Colors.green;
case 'Good':
return Colors.lightGreen;
case 'Fair':
return Colors.orange;
default:
return Colors.redAccent;
}
}

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

@@ -0,0 +1,432 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../l10n/app_localizations.dart';
class MessagesComposer extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
final int messageByteCount;
final int maxMessageBytes;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final double bottomPadding;
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onShowComposerActions;
final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onSendMessage;
const MessagesComposer({
super.key,
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
required this.messageByteCount,
required this.maxMessageBytes,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.bottomPadding,
required this.destinationLabel,
required this.destinationAvatar,
required this.onShowComposerActions,
required this.onShowRecipientSelector,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onSendMessage,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(color: Colors.transparent),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
_ComposerActionButton(
isRecording: isRecording,
onPressed: isRecording
? onStopAndSendVoice
: onShowComposerActions,
),
const SizedBox(width: 8),
Expanded(
child: _DestinationSelector(
destinationLabel: destinationLabel,
destinationAvatar: destinationAvatar,
onTap: onShowRecipientSelector,
),
),
],
),
const SizedBox(height: 8),
ListenableBuilder(
listenable: Listenable.merge([
textController,
focusNode,
]),
builder: (context, _) {
final canSendText =
!isRecording &&
!isSendingVoice &&
textController.text.trim().isNotEmpty;
final semanticsLabel = isRecording
? 'Recording... release to send voice'
: (isSendingVoice
? 'Sending voice...'
: voiceSupported
? 'Send (long press to record voice)'
: 'Send');
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _MessageInput(
textController: textController,
focusNode: focusNode,
messageByteLimiter: messageByteLimiter,
),
),
const SizedBox(width: 8),
_SendButton(
canSendText: canSendText,
isRecording: isRecording,
isSendingVoice: isSendingVoice,
voiceSupported: voiceSupported,
semanticsLabel: semanticsLabel,
messageByteCount: messageByteCount,
maxMessageBytes: maxMessageBytes,
onSendMessage: onSendMessage,
onStartVoiceRecording: onStartVoiceRecording,
onStopAndSendVoice: onStopAndSendVoice,
),
],
);
},
),
],
),
),
),
),
),
],
),
);
}
}
class _ComposerActionButton extends StatelessWidget {
final bool isRecording;
final VoidCallback onPressed;
const _ComposerActionButton({
required this.isRecording,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22),
tooltip: isRecording ? 'Stop recording' : 'More actions',
onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
),
);
}
}
class _DestinationSelector extends StatelessWidget {
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onTap;
const _DestinationSelector({
required this.destinationLabel,
required this.destinationAvatar,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 10),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more_rounded,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}
}
class _MessageInput extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
const _MessageInput({
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor.withValues(alpha: 0.35),
width: focusNode.hasFocus ? 1.4 : 1,
),
boxShadow: focusNode.hasFocus
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
controller: textController,
focusNode: focusNode,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.multiline,
inputFormatters: [messageByteLimiter],
style: const TextStyle(fontSize: 15),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(
fontSize: 15,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
filled: false,
fillColor: Colors.transparent,
border: InputBorder.none,
isCollapsed: true,
),
textInputAction: TextInputAction.newline,
),
),
);
}
}
class _SendButton extends StatelessWidget {
final bool canSendText;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final String semanticsLabel;
final int messageByteCount;
final int maxMessageBytes;
final Future<void> Function() onSendMessage;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
const _SendButton({
required this.canSendText,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.semanticsLabel,
required this.messageByteCount,
required this.maxMessageBytes,
required this.onSendMessage,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: canSendText || (voiceSupported && !isSendingVoice),
label: semanticsLabel,
onTap: canSendText ? onSendMessage : null,
onLongPress: (voiceSupported && !isSendingVoice)
? () {
if (isRecording) {
onStopAndSendVoice();
return;
}
onStartVoiceRecording();
}
: null,
child: Tooltip(
message: semanticsLabel,
excludeFromSemantics: true,
child: GestureDetector(
excludeFromSemantics: true,
onTap: canSendText ? onSendMessage : null,
onLongPressStart: (voiceSupported && !isSendingVoice)
? (_) => onStartVoiceRecording()
: null,
onLongPressEnd: (voiceSupported && isRecording)
? (_) => onStopAndSendVoice()
: null,
onLongPressCancel: (voiceSupported && isRecording)
? onStopAndSendVoice
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 46,
height: 46,
decoration: BoxDecoration(
color: canSendText || isRecording
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: canSendText || isRecording
? Colors.transparent
: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: canSendText || isRecording
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.22),
blurRadius: 14,
offset: const Offset(0, 6),
),
]
: null,
),
child: isSendingVoice
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
)
: Icon(
isRecording ? Icons.mic_rounded : Icons.send_rounded,
size: 22,
color: canSendText || isRecording
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'$messageByteCount/$maxMessageBytes',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: messageByteCount > maxMessageBytes * 0.9
? Colors.orange.shade800
: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget {
static const double defaultPadding = 8;
final List<Message> messages;
final ScrollController scrollController;
final String? highlightedMessageId;
final double bottomContentPadding;
final Future<void> Function() onRefresh;
final VoidCallback? onNavigateToMap;
final ValueChanged<Message>? onMessageTap;
const MessagesContent({
super.key,
required this.messages,
required this.scrollController,
required this.highlightedMessageId,
this.bottomContentPadding = 0,
required this.onRefresh,
this.onNavigateToMap,
this.onMessageTap,
});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.noMessagesYet,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(context)!.pullDownToSync,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: ListView.builder(
controller: scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
reverse: true,
padding: EdgeInsets.fromLTRB(
defaultPadding,
defaultPadding,
defaultPadding,
defaultPadding + bottomContentPadding,
),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return MessageBubble(
key: ValueKey(message.id),
message: message,
isHighlighted: message.id == highlightedMessageId,
onNavigateToMap: onNavigateToMap,
onTap: onMessageTap == null
? null
: () => onMessageTap!(message),
);
},
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../common/contact_avatar.dart';
/// Bottom sheet for selecting message recipient (channel, contact, or room) /// Bottom sheet for selecting message recipient (channel, contact, or room)
class RecipientSelectorSheet extends StatefulWidget { class RecipientSelectorSheet extends StatefulWidget {
@@ -168,7 +169,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
...filteredChannels.map((channel) { ...filteredChannels.map((channel) {
return _buildRecipientTile( return _buildRecipientTile(
context: context, context: context,
icon: Icons.public, contact: channel,
title: channel.getLocalizedDisplayName(context), title: channel.getLocalizedDisplayName(context),
subtitle: channel.isPublicChannel subtitle: channel.isPublicChannel
? l10n.broadcastToAllNearby ? l10n.broadcastToAllNearby
@@ -215,10 +216,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
...filteredContacts.map((contact) { ...filteredContacts.map((contact) {
return _buildRecipientTile( return _buildRecipientTile(
context: context, context: context,
icon: Icons.person, contact: contact,
title: contact.displayName, title: contact.displayName,
subtitle: contact.publicKeyShort, subtitle: contact.publicKeyShort,
emoji: contact.roleEmoji,
isSelected: _isSelected('contact', contact), isSelected: _isSelected('contact', contact),
onTap: () { onTap: () {
widget.onSelect('contact', contact); widget.onSelect('contact', contact);
@@ -261,10 +261,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
...filteredRooms.map((room) { ...filteredRooms.map((room) {
return _buildRecipientTile( return _buildRecipientTile(
context: context, context: context,
icon: Icons.meeting_room, contact: room,
title: room.displayName, title: room.displayName,
subtitle: room.publicKeyShort, subtitle: room.publicKeyShort,
emoji: room.roleEmoji,
isSelected: _isSelected('room', room), isSelected: _isSelected('room', room),
onTap: () { onTap: () {
widget.onSelect('room', room); widget.onSelect('room', room);
@@ -275,7 +274,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
], ],
// Empty state // Empty state
if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[ if (widget.contacts.isEmpty &&
widget.rooms.isEmpty &&
widget.channels.isEmpty) ...[
Padding( Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Column( child: Column(
@@ -310,36 +311,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
Widget _buildRecipientTile({ Widget _buildRecipientTile({
required BuildContext context, required BuildContext context,
required IconData icon, required Contact contact,
required String title, required String title,
required String subtitle, required String subtitle,
String? emoji,
required bool isSelected, required bool isSelected,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
return ListTile( return ListTile(
leading: Container( leading: ContactAvatar(contact: contact, radius: 20, displayName: title),
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
icon,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
title: Row( title: Row(
children: [ children: [
if (emoji != null && emoji.isNotEmpty) ...[
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
],
Expanded( Expanded(
child: Text( child: Text(
title, title,

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../../models/message.dart';
import '../../utils/message_extensions.dart';
class SystemMessageBubble extends StatelessWidget {
final Message message;
const SystemMessageBubble({super.key, required this.message});
Color _getLevelColor(String? level) {
switch (level?.toLowerCase()) {
case 'success':
return Colors.green;
case 'warning':
return Colors.orange;
case 'error':
return Colors.red;
case 'info':
default:
return Colors.blue.shade300;
}
}
IconData _getLevelIcon(String? level) {
switch (level?.toLowerCase()) {
case 'success':
return Icons.check_circle_outline;
case 'warning':
return Icons.warning_amber_outlined;
case 'error':
return Icons.error_outline;
case 'info':
default:
return Icons.info_outline;
}
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final level = message.senderName ?? 'info';
final levelColor = _getLevelColor(level);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(_getLevelIcon(level), size: 16, color: levelColor),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
level.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: levelColor,
fontWeight: FontWeight.bold,
letterSpacing: 0.4,
),
),
),
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(height: 4),
Text(
message.text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
height: 1.3,
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -2,9 +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/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';
@@ -27,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;
@@ -54,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);
@@ -62,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);
@@ -76,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;
}); });
}); });
@@ -92,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);
@@ -116,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,
@@ -155,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),
), ),
), ),
), ),
@@ -168,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),
@@ -188,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,
@@ -218,23 +257,31 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
int pathLen = 0, int pathLen = 0,
}) async { }) async {
if (_isRequesting) return; if (_isRequesting) return;
setState(() {
_isRequesting = true;
_isPartialRequest = false;
_autoPlayWhenReady = true;
_errorText = null;
});
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final voiceProvider = context.read<VoiceProvider>(); final voiceProvider = context.read<VoiceProvider>();
voiceProvider.resumeIncomingSession(sessionId); voiceProvider.resumeIncomingSession(sessionId);
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget( final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts, refreshContacts: connectionProvider.getContacts,
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,
); );
if (!mounted) return; if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) { if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch voice', 'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.', 'Sender contact is unknown. Sync contacts first.',
@@ -242,6 +289,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.unknownRoute) { if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch voice', 'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.', 'Sender route is unknown. Sync contacts/path first.',
@@ -249,27 +297,93 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.tooFar) { if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch voice', 'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
); );
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route did not respond to a path check. Sync contacts/path and try again.',
);
return;
}
final sender = resolution.target!; var sender = resolution.target!;
if (sender.outPathLen >= 2) { var routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
await connectionProvider.getContacts();
if (!mounted) return;
resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
sender = resolution.target!;
routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route did not respond on the raw transport path.',
);
return;
}
}
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.',
); );
} }
if (!mounted) return;
setState(() {
_errorText = null;
});
final deviceKey = connectionProvider.deviceInfo.publicKey; final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) { if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch voice', 'Cannot fetch voice',
'Device key is unavailable.', 'Device key is unavailable.',
@@ -281,23 +395,38 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(''); .join('');
final request = VoiceFetchRequest( final missing = voiceProvider.missingPacketIndices(sessionId);
final totalPackets = sessionPacketCount(
voiceProvider: voiceProvider,
sessionId: sessionId, sessionId: sessionId,
requesterKey6: requesterKey6, envelope: envelope,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 2,
); );
final isPartialResume =
setState(() { missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets;
_isRequesting = true; if (_isPartialRequest != isPartialResume && mounted) {
_autoPlayWhenReady = true; setState(() {
_errorText = null; _isPartialRequest = isPartialResume;
}); });
}
final request = isPartialResume
? VoiceFetchRequest(
sessionId: sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
)
: VoiceFetchRequest(
sessionId: sessionId,
requesterKey6: requesterKey6,
);
try { try {
debugPrint(
'🎙️ [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 (_) {
@@ -306,14 +435,21 @@ 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 =
envelope != null &&
totalPackets > 0 &&
missing.isNotEmpty &&
missing.length < totalPackets
? ((envelope.durationMs * missing.length) / totalPackets).round()
: envelope?.durationMs;
final txEstimate = envelope != null final txEstimate = envelope != null
? estimateVoiceTransmitDuration( ? estimateVoiceTransmitDuration(
packetCount: envelope.total, packetCount: isPartialResume ? missing.length : envelope.total,
mode: envelope.mode, mode: envelope.mode,
durationMs: envelope.durationMs, durationMs: estimatedDurationMs ?? envelope.durationMs,
pathLen: effectivePathLen, pathLen: effectivePathLen,
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
@@ -331,16 +467,34 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
); );
} }
int sessionPacketCount({
required VoiceProvider voiceProvider,
required String sessionId,
required VoiceEnvelope? envelope,
}) {
return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0;
}
void _setUnavailable() { void _setUnavailable() {
if (!mounted) return; if (!mounted) return;
_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;
}); });
} }
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false;
});
}
void _cancelReceive(String sessionId) { void _cancelReceive(String sessionId) {
if (!mounted) return; if (!mounted) return;
_requestTimeoutTimer?.cancel(); _requestTimeoutTimer?.cancel();
@@ -348,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';
}); });
@@ -392,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({
@@ -487,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

@@ -29,10 +29,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: audioplayers name: audioplayers
sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.1" version: "6.6.0"
audioplayers_android: audioplayers_android:
dependency: transitive dependency: transitive
description: description:
@@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: audioplayers_darwin name: audioplayers_darwin
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "6.4.0"
audioplayers_linux: audioplayers_linux:
dependency: transitive dependency: transitive
description: description:
@@ -69,18 +69,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: audioplayers_web name: audioplayers_web
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" version: "5.2.0"
audioplayers_windows: audioplayers_windows:
dependency: transitive dependency: transitive
description: description:
name: audioplayers_windows name: audioplayers_windows
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.1" version: "4.3.0"
bluez: bluez:
dependency: transitive dependency: transitive
description: description:
@@ -851,7 +851,7 @@ packages:
description: description:
path: "." path: "."
ref: main ref: main
resolved-ref: "3f870e98ee9527a3137bfcbdd1454036912fb609" resolved-ref: cea66b5251135c7f9b84f15c0878d4c8af6e88e9
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2026.0306.3+13 version: 2026.0307.4+17
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2

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

@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
void main() {
test('drops impossible transmit estimate for received messages', () {
expect(
sanitizeEstimatedTransmitMs(
estimatedTransmitMs: 16 * 60 * 1000 + 54 * 1000,
senderToReceiptMs: 4200,
),
isNull,
);
});
test(
'keeps close transmit estimate despite second-level timestamp rounding',
() {
expect(
sanitizeEstimatedTransmitMs(
estimatedTransmitMs: 1800,
senderToReceiptMs: 900,
),
1800,
);
},
);
test('round trips reception details json', () {
final details = MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000),
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
rssiDbm: -92,
snrDb: 7.5,
pathBytes: const [0xAA, 0xBB, 0xCC],
senderToReceiptMs: 4200,
estimatedTransmitMs: 1800,
postTransmitDelayMs: 2400,
);
final decoded = MessageReceptionDetails.fromJson(details.toJson());
expect(decoded, isNotNull);
expect(decoded!.rssiDbm, -92);
expect(decoded.snrDb, 7.5);
expect(decoded.pathBytesHex, 'aa:bb:cc');
expect(decoded.senderToReceiptMs, 4200);
expect(decoded.estimatedTransmitMs, 1800);
expect(decoded.postTransmitDelayMs, 2400);
});
}

View File

@@ -128,6 +128,8 @@ void main() {
expect(updated.displayLocation, isNotNull); expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(45.0001, 0.0001)); expect(updated.displayLocation!.latitude, closeTo(45.0001, 0.0001));
expect(updated.displayLocation!.longitude, closeTo(13.9999, 0.0001)); expect(updated.displayLocation!.longitude, closeTo(13.9999, 0.0001));
expect(updated.advLat, equals((45.0001 * 1e6).round()));
expect(updated.advLon, equals((13.9999 * 1e6).round()));
}); });
test( test(
@@ -232,5 +234,70 @@ 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('persists last valid telemetry gps on the contact across reloads', () async {
final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001,
longitude: 13.9999,
);
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
await Future<void>.delayed(Duration.zero);
final reloadedProvider = ContactsProvider();
await reloadedProvider.initializeEarly();
final reloaded = reloadedProvider.findContactByKey(publicKey)!;
expect(reloaded.advLat, equals((45.0001 * 1e6).round()));
expect(reloaded.advLon, equals((13.9999 * 1e6).round()));
expect(reloaded.advertLocation, isNotNull);
expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001));
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
});
});
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

@@ -0,0 +1,27 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/helpers/ping_tracker.dart';
void main() {
Uint8List createPublicKey() => Uint8List.fromList(
List<int>.generate(32, (index) => index + 1),
);
group('PingTracker', () {
test('completes pending ping when response uses public key prefix', () async {
final tracker = PingTracker();
final publicKey = createPublicKey();
final pingFuture = tracker.trackPing(
publicKey: publicKey,
wasDirectAttempt: true,
);
tracker.markPingSuccessful(publicKey.sublist(0, 6));
await expectLater(pingFuture, completion(isTrue));
expect(tracker.hasPendingPing(publicKey), isFalse);
});
});
}

View File

@@ -47,9 +47,8 @@ void main() {
expect(ok, isFalse); expect(ok, isFalse);
}); });
test('sends only requested indices and waits for ack', () async { test('sends only requested indices', () async {
final sent = <Uint8List>[]; final sent = <Uint8List>[];
final waited = <int>[];
final ok = await serveCachedSessionFragments<_Fragment>( final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider', providerLabel: 'TestProvider',
sessionId: 'deadbeef', sessionId: 'deadbeef',
@@ -70,15 +69,6 @@ void main() {
}) async { }) async {
sent.add(payload); sent.add(payload);
}, },
waitForFragmentAck:
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) async {
waited.add(index);
return true;
},
requestedIndices: {1, 2}, requestedIndices: {1, 2},
); );
@@ -86,37 +76,6 @@ void main() {
expect(sent.length, equals(2)); expect(sent.length, equals(2));
expect(sent[0], equals(Uint8List.fromList([20]))); expect(sent[0], equals(Uint8List.fromList([20])));
expect(sent[1], equals(Uint8List.fromList([30]))); expect(sent[1], equals(Uint8List.fromList([30])));
expect(waited, equals([1, 2]));
});
test('fails when ack does not arrive', () async {
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: 1),
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
waitForFragmentAck:
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) async {
return false;
},
);
expect(ok, isFalse);
}); });
test('fails when no requested index matches cached fragments', () async { test('fails when no requested index matches cached fragments', () async {

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

@@ -40,7 +40,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider retransmission', () { group('MessagesProvider retransmission', () {
test('direct messages stay pending until delivery ACK arrives', () { test('direct messages become sent before delivery ACK arrives', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.addSentMessage( provider.addSentMessage(
_buildDirectMessage('m1'), _buildDirectMessage('m1'),
@@ -51,7 +51,7 @@ void main() {
expect( expect(
provider.messages.single.deliveryStatus, provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending, MessageDeliveryStatus.sent,
); );
expect(provider.messages.single.expectedAckTag, 77); expect(provider.messages.single.expectedAckTag, 77);
@@ -64,6 +64,56 @@ 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',
() {
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();
provider.addSentMessage(
_buildDirectMessage('m1c'),
contact: _buildContact(),
);
provider.markMessageSent('m1c', 0, 0);
expect(
provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sent,
);
expect(provider.messages.single.expectedAckTag, 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', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.addSentMessage( provider.addSentMessage(
@@ -162,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();
@@ -172,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');
@@ -204,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

@@ -0,0 +1,63 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/map_marker_service.dart';
import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart';
void main() {
Contact buildContact({
required String name,
required ContactType type,
required int advLat,
required int advLon,
}) {
return Contact(
publicKey: Uint8List(32),
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch,
advLat: advLat,
advLon: advLon,
lastMod: DateTime.now().millisecondsSinceEpoch,
);
}
testWidgets('contact map markers render shared contact avatars', (tester) async {
final service = MapMarkerService();
final contact = buildContact(
name: 'John Smith',
type: ContactType.chat,
advLat: (46.0569 * 1e6).round(),
advLon: (14.5058 * 1e6).round(),
);
late Widget markerChild;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
final markers = service.generateContactMarkers(
contacts: [contact],
context: context,
);
markerChild = markers.single.child;
return const SizedBox.shrink();
},
),
),
);
await tester.pumpWidget(
MaterialApp(home: Scaffold(body: Center(child: markerChild))),
);
expect(find.byType(ContactAvatar), findsOneWidget);
expect(find.text('JS'), findsOneWidget);
});
}

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,92 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/utils/log_rx_route_decoder.dart';
void main() {
group('LogRxRouteDecoder.decode', () {
test('parses route and sender from LOG_RX_DATA packet', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x05,
0x04,
0xc2,
0xba,
0x5f,
0xde,
0x5c,
]);
final decoded = LogRxRouteDecoder.decode(packet);
expect(decoded, isNotNull);
expect(decoded!.payloadType, 0x01);
expect(decoded.pathHashes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.originalSenderHash, 0xc2);
});
});
group('LogRxRouteDecoder.resolveHash', () {
test('prefers own node when hash matches device key', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
contacts: const [],
ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]),
ownName: 'Base',
);
expect(resolved.isOwnNode, isTrue);
expect(resolved.label, 'Base (you)');
});
test('resolves unique contact by first public key byte', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
contacts: [_contact(name: 'Alpha', keyPrefix: 0xc2)],
);
expect(resolved.isUniqueMatch, isTrue);
expect(resolved.label, 'Alpha');
});
test('marks ambiguous matches without pretending certainty', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
contacts: [
_contact(name: 'Alpha', keyPrefix: 0xc2),
_contact(name: 'Bravo', keyPrefix: 0xc2),
],
);
expect(resolved.isUniqueMatch, isFalse);
expect(resolved.matchCount, 2);
});
});
}
Contact _contact({required String name, required int keyPrefix}) {
return Contact(
publicKey: Uint8List.fromList([
keyPrefix,
0x11,
0x22,
0x33,
0x44,
0x55,
0x66,
0x77,
]),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(0),
advName: name,
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}

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));
}); });
}); });