Compare commits

...

17 Commits

Author SHA1 Message Date
Janez T
79c8664d04 Remove iOS critical alert flags 2026-03-07 20:09:07 +01:00
Janez T
162376cc47 Add ITSAppUsesNonExemptEncryption 2026-03-07 20:05:31 +01:00
Janez Troha
adb8fe32f4 Merge pull request #14 from ydev83/main
Add Russian localization
2026-03-07 20:03:45 +01:00
Janez T
5966ddfbc1 Fix annoying plist reset 2026-03-07 20:00:32 +01:00
Janez T
1867f815d2 Modernize contact list pills 2026-03-07 18:41:37 +01:00
Janez T
4010680da0 Fix contact route parse usage 2026-03-07 18:36:38 +01:00
Janez T
6bfc08f3ae Keep contact GPS location saved 2026-03-07 18:30:36 +01:00
Janez Troha
fa37a20fc7 Merge pull request #15 from MGJ520/main
Fix the Chinese translation switching issue
2026-03-07 18:08:49 +01:00
MGJ
09f61740d2 Merge remote-tracking branch 'origin/main' 2026-03-07 22:18:06 +08:00
MGJ
97c1f3b27d Fix the Chinese translation switching issue 2026-03-07 22:17:53 +08:00
Janez T
cf70aa2bfb Reduce header space and tweak chat 2026-03-07 14:53:22 +01:00
Janez T
40e4db7b7f Remove showDirectMessageDialog 2026-03-07 14:24:45 +01:00
Janez T
2c2e7b392a Remove showDirectMessageDialog usage 2026-03-07 14:24:10 +01:00
ydev83
fd7d6afb70 Merge branch 'dz0ny:main' into main 2026-03-07 23:18:51 +10:00
Janez T
3307a93640 Add avatars display on map 2026-03-07 14:17:52 +01:00
ydev83
8a4b29b968 Add files via upload 2026-03-07 23:15:15 +10:00
Janez T
0e4f727e26 Add swarm mode transport doc 2026-03-07 14:13:54 +01:00
64 changed files with 9667 additions and 2436 deletions

View File

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

View File

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

View File

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

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 95; CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 95; CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 95; CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 95; CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 95; CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 95; CURRENT_PROJECT_VERSION = 100;
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,9 +43,11 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>95</string> <string>100</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search &amp; Rescue operations</string> <string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search &amp; Rescue operations</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>

View File

@@ -5,22 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000273"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.453879"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.436347">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="117.623994"> <testcase classname="fastlane.lanes" name="2: build_app" time="49.579697">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="209.300865">
</testcase> </testcase>

View File

@@ -12,6 +12,7 @@ import 'app_localizations_es.dart';
import 'app_localizations_fr.dart'; import 'app_localizations_fr.dart';
import 'app_localizations_hr.dart'; import 'app_localizations_hr.dart';
import 'app_localizations_it.dart'; import 'app_localizations_it.dart';
import 'app_localizations_ru.dart';
import 'app_localizations_sl.dart'; import 'app_localizations_sl.dart';
import 'app_localizations_zh.dart'; import 'app_localizations_zh.dart';
@@ -108,6 +109,7 @@ abstract class AppLocalizations {
Locale('fr'), Locale('fr'),
Locale('hr'), Locale('hr'),
Locale('it'), Locale('it'),
Locale('ru'),
Locale('sl'), Locale('sl'),
Locale('zh'), Locale('zh'),
]; ];
@@ -4226,6 +4228,7 @@ class _AppLocalizationsDelegate
'fr', 'fr',
'hr', 'hr',
'it', 'it',
'ru',
'sl', 'sl',
'zh', 'zh',
].contains(locale.languageCode); ].contains(locale.languageCode);
@@ -4251,6 +4254,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsHr(); return AppLocalizationsHr();
case 'it': case 'it':
return AppLocalizationsIt(); return AppLocalizationsIt();
case 'ru':
return AppLocalizationsRu();
case 'sl': case 'sl':
return AppLocalizationsSl(); return AppLocalizationsSl();
case 'zh': case 'zh':

File diff suppressed because it is too large Load Diff

685
lib/l10n/app_ru.arb Normal file
View File

@@ -0,0 +1,685 @@
{
"@@locale": "ru",
"appTitle": "MeshCore SAR",
"messages": "Сообщения",
"contacts": "Контакты",
"map": "Карта",
"settings": "Настройки",
"connect": "Подключить",
"disconnect": "Отключить",
"scanningForDevices": "Поиск устройств...",
"noDevicesFound": "Устройства не найдены",
"scanAgain": "Повторить поиск",
"tapToConnect": "Нажмите для подключения",
"deviceNotConnected": "Устройство не подключено",
"locationPermissionDenied": "Доступ к геолокации запрещён",
"locationPermissionPermanentlyDenied": "Доступ к геолокации запрещён навсегда. Включите его в настройках.",
"locationPermissionRequired": "Доступ к геолокации необходим для GPS-трекинга и координации команды. Вы можете включить его позже в настройках.",
"locationServicesDisabled": "Службы геолокации отключены. Пожалуйста, включите их в настройках.",
"failedToGetGpsLocation": "Не удалось получить GPS-координаты",
"advertisedAtLocation": "Транслируется в {latitude}, {longitude}",
"failedToAdvertise": "Ошибка трансляции: {error}",
"reconnecting": "Переподключение... ({attempt}/{max})",
"cancelReconnection": "Отменить переподключение",
"mapManagement": "Управление картой",
"general": "Основные",
"theme": "Тема",
"chooseTheme": "Выбрать тему",
"light": "Светлая",
"dark": "Тёмная",
"blueLightTheme": "Синяя светлая тема",
"blueDarkTheme": "Синяя тёмная тема",
"sarRed": "SAR Красная",
"alertEmergencyMode": "Режим тревоги / ЧС",
"sarGreen": "SAR Зелёная",
"safeAllClearMode": "Режим «Всё в порядке»",
"autoSystem": "Авто (Система)",
"followSystemTheme": "Следовать системной теме",
"showRxTxIndicators": "Показывать индикаторы RX/TX",
"displayPacketActivity": "Отображать активность пакетов в верхней панели",
"simpleMode": "Простой режим",
"simpleModeDescription": "Скрыть второстепенную информацию в сообщениях и контактах",
"disableMap": "Отключить карту",
"disableMapDescription": "Скрыть вкладку карты для экономии заряда батареи",
"language": "Язык",
"chooseLanguage": "Выбрать язык",
"english": "Английский",
"slovenian": "Словенский",
"croatian": "Хорватский",
"german": "Немецкий",
"spanish": "Испанский",
"french": "Французский",
"italian": "Итальянский",
"russian": "Русский",
"locationBroadcasting": "Трансляция местоположения",
"autoLocationTracking": "Авто-отслеживание геолокации",
"automaticallyBroadcastPosition": "Автоматически транслировать обновления позиции",
"configureTracking": "Настроить отслеживание",
"distanceAndTimeThresholds": "Пороги расстояния и времени",
"locationTrackingConfiguration": "Настройка отслеживания геолокации",
"configureWhenLocationBroadcasts": "Настройте условия отправки обновлений геолокации в mesh-сеть",
"minimumDistance": "Минимальное расстояние",
"broadcastAfterMoving": "Транслировать только после перемещения на {distance} м",
"maximumDistance": "Максимальное расстояние",
"alwaysBroadcastAfterMoving": "Всегда транслировать после перемещения на {distance} м",
"minimumTimeInterval": "Минимальный интервал времени",
"alwaysBroadcastEvery": "Всегда транслировать каждые {duration}",
"save": "Сохранить",
"cancel": "Отмена",
"close": "Закрыть",
"about": "О приложении",
"appVersion": "Версия приложения",
"appName": "Название приложения",
"aboutMeshCoreSar": "О MeshCore SAR",
"aboutDescription": "Приложение для поисково-спасательных операций, разработанное для аварийно-спасательных служб. Возможности:\n\n• BLE mesh-сеть для связи между устройствами\n• Офлайн-карты с несколькими вариантами слоёв\n• Отслеживание членов команды в реальном времени\n• Тактические маркеры SAR (найденный человек, пожар, место сбора)\n• Управление контактами и обмен сообщениями\n• GPS-трекинг с показанием курса компаса\n• Кэширование тайлов карт для работы офлайн",
"technologiesUsed": "Используемые технологии:",
"technologiesList": "• Flutter для кросс-платформенной разработки\n• BLE (Bluetooth Low Energy) для mesh-сети\n• OpenStreetMap для карт\n• Provider для управления состоянием\n• SharedPreferences для локального хранилища",
"moreInfo": "Подробнее",
"learnMoreAbout": "Узнать больше о MeshCore SAR",
"developer": "Разработчик",
"packageName": "Имя пакета",
"sampleData": "Тестовые данные",
"sampleDataDescription": "Загрузить или очистить тестовые контакты, сообщения каналов и маркеры SAR",
"loadSampleData": "Загрузить тестовые данные",
"clearAllData": "Очистить все данные",
"clearAllDataConfirmTitle": "Очистить все данные",
"clearAllDataConfirmMessage": "Это удалит все контакты и маркеры SAR. Вы уверены?",
"clear": "Очистить",
"loadedSampleData": "Загружено: {teamCount} членов команды, {channelCount} каналов, {sarCount} маркеров SAR, {messageCount} сообщений",
"failedToLoadSampleData": "Не удалось загрузить тестовые данные: {error}",
"allDataCleared": "Все данные очищены",
"failedToStartBackgroundTracking": "Не удалось запустить фоновое отслеживание. Проверьте разрешения и BLE-соединение.",
"locationBroadcast": "Трансляция геолокации: {latitude}, {longitude}",
"defaultPinInfo": "PIN-код по умолчанию для устройств без экрана — 123456. Проблемы с сопряжением? Удалите устройство из Bluetooth в системных настройках.",
"noMessagesYet": "Сообщений пока нет",
"pullDownToSync": "Потяните вниз для синхронизации сообщений",
"deleteContact": "Удалить контакт",
"delete": "Удалить",
"viewOnMap": "Показать на карте",
"refresh": "Обновить",
"sendDirectMessage": "Отправить",
"resetPath": "Сбросить маршрут (перепроложить)",
"publicKeyCopied": "Публичный ключ скопирован в буфер обмена",
"copiedToClipboard": "{label} скопировано в буфер обмена",
"pleaseEnterPassword": "Пожалуйста, введите пароль",
"failedToSyncContacts": "Не удалось синхронизировать контакты: {error}",
"loggedInSuccessfully": "Вход выполнен! Ожидание сообщений комнаты...",
"loginFailed": "Ошибка входа — неверный пароль",
"loggingIn": "Вход в {roomName}...",
"failedToSendLogin": "Не удалось отправить данные входа: {error}",
"lowLocationAccuracy": "Низкая точность геолокации",
"continue_": "Продолжить",
"sendSarMarker": "Отправить маркер SAR",
"deleteDrawing": "Удалить рисунок",
"drawingTools": "Инструменты рисования",
"drawLine": "Нарисовать линию",
"drawLineDesc": "Нарисуйте произвольную линию на карте",
"drawRectangle": "Нарисовать прямоугольник",
"drawRectangleDesc": "Нарисуйте прямоугольную область на карте",
"measureDistance": "Измерить расстояние",
"measureDistanceDesc": "Долгое нажатие на две точки для измерения",
"clearMeasurement": "Сбросить измерение",
"distanceLabel": "Расстояние: {distance}",
"longPressForSecondPoint": "Долгое нажатие для второй точки",
"longPressToStartMeasurement": "Долгое нажатие для установки первой точки",
"longPressToStartNewMeasurement": "Долгое нажатие для начала нового измерения",
"shareDrawings": "Поделиться рисунками",
"clearAllDrawings": "Удалить все рисунки",
"completeLine": "Завершить линию",
"broadcastDrawingsToTeam": "Транслировать {count} рисун{plural} команде",
"removeAllDrawings": "Удалить все {count} рисун{plural}",
"deleteAllDrawingsConfirm": "Удалить все {count} рисун{plural} с карты?",
"drawing": "Рисунок",
"shareDrawingsCount": "Поделиться {count} рисун{plural}",
"sentDrawingsToRoom": "Отправлено {count} рисун{plural} в {roomName}",
"sharedDrawingsToRoom": "Передано {success}/{total} рисун{plural} в {roomName}",
"showReceivedDrawings": "Показать полученные рисунки",
"showingAllDrawings": "Показаны все рисунки",
"showingOnlyYourDrawings": "Показаны только ваши рисунки",
"showSarMarkers": "Показать маркеры SAR",
"showingSarMarkers": "Маркеры SAR отображаются",
"hidingSarMarkers": "Маркеры SAR скрыты",
"clearAll": "Очистить всё",
"noLocalDrawings": "Нет локальных рисунков для отправки",
"publicChannel": "Публичный канал",
"broadcastToAll": "Трансляция всем ближайшим узлам (временно)",
"storedPermanently": "Сохранено постоянно в комнате",
"drawingsSentToPublicChannel": "Отправлено {count} рисун{plural} в публичный канал",
"drawingsSharedToPublicChannel": "Передано {success}/{total} рисунков в публичный канал",
"notConnectedToDevice": "Устройство не подключено",
"directMessage": "Личное сообщение",
"directMessageSentTo": "Личное сообщение отправлено {contactName}",
"failedToSend": "Не удалось отправить: {error}",
"directMessageInfo": "Это сообщение будет отправлено напрямую {contactName}. Оно также появится в общей ленте сообщений.",
"typeYourMessage": "Введите сообщение...",
"quickLocationMarker": "Быстрый маркер местоположения",
"markerType": "Тип маркера",
"sendTo": "Отправить в",
"noDestinationsAvailable": "Нет доступных получателей.",
"selectDestination": "Выберите получателя...",
"ephemeralBroadcastInfo": "Временно: передаётся по эфиру. Не сохраняется — узлы должны быть онлайн.",
"persistentRoomInfo": "Постоянно: хранится неизменно в комнате. Синхронизируется автоматически и доступно офлайн.",
"location": "Местоположение",
"myLocation": "Моё местоположение",
"fromMap": "С карты",
"gettingLocation": "Получение местоположения...",
"locationError": "Ошибка геолокации",
"retry": "Повторить",
"refreshLocation": "Обновить местоположение",
"accuracyMeters": "Точность: ±{accuracy}м",
"notesOptional": "Заметки (необязательно)",
"addAdditionalInformation": "Добавьте дополнительную информацию...",
"lowAccuracyWarning": "Точность геолокации: ±{accuracy}м. Этого может быть недостаточно для операций SAR.\n\nПродолжить всё равно?",
"loginToRoom": "Войти в комнату",
"enterPasswordInfo": "Введите пароль для доступа к этой комнате. Пароль будет сохранён для дальнейшего использования.",
"password": "Пароль",
"enterRoomPassword": "Введите пароль комнаты",
"loggingInDots": "Вход...",
"login": "Войти",
"failedToAddRoom": "Не удалось добавить комнату на устройство: {error}\n\nВозможно, комната ещё не объявила себя.\nПопробуйте подождать, пока комната не выйдет на связь.",
"direct": "Напрямую",
"flood": "Широковещательно",
"admin": "Администратор",
"loggedIn": "Вход выполнен",
"noGpsData": "Нет данных GPS",
"distance": "Расстояние",
"pingingDirect": "Пинг {name} (напрямую по маршруту)...",
"pingingFlood": "Пинг {name} (широковещательно — нет маршрута)...",
"directPingTimeout": "Таймаут прямого пинга — повтор {name} широковещательно...",
"pingSuccessful": "Пинг успешен: {name}{fallback}",
"viaFloodingFallback": " (через широковещательный резерв)",
"pingFailed": "Пинг не удался: {name} — ответ не получен",
"deleteContactConfirmation": "Вы уверены, что хотите удалить \"{name}\"?\n\nЭто удалит контакт как из приложения, так и с сопряжённого радиоустройства.",
"removingContact": "Удаление {name}...",
"contactRemoved": "Контакт \"{name}\" удалён",
"failedToRemoveContact": "Не удалось удалить контакт: {error}",
"type": "Тип",
"publicKey": "Публичный ключ",
"lastSeen": "Последнее появление",
"roomStatus": "Статус комнаты",
"loginStatus": "Статус входа",
"notLoggedIn": "Не выполнен вход",
"adminAccess": "Доступ администратора",
"yes": "Да",
"no": "Нет",
"permissions": "Разрешения",
"passwordSaved": "Пароль сохранён",
"locationColon": "Местоположение:",
"telemetry": "Телеметрия",
"requestingTelemetry": "Запрос телеметрии от {name}...",
"voltage": "Напряжение",
"battery": "Батарея",
"temperature": "Температура",
"humidity": "Влажность",
"pressure": "Давление",
"gpsTelemetry": "GPS (телеметрия)",
"updated": "Обновлено",
"pathResetInfo": "Маршрут сброшен для {name}. Следующее сообщение найдёт новый путь.",
"reLoginToRoom": "Войти в комнату повторно",
"heading": "Курс",
"elevation": "Высота",
"accuracy": "Точность",
"bearing": "Азимут",
"direction": "Направление",
"filterMarkers": "Фильтр маркеров",
"filterMarkersTooltip": "Фильтровать маркеры",
"contactsFilter": "Контакты",
"repeatersFilter": "Ретрансляторы",
"sarMarkers": "Маркеры SAR",
"foundPerson": "Найденный человек",
"fire": "Пожар",
"stagingArea": "Место сбора",
"showAll": "Показать все",
"nearbyContacts": "Ближайшие контакты",
"locationUnavailable": "Местоположение недоступно",
"ahead": "впереди",
"degreesRight": "{degrees}° вправо",
"degreesLeft": "{degrees}° влево",
"latLonFormat": "Ш: {latitude} Д: {longitude}",
"noContactsYet": "Контактов пока нет",
"connectToDeviceToLoadContacts": "Подключите устройство для загрузки контактов",
"teamMembers": "Члены команды",
"repeaters": "Ретрансляторы",
"rooms": "Комнаты",
"channels": "Каналы",
"cacheStatistics": "Статистика кэша",
"totalTiles": "Всего тайлов",
"cacheSize": "Размер кэша",
"storeName": "Имя хранилища",
"noCacheStatistics": "Статистика кэша недоступна",
"downloadRegion": "Скачать регион",
"mapLayer": "Слой карты",
"regionBounds": "Границы региона",
"north": "Север",
"south": "Юг",
"east": "Восток",
"west": "Запад",
"zoomLevels": "Уровни масштаба",
"minZoom": "Мин: {zoom}",
"maxZoom": "Макс: {zoom}",
"downloadingDots": "Загрузка...",
"cancelDownload": "Отменить загрузку",
"downloadRegionButton": "Скачать регион",
"downloadNote": "Внимание: большие регионы или высокие уровни масштаба могут потребовать значительного времени и места.",
"cacheManagement": "Управление кэшем",
"clearAllMaps": "Очистить все карты",
"clearMapsConfirmTitle": "Очистить все карты",
"clearMapsConfirmMessage": "Вы уверены, что хотите удалить все загруженные карты? Это действие нельзя отменить.",
"mapDownloadCompleted": "Загрузка карты завершена!",
"cacheClearedSuccessfully": "Кэш успешно очищен!",
"downloadCancelled": "Загрузка отменена",
"startingDownload": "Начало загрузки...",
"downloadingMapTiles": "Загрузка тайлов карты...",
"downloadCompletedSuccessfully": "Загрузка успешно завершена!",
"cancellingDownload": "Отмена загрузки...",
"errorLoadingStats": "Ошибка загрузки статистики: {error}",
"downloadFailed": "Ошибка загрузки: {error}",
"cancelFailed": "Ошибка отмены: {error}",
"clearCacheFailed": "Ошибка очистки кэша: {error}",
"minZoomError": "Мин. масштаб: {error}",
"maxZoomError": "Макс. масштаб: {error}",
"minZoomGreaterThanMax": "Минимальный масштаб должен быть меньше или равен максимальному",
"selectMapLayer": "Выбрать слой карты",
"mapOptions": "Параметры карты",
"showLegend": "Показать легенду",
"displayMarkerTypeCounts": "Отображать счётчики типов маркеров",
"rotateMapWithHeading": "Поворачивать карту по курсу",
"mapFollowsDirection": "Карта следует вашему направлению движения",
"resetMapRotation": "Сбросить поворот",
"resetMapRotationTooltip": "Вернуть карту на север",
"showMapDebugInfo": "Показать отладочную информацию карты",
"displayZoomLevelBounds": "Отображать уровень масштаба и границы",
"fullscreenMode": "Полноэкранный режим",
"hideUiFullMapView": "Скрыть все элементы интерфейса для полного вида карты",
"openStreetMap": "OpenStreetMap",
"openTopoMap": "OpenTopoMap",
"esriSatellite": "ESRI Спутник",
"googleHybrid": "Google Гибрид",
"googleRoadmap": "Google Дороги",
"googleTerrain": "Google Рельеф",
"downloadVisibleArea": "Скачать видимую область",
"initializingMap": "Инициализация карты...",
"dragToPosition": "Перетащите для позиционирования",
"createSarMarker": "Создать маркер SAR",
"compass": "Компас",
"navigationAndContacts": "Навигация и контакты",
"sarAlert": "ТРЕВОГА SAR",
"messageSentToPublicChannel": "Сообщение отправлено в публичный канал",
"pleaseSelectRoomToSendSar": "Пожалуйста, выберите комнату для отправки маркера SAR",
"failedToSendSarMarker": "Не удалось отправить маркер SAR: {error}",
"sarMarkerSentTo": "Маркер SAR отправлен в {roomName}",
"notConnectedCannotSync": "Нет подключения — синхронизация невозможна",
"syncedMessageCount": "Синхронизировано {count} сообщение(й)",
"noNewMessages": "Новых сообщений нет",
"syncFailed": "Синхронизация не удалась: {error}",
"failedToResendMessage": "Не удалось повторно отправить сообщение",
"retryingMessage": "Повторная отправка сообщения...",
"retryFailed": "Повтор не удался: {error}",
"textCopiedToClipboard": "Текст скопирован в буфер обмена",
"cannotReplySenderMissing": "Не удаётся ответить: нет информации об отправителе",
"cannotReplyContactNotFound": "Не удаётся ответить: контакт не найден",
"messageDeleted": "Сообщение удалено",
"copyText": "Копировать текст",
"saveAsTemplate": "Сохранить как шаблон",
"templateSaved": "Шаблон успешно сохранён",
"templateAlreadyExists": "Шаблон с таким эмодзи уже существует",
"deleteMessage": "Удалить сообщение",
"deleteMessageConfirmation": "Вы уверены, что хотите удалить это сообщение?",
"shareLocation": "Поделиться местоположением",
"shareLocationText": "{markerInfo}\n\nКоординаты: {lat}, {lon}\n\nGoogle Maps: {url}",
"sarLocationShare": "Местоположение SAR",
"locationShared": "Местоположение передано",
"refreshedContacts": "Контакты обновлены",
"justNow": "Только что",
"minutesAgo": "{minutes}м назад",
"hoursAgo": "{hours}ч назад",
"daysAgo": "{days}д назад",
"secondsAgo": "{seconds}с назад",
"sending": "Отправка...",
"sent": "Отправлено",
"delivered": "Доставлено",
"deliveredWithTime": "Доставлено ({time}мс)",
"failed": "Ошибка",
"broadcast": "Трансляция",
"deliveredToContacts": "Доставлено {delivered}/{total} контактам",
"allDelivered": "Все доставлено",
"recipientDetails": "Детали получателей",
"pending": "Ожидание",
"sarMarkerFoundPerson": "Найденный человек",
"sarMarkerFire": "Место пожара",
"sarMarkerStagingArea": "Место сбора",
"sarMarkerObject": "Найденный объект",
"from": "От",
"coordinates": "Координаты",
"tapToViewOnMap": "Нажмите, чтобы открыть на карте",
"radioSettings": "Настройки радио",
"frequencyMHz": "Частота (МГц)",
"frequencyExample": "например, 869.618",
"bandwidth": "Полоса пропускания",
"spreadingFactor": "Коэффициент расширения",
"codingRate": "Скорость кодирования",
"txPowerDbm": "Мощность TX (дБм)",
"maxPowerDbm": "Макс: {power} дБм",
"you": "Вы",
"offlineVectorMaps": "Офлайн-векторные карты",
"offlineVectorMapsDescription": "Импортируйте офлайн-векторные тайлы карт (формат MBTiles) для использования без интернета",
"importMbtiles": "Импортировать файл MBTiles",
"importMbtilesNote": "Поддерживаются файлы MBTiles с векторными тайлами (формат PBF/MVT). Отлично подходят выгрузки Geofabrik!",
"noMbtilesFiles": "Офлайн-векторные карты не найдены",
"mbtilesImportedSuccessfully": "Файл MBTiles успешно импортирован",
"failedToImportMbtiles": "Не удалось импортировать файл MBTiles",
"deleteMbtilesConfirmTitle": "Удалить офлайн-карту",
"deleteMbtilesConfirmMessage": "Вы уверены, что хотите удалить \"{name}\"? Офлайн-карта будет удалена безвозвратно.",
"mbtilesDeletedSuccessfully": "Офлайн-карта успешно удалена",
"failedToDeleteMbtiles": "Не удалось удалить офлайн-карту",
"importExportCachedTiles": "Импорт/Экспорт кэшированных тайлов",
"importExportDescription": "Резервное копирование, обмен и восстановление загруженных тайлов карт между устройствами",
"exportTilesToFile": "Экспортировать тайлы в файл",
"importTilesFromFile": "Импортировать тайлы из файла",
"selectExportLocation": "Выберите место для экспорта",
"selectImportFile": "Выберите архив тайлов",
"exportingTiles": "Экспорт тайлов...",
"importingTiles": "Импорт тайлов...",
"exportSuccess": "Экспортировано {count} тайлов",
"importSuccess": "Импортировано {count} хранилищ",
"exportFailed": "Ошибка экспорта: {error}",
"importFailed": "Ошибка импорта: {error}",
"exportNote": "Создаёт сжатый архив (.fmtc), которым можно поделиться и импортировать на других устройствах.",
"importNote": "Импортирует тайлы карт из ранее экспортированного архива. Тайлы будут объединены с существующим кэшем.",
"noTilesToExport": "Нет доступных тайлов для экспорта",
"archiveContainsStores": "Архив содержит {count} хранилищ",
"vectorTiles": "Векторные тайлы",
"schema": "Схема",
"unknown": "Неизвестно",
"bounds": "Границы",
"onlineLayers": "Онлайн-слои",
"offlineLayers": "Офлайн-слои",
"locationTrail": "Трек местоположения",
"showTrailOnMap": "Показать трек на карте",
"trailVisible": "Трек отображается на карте",
"trailHiddenRecording": "Трек скрыт (запись продолжается)",
"duration": "Продолжительность",
"points": "Точки",
"clearTrail": "Очистить трек",
"clearTrailQuestion": "Очистить трек?",
"clearTrailConfirmation": "Вы уверены, что хотите очистить текущий трек? Это действие нельзя отменить.",
"noTrailRecorded": "Трек ещё не записан",
"startTrackingToRecord": "Запустите отслеживание геолокации для записи трека",
"trailControls": "Управление треком",
"exportTrailToGpx": "Экспортировать трек в GPX",
"importTrailFromGpx": "Импортировать трек из GPX",
"trailExportedSuccessfully": "Трек успешно экспортирован!",
"failedToExportTrail": "Не удалось экспортировать трек",
"failedToImportTrail": "Не удалось импортировать трек: {error}",
"importTrail": "Импортировать трек",
"importTrailQuestion": "Импортировать трек из {pointCount} точек?\n\nВы можете заменить текущий трек или просмотреть его рядом.",
"viewAlongside": "Просмотреть рядом",
"replaceCurrent": "Заменить текущий",
"trailImported": "Трек импортирован! ({pointCount} точек)",
"trailReplaced": "Трек заменён! ({pointCount} точек)",
"contactTrails": "Треки контактов",
"showAllContactTrails": "Показать все треки контактов",
"noContactsWithLocationHistory": "Нет контактов с историей местоположения",
"showingTrailsForContacts": "Показаны треки для {count} контактов",
"individualContactTrails": "Индивидуальные треки контактов",
"deviceInformation": "Информация об устройстве",
"bleName": "Имя BLE",
"meshName": "Имя в сети Mesh",
"notSet": "Не задано",
"model": "Модель",
"version": "Версия",
"buildDate": "Дата сборки",
"firmware": "Прошивка",
"maxContacts": "Макс. контактов",
"maxChannels": "Макс. каналов",
"publicInfo": "Публичная информация",
"meshNetworkName": "Название сети Mesh",
"nameBroadcastInMesh": "Имя, транслируемое в mesh-объявлениях",
"telemetryAndLocationSharing": "Телеметрия и передача местоположения",
"lat": "Ш",
"lon": "Д",
"useCurrentLocation": "Использовать текущее местоположение",
"noneUnknown": "Нет/Неизвестно",
"chatNode": "Узел чата",
"repeater": "Ретранслятор",
"roomChannel": "Комната/Канал",
"typeNumber": "Тип {number}",
"copiedToClipboardShort": "{label} скопировано в буфер обмена",
"failedToSave": "Не удалось сохранить: {error}",
"failedToGetLocation": "Не удалось получить местоположение: {error}",
"sarTemplates": "Шаблоны SAR",
"manageSarTemplates": "Управление шаблонами целеуказания",
"addTemplate": "Добавить шаблон",
"editTemplate": "Изменить шаблон",
"deleteTemplate": "Удалить шаблон",
"templateName": "Название шаблона",
"templateNameHint": "например, Найденный человек",
"templateEmoji": "Эмодзи",
"emojiRequired": "Требуется эмодзи",
"nameRequired": "Требуется название",
"templateDescription": "Описание (необязательно)",
"templateDescriptionHint": "Добавьте дополнительный контекст...",
"templateColor": "Цвет",
"previewFormat": "Предпросмотр (формат сообщения SAR)",
"importFromClipboard": "Импорт",
"exportToClipboard": "Экспорт",
"deleteTemplateConfirmation": "Удалить шаблон '{name}'?",
"templateAdded": "Шаблон добавлен",
"templateUpdated": "Шаблон обновлён",
"templateDeleted": "Шаблон удалён",
"templatesImported": "{count, plural, =0{Шаблоны не импортированы} =1{Импортирован 1 шаблон} other{Импортировано {count} шаблонов}}",
"templatesExported": "{count, plural, =1{1 шаблон экспортирован в буфер обмена} other{{count} шаблонов экспортировано в буфер обмена}}",
"resetToDefaults": "Сбросить до умолчаний",
"resetToDefaultsConfirmation": "Все пользовательские шаблоны будут удалены и восстановлены 4 шаблона по умолчанию. Продолжить?",
"reset": "Сбросить",
"resetComplete": "Шаблоны сброшены до умолчаний",
"noTemplates": "Шаблоны недоступны",
"tapAddToCreate": "Нажмите +, чтобы создать первый шаблон",
"ok": "ОК",
"permissionsSection": "Разрешения",
"locationPermission": "Разрешение геолокации",
"checking": "Проверка...",
"locationPermissionGrantedAlways": "Разрешено (всегда)",
"locationPermissionGrantedWhileInUse": "Разрешено (при использовании)",
"locationPermissionDeniedTapToRequest": "Запрещено — нажмите для запроса",
"locationPermissionPermanentlyDeniedOpenSettings": "Запрещено навсегда — откройте настройки",
"locationPermissionDialogContent": "Доступ к геолокации запрещён навсегда. Включите его в настройках устройства для GPS-трекинга и обмена местоположением.",
"openSettings": "Открыть настройки",
"locationPermissionGranted": "Разрешение геолокации получено!",
"locationPermissionRequiredForGps": "Разрешение геолокации необходимо для GPS-трекинга и обмена местоположением.",
"locationPermissionAlreadyGranted": "Разрешение геолокации уже предоставлено.",
"sarNavyBlue": "SAR Тёмно-синяя",
"sarNavyBlueDescription": "Профессиональный / оперативный режим",
"selectRecipient": "Выбрать получателя",
"broadcastToAllNearby": "Трансляция всем ближайшим",
"searchRecipients": "Поиск получателей...",
"noContactsFound": "Контакты не найдены",
"noRoomsFound": "Комнаты не найдены",
"noContactsOrRoomsAvailable": "Нет доступных контактов или комнат",
"noRecipientsAvailable": "Нет доступных получателей",
"noChannelsFound": "Каналы не найдены",
"messagesWillBeSentToPublicChannel": "Сообщения будут отправлены в публичный канал",
"newMessage": "Новое сообщение",
"channel": "Канал",
"samplePoliceLead": "Руководитель группы полиции",
"sampleDroneOperator": "Оператор дрона",
"sampleFirefighterAlpha": "Пожарный",
"sampleMedicCharlie": "Медик",
"sampleCommandDelta": "Командование",
"sampleFireEngine": "Пожарная машина",
"sampleAirSupport": "Авиационная поддержка",
"sampleBaseCoordinator": "Базовый координатор",
"channelEmergency": "Аварийный",
"channelCoordination": "Координация",
"channelUpdates": "Обновления",
"sampleTeamMember": "Тестовый член команды",
"sampleScout": "Тестовый разведчик",
"sampleBase": "Тестовая база",
"sampleSearcher": "Тестовый поисковик",
"sampleObjectBackpack": " Найден рюкзак синего цвета",
"sampleObjectVehicle": " Брошенный автомобиль — установить владельца",
"sampleObjectCamping": " Обнаружено туристическое снаряжение",
"sampleObjectTrailMarker": " Указатель тропы найден вне маршрута",
"sampleMsgAllTeamsCheckIn": "Всем группам: сообщить о готовности",
"sampleMsgWeatherUpdate": "Погода: ясно, температура 18°C",
"sampleMsgBaseCamp": "Базовый лагерь развёрнут у места сбора",
"sampleMsgTeamAlpha": "Группа выдвигается в сектор 2",
"sampleMsgRadioCheck": "Проверка связи — всем станциям ответить",
"sampleMsgWaterSupply": "Вода доступна на контрольной точке 3",
"sampleMsgTeamBravo": "Группа докладывает: сектор 1 чист",
"sampleMsgEtaRallyPoint": "Прибытие на место сбора: 15 минут",
"sampleMsgSupplyDrop": "Сброс снаряжения подтверждён на 14:00",
"sampleMsgDroneSurvey": "Разведка дроном завершена — ничего не обнаружено",
"sampleMsgTeamCharlie": "Группа запрашивает подкрепление",
"sampleMsgRadioDiscipline": "Всем подразделениям: соблюдать радиодисциплину",
"sampleMsgUrgentMedical": "СРОЧНО: нужна медицинская помощь в секторе 4",
"sampleMsgAdultMale": " Взрослый мужчина, в сознании",
"sampleMsgFireSpotted": "Обнаружен пожар — координаты следуют",
"sampleMsgSpreadingRapidly": " Распространяется быстро!",
"sampleMsgPriorityHelicopter": "ПРИОРИТЕТ: нужна поддержка вертолёта",
"sampleMsgMedicalTeamEnRoute": "Медицинская группа движется к вашему местоположению",
"sampleMsgEvacHelicopter": "Вертолёт эвакуации: прибытие через 10 минут",
"sampleMsgEmergencyResolved": "Чрезвычайная ситуация ликвидирована — опасности нет",
"sampleMsgEmergencyStagingArea": " Аварийное место сбора",
"sampleMsgEmergencyServices": "Аварийные службы уведомлены и реагируют",
"sampleAlphaTeamLead": "Руководитель группы",
"sampleBravoScout": "Разведчик",
"sampleCharlieMedic": "Медик",
"sampleDeltaNavigator": "Навигатор",
"sampleEchoSupport": "Поддержка",
"sampleBaseCommand": "Базовое командование",
"sampleFieldCoordinator": "Полевой координатор",
"sampleMedicalTeam": "Медицинская группа",
"mapDrawing": "Рисунок на карте",
"navigateToDrawing": "Перейти к рисунку",
"copyCoordinates": "Копировать координаты",
"hideFromMap": "Скрыть с карты",
"lineDrawing": "Линия",
"rectangleDrawing": "Прямоугольник",
"coordinatesCopiedToClipboard": "Координаты скопированы в буфер обмена",
"manualCoordinates": "Ввод координат вручную",
"enterCoordinatesManually": "Ввести координаты вручную",
"latitudeLabel": "Широта",
"longitudeLabel": "Долгота",
"invalidLatitude": "Неверная широта (от -90 до 90)",
"invalidLongitude": "Неверная долгота (от -180 до 180)",
"exampleCoordinates": "Пример: 55.7558, 37.6173",
"drawingShared": "Рисунок на карте",
"drawingHidden": "Рисунок скрыт с карты",
"alreadyShared": "{count, plural, =1{1 уже передан} other{{count} уже передано}}",
"newDrawingsShared": "Передано {count} новых рисунк{plural}",
"shareDrawing": "Поделиться рисунком",
"shareWithAllNearbyDevices": "Поделиться со всеми ближайшими устройствами",
"shareToRoom": "Отправить в комнату",
"sendToPersistentStorage": "Отправить в постоянное хранилище комнаты",
"deleteDrawingConfirm": "Вы уверены, что хотите удалить этот рисунок?",
"drawingDeleted": "Рисунок удалён",
"yourDrawingsCount": "Ваши рисунки ({count})",
"shared": "Передано",
"line": "Линия",
"rectangle": "Прямоугольник",
"updateAvailable": "Доступно обновление",
"currentVersion": "Текущая",
"latestVersion": "Последняя",
"downloadUpdate": "Скачать",
"updateLater": "Позже",
"cadastralParcels": "Кадастровые участки",
"forestRoads": "Лесные дороги",
"showCadastralParcels": "Показать кадастровые участки",
"showForestRoads": "Показать лесные дороги",
"wmsOverlays": "WMS-наложения",
"hikingTrails": "Туристические маршруты",
"mainRoads": "Главные дороги",
"houseNumbers": "Номера домов",
"fireHazardZones": "Пожароопасные зоны",
"historicalFires": "Исторические пожары",
"firebreaks": "Противопожарные просеки",
"krasFireZones": "Пожарные зоны Краса",
"placeNames": "Названия мест",
"municipalityBorders": "Границы муниципалитетов",
"topographicMap": "Топографическая карта 1:25000",
"recentMessages": "Последние сообщения",
"addChannel": "Добавить канал",
"channelName": "Название канала",
"channelNameHint": "например, Группа спасения Альфа",
"channelSecret": "Секрет канала",
"channelSecretHint": "Общий пароль для этого канала",
"channelSecretHelp": "Этот секрет необходимо передать всем членам команды, которым нужен доступ к каналу",
"channelTypesInfo": "Hash-каналы (#команда): секрет генерируется из названия автоматически. Одно название = один канал на всех устройствах.\n\nЗакрытые каналы: используется явный секрет. Войти могут только те, у кого есть секрет.",
"hashChannelInfo": "Hash-канал: секрет будет автоматически создан из названия. Все, кто использует одно и то же имя, окажутся в одном канале.",
"channelNameRequired": "Требуется название канала",
"channelNameTooLong": "Название канала не должно превышать 31 символ",
"channelSecretRequired": "Требуется секрет канала",
"channelSecretTooLong": "Секрет канала не должен превышать 32 символа",
"invalidAsciiCharacters": "Разрешены только символы ASCII",
"channelCreatedSuccessfully": "Канал успешно создан",
"channelCreationFailed": "Не удалось создать канал: {error}",
"deleteChannel": "Удалить канал",
"deleteChannelConfirmation": "Вы уверены, что хотите удалить канал \"{channelName}\"? Это действие нельзя отменить.",
"channelDeletedSuccessfully": "Канал успешно удалён",
"channelDeletionFailed": "Не удалось удалить канал: {error}",
"allChannelSlotsInUse": "Все слоты каналов заняты (максимум 39 пользовательских каналов)",
"createChannel": "Создать канал",
"wizardBack": "Назад",
"wizardSkip": "Пропустить",
"wizardNext": "Далее",
"wizardGetStarted": "Начать",
"wizardWelcomeTitle": "Добро пожаловать в MeshCore SAR",
"wizardWelcomeDescription": "Мощный инструмент связи вне сети для поисково-спасательных операций. Поддерживайте связь с командой через mesh-радио, когда традиционные сети недоступны.",
"wizardConnectingTitle": "Подключение к радиоустройству",
"wizardConnectingDescription": "Подключите смартфон к радиоустройству MeshCore по Bluetooth для связи вне сети.",
"wizardConnectingFeature1": "Поиск ближайших устройств MeshCore",
"wizardConnectingFeature2": "Сопряжение с радиоустройством по Bluetooth",
"wizardConnectingFeature3": "Работает полностью офлайн — интернет не нужен",
"wizardSimpleModeTitle": "Простой режим",
"wizardSimpleModeDescription": "Впервые работаете с mesh-сетью? Включите простой режим для упрощённого интерфейса с основными функциями.",
"wizardSimpleModeFeature1": "Удобный для новичков интерфейс с основными функциями",
"wizardSimpleModeFeature2": "Переключиться в расширенный режим можно в любое время в настройках",
"wizardChannelTitle": "Каналы",
"wizardChannelDescription": "Транслируйте сообщения всем участникам канала — идеально для общих объявлений и координации команды.",
"wizardChannelFeature1": "Публичный канал для общей связи команды",
"wizardChannelFeature2": "Создавайте пользовательские каналы для отдельных групп",
"wizardChannelFeature3": "Сообщения автоматически ретранслируются через mesh",
"wizardContactsTitle": "Контакты",
"wizardContactsDescription": "Члены вашей команды появляются автоматически по мере подключения к mesh-сети. Отправляйте им личные сообщения или просматривайте их местоположение.",
"wizardContactsFeature1": "Контакты обнаруживаются автоматически",
"wizardContactsFeature2": "Отправка личных сообщений",
"wizardContactsFeature3": "Просмотр уровня заряда и времени последнего появления",
"wizardMapTitle": "Карта и местоположение",
"wizardMapDescription": "Отслеживайте команду в реальном времени и отмечайте важные места для поисково-спасательных операций.",
"wizardMapFeature1": "Маркеры SAR для найденных людей, пожаров и мест сбора",
"wizardMapFeature2": "GPS-отслеживание членов команды в реальном времени",
"wizardMapFeature3": "Загрузка офлайн-карт для отдалённых районов",
"wizardMapFeature4": "Рисование фигур и обмен тактической информацией",
"viewWelcomeTutorial": "Просмотреть обучение",
"allTeamContacts": "Все контакты команды",
"directMessagesInfo": "Личные сообщения с подтверждением. Отправлено {count} членам команды.",
"sarMarkerSentToContacts": "Маркер SAR отправлен {count} контактам",
"noContactsAvailable": "Нет доступных контактов команды",
"reply": "Ответить",
"technicalDetails": "Технические детали",
"messageTechnicalDetails": "Технические детали сообщения",
"linkQuality": "Качество связи",
"delivery": "Доставка",
"status": "Статус",
"expectedAckTag": "Ожидаемый тег ACK",
"roundTrip": "Время отклика",
"retryAttempt": "Попытка повтора",
"floodFallback": "Широковещательный резерв",
"identity": "Идентификатор",
"messageId": "ID сообщения",
"sender": "Отправитель",
"senderKey": "Ключ отправителя",
"recipient": "Получатель",
"recipientKey": "Ключ получателя",
"voice": "Голос",
"voiceId": "ID голоса",
"envelope": "Конверт",
"sessionProgress": "Прогресс сессии",
"complete": "Завершено",
"rawDump": "Необработанные данные",
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
"voiceUnavailable": "Голос сейчас недоступен",
"requestingVoice": "Запрос голоса"
}

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,186 @@
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, {int? expectedHashSize}) {
final normalized = input.trim().toUpperCase();
if (normalized.isEmpty) {
throw const ContactRouteFormatException('Route cannot be empty.');
}
if (expectedHashSize != null &&
(expectedHashSize < 1 || expectedHashSize > maxHashSize)) {
throw const ContactRouteFormatException(
'Hash size must be 1, 2, or 3 bytes.',
);
}
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.',
);
}
if (expectedHashSize != null && currentHashSize != expectedHashSize) {
throw ContactRouteFormatException(
'Hop "$token" must be $expectedHashSize '
'byte${expectedHashSize == 1 ? '' : 's'}.',
);
}
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 +190,56 @@ extension ContactLocalization on Contact {
} }
return displayName; return displayName;
} }
int get routeEncodedPathLen =>
ContactRouteCodec.toUnsignedDescriptor(outPathLen);
int get routeSignedPathLen =>
ContactRouteCodec.toSignedDescriptor(routeEncodedPathLen);
bool get routeIsUnknown => ContactRouteCodec.isUnknownDescriptor(outPathLen);
bool get routeHasPath =>
!routeIsUnknown && ContactRouteCodec.isValidDescriptor(outPathLen);
int get routeHashSize => routeHasPath ? ((routeEncodedPathLen >> 6) + 1) : 1;
int get routeHopCount => routeHasPath ? (routeEncodedPathLen & 0x3F) : -1;
int get routeByteLength => routeHasPath
? math.min(routeHopCount * routeHashSize, outPath.length)
: 0;
Uint8List get routePathBytes => routeByteLength <= 0
? Uint8List(0)
: Uint8List.fromList(outPath.sublist(0, routeByteLength));
String get routeCanonicalText {
if (!routeHasPath || routeHopCount <= 0) return '';
final bytes = routePathBytes;
final hops = <String>[];
for (var i = 0; i < bytes.length; i += routeHashSize) {
hops.add(
bytes
.sublist(i, i + routeHashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
String get routeSummary {
if (routeIsUnknown || !routeHasPath) {
return 'Flood/Unknown';
}
if (routeHopCount == 0) {
return 'Direct';
}
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
}
bool get routeSupportsLegacyRawTransport =>
routeHasPath && routeSignedPathLen >= 0;
} }

View File

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

View File

@@ -22,6 +22,7 @@ import '../utils/drawing_message_parser.dart';
import '../utils/raw_route_probe.dart'; import '../utils/raw_route_probe.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/media_swarm_protocol.dart';
import '../utils/message_airtime_estimator.dart'; import '../utils/message_airtime_estimator.dart';
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
@@ -50,6 +51,8 @@ class AppProvider with ChangeNotifier {
bool get isMapEnabled => _isMapEnabled; bool get isMapEnabled => _isMapEnabled;
bool _isContactsEnabled = true; bool _isContactsEnabled = true;
bool get isContactsEnabled => _isContactsEnabled; bool get isContactsEnabled => _isContactsEnabled;
bool _isSensorsEnabled = true;
bool get isSensorsEnabled => _isSensorsEnabled;
bool _isVoiceSilenceTrimmingEnabled = true; bool _isVoiceSilenceTrimmingEnabled = true;
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
@@ -63,6 +66,7 @@ class AppProvider with ChangeNotifier {
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts; bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
static const Duration _packetRetryDelay = Duration(milliseconds: 1200); static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
static const int _maxPacketRetryAttempts = 4; static const int _maxPacketRetryAttempts = 4;
final Map<String, String> _voiceSessionSenderKey6 = {}; final Map<String, String> _voiceSessionSenderKey6 = {};
final Map<String, String> _imageSessionSenderKey6 = {}; final Map<String, String> _imageSessionSenderKey6 = {};
@@ -72,6 +76,9 @@ class AppProvider with ChangeNotifier {
final Map<String, int> _imageMissingRetryAttempts = {}; final Map<String, int> _imageMissingRetryAttempts = {};
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {}; final Map<String, Future<bool>> _pendingRawRouteProbes = {};
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
final Map<String, Map<String, MediaSwarmAvailability>>
_pendingMediaSwarmResponses = {};
Timer? _packetCaptureFlushTimer; Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature; String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false; bool _isPersistingPacketCapture = false;
@@ -92,6 +99,7 @@ class AppProvider with ChangeNotifier {
_loadSimpleMode(); _loadSimpleMode();
_loadMapEnabled(); _loadMapEnabled();
_loadContactsEnabled(); _loadContactsEnabled();
_loadSensorsEnabled();
_loadVoiceSilenceTrimmingEnabled(); _loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled(); _loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled(); _loadVoiceCompressorEnabled();
@@ -183,12 +191,12 @@ class AppProvider with ChangeNotifier {
void _restoreSessionMetadataFromMessages() { void _restoreSessionMetadataFromMessages() {
final restored = restoreSessionMetadataFromMessages( final restored = restoreSessionMetadataFromMessages(
messagesProvider.messages.map((message) => message.text), messagesProvider.messages,
); );
_voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession); _voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession);
_imageSessionSenderKey6.addAll(restored.imageSenderKeyBySession);
for (final entry in restored.imageEnvelopeBySession.entries) { for (final entry in restored.imageEnvelopeBySession.entries) {
_imageSessionSenderKey6[entry.key] = entry.value.senderKey6.toLowerCase();
imageProvider.registerEnvelope(entry.value); imageProvider.registerEnvelope(entry.value);
} }
@@ -286,6 +294,29 @@ class AppProvider with ChangeNotifier {
} }
} }
/// Load sensors enabled setting from shared preferences
Future<void> _loadSensorsEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isSensorsEnabled = prefs.getBool('sensors_enabled') ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading sensors enabled setting: $e');
}
}
/// Toggle sensors tab on/off
Future<void> toggleSensorsEnabled(bool enabled) async {
try {
_isSensorsEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('sensors_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving sensors enabled setting: $e');
}
}
/// Load voice silence trimming setting from shared preferences. /// Load voice silence trimming setting from shared preferences.
Future<void> _loadVoiceSilenceTrimmingEnabled() async { Future<void> _loadVoiceSilenceTrimmingEnabled() async {
try { try {
@@ -681,9 +712,15 @@ class AppProvider with ChangeNotifier {
// Voice envelope message (new public/direct on-demand format). // Voice envelope message (new public/direct on-demand format).
final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text); final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text);
if (voiceEnvelope != null) { if (voiceEnvelope != null) {
_voiceSessionSenderKey6[voiceEnvelope.sessionId] = voiceEnvelope final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
.senderKey6 if (senderPrefix != null && senderPrefix.length >= 6) {
.toLowerCase(); _voiceSessionSenderKey6[voiceEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
voiceProvider.registerEnvelope(voiceEnvelope);
enrichedMessage = enrichedMessage.copyWith( enrichedMessage = enrichedMessage.copyWith(
isVoice: true, isVoice: true,
voiceId: voiceEnvelope.sessionId, voiceId: voiceEnvelope.sessionId,
@@ -713,9 +750,14 @@ class AppProvider with ChangeNotifier {
// Image envelope (IE1): announce image availability. // Image envelope (IE1): announce image availability.
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text); final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
if (imageEnvelope != null) { if (imageEnvelope != null) {
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
.senderKey6 if (senderPrefix != null && senderPrefix.length >= 6) {
.toLowerCase(); _imageSessionSenderKey6[imageEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
imageProvider.registerEnvelope(imageEnvelope); imageProvider.registerEnvelope(imageEnvelope);
messagesProvider.addMessage( messagesProvider.addMessage(
enrichedMessage, enrichedMessage,
@@ -739,19 +781,6 @@ class AppProvider with ChangeNotifier {
return; return;
} }
// If it's a text-format voice packet, feed it to VoiceProvider
if (VoicePacket.isVoiceText(enrichedMessage.text)) {
final pkt = VoicePacket.tryParseText(enrichedMessage.text);
if (pkt != null) {
voiceProvider.addPacket(pkt);
// Mark the message with voice metadata before adding to chat
enrichedMessage = enrichedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
// Pass contact lookup function to link channel messages with contacts // Pass contact lookup function to link channel messages with contacts
messagesProvider.addMessage( messagesProvider.addMessage(
enrichedMessage, enrichedMessage,
@@ -803,8 +832,9 @@ class AppProvider with ChangeNotifier {
}; };
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84) // When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request. // Magic 0x6d 'm' = swarm control; 0x72 'r' = voice fetch request.
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet. // Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
// Magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload); final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
if (rawProbeRequest != null) { if (rawProbeRequest != null) {
@@ -824,6 +854,20 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final mediaSwarmRequest = MediaSwarmRequest.tryParseBinary(payload);
if (mediaSwarmRequest != null) {
_handleIncomingMediaSwarmRequest(mediaSwarmRequest);
return;
}
final mediaSwarmAvailability = MediaSwarmAvailability.tryParseBinary(
payload,
);
if (mediaSwarmAvailability != null) {
_handleIncomingMediaSwarmAvailability(mediaSwarmAvailability);
return;
}
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload); final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
if (voiceFetchRequest != null) { if (voiceFetchRequest != null) {
debugPrint( debugPrint(
@@ -841,26 +885,34 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
if (requester.outPathLen > _maxDirectPayloadHops) { if (requester.routeHopCount > _maxDirectPayloadHops) {
debugPrint( debugPrint(
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops', '⚠️ [AppProvider] Voice fetch requester too far: ${requester.routeHopCount} hops',
); );
messagesProvider.logSystemMessage( messagesProvider.logSystemMessage(
text: text:
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', 'Cannot fetch voice for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).',
level: 'warning', level: 'warning',
); );
return; return;
} }
unawaited( unawaited(() async {
voiceProvider.serveSessionTo( final served = await voiceProvider.serveSessionTo(
sessionId: voiceFetchRequest.sessionId, sessionId: voiceFetchRequest.sessionId,
requester: requester, requester: requester,
requestedIndices: voiceFetchRequest.want == 'missing' requestedIndices: voiceFetchRequest.want == 'missing'
? voiceFetchRequest.missingIndices.toSet() ? voiceFetchRequest.missingIndices.toSet()
: null, : null,
), );
); if (served) {
messagesProvider.recordMediaTransfer(
sessionId: voiceFetchRequest.sessionId,
mediaType: 'voice',
requesterKey6: voiceFetchRequest.requesterKey6,
requesterName: requester.advName,
);
}
}());
return; return;
} }
@@ -880,32 +932,40 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
if (requester.outPathLen > _maxDirectPayloadHops) { if (requester.routeHopCount > _maxDirectPayloadHops) {
debugPrint( debugPrint(
'⚠️ [AppProvider] Image fetch requester too far: ' '⚠️ [AppProvider] Image fetch requester too far: '
'${requester.outPathLen} hops for session ' '${requester.routeHopCount} hops for session '
'${imageFetchRequest.sessionId}', '${imageFetchRequest.sessionId}',
); );
messagesProvider.logSystemMessage( messagesProvider.logSystemMessage(
text: text:
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).', 'Cannot fetch image for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).',
level: 'warning', level: 'warning',
); );
return; return;
} }
debugPrint( debugPrint(
'📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} ' '📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} '
'to ${requester.advName} via ${requester.outPathLen} hop(s)', 'to ${requester.advName} via ${requester.routeHopCount} hop(s)',
); );
unawaited( unawaited(() async {
imageProvider.serveSessionTo( final served = await imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId, sessionId: imageFetchRequest.sessionId,
requester: requester, requester: requester,
requestedIndices: imageFetchRequest.want == 'missing' requestedIndices: imageFetchRequest.want == 'missing'
? imageFetchRequest.missingIndices.toSet() ? imageFetchRequest.missingIndices.toSet()
: null, : null,
), );
); if (served) {
messagesProvider.recordMediaTransfer(
sessionId: imageFetchRequest.sessionId,
mediaType: 'image',
requesterKey6: imageFetchRequest.requesterKey6,
requesterName: requester.advName,
);
}
}());
return; return;
} }
@@ -914,8 +974,23 @@ class AppProvider with ChangeNotifier {
if (frag == null) return; if (frag == null) return;
debugPrint('📷 [AppProvider] Binary image fragment received: $frag'); debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId); final session = imageProvider.session(frag.sessionId);
if (session == null && frag.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact image fragment without envelope '
'for session ${frag.sessionId}',
);
return;
}
imageProvider.addFragment( imageProvider.addFragment(
frag, session == null
? frag
: ImagePacket(
sessionId: frag.sessionId,
format: session.format,
index: frag.index,
total: session.total,
data: frag.data,
),
width: session?.width ?? 0, width: session?.width ?? 0,
height: session?.height ?? 0, height: session?.height ?? 0,
); );
@@ -930,7 +1005,25 @@ class AppProvider with ChangeNotifier {
final pkt = VoicePacket.tryParseBinary(payload); final pkt = VoicePacket.tryParseBinary(payload);
if (pkt == null) return; if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt'); debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final justComplete = voiceProvider.addPacket(pkt); final session = voiceProvider.session(pkt.sessionId);
if (session == null && pkt.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact voice packet without envelope '
'for session ${pkt.sessionId}',
);
return;
}
final justComplete = voiceProvider.addPacket(
session == null
? pkt
: VoicePacket(
sessionId: pkt.sessionId,
mode: session.mode,
index: pkt.index,
total: session.total,
codec2Data: pkt.codec2Data,
),
);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete); _scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
// Insert or update the placeholder message in the chat list // Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete); _handleIncomingVoicePacket(pkt, justComplete: justComplete);
@@ -1362,6 +1455,291 @@ class AppProvider with ChangeNotifier {
return null; return null;
} }
String _mediaSwarmKey(String mediaType, String sessionId) =>
'$mediaType:$sessionId';
String? _deviceKey6Hex() {
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
return null;
}
return deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
List<int> _availableIndicesForSession(String mediaType, String sessionId) {
return switch (mediaType) {
'voice' => voiceProvider.availablePacketIndices(sessionId),
'image' => imageProvider.availableFragmentIndices(sessionId),
_ => const <int>[],
};
}
List<int> _matchingAvailableIndices(MediaSwarmRequest request) {
final available = _availableIndicesForSession(
request.mediaType,
request.sessionId,
);
if (available.isEmpty) return const [];
if (request.requestsAll) return available;
final requested = request.missingIndices.toSet();
return available.where(requested.contains).toList()..sort();
}
List<Contact> _eligibleSwarmPeers({String? excludeKey6}) {
final ownKey6 = _deviceKey6Hex();
return contactsProvider.contacts.where((contact) {
if (!contact.routeHasPath ||
contact.routeHopCount > _maxDirectPayloadHops ||
!contact.routeSupportsLegacyRawTransport ||
contact.outPath.isEmpty ||
contact.publicKey.length < 6) {
return false;
}
final key6 = contact.publicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
if (key6 == ownKey6 || key6 == excludeKey6) {
return false;
}
return true;
}).toList();
}
void _handleIncomingMediaSwarmRequest(MediaSwarmRequest request) {
final ownKey6 = _deviceKey6Hex();
if (ownKey6 == null || request.requesterKey6 == ownKey6) {
return;
}
final available = _matchingAvailableIndices(request);
if (available.isEmpty) {
return;
}
final availability = MediaSwarmAvailability(
mediaType: request.mediaType,
sessionId: request.sessionId,
requesterKey6: request.requesterKey6,
responderKey6: ownKey6,
availableIndices: available,
);
debugPrint(
'🌐 [AppProvider] Media swarm availability for ${request.mediaType} '
'${request.sessionId}: ${available.length} fragment(s)',
);
final requester = _resolveContactByPrefixHex(request.requesterKey6);
if (requester == null ||
!requester.routeHasPath ||
requester.routeHopCount > _maxDirectPayloadHops ||
!requester.routeSupportsLegacyRawTransport ||
requester.outPath.isEmpty) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
payload: availability.encodeBinary(),
),
);
}
void _handleIncomingMediaSwarmAvailability(
MediaSwarmAvailability availability,
) {
final ownKey6 = _deviceKey6Hex();
if (ownKey6 == null || availability.requesterKey6 != ownKey6) {
return;
}
final key = _mediaSwarmKey(availability.mediaType, availability.sessionId);
final responses = _pendingMediaSwarmResponses[key];
if (responses == null) {
return;
}
responses[availability.responderKey6] = availability;
debugPrint(
'🌐 [AppProvider] Media swarm response for ${availability.mediaType} '
'${availability.sessionId} from ${availability.responderKey6} '
'(${availability.servesAll ? 'all' : availability.availableIndices.length})',
);
}
Future<bool> _requestMissingMediaViaSwarm({
required String mediaType,
required String sessionId,
required List<int> missingIndices,
required String? originalSenderKey6,
}) async {
if (!connectionProvider.deviceInfo.isConnected || missingIndices.isEmpty) {
return false;
}
final key = _mediaSwarmKey(mediaType, sessionId);
final pending = _pendingMediaSwarmFetches[key];
if (pending != null) {
return pending;
}
final requesterKey6 = _deviceKey6Hex();
if (requesterKey6 == null) {
return false;
}
final future = () async {
final responses = <String, MediaSwarmAvailability>{};
_pendingMediaSwarmResponses[key] = responses;
try {
final request = MediaSwarmRequest(
mediaType: mediaType,
sessionId: sessionId,
requesterKey6: requesterKey6,
missingIndices: missingIndices,
);
final peers = _eligibleSwarmPeers(excludeKey6: originalSenderKey6);
if (peers.isEmpty) {
return false;
}
debugPrint(
'🌐 [AppProvider] Media swarm request for $mediaType $sessionId '
'(${missingIndices.length} needed fragment(s), ${peers.length} peer(s))',
);
for (final peer in peers) {
await connectionProvider.sendRawVoicePacket(
contactPath: peer.outPath,
contactPathLen: peer.routeSignedPathLen,
payload: request.encodeBinary(),
);
}
await Future<void>.delayed(_mediaSwarmResponseWindow);
final orderedResponses =
responses.values
.where(
(response) => response.responderKey6 != originalSenderKey6,
)
.toList()
..sort((a, b) {
final aScore = _swarmResponseScore(a, missingIndices);
final bScore = _swarmResponseScore(b, missingIndices);
return bScore.compareTo(aScore);
});
for (final response in orderedResponses) {
final responder = _resolveContactByPrefixHex(response.responderKey6);
if (responder == null ||
!responder.routeHasPath ||
responder.routeHopCount > _maxDirectPayloadHops ||
!responder.routeSupportsLegacyRawTransport ||
responder.outPath.isEmpty) {
continue;
}
final requestedSubset = response.servesAll
? missingIndices
: missingIndices
.where(response.availableIndices.toSet().contains)
.toList();
if (requestedSubset.isEmpty) {
continue;
}
final requestedSet = requestedSubset.toSet();
final sent = await _sendDirectMediaFetchRequest(
mediaType: mediaType,
sessionId: sessionId,
target: responder,
requesterKey6: requesterKey6,
missingIndices: requestedSet,
);
if (sent) {
debugPrint(
'🌐 [AppProvider] Requested $mediaType $sessionId '
'from swarm peer ${responder.advName} '
'(${requestedSubset.length} fragment(s))',
);
return true;
}
}
return false;
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Media swarm request failed for $mediaType '
'$sessionId: $e',
);
return false;
} finally {
_pendingMediaSwarmResponses.remove(key);
}
}();
_pendingMediaSwarmFetches[key] = future;
try {
return await future;
} finally {
_pendingMediaSwarmFetches.remove(key);
}
}
int _swarmResponseScore(
MediaSwarmAvailability response,
List<int> missingIndices,
) {
if (response.servesAll) {
return missingIndices.length;
}
final needed = missingIndices.toSet();
return response.availableIndices.where(needed.contains).length;
}
Future<bool> _sendDirectMediaFetchRequest({
required String mediaType,
required String sessionId,
required Contact target,
required String requesterKey6,
required Set<int> missingIndices,
}) async {
try {
final payload = switch (mediaType) {
'voice' => VoiceFetchRequest(
sessionId: sessionId,
want: missingIndices.isEmpty ? 'all' : 'missing',
missingIndices: missingIndices.toList()..sort(),
requesterKey6: requesterKey6,
).encodeBinary(),
'image' => ImageFetchRequest(
sessionId: sessionId,
want: missingIndices.isEmpty ? 'all' : 'missing',
missingIndices: missingIndices.toList()..sort(),
requesterKey6: requesterKey6,
).encodeBinary(),
_ => null,
};
if (payload == null) {
return false;
}
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen,
payload: payload,
);
return true;
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Direct $mediaType fetch via ${target.advName} failed: $e',
);
return false;
}
}
void _scheduleVoiceMissingRetry( void _scheduleVoiceMissingRetry(
String sessionId, { String sessionId, {
required bool justComplete, required bool justComplete,
@@ -1434,8 +1812,8 @@ class AppProvider with ChangeNotifier {
final senderKey6 = _voiceSessionSenderKey6[sessionId]; final senderKey6 = _voiceSessionSenderKey6[sessionId];
if (senderKey6 == null) return; if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6); final sender = _resolveContactByPrefixHex(senderKey6);
final deviceKey = connectionProvider.deviceInfo.publicKey; final requesterKey6 = _deviceKey6Hex();
if (sender == null || deviceKey == null || deviceKey.length < 6) return; if (requesterKey6 == null) return;
final missing = voiceProvider.missingPacketIndices(sessionId); final missing = voiceProvider.missingPacketIndices(sessionId);
if (missing.isEmpty) { if (missing.isEmpty) {
@@ -1443,27 +1821,28 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final requesterKey6 = deviceKey var sent = false;
.sublist(0, 6) if (sender != null) {
.map((b) => b.toRadixString(16).padLeft(2, '0')) final routeOk = await verifyRawTransportRoute(sender);
.join(''); if (routeOk) {
sent = await _sendDirectMediaFetchRequest(
final request = VoiceFetchRequest( mediaType: 'voice',
sessionId: sessionId, sessionId: sessionId,
want: 'missing', target: sender,
missingIndices: missing, requesterKey6: requesterKey6,
requesterKey6: requesterKey6, missingIndices: missing.toSet(),
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, );
version: 2, }
); }
if (!sent) {
try { sent = await _requestMissingMediaViaSwarm(
await connectionProvider.sendRawVoicePacket( mediaType: 'voice',
contactPath: sender.outPath, sessionId: sessionId,
contactPathLen: sender.outPathLen, missingIndices: missing,
payload: request.encodeBinary(), originalSenderKey6: senderKey6,
); );
} catch (_) { }
if (!sent) {
return; return;
} }
@@ -1496,8 +1875,8 @@ class AppProvider with ChangeNotifier {
final senderKey6 = _imageSessionSenderKey6[sessionId]; final senderKey6 = _imageSessionSenderKey6[sessionId];
if (senderKey6 == null) return; if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6); final sender = _resolveContactByPrefixHex(senderKey6);
final deviceKey = connectionProvider.deviceInfo.publicKey; final requesterKey6 = _deviceKey6Hex();
if (sender == null || deviceKey == null || deviceKey.length < 6) return; if (requesterKey6 == null) return;
final missing = imageProvider.missingFragmentIndices(sessionId); final missing = imageProvider.missingFragmentIndices(sessionId);
if (missing.isEmpty) { if (missing.isEmpty) {
@@ -1505,26 +1884,28 @@ class AppProvider with ChangeNotifier {
return; return;
} }
final requesterKey6 = deviceKey var sent = false;
.sublist(0, 6) if (sender != null) {
.map((b) => b.toRadixString(16).padLeft(2, '0')) final routeOk = await verifyRawTransportRoute(sender);
.join(''); if (routeOk) {
sent = await _sendDirectMediaFetchRequest(
final request = ImageFetchRequest( mediaType: 'image',
sessionId: sessionId, sessionId: sessionId,
want: 'missing', target: sender,
missingIndices: missing, requesterKey6: requesterKey6,
requesterKey6: requesterKey6, missingIndices: missing.toSet(),
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, );
); }
}
try { if (!sent) {
await connectionProvider.sendRawVoicePacket( sent = await _requestMissingMediaViaSwarm(
contactPath: sender.outPath, mediaType: 'image',
contactPathLen: sender.outPathLen, sessionId: sessionId,
payload: request.encodeBinary(), missingIndices: missing,
originalSenderKey6: senderKey6,
); );
} catch (_) { }
if (!sent) {
return; return;
} }
@@ -1585,7 +1966,10 @@ class AppProvider with ChangeNotifier {
if (!connectionProvider.deviceInfo.isConnected) { if (!connectionProvider.deviceInfo.isConnected) {
return false; return false;
} }
if (target.outPathLen < 0 || target.outPathLen > _maxDirectPayloadHops) { if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
return false;
}
if (!target.routeSupportsLegacyRawTransport) {
return false; return false;
} }
if (target.outPath.isEmpty) { if (target.outPath.isEmpty) {
@@ -1616,11 +2000,11 @@ class AppProvider with ChangeNotifier {
try { try {
debugPrint( debugPrint(
'📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.outPathLen} nonce=${nonce.toRadixString(16)}', '📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.routeHopCount} nonce=${nonce.toRadixString(16)}',
); );
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath, contactPath: target.outPath,
contactPathLen: target.outPathLen, contactPathLen: target.routeSignedPathLen,
payload: RawRouteProbeRequest( payload: RawRouteProbeRequest(
nonce: nonce, nonce: nonce,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
@@ -1648,7 +2032,7 @@ class AppProvider with ChangeNotifier {
if (target.publicKeyHex.isNotEmpty) { if (target.publicKeyHex.isNotEmpty) {
return 'pk:${target.publicKeyHex}'; return 'pk:${target.publicKeyHex}';
} }
return 'name:${target.advName}:${target.outPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
} }
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) { void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
@@ -1659,23 +2043,26 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
if (requester.outPathLen < 0 || if (!requester.routeHasPath ||
requester.outPathLen > _maxDirectPayloadHops) { requester.routeHopCount > _maxDirectPayloadHops) {
debugPrint( debugPrint(
'⚠️ [AppProvider] Raw route probe requester out of range: ${requester.outPathLen}', '⚠️ [AppProvider] Raw route probe requester out of range: ${requester.routeHopCount}',
); );
return; return;
} }
if (!requester.routeSupportsLegacyRawTransport) {
return;
}
if (requester.outPath.isEmpty) { if (requester.outPath.isEmpty) {
return; return;
} }
debugPrint( debugPrint(
'📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.outPathLen} nonce=${request.nonce.toRadixString(16)}', '📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.routeHopCount} nonce=${request.nonce.toRadixString(16)}',
); );
unawaited( unawaited(
connectionProvider.sendRawVoicePacket( connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.outPathLen, contactPathLen: requester.routeSignedPathLen,
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
), ),
); );

View File

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

View File

@@ -303,11 +303,28 @@ class ContactsProvider with ChangeNotifier {
} else { } else {
// Existing contact - preserve history and isNew status // Existing contact - preserve history and isNew status
final existingContact = _contacts[contact.publicKeyHex]!; final existingContact = _contacts[contact.publicKeyHex]!;
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact.telemetry,
incomingTelemetry: contact.telemetry,
);
final incomingAdvertLocation = contact.advertLocation;
final existingAdvertLocation = existingContact.advertLocation;
// Start with existing contact // Start with existing contact
updatedContact = contact.copyWith( updatedContact = contact.copyWith(
isNew: existingContact.isNew, isNew: existingContact.isNew,
advertHistory: existingContact.advertHistory, advertHistory: existingContact.advertHistory,
telemetry: mergedTelemetry,
advLat: incomingAdvertLocation != null
? contact.advLat
: existingAdvertLocation != null
? existingContact.advLat
: contact.advLat,
advLon: incomingAdvertLocation != null
? contact.advLon
: existingAdvertLocation != null
? existingContact.advLon
: contact.advLon,
); );
// Add new location to history if location has changed // Add new location to history if location has changed
@@ -397,23 +414,22 @@ class ContactsProvider with ChangeNotifier {
); );
} }
// Keep last valid GPS for router/chat/room contacts when current final previousTelemetry = contact.telemetry;
// telemetry does not provide a valid GPS fix.
if (_shouldRetainLastValidGps(contact, telemetry.gpsLocation)) { final mergedTelemetry = _mergeTelemetryForContact(
debugPrint( existingTelemetry: previousTelemetry,
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps', incomingTelemetry: telemetry,
); );
final previousGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation); if (mergedTelemetry != null) {
telemetry = ContactTelemetry( if (_shouldRetainLastValidGps(
gpsLocation: previousGps, previousTelemetry,
batteryPercentage: telemetry.batteryPercentage, telemetry.gpsLocation,
batteryMilliVolts: telemetry.batteryMilliVolts, )) {
temperature: telemetry.temperature, debugPrint(
timestamp: telemetry.timestamp, ' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
humidity: telemetry.humidity, );
pressure: telemetry.pressure, }
extraSensorData: telemetry.extraSensorData, telemetry = mergedTelemetry;
);
} }
// Update contact with new telemetry AND last seen time // Update contact with new telemetry AND last seen time
@@ -423,9 +439,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)');
@@ -460,15 +483,12 @@ class ContactsProvider with ChangeNotifier {
return location; return location;
} }
bool _shouldRetainLastValidGps(Contact contact, LatLng? incomingGps) { bool _shouldRetainLastValidGps(
final isSupportedType = ContactTelemetry? existingTelemetry,
contact.isChat || contact.isRepeater || contact.isRoom; LatLng? incomingGps,
if (!isSupportedType) { ) {
return false;
}
final hasPreviousValidGps = final hasPreviousValidGps =
_getValidGpsOrNull(contact.telemetry?.gpsLocation) != null; _getValidGpsOrNull(existingTelemetry?.gpsLocation) != null;
if (!hasPreviousValidGps) { if (!hasPreviousValidGps) {
return false; return false;
} }
@@ -476,6 +496,44 @@ class ContactsProvider with ChangeNotifier {
return incomingGps == null; return incomingGps == null;
} }
ContactTelemetry? _mergeTelemetryForContact({
ContactTelemetry? existingTelemetry,
ContactTelemetry? incomingTelemetry,
}) {
if (incomingTelemetry == null) {
return existingTelemetry;
}
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
final mergedExtraSensorData = <String, dynamic>{
...?existingTelemetry?.extraSensorData,
...?incomingTelemetry.extraSensorData,
};
return ContactTelemetry(
gpsLocation: incomingGps ?? previousGps,
batteryPercentage:
incomingTelemetry.batteryPercentage ??
existingTelemetry?.batteryPercentage,
batteryMilliVolts:
incomingTelemetry.batteryMilliVolts ??
existingTelemetry?.batteryMilliVolts,
temperature:
incomingTelemetry.temperature ?? existingTelemetry?.temperature,
timestamp: incomingTelemetry.timestamp,
humidity: incomingTelemetry.humidity ?? existingTelemetry?.humidity,
pressure: incomingTelemetry.pressure ?? existingTelemetry?.pressure,
extraSensorData: mergedExtraSensorData.isEmpty
? null
: mergedExtraSensorData,
);
}
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 +579,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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,254 @@
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 }
class SensorsProvider with ChangeNotifier {
static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans';
static const Set<String> _defaultVisibleFields = <String>{
'voltage',
'battery',
'temperature',
'humidity',
'pressure',
'gps',
};
final List<String> _watchedSensorKeys = <String>[];
final Map<String, SensorRefreshState> _refreshStates =
<String, SensorRefreshState>{};
final Map<String, Set<String>> _visibleFieldsBySensor =
<String, Set<String>>{};
final Map<String, Map<String, int>> _fieldSpansBySensor =
<String, Map<String, int>>{};
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);
final storedSpansJson = prefs.getString(_fieldSpanKey);
_watchedSensorKeys
..clear()
..addAll(stored);
_visibleFieldsBySensor.clear();
_fieldSpansBySensor.clear();
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
_visibleFieldsBySensor[entry.key] = (entry.value as List<dynamic>)
.cast<String>()
.toSet();
}
}
if (storedSpansJson != null && storedSpansJson.isNotEmpty) {
final decoded = jsonDecode(storedSpansJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
_fieldSpansBySensor[entry.key] = (entry.value as Map<String, dynamic>)
.map((key, value) => MapEntry(key, value as int));
}
}
for (final key in _watchedSensorKeys) {
_visibleFieldsBySensor.putIfAbsent(
key,
() => Set<String>.from(_defaultVisibleFields),
);
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
}
} 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 _visibleFieldsBySensor.entries) {
encoded[entry.key] = entry.value.toList();
}
await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded));
} catch (e) {
debugPrint('Error saving visible sensor metrics: $e');
}
}
Future<void> _persistFieldSpans() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_fieldSpanKey, jsonEncode(_fieldSpansBySensor));
} catch (e) {
debugPrint('Error saving sensor field spans: $e');
}
}
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
);
bool showsField(String publicKeyHex, String fieldKey) =>
visibleFieldsFor(publicKeyHex).contains(fieldKey);
int fieldSpanFor(String publicKeyHex, String fieldKey) {
final sensorSpans = _fieldSpansBySensor[publicKeyHex];
final span = sensorSpans?[fieldKey] ?? 1;
return span == 2 ? 2 : 1;
}
Future<void> toggleMetric(
String publicKeyHex,
String fieldKey,
bool visible,
) async {
final visibleFields = _visibleFieldsBySensor.putIfAbsent(
publicKeyHex,
() => Set<String>.from(_defaultVisibleFields),
);
if (visible) {
visibleFields.add(fieldKey);
} else {
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
return;
}
visibleFields.remove(fieldKey);
}
await _persistVisibleMetrics();
notifyListeners();
}
Future<void> setFieldSpan(
String publicKeyHex,
String fieldKey,
int span,
) async {
final sensorSpans = _fieldSpansBySensor.putIfAbsent(
publicKeyHex,
() => <String, int>{},
);
sensorSpans[fieldKey] = span == 2 ? 2 : 1;
await _persistFieldSpans();
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();
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
_defaultVisibleFields,
);
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
await _persistVisibleMetrics();
await _persistFieldSpans();
notifyListeners();
}
Future<void> removeSensor(String publicKeyHex) async {
_watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex);
_visibleFieldsBySensor.remove(publicKeyHex);
_fieldSpansBySensor.remove(publicKeyHex);
await _persistWatchedSensors();
await _persistVisibleMetrics();
await _persistFieldSpans();
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();
}
}
}

View File

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

View File

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

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

1061
lib/screens/sensors_tab.dart Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
import '../utils/sample_data_generator.dart'; import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
@@ -56,6 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
bool _isCheckingForUpdates = false; bool _isCheckingForUpdates = false;
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
int _routeHashSize = RouteHashPreferences.defaultHashSize;
int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageMaxSize = ImagePreferences.defaultMaxSize;
int _imageCompression = ImagePreferences.defaultQuality; int _imageCompression = ImagePreferences.defaultQuality;
bool _imageGrayscale = ImagePreferences.defaultGrayscale; bool _imageGrayscale = ImagePreferences.defaultGrayscale;
@@ -77,6 +79,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_initializeLocationService(); _initializeLocationService();
_loadRxTxPreference(); _loadRxTxPreference();
_loadVoiceBitratePreference(); _loadVoiceBitratePreference();
_loadRouteHashSizePreference();
_loadImagePreferences(); _loadImagePreferences();
} }
@@ -132,6 +135,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
return '$bitrate bps'; return '$bitrate bps';
} }
Future<void> _loadRouteHashSizePreference() async {
final value = await RouteHashPreferences.getHashSize();
if (!mounted) return;
setState(() {
_routeHashSize = value;
});
}
Future<void> _saveRouteHashSizePreference(int value) async {
await RouteHashPreferences.setHashSize(value);
if (!mounted) return;
setState(() {
_routeHashSize = value;
});
}
Future<void> _loadImagePreferences() async { Future<void> _loadImagePreferences() async {
final size = await ImagePreferences.getMaxSize(); final size = await ImagePreferences.getMaxSize();
final compression = await ImagePreferences.getCompression(); final compression = await ImagePreferences.getCompression();
@@ -658,104 +677,176 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Future<void> _showRouteHashSizeDialog() async {
final selected = await showDialog<int>(
context: context,
builder: (context) => SimpleDialog(
title: const Text('Route path byte size'),
children: [
for (final value in [1, 2, 3])
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(value),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('$value byte${value == 1 ? '' : 's'}'),
Text(
'Use ${value * 2} hex characters per hop',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (_routeHashSize == value)
Icon(
Icons.check,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
],
),
);
if (selected == null) return;
await _saveRouteHashSizePreference(selected);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
body: ListView( body: ListView(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
children: [ children: [
// General Settings Section _buildSectionHeader('Appearance'),
_buildSectionHeader(AppLocalizations.of(context)!.general), _buildSettingsCard([
ListTile( ListTile(
leading: const Icon(Icons.palette), leading: const Icon(Icons.palette),
title: Text(AppLocalizations.of(context)!.theme), title: Text(AppLocalizations.of(context)!.theme),
subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)), subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeDialog(), onTap: () => _showThemeDialog(),
), ),
SwitchListTile( ListTile(
secondary: const Icon(Icons.radar), leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.showRxTxIndicators), title: Text(AppLocalizations.of(context)!.language),
subtitle: Text(AppLocalizations.of(context)!.displayPacketActivity), subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
value: _showRxTxIndicators, trailing: const Icon(Icons.chevron_right),
onChanged: (value) async { onTap: () => _showLanguageDialog(),
setState(() { ),
_showRxTxIndicators = value; SwitchListTile(
}); secondary: const Icon(Icons.radar),
await _saveRxTxPreference(value); title: Text(AppLocalizations.of(context)!.showRxTxIndicators),
},
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.visibility_off),
title: Text(AppLocalizations.of(context)!.simpleMode),
subtitle: Text( subtitle: Text(
AppLocalizations.of(context)!.simpleModeDescription, AppLocalizations.of(context)!.displayPacketActivity,
), ),
value: appProvider.isSimpleMode, value: _showRxTxIndicators,
onChanged: (value) async { onChanged: (value) async {
await appProvider.toggleSimpleMode(value); setState(() {
_showRxTxIndicators = value;
});
await _saveRxTxPreference(value);
}, },
), ),
), ]),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( _buildSectionHeader('Navigation'),
secondary: const Icon(Icons.person_add_alt_1), _buildSettingsCard([
title: const Text('Auto-add discovered contacts'), Consumer<AppProvider>(
subtitle: const Text( builder: (context, appProvider, child) => SwitchListTile(
'Automatically fetch and add new contacts when they are discovered', secondary: const Icon(Icons.visibility_off),
), title: Text(AppLocalizations.of(context)!.simpleMode),
value: appProvider.autoAddDiscoveredContacts, subtitle: Text(
onChanged: (value) async { AppLocalizations.of(context)!.simpleModeDescription,
await appProvider.toggleAutoAddDiscoveredContacts(value); ),
}, value: appProvider.isSimpleMode,
), onChanged: (value) async {
), await appProvider.toggleSimpleMode(value);
Consumer<AppProvider>( },
builder: (context, appProvider, child) => SwitchListTile( ),
secondary: const Icon(Icons.map_outlined), ),
title: Text(AppLocalizations.of(context)!.disableMap), Consumer<AppProvider>(
subtitle: Text( builder: (context, appProvider, child) => SwitchListTile(
AppLocalizations.of(context)!.disableMapDescription, secondary: const Icon(Icons.map_outlined),
), title: Text(AppLocalizations.of(context)!.disableMap),
value: !appProvider.isMapEnabled, subtitle: Text(
onChanged: (value) async { AppLocalizations.of(context)!.disableMapDescription,
await appProvider.toggleMapEnabled(!value); ),
}, value: !appProvider.isMapEnabled,
), onChanged: (value) async {
), await appProvider.toggleMapEnabled(!value);
Consumer<AppProvider>( },
builder: (context, appProvider, child) => SwitchListTile( ),
secondary: const Icon(Icons.contacts_outlined), ),
title: const Text('Disable Contacts'), Consumer<AppProvider>(
subtitle: const Text( builder: (context, appProvider, child) => SwitchListTile(
'Hide the contacts tab to simplify navigation', secondary: const Icon(Icons.contacts_outlined),
), title: const Text('Disable Contacts'),
value: !appProvider.isContactsEnabled, subtitle: const Text(
onChanged: (value) async { 'Hide the contacts tab to simplify navigation',
await appProvider.toggleContactsEnabled(!value); ),
}, value: !appProvider.isContactsEnabled,
), onChanged: (value) async {
), await appProvider.toggleContactsEnabled(!value);
ListTile( },
leading: const Icon(Icons.language), ),
title: Text(AppLocalizations.of(context)!.language), ),
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)), Consumer<AppProvider>(
trailing: const Icon(Icons.chevron_right), builder: (context, appProvider, child) => SwitchListTile(
onTap: () => _showLanguageDialog(), secondary: const Icon(Icons.sensors),
), title: const Text('Enable Sensors tab'),
ListTile( subtitle: const Text(
leading: const Icon(Icons.delete_sweep, color: Colors.red), 'Show a dedicated tab for watched relay and node telemetry',
title: const Text( ),
'Clear Messages', value: appProvider.isSensorsEnabled,
style: TextStyle(color: Colors.red), onChanged: (value) async {
), await appProvider.toggleSensorsEnabled(value);
subtitle: const Text('Delete all stored message history'), },
onTap: _clearMessages, ),
), ),
const Divider(), ]),
_buildSectionHeader('Messaging'),
_buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
ListTile(
leading: const Icon(Icons.alt_route),
title: const Text('Route path byte size'),
subtitle: Text(
'$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes',
),
trailing: const Icon(Icons.chevron_right),
onTap: _showRouteHashSizeDialog,
),
ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text(
'Clear Messages',
style: TextStyle(color: Colors.red),
),
subtitle: const Text('Delete all stored message history'),
onTap: _clearMessages,
),
]),
// Voice Settings Section
_buildSectionHeader('Voice'), _buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) => builder: (context, appProvider, connectionProvider, child) =>
@@ -768,232 +859,244 @@ class _SettingsScreenState extends State<SettingsScreen> {
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
), ),
), ),
ListTile( _buildSettingsCard([
leading: const Icon(Icons.graphic_eq), ListTile(
title: const Text('Voice bitrate'), leading: const Icon(Icons.graphic_eq),
subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)), title: const Text('Voice bitrate'),
trailing: const Icon(Icons.chevron_right), subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)),
onTap: _showVoiceBitrateDialog, trailing: const Icon(Icons.chevron_right),
), onTap: _showVoiceBitrateDialog,
Consumer<AppProvider>( ),
builder: (context, appProvider, child) => SwitchListTile( Consumer<AppProvider>(
secondary: const Icon(Icons.tune), builder: (context, appProvider, child) => SwitchListTile(
title: const Text('Band-pass filter voice'), secondary: const Icon(Icons.tune),
subtitle: const Text( title: const Text('Band-pass filter voice'),
'Keeps speech frequencies and cuts low/high noise', subtitle: const Text(
'Keeps speech frequencies and cuts low/high noise',
),
value: appProvider.isVoiceBandPassFilterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceBandPassFilterEnabled(value);
},
), ),
value: appProvider.isVoiceBandPassFilterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceBandPassFilterEnabled(value);
},
), ),
), Consumer<AppProvider>(
Consumer<AppProvider>( builder: (context, appProvider, child) => SwitchListTile(
builder: (context, appProvider, child) => SwitchListTile( secondary: const Icon(Icons.compress),
secondary: const Icon(Icons.compress), title: const Text('Voice compressor'),
title: const Text('Voice compressor'), subtitle: const Text('Balances quiet and loud speech levels'),
subtitle: const Text('Balances quiet and loud speech levels'), value: appProvider.isVoiceCompressorEnabled,
value: appProvider.isVoiceCompressorEnabled, onChanged: (value) async {
onChanged: (value) async { await appProvider.toggleVoiceCompressorEnabled(value);
await appProvider.toggleVoiceCompressorEnabled(value); },
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.speed),
title: const Text('Voice limiter'),
subtitle: const Text('Prevents clipping peaks before encoding'),
value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
title: const Text('Trim silence in voice messages'),
subtitle: const Text(
'Removes long silent parts before sending voice',
), ),
value: appProvider.isVoiceSilenceTrimmingEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceSilenceTrimmingEnabled(value);
},
), ),
), Consumer<AppProvider>(
const Divider(), builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.speed),
title: const Text('Voice limiter'),
subtitle: const Text('Prevents clipping peaks before encoding'),
value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
title: const Text('Trim silence in voice messages'),
subtitle: const Text(
'Removes long silent parts before sending voice',
),
value: appProvider.isVoiceSilenceTrimmingEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceSilenceTrimmingEnabled(value);
},
),
),
]),
// Image Settings Section _buildSectionHeader('Images'),
_buildSectionHeader('Image'), _buildSettingsCard([
ListTile( ListTile(
leading: const Icon(Icons.photo_size_select_large), leading: const Icon(Icons.photo_size_select_large),
title: const Text('Max image size'), title: const Text('Max image size'),
subtitle: Text('$_imageMaxSize×$_imageMaxSize px'), subtitle: Text('$_imageMaxSize×$_imageMaxSize px'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _showImageMaxSizeDialog, onTap: _showImageMaxSizeDialog,
),
ListTile(
leading: const Icon(Icons.tune),
title: const Text('Image compression'),
subtitle: Text('$_imageCompression / 90 (higher = smaller file)'),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Slider(
value: _imageCompression.toDouble(),
min: 10,
max: 90,
divisions: 8,
label: '$_imageCompression',
onChanged: (v) => setState(() => _imageCompression = v.round()),
onChangeEnd: (v) => _saveImageCompression(v.round()),
), ),
), ListTile(
SwitchListTile( leading: const Icon(Icons.tune),
secondary: const Icon(Icons.invert_colors), title: const Text('Image compression'),
title: const Text('Grayscale'), subtitle: Text(
subtitle: const Text( '$_imageCompression / 90 (higher = smaller file)',
'Converts image to grayscale for smaller file size', ),
), ),
value: _imageGrayscale, Padding(
onChanged: (value) async { padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
await ImagePreferences.setGrayscale(value); child: Slider(
setState(() => _imageGrayscale = value); value: _imageCompression.toDouble(),
await _refreshImageModePreview(); min: 10,
}, max: 90,
), divisions: 8,
SwitchListTile( label: '$_imageCompression',
secondary: const Icon(Icons.compress), onChanged: (v) => setState(() => _imageCompression = v.round()),
title: const Text('Ultra mode'), onChangeEnd: (v) => _saveImageCompression(v.round()),
subtitle: const Text( ),
'Extra-aggressive compression with stronger AVIF settings',
), ),
value: _imageUltraMode, SwitchListTile(
onChanged: (value) async { secondary: const Icon(Icons.invert_colors),
await ImagePreferences.setUltraMode(value); title: const Text('Grayscale'),
setState(() { subtitle: const Text(
_imageUltraMode = value; 'Converts image to grayscale for smaller file size',
}); ),
await _refreshImageModePreview(); value: _imageGrayscale,
}, onChanged: (value) async {
), await ImagePreferences.setGrayscale(value);
setState(() => _imageGrayscale = value);
await _refreshImageModePreview();
},
),
SwitchListTile(
secondary: const Icon(Icons.compress),
title: const Text('Ultra mode'),
subtitle: const Text(
'Extra-aggressive compression with stronger AVIF settings',
),
value: _imageUltraMode,
onChanged: (value) async {
await ImagePreferences.setUltraMode(value);
setState(() {
_imageUltraMode = value;
});
await _refreshImageModePreview();
},
),
]),
Consumer<ConnectionProvider>( Consumer<ConnectionProvider>(
builder: (context, connectionProvider, child) => builder: (context, connectionProvider, child) =>
_buildImageModePreviewCard(connectionProvider), _buildImageModePreviewCard(connectionProvider),
), ),
const Divider(), _buildSectionHeader('Templates & Help'),
_buildSettingsCard([
// Templates Section ListTile(
_buildSectionHeader('Templates'), leading: const Icon(Icons.location_searching),
ListTile( title: Text(AppLocalizations.of(context)!.sarTemplates),
leading: const Icon(Icons.location_searching), subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates),
title: Text(AppLocalizations.of(context)!.sarTemplates), trailing: const Icon(Icons.chevron_right),
subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates), onTap: () {
trailing: const Icon(Icons.chevron_right), Navigator.push(
onTap: () { context,
Navigator.push( MaterialPageRoute(
context, builder: (context) => const SarTemplateManagementScreen(),
MaterialPageRoute(
builder: (context) => const SarTemplateManagementScreen(),
),
);
},
),
ListTile(
leading: const Icon(Icons.school),
title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
// Show wizard without resetting state - just as a modal
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WelcomeWizardScreen(
onCompleted: () {
// Just pop back to settings when done
Navigator.of(context).pop();
},
), ),
), );
);
},
),
const Divider(),
// Network Sharing Section
const ConnectionModeSelector(),
const Divider(),
// Permissions Section
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
ListTile(
leading: const Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),
subtitle: FutureBuilder<LocationPermission>(
future: Geolocator.checkPermission(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text(AppLocalizations.of(context)!.checking);
}
final permission = snapshot.data!;
String statusText;
Color statusColor;
switch (permission) {
case LocationPermission.always:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedAlways;
statusColor = Colors.green;
break;
case LocationPermission.whileInUse:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedWhileInUse;
statusColor = Colors.green;
break;
case LocationPermission.denied:
statusText = AppLocalizations.of(
context,
)!.locationPermissionDeniedTapToRequest;
statusColor = Colors.orange;
break;
case LocationPermission.deniedForever:
statusText = AppLocalizations.of(
context,
)!.locationPermissionPermanentlyDeniedOpenSettings;
statusColor = Colors.red;
break;
default:
statusText = AppLocalizations.of(context)!.unknown;
statusColor = Colors.grey;
}
return Text(statusText, style: TextStyle(color: statusColor));
}, },
), ),
trailing: const Icon(Icons.chevron_right), ListTile(
onTap: () => _handleLocationPermissionTap(), leading: const Icon(Icons.school),
), title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
const Divider(), trailing: const Icon(Icons.chevron_right),
onTap: () async {
// About Section await Navigator.push(
_buildSectionHeader(AppLocalizations.of(context)!.about), context,
ListTile( MaterialPageRoute(
leading: const Icon(Icons.info), builder: (context) => WelcomeWizardScreen(
title: Text(AppLocalizations.of(context)!.appVersion), onCompleted: () {
subtitle: Text( Navigator.of(context).pop();
_packageInfo != null },
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})' ),
: 'Loading...', ),
);
},
), ),
), ]),
// Check for Updates button (Android only)
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),
subtitle: FutureBuilder<LocationPermission>(
future: Geolocator.checkPermission(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text(AppLocalizations.of(context)!.checking);
}
final permission = snapshot.data!;
String statusText;
Color statusColor;
switch (permission) {
case LocationPermission.always:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedAlways;
statusColor = Colors.green;
break;
case LocationPermission.whileInUse:
statusText = AppLocalizations.of(
context,
)!.locationPermissionGrantedWhileInUse;
statusColor = Colors.green;
break;
case LocationPermission.denied:
statusText = AppLocalizations.of(
context,
)!.locationPermissionDeniedTapToRequest;
statusColor = Colors.orange;
break;
case LocationPermission.deniedForever:
statusText = AppLocalizations.of(
context,
)!.locationPermissionPermanentlyDeniedOpenSettings;
statusColor = Colors.red;
break;
default:
statusText = AppLocalizations.of(context)!.unknown;
statusColor = Colors.grey;
}
return Text(statusText, style: TextStyle(color: statusColor));
},
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _handleLocationPermissionTap(),
),
]),
_buildSectionHeader(AppLocalizations.of(context)!.about),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.info),
title: Text(AppLocalizations.of(context)!.appVersion),
subtitle: Text(
_packageInfo != null
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
: 'Loading...',
),
),
ListTile(
leading: const Icon(Icons.badge),
title: Text(AppLocalizations.of(context)!.appName),
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
),
ListTile(
leading: const Icon(Icons.description),
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
subtitle: Text(
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
),
onTap: () => _showAboutDialog(),
),
]),
if (Platform.isAndroid) if (Platform.isAndroid)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.only(top: 8),
child: FilledButton.icon( child: FilledButton.icon(
onPressed: _isCheckingForUpdates ? null : _checkForUpdates, onPressed: _isCheckingForUpdates ? null : _checkForUpdates,
icon: _isCheckingForUpdates icon: _isCheckingForUpdates
@@ -1014,78 +1117,67 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
), ),
ListTile( _buildSectionHeader('Developer & Data'),
leading: const Icon(Icons.badge), _buildSettingsCard([
title: Text(AppLocalizations.of(context)!.appName), ListTile(
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'), leading: const Icon(Icons.bug_report),
), title: Text(AppLocalizations.of(context)!.packageName),
ListTile( subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
leading: const Icon(Icons.description),
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
subtitle: Text(
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
), ),
onTap: () => _showAboutDialog(), Padding(
), padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
const Divider(), child: Text(
AppLocalizations.of(context)!.sampleData,
// Developer Section style: Theme.of(context).textTheme.titleSmall,
_buildSectionHeader(AppLocalizations.of(context)!.developer),
ListTile(
leading: const Icon(Icons.bug_report),
title: Text(AppLocalizations.of(context)!.packageName),
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
),
const Divider(),
// Sample Data Section
_buildSectionHeader(AppLocalizations.of(context)!.sampleData),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
AppLocalizations.of(context)!.sampleDataDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
), ),
), ),
), Padding(
Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text(
child: Row( AppLocalizations.of(context)!.sampleDataDescription,
children: [ style: Theme.of(context).textTheme.bodySmall?.copyWith(
Expanded( color: Theme.of(
child: ElevatedButton.icon( context,
onPressed: _isLoadingSampleData ? null : _loadSampleData, ).colorScheme.onSurface.withValues(alpha: 0.6),
icon: _isLoadingSampleData
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_circle_outline),
label: Text(AppLocalizations.of(context)!.loadSampleData),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
), ),
const SizedBox(width: 12), ),
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoadingSampleData ? null : _clearSampleData,
icon: const Icon(Icons.delete_outline),
label: Text(AppLocalizations.of(context)!.clearAllData),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
), ),
), Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isLoadingSampleData ? null : _loadSampleData,
icon: _isLoadingSampleData
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_circle_outline),
label: Text(AppLocalizations.of(context)!.loadSampleData),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoadingSampleData ? null : _clearSampleData,
icon: const Icon(Icons.delete_outline),
label: Text(AppLocalizations.of(context)!.clearAllData),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
]),
], ],
), ),
); );
@@ -1093,7 +1185,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget _buildSectionHeader(String title) { Widget _buildSectionHeader(String title) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Text( child: Text(
title, title,
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -1104,6 +1196,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Widget _buildSettingsCard(List<Widget> children) {
return Card(
margin: EdgeInsets.zero,
child: Column(
children: ListTile.divideTiles(
context: context,
tiles: children,
color: Theme.of(context).dividerColor.withValues(alpha: 0.4),
).toList(),
),
);
}
Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) { Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) {
final sourceBytes = _previewSourceBytes; final sourceBytes = _previewSourceBytes;
final fileName = _previewSourceName ?? 'No image selected'; final fileName = _previewSourceName ?? 'No image selected';

View File

@@ -15,6 +15,8 @@ class LocalePreferences {
Locale('fr'), // French Locale('fr'), // French
Locale('it'), // Italian Locale('it'), // Italian
Locale('el'), // Greek Locale('el'), // Greek
Locale('ru'), // Russian
Locale('zh'), // Chinese
]; ];
/// Get the saved locale or return null to use system locale /// Get the saved locale or return null to use system locale
@@ -64,6 +66,8 @@ class LocalePreferences {
return 'Italiano'; return 'Italiano';
case 'el': case 'el':
return 'Greek'; return 'Greek';
case 'ru':
return 'Русский';
case 'zh': case 'zh':
return '简体中文'; return '简体中文';
default: default:
@@ -90,6 +94,8 @@ class LocalePreferences {
return 'Italiano'; return 'Italiano';
case 'el': case 'el':
return 'Ελληνικά'; return 'Ελληνικά';
case 'ru':
return 'Русский';
case 'zh': case 'zh':
return '简体中文'; return '简体中文';
default: default:

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

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

View File

@@ -58,7 +58,6 @@ class NotificationService {
requestAlertPermission: true, requestAlertPermission: true,
requestBadgePermission: true, requestBadgePermission: true,
requestSoundPermission: true, requestSoundPermission: true,
requestCriticalPermission: true, // For urgent SAR notifications
); );
// Combined initialization settings // Combined initialization settings
@@ -100,8 +99,6 @@ class NotificationService {
alert: true, alert: true,
badge: true, badge: true,
sound: true, sound: true,
critical:
true, // Request critical alert permission for urgent SAR notifications
); );
_permissionGranted = granted ?? false; _permissionGranted = granted ?? false;
debugPrint( debugPrint(
@@ -274,8 +271,7 @@ class NotificationService {
badgeNumber: 1, badgeNumber: 1,
threadIdentifier: 'sar_markers', threadIdentifier: 'sar_markers',
categoryIdentifier: 'SAR_ALERT', categoryIdentifier: 'SAR_ALERT',
interruptionLevel: interruptionLevel: InterruptionLevel.timeSensitive,
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
); );
// Combined notification details // Combined notification details

View File

@@ -0,0 +1,26 @@
import 'package:shared_preferences/shared_preferences.dart';
class RouteHashPreferences {
static const String _hashSizeKey = 'route_hash_size';
static const int defaultHashSize = 1;
static Future<int> getHashSize() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize;
return _normalize(value);
}
static Future<void> setHashSize(int value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_hashSizeKey, _normalize(value));
}
static int normalizeSync(int value) => _normalize(value);
static int _normalize(int value) {
if (value < 1 || value > 3) {
return defaultHashSize;
}
return value;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart'; import '../../providers/drawing_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 '../contacts/direct_message_sheet.dart';
import '../drawing_minimap_preview.dart'; import '../drawing_minimap_preview.dart';
import '../../models/ble_packet_log.dart'; import '../../models/ble_packet_log.dart';
import '../../services/sar_template_service.dart'; import '../../services/sar_template_service.dart';
@@ -27,6 +26,7 @@ import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../../models/message_transfer_details.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart'; import 'tictactoe_message_bubble.dart';
@@ -119,20 +119,9 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
try { try {
// Create new message ID for retry
final retryMessageId = '${failedMessage.id}_retry';
// Create retry message
final retryMessage = failedMessage.copyWith(
id: retryMessageId,
deliveryStatus: MessageDeliveryStatus.sending,
);
// Add retry message to provider
Contact? roomContact; Contact? roomContact;
if (failedMessage.messageType == MessageType.contact) { if (failedMessage.messageType == MessageType.contact) {
if (failedMessage.recipientPublicKey == null) { if (failedMessage.recipientPublicKey == null) {
messagesProvider.markMessageFailed(retryMessageId);
ToastLogger.error( ToastLogger.error(
context, context,
AppLocalizations.of(context)!.cannotRetryMissingRecipient, AppLocalizations.of(context)!.cannotRetryMissingRecipient,
@@ -148,13 +137,10 @@ class _MessageBubbleState extends State<MessageBubble> {
}).firstOrNull; }).firstOrNull;
} }
messagesProvider.addSentMessage(retryMessage, contact: roomContact);
// Resend the message // Resend the message
if (failedMessage.messageType == MessageType.contact) { if (failedMessage.messageType == MessageType.contact) {
// Direct message retry (for SAR markers sent to rooms) // Direct message retry (for SAR markers sent to rooms)
if (failedMessage.recipientPublicKey == null) { if (failedMessage.recipientPublicKey == null) {
messagesProvider.markMessageFailed(retryMessageId);
ToastLogger.error( ToastLogger.error(
context, context,
AppLocalizations.of(context)!.cannotRetryMissingRecipient, AppLocalizations.of(context)!.cannotRetryMissingRecipient,
@@ -162,26 +148,39 @@ class _MessageBubbleState extends State<MessageBubble> {
return; return;
} }
// Resend to the same room final prepared = messagesProvider.prepareMessageForRetry(
failedMessage.id,
);
if (!prepared) {
return;
}
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: failedMessage.recipientPublicKey!, contactPublicKey: failedMessage.recipientPublicKey!,
text: failedMessage.text, text: failedMessage.text,
messageId: retryMessageId, messageId: failedMessage.id,
contact: roomContact, contact: roomContact,
); );
if (!context.mounted) return; if (!context.mounted) return;
if (!sentSuccessfully) { if (!sentSuccessfully) {
messagesProvider.markMessageFailed(retryMessageId); messagesProvider.markMessageFailed(failedMessage.id);
ToastLogger.error(context, 'Failed to resend message'); ToastLogger.error(context, 'Failed to resend message');
} }
} else if (failedMessage.messageType == MessageType.channel) { } else if (failedMessage.messageType == MessageType.channel) {
final prepared = messagesProvider.prepareMessageForRetry(
failedMessage.id,
);
if (!prepared) {
return;
}
// Channel message retry // Channel message retry
await connectionProvider.sendChannelMessage( await connectionProvider.sendChannelMessage(
channelIdx: failedMessage.channelIdx ?? 0, channelIdx: failedMessage.channelIdx ?? 0,
text: failedMessage.text, text: failedMessage.text,
messageId: retryMessageId, messageId: failedMessage.id,
); );
if (!context.mounted) return; if (!context.mounted) return;
@@ -193,19 +192,12 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
void _showMessageOptions(BuildContext context) { void _showMessageOptions(BuildContext context) {
// Determine if this is own message
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = final isOwnMessage =
widget.message.isSentMessage || widget.message.isSentMessage ||
widget.message.isFromSelf(selfPublicKey); widget.message.isFromSelf(selfPublicKey);
// Check if we can reply to this message (must be contact message from someone else)
final canReply =
widget.message.isContactMessage &&
!isOwnMessage &&
widget.message.senderPublicKeyPrefix != null;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
@@ -217,16 +209,6 @@ class _MessageBubbleState extends State<MessageBubble> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Reply option (only for contact messages from others)
if (canReply)
ListTile(
leading: const Icon(Icons.reply),
title: Text(AppLocalizations.of(context)!.reply),
onTap: () {
Navigator.pop(context);
_showReplySheet(context);
},
),
// Copy text option // Copy text option
ListTile( ListTile(
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
@@ -415,9 +397,11 @@ class _MessageBubbleState extends State<MessageBubble> {
final receptionDetails = messagesProvider.getMessageReceptionDetails( final receptionDetails = messagesProvider.getMessageReceptionDetails(
widget.message.id, widget.message.id,
); );
final transferDetails = messagesProvider.getMessageTransferDetails(
widget.message.id,
);
final envelope = VoiceEnvelope.tryParseText(widget.message.text); final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
final voiceSession = widget.message.voiceId != null final voiceSession = widget.message.voiceId != null
? voiceProvider.session(widget.message.voiceId!) ? voiceProvider.session(widget.message.voiceId!)
: null; : null;
@@ -454,16 +438,6 @@ class _MessageBubbleState extends State<MessageBubble> {
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
) )
: legacyVoicePacket != null
? estimateVoiceTransmitDuration(
mode: legacyVoicePacket.mode,
packetCount: legacyVoicePacket.total,
durationMs: legacyVoicePacket.durationMs * legacyVoicePacket.total,
pathLen: widget.message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
)
: Duration.zero; : Duration.zero;
final senderPrefixHex = widget.message.senderPublicKeyPrefix final senderPrefixHex = widget.message.senderPublicKeyPrefix
@@ -540,10 +514,17 @@ class _MessageBubbleState extends State<MessageBubble> {
'Text length: ${widget.message.text.length}', 'Text length: ${widget.message.text.length}',
]; ];
if (transferDetails != null) {
rawLines.add('Transfers served: ${transferDetails.totalTransfers}');
rawLines.add(
'Downloaded by: ${_formatDownloaderSummary(transferDetails)}',
);
}
if (widget.message.isVoice) { if (widget.message.isVoice) {
rawLines.add('--- Voice Technical ---'); rawLines.add('--- Voice Technical ---');
if (envelope != null) { if (envelope != null) {
rawLines.add('Envelope format: VE1 compact'); rawLines.add('Envelope format: VE3 compact');
rawLines.add( rawLines.add(
'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})', 'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})',
); );
@@ -551,17 +532,7 @@ class _MessageBubbleState extends State<MessageBubble> {
rawLines.add( rawLines.add(
'Estimated duration ms (envelope): ${envelope.durationMs}', 'Estimated duration ms (envelope): ${envelope.durationMs}',
); );
rawLines.add('Envelope senderKey6: ${envelope.senderKey6}');
rawLines.add('Envelope ts: ${envelope.timestampSec}');
rawLines.add('Envelope ver: ${envelope.version}'); rawLines.add('Envelope ver: ${envelope.version}');
} else if (legacyVoicePacket != null) {
rawLines.add('Envelope format: legacy V packet');
rawLines.add(
'Legacy segment index/total: ${legacyVoicePacket.index + 1}/${legacyVoicePacket.total}',
);
rawLines.add(
'Legacy codec mode: ${legacyVoicePacket.mode.label} (id=${legacyVoicePacket.mode.id})',
);
} else { } else {
rawLines.add('Envelope format: unknown'); rawLines.add('Envelope format: unknown');
} }
@@ -604,8 +575,6 @@ class _MessageBubbleState extends State<MessageBubble> {
rawLines.add( rawLines.add(
'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)', 'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)',
); );
rawLines.add('Envelope senderKey6: ${imageEnvelope.senderKey6}');
rawLines.add('Envelope ts: ${imageEnvelope.timestampSec}');
rawLines.add('Envelope ver: ${imageEnvelope.version}'); rawLines.add('Envelope ver: ${imageEnvelope.version}');
if (imageSession != null) { if (imageSession != null) {
@@ -916,11 +885,7 @@ class _MessageBubbleState extends State<MessageBubble> {
_detailRow( _detailRow(
context, context,
label: l10n.envelope, label: l10n.envelope,
value: envelope != null value: envelope != null ? 'VE3 compact' : l10n.unknown,
? 'VE1 compact'
: legacyVoicePacket != null
? 'Legacy V packet'
: l10n.unknown,
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
@@ -937,6 +902,21 @@ class _MessageBubbleState extends State<MessageBubble> {
? l10n.yes ? l10n.yes
: l10n.no, : l10n.no,
), ),
if (transferDetails != null)
_detailRow(
context,
label: 'Transfers',
value: '${transferDetails.totalTransfers}',
),
if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty)
_detailRow(
context,
label: 'Downloaded by',
value: _formatDownloaderSummary(
transferDetails,
),
),
if (voiceTxEstimate > Duration.zero) if (voiceTxEstimate > Duration.zero)
_detailRow( _detailRow(
context, context,
@@ -988,6 +968,21 @@ class _MessageBubbleState extends State<MessageBubble> {
? l10n.yes ? l10n.yes
: l10n.no, : l10n.no,
), ),
if (transferDetails != null)
_detailRow(
context,
label: 'Transfers',
value: '${transferDetails.totalTransfers}',
),
if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty)
_detailRow(
context,
label: 'Downloaded by',
value: _formatDownloaderSummary(
transferDetails,
),
),
if (imageTxEstimate > Duration.zero) if (imageTxEstimate > Duration.zero)
_detailRow( _detailRow(
context, context,
@@ -1147,6 +1142,20 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
String _formatDownloaderSummary(MessageTransferDetails transferDetails) {
return transferDetails.downloaders.map(_formatDownloaderLabel).join(', ');
}
String _formatDownloaderLabel(MessageTransferDownloader downloader) {
final name = downloader.requesterName?.trim();
final base = name != null && name.isNotEmpty
? '$name (${downloader.requesterKey6})'
: downloader.requesterKey6;
return downloader.transferCount > 1
? '$base ×${downloader.transferCount}'
: base;
}
Widget _signalRow( Widget _signalRow(
BuildContext context, { BuildContext context, {
required String label, required String label,
@@ -1257,44 +1266,6 @@ class _MessageBubbleState extends State<MessageBubble> {
return raw.sublist(5, 5 + pathLen); return raw.sublist(5, 5 + pathLen);
} }
void _showReplySheet(BuildContext context) {
// Find the sender contact by public key prefix
final contactsProvider = context.read<ContactsProvider>();
if (widget.message.senderPublicKeyPrefix == null) {
ToastLogger.error(context, 'Cannot reply: sender information missing');
return;
}
// Find contact by public key prefix (first 6 bytes)
final senderKeyHex = widget.message.senderPublicKeyPrefix!
.sublist(
0,
widget.message.senderPublicKeyPrefix!.length < 6
? widget.message.senderPublicKeyPrefix!.length
: 6,
)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final senderContact = contactsProvider.contacts.where((c) {
return c.publicKeyHex.startsWith(senderKeyHex);
}).firstOrNull;
if (senderContact == null) {
ToastLogger.error(context, 'Cannot reply: contact not found');
return;
}
// Show direct message sheet for the sender
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => DirectMessageSheet(contact: senderContact),
);
}
void _showDeleteConfirmation(BuildContext context) { void _showDeleteConfirmation(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
showDialog( showDialog(

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ import geolocator_apple
import nsd_macos import nsd_macos
import objectbox_flutter_libs import objectbox_flutter_libs
import package_info_plus import package_info_plus
import path_provider_foundation
import record_macos import record_macos
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
@@ -35,7 +34,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -20,6 +20,8 @@
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>

View File

@@ -129,6 +129,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
codec2_flutter: codec2_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -507,34 +515,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad" sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "20.1.0" version: "21.0.0"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "8.0.0"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.0" version: "11.0.0"
flutter_local_notifications_windows: flutter_local_notifications_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_windows name: flutter_local_notifications_windows
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "3.0.0"
flutter_localizations: flutter_localizations:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -638,6 +646,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.5" version: "0.2.5"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
gsettings: gsettings:
dependency: transitive dependency: transitive
description: description:
@@ -646,6 +662,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.8" version: "0.2.8"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -822,6 +846,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.2" version: "2.6.2"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -879,6 +911,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
url: "https://pub.dev"
source: hosted
version: "0.17.5"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -951,6 +991,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.3.1" version: "4.3.1"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_info_plus: package_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -995,10 +1043,10 @@ packages:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.1" version: "2.6.0"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -1135,6 +1183,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record: record:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1424,10 +1480,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: timezone name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.10.1" version: "0.11.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1647,4 +1703,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0" flutter: ">=3.38.1"

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.0307.2+15 version: 2026.0307.6+19
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -111,8 +111,8 @@ dependencies:
flutter_background_service: ^5.0.13 flutter_background_service: ^5.0.13
# Notifications # Notifications
flutter_local_notifications: ^20.1.0 flutter_local_notifications: ^21.0.0
timezone: ^0.10.0 timezone: ^0.11.0
# Vibration # Vibration
vibration: ^3.1.4 vibration: ^3.1.4
@@ -147,7 +147,7 @@ dev_dependencies:
fake_async: ^1.3.3 fake_async: ^1.3.3
dependency_overrides: dependency_overrides:
path_provider_foundation: 2.5.1 path_provider_foundation: 2.6.0
flutter_launcher_icons: flutter_launcher_icons:
android: "launcher_icon" android: "launcher_icon"

View File

@@ -0,0 +1,117 @@
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 routes that do not match the configured hash size', () {
expect(
() => ContactRouteCodec.parse('AABB,CCDD', expectedHashSize: 1),
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

@@ -128,15 +128,18 @@ 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(
'retains last valid gps for chat/repeater/room when telemetry gps is invalid or missing', 'retains last valid gps for any contact when telemetry gps is invalid or missing',
() { () {
final contactTypes = <ContactType>[ final contactTypes = <ContactType>[
ContactType.chat, ContactType.chat,
ContactType.repeater, ContactType.repeater,
ContactType.room, ContactType.room,
ContactType.channel,
]; ];
for (var i = 0; i < contactTypes.length; i++) { for (var i = 0; i < contactTypes.length; i++) {
@@ -195,6 +198,49 @@ void main() {
}, },
); );
test(
'retains last known gps when a contact refresh arrives without location',
() {
final firstFix = CayenneLppParser.createGpsData(
latitude: 45.1234,
longitude: 13.8765,
);
provider.updateTelemetry(publicKey.sublist(0, 6), firstFix);
provider.addOrUpdateContact(
Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Test Contact',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNotNull);
expect(
updated.telemetry!.gpsLocation!.latitude,
closeTo(45.1234, 0.0001),
);
expect(
updated.telemetry!.gpsLocation!.longitude,
closeTo(13.8765, 0.0001),
);
expect(updated.advLat, equals((45.1234 * 1e6).round()));
expect(updated.advLon, equals((13.8765 * 1e6).round()));
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
},
);
test('builds message snapshot from latest valid telemetry', () { test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData( final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001, latitude: 45.0001,
@@ -232,5 +278,73 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

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