diff --git a/docs/image-mode-technical.md b/docs/image-mode-technical.md index 451766a..d5d6743 100644 --- a/docs/image-mode-technical.md +++ b/docs/image-mode-technical.md @@ -2,10 +2,11 @@ ## 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):** - - `IE2:` image envelope announces image availability in chat. + - `IE4:` image envelope announces image availability in chat. - **Control plane (raw binary request):** - Binary image fetch request (same raw route as image fragments). - **Data plane (raw binary packets):** @@ -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; 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 - `lib/utils/image_message_parser.dart` - `ImagePacket` (binary fragment format) - - `ImageEnvelope` (`IE2`) + - `ImageEnvelope` (`IE4`) - `ImageFetchRequest` (binary) - `fragmentImage()` — split compressed bytes into packets - `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` - Reassembly sessions, outgoing cache, deferred serving - 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` - - 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` - Square cover thumbnail (up to 256 px); tap-to-load for received images; 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.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: @@ -54,51 +59,50 @@ Fields: | `w` | base36 | Actual image width after compression (pixels) | | `h` | base36 | Actual image height after compression (pixels) | | `bytes` | base36 | Total compressed size in bytes | -| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) | -| `ts` | base36 | Unix timestamp (seconds) | - Compact format: ```text -IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts} +IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes} ``` Example (256×171 landscape image, 14 fragments): ```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. `w` and `h` reflect the actual post-compression dimensions, which preserve the source aspect ratio (contain within the configured max size). -### 3.2 Image Fetch Request (binary) +### 3.2 Image Fetch Request (`IR4` + binary) + +Text format: + +```text +IR4:{sid}:{want}:{requesterKey6} +``` Binary payload format: ```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 | |------------------|--------------------------| | `flags` | bit0=1 => request missing indices, else all | | `requesterKey6` | 6-byte requester key prefix | -| `ts` | unix timestamp seconds (u32) | - ### 3.3 Raw Image Packet (data plane) Binary payload structure: - Byte 0: magic `0x49` (`'I'`) - Bytes 1..4: session ID (4 bytes) -- Byte 5: format ID -- Byte 6: fragment index (0-based) -- Byte 7: total fragments -- Bytes 8..N: image data (max 152 bytes per fragment) +- Byte 5: fragment index (0-based) +- Bytes 6..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 @@ -152,11 +156,11 @@ only the shorter axis is padded — no cropping occurs. 7. Envelope sent via normal message path: - Channel: `sendChannelMessage` - Direct: `sendTextMessage` -8. Local placeholder message added (`IE2:` text, `deliveryStatus.sending`). +8. Local placeholder message added (`IE4:` text, `deliveryStatus.sending`). ## 6. Incoming Flow (Receive) -### 6.1 `IE2` envelope received +### 6.1 `IE4` envelope received `AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to 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): -- Validates requester key prefix. - Resolves requester contact. - 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 `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 (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 `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)` square thumbnail; tap → full-screen `InteractiveViewer` with fade transition. - **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. - **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: - Source inputs: - - `total` fragments and `bytes` from `IE2` envelope + - `total` fragments and `bytes` from `IE4` envelope - all numeric envelope values are decoded from base36 - `pathLen` from message metadata - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` - Per-fragment payload model: - - `meshHeader(2)` + `pathLen` + `imageHeader(8)` + `fragmentBytes` + - `meshHeader(2)` + `pathLen` + `imageHeader(6)` + `fragmentBytes` - LoRa airtime: - standard symbol-time formula (preamble + payload symbols) - Mesh pacing/hops: @@ -246,9 +264,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz` ## 11. Operational Constraints - 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. - 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 @@ -260,7 +281,28 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz` - only nodes on that path relay it; - 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 sequenceDiagram @@ -272,8 +314,8 @@ sequenceDiagram A->>A: Compress: contain resize → grayscale → PNG → AVIF A->>A: Fragment into ≤152B packets A->>A: Cache outgoing + populate local session (immediate display) - A->>M: Send IE2 envelope (actual w×h, fragment count) - M->>B: Deliver IE2 + A->>M: Send IE4 envelope (actual w×h, fragment count) + M->>B: Deliver IE4 B->>B: Render grey placeholder bubble B->>A: Tap → send binary fetch request A->>B: Stream binary ImagePackets diff --git a/docs/swarm-mode-technical.md b/docs/swarm-mode-technical.md new file mode 100644 index 0000000..d945f6f --- /dev/null +++ b/docs/swarm-mode-technical.md @@ -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. diff --git a/docs/voice-mode-technical.md b/docs/voice-mode-technical.md index cce25cb..9861dae 100644 --- a/docs/voice-mode-technical.md +++ b/docs/voice-mode-technical.md @@ -2,10 +2,11 @@ ## 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):** - - `VE2:` voice envelope announces voice availability in chat. + - `VE3:` voice envelope announces voice availability in chat. - **Control plane (raw binary request):** - Binary voice fetch request (same raw route as voice packets). - **Data plane (raw binary packets):** @@ -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. +Swarm fallback is documented in [Swarm Mode Technical Design](./swarm-mode-technical.md). + ## 2. Key Modules - `lib/utils/voice_message_parser.dart` - - `VoicePacket` (legacy text + binary packet format) - - `VoiceEnvelope` (`VE2`) + - `VoicePacket` (binary direct-packet format) + - `VoiceEnvelope` (`VE3`) - `VoiceFetchRequest` (binary) - `lib/screens/messages_tab.dart` - 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 - Outgoing session cache + deferred serving - `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 - `lib/widgets/messages/voice_message_bubble.dart` - Play behavior (immediate play if complete, otherwise fetch + auto-play) - `lib/providers/messages_provider.dart` - - Message-level voice detection (`VE2` + legacy `V:`) + - Message-level voice detection (`VE3`) - `lib/services/message_storage_service.dart` - Persists `isVoice` and `voiceId` ## 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: @@ -46,21 +49,19 @@ Fields: - `mode` (base36): codec mode ID (`VoicePacketMode.id`) - `total` (base36): packet count (1..255) - `durS` (base36): estimated duration in seconds -- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes) -- `ts` (base36): unix timestamp seconds `sid` is base36 on wire and expands to 8-hex internally. Compact format: ```text -VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts} +VE3:{sid}:{mode}:{total}:{durS} ``` Example: ```text -VE2:a:1:4:4:aabbccddeeff:s44we8 +VE3:a:1:4:4 ``` ### 3.2 Voice Fetch Request (binary) @@ -68,7 +69,7 @@ VE2:a:1:4:4:aabbccddeeff:s44we8 Binary payload format: ```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) @@ -77,10 +78,10 @@ Binary payload structure: - Byte 0: magic `0x56` (`'V'`) - Bytes 1..4: session ID (4 bytes) -- Byte 5: mode ID -- Byte 6: packet index -- Byte 7: total packets -- Bytes 8..N: codec2 data +- Byte 5: packet index +- Bytes 6..N: codec2 data + +Header is 6 bytes. Mode and total packet count come from the `VE3` envelope. ## 4. Outgoing Flow (Send) @@ -88,27 +89,38 @@ Binary payload structure: 2. Each chunk is codec2-encoded into `VoicePacket` objects. 3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min). 4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`). -5. Sender sends one envelope (`VE2`) through normal message path: +5. Sender sends one envelope (`VE3`) through normal message path: - channel/room: `sendChannelMessage` - direct: `sendTextMessage` 6. **No raw audio packets are sent during initial send.** ## 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 `AppProvider` treats it as control-plane only: - request is not added to chat -- validates requester prefix match against sender metadata - resolves requester contact via key prefix - 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`. @@ -118,10 +130,15 @@ In `VoiceMessageBubble`: - If session already complete: play immediately. - If incomplete/missing: - 1. Resolve sender contact (message sender prefix or `VE2.senderKey6` fallback) - 2. Send direct binary fetch request - 3. Show requesting state in UI - 4. Auto-play when session becomes complete + 1. Resolve sender contact from message sender metadata + 2. Prefer a direct fetch from the original sender if its raw route is healthy + 3. If the sender path does not respond, fan out a raw swarm request with the + 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"**. @@ -136,10 +153,13 @@ If sender cannot be resolved or request cannot be sent, bubble remains and shows Serving prerequisites: -- session exists in cache +- session exists in outgoing cache or already-received session state - `sendRawPacketCallback` configured - requester has direct path (`outPathLen >= 0`) +Received partial sessions can therefore act as relay sources during swarm +recovery. + ## 8. Persistence `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: - Source inputs: - - `packetCount` and `durationMs` from `VE2` envelope, or + - `packetCount` and `durationMs` from `VE3` envelope, or - numeric envelope values decoded from base36 - actual received `VoicePacket.codec2Data.length` bytes when local session packets exist - `pathLen` from message metadata - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` - Per-packet payload model: - - `meshHeader(2)` + `pathLen` + `voiceHeader(8)` + `codec2Bytes` + - `meshHeader(2)` + `pathLen` + `voiceHeader(6)` + `codec2Bytes` - LoRa airtime: - standard symbol-time formula (preamble + payload symbols) - Mesh pacing/hops: @@ -192,9 +212,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz` ## 11. Operational Constraints - 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. - 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 @@ -206,26 +229,27 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz` - only nodes on that path relay it; - it is **not** received by everyone in the mesh. -## 12. Backward Compatibility - -- Legacy `V:` text packet parsing is still supported. -- Message voice detection accepts `VE2` and legacy `V:` formats. - -## 13. High-Level Sequence +## 12. High-Level Sequence ```mermaid sequenceDiagram 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 A->>A: Record + encode voice packets A->>A: Cache session packets (TTL 15m) - A->>M: Send VE2 envelope - M->>B: Deliver VE2 + A->>M: Send VE3 envelope + M->>B: Deliver VE3 B->>B: Render voice bubble (metadata only) 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: Auto-play when complete ``` diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index cefb3aa..c994681 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 95; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 95; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 95; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 95; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 95; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 95; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5530358..2b85496 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 95 + 96 LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 81a002b..ecfd512 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 50dd7f1..ce80b6d 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -1,10 +1,172 @@ export 'package:meshcore_client/meshcore_client.dart' show Contact, ContactType, ContactTelemetry, AdvertLocation; +import 'dart:math' as math; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../l10n/app_localizations.dart'; import 'package:meshcore_client/meshcore_client.dart'; +class ParsedContactRoute { + final String canonicalText; + final int hashSize; + final int hopCount; + final int encodedPathLen; + final int signedEncodedPathLen; + final Uint8List pathBytes; + final Uint8List paddedPathBytes; + + const ParsedContactRoute({ + required this.canonicalText, + required this.hashSize, + required this.hopCount, + required this.encodedPathLen, + required this.signedEncodedPathLen, + required this.pathBytes, + required this.paddedPathBytes, + }); + + int get byteLength => pathBytes.length; + String get summary => hopCount == 0 + ? 'Direct' + : '$hopCount hop${hopCount == 1 ? '' : 's'} via $hashSize-byte hashes'; +} + +class ContactRouteFormatException implements Exception { + final String message; + + const ContactRouteFormatException(this.message); + + @override + String toString() => message; +} + +class ContactRouteCodec { + static const int maxHashSize = 3; + static const int maxPathBytes = 64; + static const int _unknownDescriptor = 0xFF; + + static ParsedContactRoute parse(String input) { + final normalized = input.trim().toUpperCase(); + if (normalized.isEmpty) { + throw const ContactRouteFormatException('Route cannot be empty.'); + } + + final hopTokens = normalized + .split(',') + .map((token) => token.trim()) + .toList(); + if (hopTokens.any((token) => token.isEmpty)) { + throw const ContactRouteFormatException('Route contains an empty hop.'); + } + + final hopBytes = >[]; + int? hashSize; + for (final token in hopTokens) { + final compact = token.replaceAll(':', ''); + if (compact.isEmpty || !RegExp(r'^[0-9A-F]+$').hasMatch(compact)) { + throw ContactRouteFormatException('Invalid hop "$token".'); + } + if (compact.length.isOdd) { + throw ContactRouteFormatException( + 'Hop "$token" must contain full bytes.', + ); + } + + final currentHashSize = compact.length ~/ 2; + if (currentHashSize < 1 || currentHashSize > maxHashSize) { + throw ContactRouteFormatException( + 'Hop "$token" must be 1, 2, or 3 bytes.', + ); + } + + hashSize ??= currentHashSize; + if (hashSize != currentHashSize) { + throw const ContactRouteFormatException( + 'All hops in a route must use the same hash size.', + ); + } + + final bytes = []; + 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 { /// Returns the localized display name for special contacts (e.g. Public Channel). /// For all other contacts, returns [displayName]. @@ -14,4 +176,56 @@ extension ContactLocalization on Contact { } 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 = []; + 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; } diff --git a/lib/models/message_transfer_details.dart b/lib/models/message_transfer_details.dart new file mode 100644 index 0000000..26659af --- /dev/null +++ b/lib/models/message_transfer_details.dart @@ -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 toJson() { + return { + 'requesterKey6': requesterKey6, + 'requesterName': requesterName, + 'transferCount': transferCount, + 'lastTransferredAtMillis': lastTransferredAt.millisecondsSinceEpoch, + }; + } + + static MessageTransferDownloader? fromJson(Map 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 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.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 toJson() { + return { + 'totalTransfers': totalTransfers, + 'downloaders': downloaders.map((entry) => entry.toJson()).toList(), + }; + } + + static MessageTransferDetails? fromJson(Map json) { + final totalTransfers = json['totalTransfers']; + if (totalTransfers is! int) { + return null; + } + + final rawDownloaders = json['downloaders'] as List? ?? const []; + final downloaders = rawDownloaders + .whereType>() + .map(MessageTransferDownloader.fromJson) + .whereType() + .toList(); + + return MessageTransferDetails( + totalTransfers: totalTransfers, + downloaders: downloaders, + ); + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 81f5267..d1c76aa 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -22,6 +22,7 @@ import '../utils/drawing_message_parser.dart'; import '../utils/raw_route_probe.dart'; import '../utils/voice_message_parser.dart'; import '../utils/image_message_parser.dart'; +import '../utils/media_swarm_protocol.dart'; import '../utils/message_airtime_estimator.dart'; /// Main App Provider - coordinates all other providers @@ -63,6 +64,7 @@ class AppProvider with ChangeNotifier { bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts; static const Duration _packetRetryDelay = Duration(milliseconds: 1200); + static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10); static const int _maxPacketRetryAttempts = 4; final Map _voiceSessionSenderKey6 = {}; final Map _imageSessionSenderKey6 = {}; @@ -72,6 +74,9 @@ class AppProvider with ChangeNotifier { final Map _imageMissingRetryAttempts = {}; final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); final Map> _pendingRawRouteProbes = {}; + final Map> _pendingMediaSwarmFetches = {}; + final Map> + _pendingMediaSwarmResponses = {}; Timer? _packetCaptureFlushTimer; String? _lastPersistedPacketSignature; bool _isPersistingPacketCapture = false; @@ -183,12 +188,12 @@ class AppProvider with ChangeNotifier { void _restoreSessionMetadataFromMessages() { final restored = restoreSessionMetadataFromMessages( - messagesProvider.messages.map((message) => message.text), + messagesProvider.messages, ); _voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession); + _imageSessionSenderKey6.addAll(restored.imageSenderKeyBySession); for (final entry in restored.imageEnvelopeBySession.entries) { - _imageSessionSenderKey6[entry.key] = entry.value.senderKey6.toLowerCase(); imageProvider.registerEnvelope(entry.value); } @@ -681,9 +686,15 @@ class AppProvider with ChangeNotifier { // Voice envelope message (new public/direct on-demand format). final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text); if (voiceEnvelope != null) { - _voiceSessionSenderKey6[voiceEnvelope.sessionId] = voiceEnvelope - .senderKey6 - .toLowerCase(); + final senderPrefix = enrichedMessage.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + _voiceSessionSenderKey6[voiceEnvelope.sessionId] = senderPrefix + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } + voiceProvider.registerEnvelope(voiceEnvelope); enrichedMessage = enrichedMessage.copyWith( isVoice: true, voiceId: voiceEnvelope.sessionId, @@ -713,9 +724,14 @@ class AppProvider with ChangeNotifier { // Image envelope (IE1): announce image availability. final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text); if (imageEnvelope != null) { - _imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope - .senderKey6 - .toLowerCase(); + final senderPrefix = enrichedMessage.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + _imageSessionSenderKey6[imageEnvelope.sessionId] = senderPrefix + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } imageProvider.registerEnvelope(imageEnvelope); messagesProvider.addMessage( enrichedMessage, @@ -739,19 +755,6 @@ class AppProvider with ChangeNotifier { 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 messagesProvider.addMessage( enrichedMessage, @@ -803,8 +806,9 @@ class AppProvider with ChangeNotifier { }; // When raw binary data is received (PUSH_CODE_RAW_DATA 0x84) - // Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request. - // Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet. + // Magic 0x6d 'm' = swarm control; 0x72 'r' = voice fetch request. + // Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet. + // Magic 0x49 'I' = image packet. connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload); if (rawProbeRequest != null) { @@ -824,6 +828,20 @@ class AppProvider with ChangeNotifier { 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); if (voiceFetchRequest != null) { debugPrint( @@ -841,26 +859,34 @@ class AppProvider with ChangeNotifier { ); return; } - if (requester.outPathLen > _maxDirectPayloadHops) { + if (requester.routeHopCount > _maxDirectPayloadHops) { debugPrint( - '⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops', + '⚠️ [AppProvider] Voice fetch requester too far: ${requester.routeHopCount} hops', ); messagesProvider.logSystemMessage( 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', ); return; } - unawaited( - voiceProvider.serveSessionTo( + unawaited(() async { + final served = await voiceProvider.serveSessionTo( sessionId: voiceFetchRequest.sessionId, requester: requester, requestedIndices: voiceFetchRequest.want == 'missing' ? voiceFetchRequest.missingIndices.toSet() : null, - ), - ); + ); + if (served) { + messagesProvider.recordMediaTransfer( + sessionId: voiceFetchRequest.sessionId, + mediaType: 'voice', + requesterKey6: voiceFetchRequest.requesterKey6, + requesterName: requester.advName, + ); + } + }()); return; } @@ -880,32 +906,40 @@ class AppProvider with ChangeNotifier { ); return; } - if (requester.outPathLen > _maxDirectPayloadHops) { + if (requester.routeHopCount > _maxDirectPayloadHops) { debugPrint( '⚠️ [AppProvider] Image fetch requester too far: ' - '${requester.outPathLen} hops for session ' + '${requester.routeHopCount} hops for session ' '${imageFetchRequest.sessionId}', ); messagesProvider.logSystemMessage( 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', ); return; } debugPrint( '📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} ' - 'to ${requester.advName} via ${requester.outPathLen} hop(s)', + 'to ${requester.advName} via ${requester.routeHopCount} hop(s)', ); - unawaited( - imageProvider.serveSessionTo( + unawaited(() async { + final served = await imageProvider.serveSessionTo( sessionId: imageFetchRequest.sessionId, requester: requester, requestedIndices: imageFetchRequest.want == 'missing' ? imageFetchRequest.missingIndices.toSet() : null, - ), - ); + ); + if (served) { + messagesProvider.recordMediaTransfer( + sessionId: imageFetchRequest.sessionId, + mediaType: 'image', + requesterKey6: imageFetchRequest.requesterKey6, + requesterName: requester.advName, + ); + } + }()); return; } @@ -914,8 +948,23 @@ class AppProvider with ChangeNotifier { if (frag == null) return; debugPrint('📷 [AppProvider] Binary image fragment received: $frag'); 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( - frag, + session == null + ? frag + : ImagePacket( + sessionId: frag.sessionId, + format: session.format, + index: frag.index, + total: session.total, + data: frag.data, + ), width: session?.width ?? 0, height: session?.height ?? 0, ); @@ -930,7 +979,25 @@ class AppProvider with ChangeNotifier { final pkt = VoicePacket.tryParseBinary(payload); if (pkt == null) return; 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); // Insert or update the placeholder message in the chat list _handleIncomingVoicePacket(pkt, justComplete: justComplete); @@ -1362,6 +1429,291 @@ class AppProvider with ChangeNotifier { 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 _availableIndicesForSession(String mediaType, String sessionId) { + return switch (mediaType) { + 'voice' => voiceProvider.availablePacketIndices(sessionId), + 'image' => imageProvider.availableFragmentIndices(sessionId), + _ => const [], + }; + } + + List _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 _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 _requestMissingMediaViaSwarm({ + required String mediaType, + required String sessionId, + required List 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 = {}; + _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.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 missingIndices, + ) { + if (response.servesAll) { + return missingIndices.length; + } + final needed = missingIndices.toSet(); + return response.availableIndices.where(needed.contains).length; + } + + Future _sendDirectMediaFetchRequest({ + required String mediaType, + required String sessionId, + required Contact target, + required String requesterKey6, + required Set 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( String sessionId, { required bool justComplete, @@ -1434,8 +1786,8 @@ class AppProvider with ChangeNotifier { final senderKey6 = _voiceSessionSenderKey6[sessionId]; if (senderKey6 == null) return; final sender = _resolveContactByPrefixHex(senderKey6); - final deviceKey = connectionProvider.deviceInfo.publicKey; - if (sender == null || deviceKey == null || deviceKey.length < 6) return; + final requesterKey6 = _deviceKey6Hex(); + if (requesterKey6 == null) return; final missing = voiceProvider.missingPacketIndices(sessionId); if (missing.isEmpty) { @@ -1443,27 +1795,28 @@ class AppProvider with ChangeNotifier { return; } - final requesterKey6 = deviceKey - .sublist(0, 6) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(''); - - final request = VoiceFetchRequest( - sessionId: sessionId, - want: 'missing', - missingIndices: missing, - requesterKey6: requesterKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, - version: 2, - ); - - try { - await connectionProvider.sendRawVoicePacket( - contactPath: sender.outPath, - contactPathLen: sender.outPathLen, - payload: request.encodeBinary(), + var sent = false; + if (sender != null) { + final routeOk = await verifyRawTransportRoute(sender); + if (routeOk) { + sent = await _sendDirectMediaFetchRequest( + mediaType: 'voice', + sessionId: sessionId, + target: sender, + requesterKey6: requesterKey6, + missingIndices: missing.toSet(), + ); + } + } + if (!sent) { + sent = await _requestMissingMediaViaSwarm( + mediaType: 'voice', + sessionId: sessionId, + missingIndices: missing, + originalSenderKey6: senderKey6, ); - } catch (_) { + } + if (!sent) { return; } @@ -1496,8 +1849,8 @@ class AppProvider with ChangeNotifier { final senderKey6 = _imageSessionSenderKey6[sessionId]; if (senderKey6 == null) return; final sender = _resolveContactByPrefixHex(senderKey6); - final deviceKey = connectionProvider.deviceInfo.publicKey; - if (sender == null || deviceKey == null || deviceKey.length < 6) return; + final requesterKey6 = _deviceKey6Hex(); + if (requesterKey6 == null) return; final missing = imageProvider.missingFragmentIndices(sessionId); if (missing.isEmpty) { @@ -1505,26 +1858,28 @@ class AppProvider with ChangeNotifier { return; } - final requesterKey6 = deviceKey - .sublist(0, 6) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(''); - - final request = ImageFetchRequest( - sessionId: sessionId, - want: 'missing', - missingIndices: missing, - requesterKey6: requesterKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, - ); - - try { - await connectionProvider.sendRawVoicePacket( - contactPath: sender.outPath, - contactPathLen: sender.outPathLen, - payload: request.encodeBinary(), + var sent = false; + if (sender != null) { + final routeOk = await verifyRawTransportRoute(sender); + if (routeOk) { + sent = await _sendDirectMediaFetchRequest( + mediaType: 'image', + sessionId: sessionId, + target: sender, + requesterKey6: requesterKey6, + missingIndices: missing.toSet(), + ); + } + } + if (!sent) { + sent = await _requestMissingMediaViaSwarm( + mediaType: 'image', + sessionId: sessionId, + missingIndices: missing, + originalSenderKey6: senderKey6, ); - } catch (_) { + } + if (!sent) { return; } @@ -1585,7 +1940,10 @@ class AppProvider with ChangeNotifier { if (!connectionProvider.deviceInfo.isConnected) { return false; } - if (target.outPathLen < 0 || target.outPathLen > _maxDirectPayloadHops) { + if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) { + return false; + } + if (!target.routeSupportsLegacyRawTransport) { return false; } if (target.outPath.isEmpty) { @@ -1616,11 +1974,11 @@ class AppProvider with ChangeNotifier { try { 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( contactPath: target.outPath, - contactPathLen: target.outPathLen, + contactPathLen: target.routeSignedPathLen, payload: RawRouteProbeRequest( nonce: nonce, requesterKey6: requesterKey6, @@ -1648,7 +2006,7 @@ class AppProvider with ChangeNotifier { if (target.publicKeyHex.isNotEmpty) { 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) { @@ -1659,23 +2017,26 @@ class AppProvider with ChangeNotifier { ); return; } - if (requester.outPathLen < 0 || - requester.outPathLen > _maxDirectPayloadHops) { + if (!requester.routeHasPath || + requester.routeHopCount > _maxDirectPayloadHops) { debugPrint( - '⚠️ [AppProvider] Raw route probe requester out of range: ${requester.outPathLen}', + '⚠️ [AppProvider] Raw route probe requester out of range: ${requester.routeHopCount}', ); return; } + if (!requester.routeSupportsLegacyRawTransport) { + return; + } if (requester.outPath.isEmpty) { return; } 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( connectionProvider.sendRawVoicePacket( contactPath: requester.outPath, - contactPathLen: requester.outPathLen, + contactPathLen: requester.routeSignedPathLen, payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), ), ); diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 28821ec..fa50f3c 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -4,10 +4,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:crypto/crypto.dart'; +import '../models/contact.dart'; import '../models/device_info.dart'; import '../models/room_login_state.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 '../utils/sar_message_parser.dart'; import 'helpers/room_login_manager.dart'; @@ -1121,9 +1122,11 @@ class ConnectionProvider with ChangeNotifier { ); } debugPrint(' Type: ${contact.type.displayName}'); - debugPrint(' Path status: ${contact.pathDescription}'); - if (contact.hasPath) { - debugPrint(' ✅ Using learned path (${contact.outPathLen} bytes)'); + debugPrint(' Path status: ${contact.routeSummary}'); + if (contact.routeHasPath) { + debugPrint( + ' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)', + ); } else { debugPrint(' ⚠️ No path available - will use flood mode'); } @@ -1173,6 +1176,19 @@ class ConnectionProvider with ChangeNotifier { 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) // If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically if (contact != null) { @@ -2006,6 +2022,30 @@ class ConnectionProvider with ChangeNotifier { } } + Future 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 /// /// Deletes the contact from the device's internal contact table. diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 8377885..cd50ae1 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -521,7 +521,39 @@ class ContactsProvider with ChangeNotifier { /// prefer flood routing until the radio reports a fresh route. void markPathUnhealthy(Uint8List 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; } diff --git a/lib/providers/helpers/message_delivery_tracker.dart b/lib/providers/helpers/message_delivery_tracker.dart index 9ebaba6..d91ea9b 100644 --- a/lib/providers/helpers/message_delivery_tracker.dart +++ b/lib/providers/helpers/message_delivery_tracker.dart @@ -98,6 +98,11 @@ class MessageDeliveryTracker { 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 /// /// Cleans up both forward and reverse mappings. diff --git a/lib/providers/helpers/message_retry_manager.dart b/lib/providers/helpers/message_retry_manager.dart index a0b6228..c46a33d 100644 --- a/lib/providers/helpers/message_retry_manager.dart +++ b/lib/providers/helpers/message_retry_manager.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:math' as math; import '../../models/message.dart'; import '../../models/contact.dart'; @@ -55,8 +54,8 @@ class MessageRetryManager { final payloadBytes = utf8.encode(text).length; final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes); - final hopCount = contact?.hasPath == true - ? math.max(contact!.outPathLen, 0) + final hopCount = contact?.routeHasPath == true + ? contact!.routeHopCount : -1; if (hopCount < 0) { @@ -87,7 +86,7 @@ class MessageRetryManager { // Only retry if contact has a learned path // 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 @@ -101,7 +100,7 @@ class MessageRetryManager { /// Contacts without paths already use flood mode automatically. bool shouldUseFloodFallback(Message message, Contact contact) { return message.retryAttempt >= 3 && - contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths + contact.routeHasPath && !message.usedFloodFallback; } diff --git a/lib/providers/helpers/raw_session_retransmit.dart b/lib/providers/helpers/raw_session_retransmit.dart index 60ca230..1d22d8c 100644 --- a/lib/providers/helpers/raw_session_retransmit.dart +++ b/lib/providers/helpers/raw_session_retransmit.dart @@ -28,13 +28,19 @@ Future serveCachedSessionFragments({ debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set'); return false; } - if (requester.outPathLen < 0) { + if (!requester.routeHasPath) { debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path'); return false; } - if (requester.outPathLen > maxDirectPayloadHops) { + if (requester.routeHopCount > maxDirectPayloadHops) { 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; } @@ -58,7 +64,7 @@ Future serveCachedSessionFragments({ try { await sendRawPacket( contactPath: requester.outPath, - contactPathLen: requester.outPathLen, + contactPathLen: requester.routeSignedPathLen, payload: encodeBinary(fragment), ); servedCount++; diff --git a/lib/providers/helpers/session_metadata_restore.dart b/lib/providers/helpers/session_metadata_restore.dart index 892f946..9476867 100644 --- a/lib/providers/helpers/session_metadata_restore.dart +++ b/lib/providers/helpers/session_metadata_restore.dart @@ -1,38 +1,57 @@ +import '../../models/message.dart'; import '../../utils/image_message_parser.dart'; import '../../utils/voice_message_parser.dart'; class RestoredSessionMetadata { final Map voiceSenderKeyBySession; + final Map imageSenderKeyBySession; final Map imageEnvelopeBySession; const RestoredSessionMetadata({ required this.voiceSenderKeyBySession, + required this.imageSenderKeyBySession, required this.imageEnvelopeBySession, }); } RestoredSessionMetadata restoreSessionMetadataFromMessages( - Iterable messageTexts, + Iterable messages, ) { final voiceSenderKeyBySession = {}; + final imageSenderKeyBySession = {}; final imageEnvelopeBySession = {}; - for (final text in messageTexts) { + for (final message in messages) { + final text = message.text; final voiceEnvelope = VoiceEnvelope.tryParseText(text); if (voiceEnvelope != null) { - voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope - .senderKey6 - .toLowerCase(); + final senderPrefix = message.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } } final imageEnvelope = ImageEnvelope.tryParse(text); if (imageEnvelope != null) { 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( voiceSenderKeyBySession: voiceSenderKeyBySession, + imageSenderKeyBySession: imageSenderKeyBySession, imageEnvelopeBySession: imageEnvelopeBySession, ); } diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 2d46517..62a6c4c 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -96,11 +96,28 @@ class ImageProvider with ChangeNotifier { return missing; } + List 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 = []; + for (var i = 0; i < session.fragments.length; i++) { + if (session.fragments[i] != null) { + indices.add(i); + } + } + return indices; + } + // ── Incoming fragment reception ────────────────────────────────────────── - /// Add a received [fragment]. Creates the session on first fragment using - /// metadata from the fragment itself (requires envelope to have been - /// announced first; if not, defaults width/height to 0 — corrected on save). + /// Add a received [fragment]. New compact fragments rely on prior envelope + /// metadata for total/format, while legacy fragments can still self-describe. /// /// Returns true when the session just became complete. bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) { @@ -110,16 +127,20 @@ class ImageProvider with ChangeNotifier { ); return false; } - _sessions.putIfAbsent( - fragment.sessionId, - () => ImageSession( + _sessions.putIfAbsent(fragment.sessionId, () { + if (fragment.total < 1) { + throw StateError( + 'Image envelope missing for compact fragment ${fragment.sessionId}', + ); + } + return ImageSession( sessionId: fragment.sessionId, format: fragment.format, total: fragment.total, width: width, height: height, - ), - ); + ); + }); final session = _sessions[fragment.sessionId]!; if (fragment.index < session.total) { @@ -244,16 +265,22 @@ class ImageProvider with ChangeNotifier { required Contact requester, Set? requestedIndices, }) async { - final cached = _outgoing[sessionId]; - if (cached == null) { - debugPrint('⚠️ [ImageProvider] No cached session for $sessionId'); + final outgoing = _outgoing[sessionId]; + final fragments = outgoing != null + ? List.from(outgoing.fragments) + : _sessions[sessionId]?.fragments.whereType().toList() ?? + const []; + if (fragments.isEmpty) { + debugPrint( + '⚠️ [ImageProvider] No cached or received session for $sessionId', + ); return false; } return serveCachedSessionFragments( providerLabel: 'ImageProvider', sessionId: sessionId, requester: requester, - fragments: cached.fragments, + fragments: fragments, maxDirectPayloadHops: maxDirectPayloadHops, indexOf: (fragment) => fragment.index, encodeBinary: (fragment) => fragment.encodeBinary(), diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 65e4e94..f4bccea 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -4,6 +4,7 @@ import '../models/message.dart'; import '../models/contact.dart'; import '../models/message_contact_location.dart'; import '../models/message_reception_details.dart'; +import '../models/message_transfer_details.dart'; import '../models/sar_marker.dart'; import '../models/map_drawing.dart'; import '../services/message_storage_service.dart'; @@ -25,6 +26,7 @@ class MessagesProvider with ChangeNotifier { AppLocalizations? _localizations; final Map _messageContactLocations = {}; final Map _messageReceptionDetails = {}; + final Map _messageTransferDetails = {}; // Track pending sent messages by expected ACK/TAG final Map _pendingSentMessages = {}; @@ -121,6 +123,9 @@ class MessagesProvider with ChangeNotifier { MessageReceptionDetails? getMessageReceptionDetails(String messageId) => _messageReceptionDetails[messageId]; + MessageTransferDetails? getMessageTransferDetails(String messageId) => + _messageTransferDetails[messageId]; + /// Set localizations for notifications void setLocalizations(AppLocalizations localizations) { _localizations = localizations; @@ -153,12 +158,17 @@ class MessagesProvider with ChangeNotifier { .loadMessageContactLocations(); final storedReceptionDetails = await _storageService .loadMessageReceptionDetails(); + final storedTransferDetails = await _storageService + .loadMessageTransferDetails(); _messageContactLocations ..clear() ..addAll(storedContactLocations); _messageReceptionDetails ..clear() ..addAll(storedReceptionDetails); + _messageTransferDetails + ..clear() + ..addAll(storedTransferDetails); // Add stored messages with enhancement to ensure SAR detection for (final message in storedMessages) { @@ -198,14 +208,6 @@ class MessagesProvider with ChangeNotifier { isVoice: true, 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, 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); if (voiceEnvelope != null) { final seconds = (voiceEnvelope.durationMs / 1000).ceil(); - final route = isChannelMessage - ? 'Channel: ${channelName ?? _resolveChannelName(message.channelIdx)}' - : 'From: $senderName'; - return '$route\nVoice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets'; + final summary = + 'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets'; + return isChannelMessage ? '$senderName\n$summary' : summary; } final imageEnvelope = ImageEnvelope.tryParse(message.text); if (imageEnvelope != null) { - final route = isChannelMessage - ? 'Channel: ${channelName ?? _resolveChannelName(message.channelIdx)}' - : 'From: $senderName'; - return '$route\nImage - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}'; + final summary = + 'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}'; + return isChannelMessage ? '$senderName\n$summary' : summary; } if (!isChannelMessage && message.recipientPublicKey != null) { @@ -669,12 +661,12 @@ class MessagesProvider with ChangeNotifier { fallback: null, ); if (recipientName != 'Unknown') { - return 'From: $senderName\nTo: $recipientName\n${message.text}'; + return 'To: $recipientName\n${message.text}'; } } - if (isChannelMessage && channelName != null && channelName.isNotEmpty) { - return 'Channel: $channelName\n${message.text}'; + if (isChannelMessage) { + return '$senderName\n${message.text}'; } return message.text; @@ -695,6 +687,7 @@ class MessagesProvider with ChangeNotifier { _messages, messageContactLocations: _messageContactLocations, messageReceptionDetails: _messageReceptionDetails, + messageTransferDetails: _messageTransferDetails, ); } catch (e) { debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); @@ -812,6 +805,7 @@ class MessagesProvider with ChangeNotifier { _groupedMessageMapping.remove(messageId); _messageContactLocations.remove(messageId); _messageReceptionDetails.remove(messageId); + _messageTransferDetails.remove(messageId); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); @@ -842,6 +836,7 @@ class MessagesProvider with ChangeNotifier { _sarMarkers.clear(); _messageContactLocations.clear(); _messageReceptionDetails.clear(); + _messageTransferDetails.clear(); _persistMessages(); notifyListeners(); } @@ -858,10 +853,69 @@ class MessagesProvider with ChangeNotifier { _sarMarkers.clear(); _messageContactLocations.clear(); _messageReceptionDetails.clear(); + _messageTransferDetails.clear(); _persistMessages(); 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 Future> getStorageStats() async { return await _storageService.getStorageStats(); @@ -957,14 +1011,6 @@ class MessagesProvider with ChangeNotifier { isVoice: true, 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(' Retry attempt: ${message.retryAttempt}'); - debugPrint(' Contact has path: ${contact?.hasPath ?? false}'); + debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}'); debugPrint(' Used flood fallback: ${message.usedFloodFallback}'); // Decision tree for retry/flood/fail @@ -1784,7 +1830,7 @@ class MessagesProvider with ChangeNotifier { _retryManager.clearRetry(messageId); final failedContact = _messageContactMap[messageId]; - if (failedContact != null && failedContact.hasPath) { + if (failedContact != null && failedContact.routeHasPath) { final failureStreak = _retryManager.recordPathFailure(failedContact); debugPrint( ' 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 - Future resendMessage(String messageId) async { + Future resendMessage(String messageId, {Contact? contact}) async { final index = _messages.indexWhere((m) => m.id == messageId); if (index == -1) { debugPrint( @@ -1815,9 +1922,9 @@ class MessagesProvider with ChangeNotifier { } final message = _messages[index]; - final contact = _messageContactMap[messageId]; + final resolvedContact = contact ?? _messageContactMap[messageId]; - if (contact == null) { + if (resolvedContact == null) { debugPrint( '⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId', ); @@ -1826,26 +1933,19 @@ class MessagesProvider with ChangeNotifier { debugPrint('🔁 [MessagesProvider] Resending message $messageId'); - // Reset retry state - _messages[index] = message.copyWith( - retryAttempt: 0, - usedFloodFallback: false, - deliveryStatus: MessageDeliveryStatus.sending, - lastRetryAt: DateTime.now(), - ); - - // Clear retry tracking - _retryManager.clearRetry(messageId); - - notifyListeners(); + _messageContactMap[messageId] = resolvedContact; + final prepared = prepareMessageForRetry(messageId); + if (!prepared) { + return; + } // Send again if (sendMessageCallback != null) { final queued = await sendMessageCallback!( - contactPublicKey: contact.publicKey, + contactPublicKey: resolvedContact.publicKey, text: message.text, messageId: messageId, - contact: contact, + contact: resolvedContact, retryAttempt: 0, ); if (!queued) { diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 343d18c..adcd223 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -125,6 +125,23 @@ class VoiceProvider with ChangeNotifier { return missing; } + List 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 = []; + for (var i = 0; i < session.packets.length; i++) { + if (session.packets[i] != null) { + indices.add(i); + } + } + return indices; + } + // ── Packet reception ───────────────────────────────────────────────────── /// Add an incoming [packet] to its session. Creates the session on first packet. @@ -136,14 +153,18 @@ class VoiceProvider with ChangeNotifier { ); return false; } - _sessions.putIfAbsent( - packet.sessionId, - () => VoiceSession( + _sessions.putIfAbsent(packet.sessionId, () { + if (packet.total < 1) { + throw StateError( + 'Voice envelope missing for compact packet ${packet.sessionId}', + ); + } + return VoiceSession( sessionId: packet.sessionId, mode: packet.mode, total: packet.total, - ), - ); + ); + }); final session = _sessions[packet.sessionId]!; 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. void cacheOutgoingSession(String sessionId, List packets) { if (packets.isEmpty) return; @@ -195,10 +257,14 @@ class VoiceProvider with ChangeNotifier { required Contact requester, Set? requestedIndices, }) async { - final cached = _outgoingSessions[sessionId]; - if (cached == null) { + final outgoing = _outgoingSessions[sessionId]; + final packets = outgoing != null + ? List.from(outgoing.packets) + : _sessions[sessionId]?.packets.whereType().toList() ?? + const []; + if (packets.isEmpty) { debugPrint( - '⚠️ [VoiceProvider] No cached outgoing session for $sessionId', + '⚠️ [VoiceProvider] No cached or received session for $sessionId', ); return false; } @@ -206,7 +272,7 @@ class VoiceProvider with ChangeNotifier { providerLabel: 'VoiceProvider', sessionId: sessionId, requester: requester, - fragments: cached.packets, + fragments: packets, maxDirectPayloadHops: maxDirectPayloadHops, indexOf: (packet) => packet.index, encodeBinary: (packet) => packet.encodeBinary(), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 999e565..0cbb4df 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -502,6 +502,13 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ), child: TabBar( controller: _tabController, + onTap: (index) { + final tabs = _enabledTabs; + if (index < 0 || index >= tabs.length) { + return; + } + _handleTabActivated(tabs[index]); + }, tabs: enabledTabs.map((tab) { switch (tab) { case _HomeTab.messages: diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 39742ed..2380efd 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -575,9 +575,9 @@ class _MessagesTabState extends State { if (_destinationType == MessageDestinationPreferences.destinationTypeContact && _selectedRecipient != null && - _selectedRecipient!.outPathLen >= 0) { + _selectedRecipient!.routeHasPath) { imageDataBytesPerFragment = safeImageDataBytesForPath( - _selectedRecipient!.outPathLen, + _selectedRecipient!.routeHopCount, ); } @@ -601,11 +601,6 @@ class _MessagesTabState extends State { ToastLogger.error(context, 'Device key unavailable'); return; } - final senderKey6 = deviceKey - .sublist(0, 6) - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); - final envelope = ImageEnvelope( sessionId: sessionId, format: ImageFormat.avif, @@ -613,8 +608,6 @@ class _MessagesTabState extends State { width: result.width, height: result.height, sizeBytes: compressed.length, - senderKey6: senderKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); if (!mounted) return; @@ -915,9 +908,6 @@ class _MessagesTabState extends State { return; } - final senderKey6 = senderPublicKeyPrefix - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(''); final durationMs = encodedPackets.fold( 0, (sum, p) => sum + p.durationMs, @@ -927,9 +917,7 @@ class _MessagesTabState extends State { mode: mode, total: encodedPackets.length, durationMs: durationMs, - senderKey6: senderKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, - version: 1, + version: 3, ); final envelopeText = envelope.encodeText(); diff --git a/lib/services/mesh_map_nodes_service.dart b/lib/services/mesh_map_nodes_service.dart index 4b5cccb..0d41ca3 100644 --- a/lib/services/mesh_map_nodes_service.dart +++ b/lib/services/mesh_map_nodes_service.dart @@ -35,6 +35,7 @@ class MeshMapNodesService { 'https://api.meshcore.nz/api/v1/map/nodes'; static const Duration _cacheTtl = Duration(minutes: 2); static const Duration traceCacheTtl = Duration(minutes: 10); + static const Duration traceTimeout = Duration(seconds: 30); static List? _cachedNodes; static DateTime? _cachedAt; @@ -52,7 +53,7 @@ class MeshMapNodesService { final response = await http .get(Uri.parse(_nodesEndpoint)) - .timeout(const Duration(seconds: 12)); + .timeout(traceTimeout); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Map nodes API returned ${response.statusCode}'); } diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index 4604724..32858ed 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/message.dart'; import '../models/message_contact_location.dart'; import '../models/message_reception_details.dart'; +import '../models/message_transfer_details.dart'; import 'package:latlong2/latlong.dart'; /// Service for persisting messages to local storage @@ -13,6 +14,8 @@ class MessageStorageService { 'stored_message_contact_locations'; static const String _messageReceptionDetailsKey = 'stored_message_reception_details'; + static const String _messageTransferDetailsKey = + 'stored_message_transfer_details'; static const int _maxStoredMessages = 1000; // Store up to 1000 messages /// Save messages to persistent storage @@ -20,6 +23,7 @@ class MessageStorageService { List messages, { Map messageContactLocations = const {}, Map messageReceptionDetails = const {}, + Map messageTransferDetails = const {}, }) async { try { final prefs = await SharedPreferences.getInstance(); @@ -39,6 +43,7 @@ class MessageStorageService { .toSet(); final locationJson = {}; final receptionJson = {}; + final transferJson = {}; for (final entry in messageContactLocations.entries) { if (retainedMessageIds.contains(entry.key)) { locationJson[entry.key] = entry.value.toJson(); @@ -49,6 +54,11 @@ class MessageStorageService { 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( _messageContactLocationsKey, jsonEncode(locationJson), @@ -57,6 +67,10 @@ class MessageStorageService { _messageReceptionDetailsKey, jsonEncode(receptionJson), ); + await prefs.setString( + _messageTransferDetailsKey, + jsonEncode(transferJson), + ); debugPrint( '✅ [MessageStorage] Saved ${limitedList.length} messages to storage', @@ -126,6 +140,36 @@ class MessageStorageService { } } + Future> + 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) { + return const {}; + } + + final result = {}; + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) 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 Future> loadMessages() async { try { @@ -161,6 +205,7 @@ class MessageStorageService { await prefs.remove(_messagesKey); await prefs.remove(_messageContactLocationsKey); await prefs.remove(_messageReceptionDetailsKey); + await prefs.remove(_messageTransferDetailsKey); debugPrint('✅ [MessageStorage] Cleared all stored messages'); } catch (e) { debugPrint('❌ [MessageStorage] Error clearing messages: $e'); diff --git a/lib/utils/image_message_parser.dart b/lib/utils/image_message_parser.dart index 16bbe68..a0f26ef 100644 --- a/lib/utils/image_message_parser.dart +++ b/lib/utils/image_message_parser.dart @@ -4,7 +4,7 @@ const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_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 _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) @@ -31,7 +31,7 @@ enum ImageFormat { /// A single binary fragment of a compressed image. /// /// 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. class ImagePacket { @@ -50,7 +50,7 @@ class ImagePacket { }); 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 = 152; // Conservative default for compatibility. @@ -65,16 +65,14 @@ class ImagePacket { .sublist(1, 5) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); - final fmtId = payload[5]; - final index = payload[6]; - final total = payload[7]; - if (total < 1) return null; + final index = payload[5]; + final data = payload.sublist(_headerLen); return ImagePacket( sessionId: sessionId, - format: ImageFormat.fromId(fmtId), + format: ImageFormat.avif, index: index, - total: total, - data: payload.sublist(_headerLen), + total: 0, + data: data, ); } catch (_) { return null; @@ -92,16 +90,16 @@ class ImagePacket { final out = Uint8List(_headerLen + data.length); out[0] = _magic; out.setRange(1, 5, sessionBytes); - out[5] = format.id; - out[6] = index; - out[7] = total; + out[5] = index; out.setRange(_headerLen, out.length, data); return out; } @override - String toString() => - 'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)'; + String toString() { + 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. @@ -246,11 +244,11 @@ int _resolveBandwidthHz(int? rawBw) { /// Envelope announcing image availability (control plane). /// /// Text format: -/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts} +/// IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes} /// Example: -/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8 +/// IE4:deadbeef:0:7:3k:3k:t6 class ImageEnvelope { - static const String _prefix = 'IE2:'; + static const String _prefixV4 = 'IE4:'; final String sessionId; // 8 hex chars final ImageFormat format; @@ -258,8 +256,6 @@ class ImageEnvelope { final int width; final int height; final int sizeBytes; // total compressed image size - final String senderKey6; // 12 hex chars (6 bytes) - final int timestampSec; final int version; const ImageEnvelope({ @@ -269,18 +265,16 @@ class ImageEnvelope { required this.width, required this.height, required this.sizeBytes, - required this.senderKey6, - required this.timestampSec, - this.version = 2, + this.version = 4, }); - static bool isEnvelope(String text) => text.startsWith(_prefix); + static bool isEnvelope(String text) => text.startsWith(_prefixV4); static ImageEnvelope? tryParse(String text) { if (!isEnvelope(text)) return null; - final body = text.substring(_prefix.length); + final body = text.substring(_prefixV4.length); final parts = body.split(':'); - if (parts.length != 8) return null; + if (parts.length != 6) return null; try { final sid = _decodeSessionId(parts[0]); final fmtId = _parseInt(parts[1], base36: true); @@ -288,16 +282,12 @@ class ImageEnvelope { final w = _parseInt(parts[3], base36: true); final h = _parseInt(parts[4], base36: true); final bytes = _parseInt(parts[5], base36: true); - final senderKey6 = parts[6]; - final ts = _parseInt(parts[7], base36: true); if (sid == null) return null; if (fmtId == null) return null; if (total == null || total < 1 || total > 255) return null; if (w == null || h == null || w < 1 || h < 1) return null; if (bytes == null || bytes < 1) return null; - if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null; - if (ts == null || ts <= 0) return null; return ImageEnvelope( sessionId: sid, @@ -306,9 +296,7 @@ class ImageEnvelope { width: w, height: h, sizeBytes: bytes, - senderKey6: senderKey6.toLowerCase(), - timestampSec: ts, - version: 2, + version: 4, ); } catch (_) { return null; @@ -316,27 +304,25 @@ class ImageEnvelope { } String encode() => - '$_prefix${_encodeSessionId(sessionId)}:' + '$_prefixV4${_encodeSessionId(sessionId)}:' '${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:' - '${_toBase36(height)}:${_toBase36(sizeBytes)}:' - '${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}'; + '${_toBase36(height)}:${_toBase36(sizeBytes)}'; } /// Direct request to fetch image fragments (control plane). /// /// Text format: -/// IR2:{sid}:{want}:{requesterKey6}:{ts} +/// IR4:{sid}:{want}:{requesterKey6} /// Example: -/// IR2:deadbeef:a:aabbccddeeff:s44wea +/// IR4:deadbeef:a:aabbccddeeff class ImageFetchRequest { - static const String _prefix = 'IR2:'; + static const String _prefixV4 = 'IR4:'; static const int _binaryMagic = 0x69; // 'i' final String sessionId; final String want; // 'all' or 'missing' final List missingIndices; final String requesterKey6; // 12 hex chars - final int timestampSec; final int version; const ImageFetchRequest({ @@ -344,24 +330,22 @@ class ImageFetchRequest { this.want = 'all', this.missingIndices = const [], required this.requesterKey6, - required this.timestampSec, - this.version = 2, + this.version = 4, }); - static bool isRequest(String text) => text.startsWith(_prefix); + static bool isRequest(String text) => text.startsWith(_prefixV4); static bool isRequestBinary(Uint8List payload) => payload.isNotEmpty && payload[0] == _binaryMagic; static ImageFetchRequest? tryParse(String text) { if (!isRequest(text)) return null; - final body = text.substring(_prefix.length); + final body = text.substring(_prefixV4.length); final parts = body.split(':'); - if (parts.length != 4) return null; + if (parts.length != 3) return null; try { final sid = _decodeSessionId(parts[0]); final wantToken = parts[1]; final requesterKey6 = parts[2]; - final ts = _parseInt(parts[3], base36: true); final normalizedWant = wantToken == 'a' ? 'all' : ((wantToken.startsWith('m')) ? 'missing' : wantToken); @@ -377,15 +361,13 @@ class ImageFetchRequest { return null; } if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null; - if (ts == null || ts <= 0) return null; return ImageFetchRequest( sessionId: sid, want: normalizedWant, missingIndices: missingIndices, requesterKey6: requesterKey6.toLowerCase(), - timestampSec: ts, - version: 2, + version: 4, ); } catch (_) { return null; @@ -394,7 +376,7 @@ class ImageFetchRequest { static ImageFetchRequest? tryParseBinary(Uint8List payload) { 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 { final sid = payload .sublist(1, 5) @@ -407,25 +389,19 @@ class ImageFetchRequest { .map((b) => b.toRadixString(16).padLeft(2, '0')) .join() .toLowerCase(); - final ts = - (payload[12] << 24) | - (payload[13] << 16) | - (payload[14] << 8) | - payload[15]; - final missingCount = payload[16]; - if (payload.length != 17 + missingCount) return null; + final missingCount = payload[12]; + if (payload.length != 13 + missingCount) return null; final wantMissing = (flags & 0x01) == 0x01; final missing = []; for (var i = 0; i < missingCount; i++) { - missing.add(payload[17 + i]); + missing.add(payload[13 + i]); } return ImageFetchRequest( sessionId: sid, want: wantMissing ? 'missing' : 'all', missingIndices: missing, requesterKey6: requesterKey6, - timestampSec: ts, - version: 2, + version: 4, ); } catch (_) { return null; @@ -436,7 +412,7 @@ class ImageFetchRequest { final wantToken = want == 'missing' && missingIndices.isNotEmpty ? 'm${_encodeMissingIndicesCompact(missingIndices)}' : (want == 'all' ? 'a' : want); - return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}'; + return '$_prefixV4${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}'; } Uint8List encodeBinary() { @@ -455,7 +431,7 @@ class ImageFetchRequest { ? missingIndices.where((v) => v >= 0 && v <= 254).toList() : []; - final out = Uint8List(17 + missing.length); + final out = Uint8List(13 + missing.length); out[0] = _binaryMagic; for (var i = 0; i < 4; i++) { out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); @@ -467,13 +443,9 @@ class ImageFetchRequest { radix: 16, ); } - out[12] = (timestampSec >> 24) & 0xFF; - out[13] = (timestampSec >> 16) & 0xFF; - out[14] = (timestampSec >> 8) & 0xFF; - out[15] = timestampSec & 0xFF; - out[16] = missing.length; + out[12] = missing.length; for (var i = 0; i < missing.length; i++) { - out[17 + i] = missing[i]; + out[13 + i] = missing[i]; } return out; } diff --git a/lib/utils/media_swarm_protocol.dart b/lib/utils/media_swarm_protocol.dart new file mode 100644 index 0000000..70191c5 --- /dev/null +++ b/lib/utils/media_swarm_protocol.dart @@ -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 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 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(); +} diff --git a/lib/utils/transmission_target_resolver.dart b/lib/utils/transmission_target_resolver.dart index 3757801..e4617d8 100644 --- a/lib/utils/transmission_target_resolver.dart +++ b/lib/utils/transmission_target_resolver.dart @@ -3,7 +3,12 @@ import 'dart:typed_data'; import '../models/contact.dart'; import '../providers/contacts_provider.dart'; -enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar, unreachable } +enum TransmissionTargetFailure { + unknownContact, + unknownRoute, + tooFar, + unreachable, +} class TransmissionTargetResolution { final Contact? target; @@ -16,7 +21,7 @@ class TransmissionTargetResolution { required this.maxHops, }); - int get hops => target?.outPathLen ?? -1; + int get hops => target?.routeHopCount ?? -1; bool get isValid => target != null && failure == null; } @@ -32,11 +37,17 @@ class TransmissionTargetResolver { String? senderName, }) { if (isSentByMe) { - final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey); + final recipient = _findByRecipientKey( + contactsProvider, + recipientPublicKey, + ); if (recipient != null) return recipient; } - final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope); + final byEnvelope = _findByEnvelopeKey6( + contactsProvider, + senderKey6FromEnvelope, + ); if (byEnvelope != null) return byEnvelope; final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix); @@ -64,7 +75,9 @@ class TransmissionTargetResolver { senderName: senderName, ); - if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) { + if (target == null || + !target.routeHasPath || + target.routeHopCount > maxFetchHops) { await refreshContacts(); target = resolveLocalTarget( contactsProvider: contactsProvider, @@ -83,14 +96,14 @@ class TransmissionTargetResolver { maxHops: maxFetchHops, ); } - if (target.outPathLen < 0) { + if (!target.routeHasPath) { return TransmissionTargetResolution( target: target, failure: TransmissionTargetFailure.unknownRoute, maxHops: maxFetchHops, ); } - if (target.outPathLen > maxFetchHops) { + if (target.routeHopCount > maxFetchHops) { return TransmissionTargetResolution( target: target, failure: TransmissionTargetFailure.tooFar, diff --git a/lib/utils/voice_message_parser.dart b/lib/utils/voice_message_parser.dart index c344d37..0d093ff 100644 --- a/lib/utils/voice_message_parser.dart +++ b/lib/utils/voice_message_parser.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; 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 _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) @@ -38,7 +38,7 @@ enum VoicePacketMode { /// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2} /// /// 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 { final String sessionId; // 8 hex chars (4 bytes) final VoicePacketMode mode; @@ -104,8 +104,7 @@ class VoicePacket { // ── Binary format ──────────────────────────────────────────────────────── static const int _binaryMagic = 0x56; // 'V' - static const int _binaryHeaderLen = - 8; // magic(1)+session(4)+mode(1)+idx(1)+total(1) + static const int _binaryHeaderLen = 6; // magic(1)+session(4)+idx(1) static bool isVoiceBinary(Uint8List payload) => payload.isNotEmpty && payload[0] == _binaryMagic; @@ -119,16 +118,13 @@ class VoicePacket { final sessionId = sessionBytes .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); - final modeId = payload[5]; - final index = payload[6]; - final total = payload[7]; - if (total < 1) return null; + final index = payload[5]; final codec2Data = payload.sublist(_binaryHeaderLen); return VoicePacket( sessionId: sessionId, - mode: VoicePacketMode.fromId(modeId), + mode: VoicePacketMode.mode1300, index: index, - total: total, + total: 0, codec2Data: codec2Data, ); } catch (_) { @@ -148,9 +144,7 @@ class VoicePacket { final out = Uint8List(_binaryHeaderLen + codec2Data.length); out[0] = _binaryMagic; out.setRange(1, 5, sessionBytes); - out[5] = mode.id; - out[6] = index; - out[7] = total; + out[5] = index; out.setRange(_binaryHeaderLen, out.length, codec2Data); return out; } @@ -174,25 +168,25 @@ class VoicePacket { } @override - String toString() => - 'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)'; + String toString() { + 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. /// /// Text format: -/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts} +/// VE3:{sid}:{mode}:{total}:{durS} /// Example: -/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi +/// VE3:00112233:1:4:4 class VoiceEnvelope { - static const String _prefix = 'VE2:'; + static const String _prefix = 'VE3:'; final String sessionId; final VoicePacketMode mode; final int total; final int durationMs; - final String senderKey6; - final int timestampSec; final int version; const VoiceEnvelope({ @@ -200,9 +194,7 @@ class VoiceEnvelope { required this.mode, required this.total, required this.durationMs, - required this.senderKey6, - required this.timestampSec, - this.version = 2, + this.version = 3, }); static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix); @@ -215,14 +207,12 @@ class VoiceEnvelope { static VoiceEnvelope? _tryParse(String body) { final parts = body.split(':'); - if (parts.length != 6) return null; + if (parts.length != 4) return null; try { final sid = _decodeSessionId(parts[0]); final mode = _parseInt(parts[1], base36: true); final total = _parseInt(parts[2], base36: true); final durS = _parseInt(parts[3], base36: true); - final senderKey6 = parts[4]; - final ts = _parseInt(parts[5], base36: true); if (sid == null) { return null; @@ -232,19 +222,13 @@ class VoiceEnvelope { } if (total == null || total < 1 || total > 255) 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( sessionId: sid, mode: VoicePacketMode.fromId(mode), total: total, durationMs: durS * 1000, - senderKey6: senderKey6.toLowerCase(), - timestampSec: ts, - version: 2, + version: 3, ); } catch (_) { return null; @@ -253,7 +237,7 @@ class VoiceEnvelope { String encodeText() { 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. /// /// Text format: -/// VR2:{sid}:{want}:{requesterKey6}:{ts} +/// VR3:{sid}:{want}:{requesterKey6} /// Example: -/// VR2:00112233:a:aabbccddeeff:kf12oi +/// VR3:00112233:a:aabbccddeeff class VoiceFetchRequest { - static const String _prefix = 'VR2:'; + static const String _prefix = 'VR3:'; static const int _binaryMagic = 0x72; // 'r' final String sessionId; final String want; final List missingIndices; final String requesterKey6; - final int timestampSec; final int version; const VoiceFetchRequest({ @@ -433,8 +416,7 @@ class VoiceFetchRequest { this.want = 'all', this.missingIndices = const [], required this.requesterKey6, - required this.timestampSec, - this.version = 2, + this.version = 3, }); static bool isVoiceFetchRequestText(String text) => @@ -450,7 +432,7 @@ class VoiceFetchRequest { static VoiceFetchRequest? tryParseBinary(Uint8List payload) { 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 { final sidBytes = payload.sublist(1, 5); final sid = sidBytes @@ -463,25 +445,19 @@ class VoiceFetchRequest { .map((b) => b.toRadixString(16).padLeft(2, '0')) .join() .toLowerCase(); - final ts = - (payload[12] << 24) | - (payload[13] << 16) | - (payload[14] << 8) | - payload[15]; - final missingCount = payload[16]; - if (payload.length != 17 + missingCount) return null; + final missingCount = payload[12]; + if (payload.length != 13 + missingCount) return null; final wantMissing = (flags & 0x01) == 0x01; final missing = []; for (var i = 0; i < missingCount; i++) { - missing.add(payload[17 + i]); + missing.add(payload[13 + i]); } return VoiceFetchRequest( sessionId: sid, want: wantMissing ? 'missing' : 'all', missingIndices: missing, requesterKey6: requesterKey6, - timestampSec: ts, - version: 2, + version: 3, ); } catch (_) { return null; @@ -490,12 +466,11 @@ class VoiceFetchRequest { static VoiceFetchRequest? _tryParse(String body) { final parts = body.split(':'); - if (parts.length != 4) return null; + if (parts.length != 3) return null; try { final sid = _decodeSessionId(parts[0]); final wantToken = parts[1]; final requesterKey6 = parts[2]; - final ts = _parseInt(parts[3], base36: true); final normalizedWant = wantToken == 'a' ? 'all' : ((wantToken.startsWith('m')) @@ -517,15 +492,13 @@ class VoiceFetchRequest { if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { return null; } - if (ts == null || ts <= 0) return null; return VoiceFetchRequest( sessionId: sid, want: normalizedWant, missingIndices: missingIndices, requesterKey6: requesterKey6.toLowerCase(), - timestampSec: ts, - version: 2, + version: 3, ); } catch (_) { return null; @@ -536,7 +509,7 @@ class VoiceFetchRequest { final wantToken = want == 'missing' && missingIndices.isNotEmpty ? 'm${_encodeMissingIndicesCompact(missingIndices)}' : (want == 'all' ? 'a' : want); - return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}'; + return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}'; } Uint8List encodeBinary() { @@ -555,7 +528,7 @@ class VoiceFetchRequest { ? missingIndices.where((v) => v >= 0 && v <= 254).toList() : []; - final out = Uint8List(17 + missing.length); + final out = Uint8List(13 + missing.length); out[0] = _binaryMagic; for (var i = 0; i < 4; i++) { out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); @@ -567,13 +540,9 @@ class VoiceFetchRequest { radix: 16, ); } - out[12] = (timestampSec >> 24) & 0xFF; - out[13] = (timestampSec >> 16) & 0xFF; - out[14] = (timestampSec >> 8) & 0xFF; - out[15] = timestampSec & 0xFF; - out[16] = missing.length; + out[12] = missing.length; for (var i = 0; i < missing.length; i++) { - out[17 + i] = missing[i]; + out[13 + i] = missing[i]; } return out; } diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart new file mode 100644 index 0000000..a54815f --- /dev/null +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; + +import '../../models/contact.dart'; + +class ContactRouteDialog extends StatefulWidget { + final Contact contact; + final List availableContacts; + + const ContactRouteDialog({ + super.key, + required this.contact, + required this.availableContacts, + }); + + static Future show( + BuildContext context, { + required Contact contact, + required List availableContacts, + }) { + return showDialog( + context: context, + builder: (context) => ContactRouteDialog( + contact: contact, + availableContacts: availableContacts, + ), + ); + } + + @override + State createState() => _ContactRouteDialogState(); +} + +class _ContactRouteDialogState extends State { + late final TextEditingController _controller; + late int _selectedHashSize; + ParsedContactRoute? _parsedRoute; + String? _errorText; + + @override + void initState() { + super.initState(); + _selectedHashSize = widget.contact.routeHasPath + ? widget.contact.routeHashSize + : 1; + _controller = TextEditingController( + text: widget.contact.routeCanonicalText, + ); + _controller.addListener(_reparse); + _reparse(); + } + + @override + void dispose() { + _controller + ..removeListener(_reparse) + ..dispose(); + super.dispose(); + } + + void _reparse() { + final input = _controller.text.trim(); + if (input.isEmpty) { + setState(() { + _parsedRoute = null; + _errorText = null; + }); + return; + } + + try { + final parsed = ContactRouteCodec.parse(input); + setState(() { + _parsedRoute = parsed; + _selectedHashSize = parsed.hashSize; + _errorText = null; + }); + } on ContactRouteFormatException catch (error) { + setState(() { + _parsedRoute = null; + _errorText = error.message; + }); + } + } + + String _tokenFor(Contact contact, int hashSize) { + final hex = contact.publicKeyHex.toUpperCase(); + final length = hashSize * 2; + if (hex.length < length) { + return hex; + } + return hex.substring(0, length); + } + + void _appendHop(Contact contact) { + final token = _tokenFor(contact, _selectedHashSize); + final current = _controller.text.trim(); + _controller.text = current.isEmpty ? token : '$current,$token'; + _controller.selection = TextSelection.fromPosition( + TextPosition(offset: _controller.text.length), + ); + } + + @override + Widget build(BuildContext context) { + final routeCandidates = + widget.availableContacts + .where((contact) => contact.isRepeater || contact.isRoom) + .toList() + ..sort((a, b) => a.displayName.compareTo(b.displayName)); + + return AlertDialog( + title: Text('Set Route for ${widget.contact.displayName}'), + content: SizedBox( + width: 560, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Path hash size', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [1, 2, 3] + .map( + (hashSize) => ChoiceChip( + label: Text( + '$hashSize byte${hashSize == 1 ? '' : 's'}', + ), + selected: _selectedHashSize == hashSize, + onSelected: (_) { + setState(() { + _selectedHashSize = hashSize; + }); + }, + ), + ) + .toList(), + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + textCapitalization: TextCapitalization.characters, + decoration: InputDecoration( + labelText: 'Route', + hintText: _selectedHashSize == 1 + ? 'AA,BB,CC' + : _selectedHashSize == 2 + ? 'AABB,CCDD' + : 'AABBCC,DDEEFF', + helperText: + 'Use comma-separated hops. Colon form like AA:BB is also accepted.', + errorText: _errorText, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Text( + _parsedRoute == null + ? 'Preview: enter a route to validate it.' + : 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (_parsedRoute != null) ...[ + const SizedBox(height: 4), + SelectableText( + _parsedRoute!.canonicalText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ), + ], + const SizedBox(height: 16), + Text( + 'Pick hops from contacts', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + if (routeCandidates.isEmpty) + const Text( + 'No repeater or room contacts are available for route building.', + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: ListView.builder( + shrinkWrap: true, + itemCount: routeCandidates.length, + itemBuilder: (context, index) { + final candidate = routeCandidates[index]; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(candidate.displayName), + subtitle: Text( + '1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}', + style: const TextStyle(fontFamily: 'monospace'), + ), + trailing: TextButton( + onPressed: () => _appendHop(candidate), + child: Text( + 'Use ${_tokenFor(candidate, _selectedHashSize)}', + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _parsedRoute == null + ? null + : () => Navigator.of(context).pop(_parsedRoute), + child: const Text('Set Route'), + ), + ], + ); + } +} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index d6adb1a..107e775 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -9,6 +9,7 @@ import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/map_provider.dart'; import '../../providers/app_provider.dart'; +import 'contact_route_dialog.dart'; import 'direct_message_sheet.dart'; import 'room_login_sheet.dart'; import '../../utils/location_formats.dart'; @@ -52,6 +53,7 @@ class ContactTile extends StatelessWidget { contact.telemetry != null && contact.telemetry!.isRecent; final battery = contact.displayBattery; final location = contact.displayLocation; + final routeHasPath = contact.routeHasPath; // Calculate distance if both positions are available String? distanceText; @@ -159,12 +161,12 @@ class ContactTile extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( - color: contact.hasPath + color: routeHasPath ? Colors.green.withValues(alpha: 0.15) : Colors.orange.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(3), border: Border.all( - color: contact.hasPath ? Colors.green : Colors.orange, + color: routeHasPath ? Colors.green : Colors.orange, width: 0.5, ), ), @@ -172,19 +174,19 @@ class ContactTile extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - contact.hasPath ? Icons.route : Icons.waves, + routeHasPath ? Icons.route : Icons.waves, size: 10, - color: contact.hasPath ? Colors.green : Colors.orange, + color: routeHasPath ? Colors.green : Colors.orange, ), const SizedBox(width: 2), Text( - contact.hasPath + routeHasPath ? AppLocalizations.of(context)!.direct : AppLocalizations.of(context)!.flood, style: TextStyle( fontSize: 9, fontWeight: FontWeight.w600, - color: contact.hasPath ? Colors.green : Colors.orange, + color: routeHasPath ? Colors.green : Colors.orange, ), ), ], @@ -478,7 +480,7 @@ class ContactTile extends StatelessWidget { final connectionProvider = context.read(); // Determine if we should use flooding (no path) or direct (has path) - final hasPath = contact.hasPath; + final hasPath = contact.routeHasPath; // Use smart ping with automatic fallback final result = await connectionProvider.smartPing( @@ -622,407 +624,485 @@ class ContactTile extends StatelessWidget { maxChildSize: 0.9, expand: false, builder: (context, scrollController) { + final contactsProvider = context.watch(); + final currentContact = + contactsProvider.findContactByKey(contact.publicKey) ?? contact; final isPingInProgress = context .watch() .isPingInProgress(contact.publicKey); return Column( children: [ - // Handle bar - Container( - margin: const EdgeInsets.only(top: 8, bottom: 16), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), + // Handle bar + Container( + margin: const EdgeInsets.only(top: 8, bottom: 16), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), ), - ), - // Header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - CircleAvatar( - backgroundColor: _getTypeColor(contact.type, context), - child: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 24), - ) - : Icon(_getTypeIcon(contact.type), color: Colors.white), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - contact.displayName, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + CircleAvatar( + backgroundColor: _getTypeColor(contact.type, context), + child: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 24), + ) + : Icon( + _getTypeIcon(contact.type), + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + contact.displayName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - const Divider(), - // Content - Expanded( - child: ListView( - controller: scrollController, - padding: const EdgeInsets.all(16), - children: [ - _detailRow(l10n.type, contact.type.displayName), - if (contact.isChannel) ...[ - _detailRow( - l10n.channel, - contact.getLocalizedDisplayName(context), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), ), - if (!contact.isPublicChannel) + ], + ), + ), + const Divider(), + // Content + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: [ + _detailRow(l10n.type, contact.type.displayName), + if (contact.isChannel) ...[ _detailRow( - 'Slot', - '${l10n.channel} ${contact.publicKey.length > 1 ? contact.publicKey[1] : '-'}', + l10n.channel, + contact.getLocalizedDisplayName(context), ), - ] else - // Public Key with copy button - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 100, - child: Text( - '${l10n.publicKey}:', - style: const TextStyle( - fontWeight: FontWeight.w500, + if (!contact.isPublicChannel) + _detailRow( + 'Slot', + '${l10n.channel} ${contact.publicKey.length > 1 ? contact.publicKey[1] : '-'}', + ), + ] else + // Public Key with copy button + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 100, + child: Text( + '${l10n.publicKey}:', + style: const TextStyle( + fontWeight: FontWeight.w500, + ), ), ), + Expanded(child: Text(contact.publicKeyShort)), + const SizedBox(width: 8), + InkWell( + onTap: () { + Clipboard.setData( + ClipboardData(text: contact.publicKeyHex), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.publicKeyCopied), + duration: const Duration(seconds: 2), + ), + ); + }, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.copy, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ), + ), + _detailRow( + l10n.lastSeen, + _getLocalizedTimeSinceLastSeen(context), + ), + const SizedBox(height: 16), + // Room Login Status + if (roomLoginState != null) ...[ + Text( + '${AppLocalizations.of(context)!.roomStatus}:', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + _detailRow( + AppLocalizations.of(context)!.loginStatus, + roomLoginState.isLoggedIn + ? AppLocalizations.of(context)!.loggedIn + : AppLocalizations.of(context)!.notLoggedIn, + ), + if (roomLoginState.isLoggedIn) ...[ + _detailRow( + AppLocalizations.of(context)!.adminAccess, + roomLoginState.isAdmin + ? AppLocalizations.of(context)!.yes + : AppLocalizations.of(context)!.no, + ), + _detailRow( + AppLocalizations.of(context)!.permissions, + roomLoginState.permissions.toString(), + ), + if (roomLoginState.loginDurationFormatted != null) + _detailRow( + AppLocalizations.of(context)!.loggedIn, + roomLoginState.loginDurationFormatted!, ), - Expanded(child: Text(contact.publicKeyShort)), - const SizedBox(width: 8), - InkWell( - onTap: () { - Clipboard.setData( - ClipboardData(text: contact.publicKeyHex), - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.publicKeyCopied), - duration: const Duration(seconds: 2), + ], + _detailRow( + AppLocalizations.of(context)!.passwordSaved, + roomLoginState.hasPassword + ? AppLocalizations.of(context)!.yes + : AppLocalizations.of(context)!.no, + ), + const SizedBox(height: 16), + ], + if (contact.displayLocation != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppLocalizations.of(context)!.locationColon, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + TextButton.icon( + onPressed: () { + // Navigate to map and close modal + final mapProvider = context.read(); + mapProvider.navigateToLocation( + location: LatLng( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, ), ); + Navigator.pop(context); + + // Switch to map tab using callback + onNavigateToMap?.call(); }, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon( - Icons.copy, - size: 16, - color: Theme.of(context).colorScheme.primary, + icon: const Icon(Icons.map, size: 18), + label: Text( + AppLocalizations.of(context)!.viewOnMap, + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, ), ), ), ], ), - ), - _detailRow( - l10n.lastSeen, - _getLocalizedTimeSinceLastSeen(context), - ), - const SizedBox(height: 16), - // Room Login Status - if (roomLoginState != null) ...[ - Text( - '${AppLocalizations.of(context)!.roomStatus}:', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + const SizedBox(height: 8), + // Decimal Degrees (DD) + _detailRowWithCopy( + context, + 'DD', + '${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}', ), - ), - const SizedBox(height: 8), - _detailRow( - AppLocalizations.of(context)!.loginStatus, - roomLoginState.isLoggedIn - ? AppLocalizations.of(context)!.loggedIn - : AppLocalizations.of(context)!.notLoggedIn, - ), - if (roomLoginState.isLoggedIn) ...[ - _detailRow( - AppLocalizations.of(context)!.adminAccess, - roomLoginState.isAdmin - ? AppLocalizations.of(context)!.yes - : AppLocalizations.of(context)!.no, - ), - _detailRow( - AppLocalizations.of(context)!.permissions, - roomLoginState.permissions.toString(), - ), - if (roomLoginState.loginDurationFormatted != null) - _detailRow( - AppLocalizations.of(context)!.loggedIn, - roomLoginState.loginDurationFormatted!, + // Degrees Minutes Seconds (DMS) + _detailRowWithCopy( + context, + 'DMS', + _convertToDMS( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, ), + ), + // Degrees Decimal Minutes (DDM) + _detailRowWithCopy( + context, + 'DDM', + _convertToDDM( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), + ), + // MGRS (Military Grid Reference System) + _detailRowWithCopy( + context, + 'MGRS', + _convertToMGRS( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), + ), + // Google Plus Code + _detailRowWithCopy( + context, + 'Plus Code', + formatPlusCode( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), + ), + const SizedBox(height: 16), ], - _detailRow( - AppLocalizations.of(context)!.passwordSaved, - roomLoginState.hasPassword - ? AppLocalizations.of(context)!.yes - : AppLocalizations.of(context)!.no, - ), - const SizedBox(height: 16), - ], - if (contact.displayLocation != null) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - AppLocalizations.of(context)!.locationColon, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + if (contact.telemetry != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${AppLocalizations.of(context)!.telemetry}:', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), - ), - TextButton.icon( - onPressed: () { - // Navigate to map and close modal - final mapProvider = context.read(); - mapProvider.navigateToLocation( - location: LatLng( - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, + TextButton.icon( + onPressed: isPingInProgress + ? null + : () { + final connectionProvider = context + .read(); + connectionProvider.requestTelemetry( + contact.publicKey, + zeroHop: true, + ); + }, + icon: isPingInProgress + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.refresh, size: 18), + label: Text(AppLocalizations.of(context)!.refresh), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, ), - ); - Navigator.pop(context); - - // Switch to map tab using callback - onNavigateToMap?.call(); + ), + ), + ], + ), + const SizedBox(height: 8), + if (contact.telemetry!.batteryMilliVolts != null) + _detailRow( + AppLocalizations.of(context)!.voltage, + '${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V' + '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', + ) + else if (contact.telemetry!.batteryPercentage != null) + _detailRow( + AppLocalizations.of(context)!.battery, + '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%', + ), + if (contact.telemetry!.temperature != null) + _detailRow( + AppLocalizations.of(context)!.temperature, + '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C', + ), + if (contact.telemetry!.humidity != null) + _detailRow( + AppLocalizations.of(context)!.humidity, + '${contact.telemetry!.humidity!.toStringAsFixed(1)}%', + ), + if (contact.telemetry!.pressure != null) + _detailRow( + AppLocalizations.of(context)!.pressure, + '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa', + ), + if (contact.telemetry!.gpsLocation != null) + _detailRow( + AppLocalizations.of(context)!.gpsTelemetry, + '${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}', + ), + _detailRow( + AppLocalizations.of(context)!.updated, + '${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})', + ), + ], + if (!currentContact.isChannel) ...[ + const SizedBox(height: 16), + Text( + 'Route', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + _detailRow('Mode', currentContact.routeSummary), + if (currentContact.routeHopCount > 0) + _detailRow('Route', currentContact.routeCanonicalText), + if (currentContact.routeHopCount > 0) + _detailRow( + 'Descriptor', + '0x${currentContact.routeEncodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => + _showSetRouteDialog(context, currentContact), + icon: const Icon(Icons.route), + label: const Text('Set Route'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: currentContact.isPublicChannel + ? null + : () async { + contactsProvider.resetContactRouteLocal( + currentContact.publicKey, + ); + try { + await connectionProvider.resetPath( + currentContact.publicKey, + ); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.pathResetInfo( + currentContact.displayName, + ), + ), + ), + ); + } + } catch (_) { + contactsProvider.setContactRouteLocal( + currentContact.publicKey, + signedEncodedPathLen: + currentContact.routeSignedPathLen, + paddedPathBytes: + currentContact.outPath, + ); + if (context.mounted) { + ToastLogger.error( + context, + 'Failed to reset route.', + ); + } + } + }, + icon: const Icon(Icons.refresh), + label: Text( + AppLocalizations.of(context)!.resetPath, + ), + ), + ), + ], + ), + ], + // Direct Message button for chat contacts + if (contact.type == ContactType.chat) ...[ + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); // Close details first + _showDirectMessageDialog(context, contact); }, - icon: const Icon(Icons.map, size: 18), - label: Text(AppLocalizations.of(context)!.viewOnMap), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, + icon: const Icon(Icons.message), + label: Text( + AppLocalizations.of(context)!.sendDirectMessage, + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _getTypeColor( + contact.type, + context, ), + foregroundColor: Colors.white, ), ), - ], - ), - const SizedBox(height: 8), - // Decimal Degrees (DD) - _detailRowWithCopy( - context, - 'DD', - '${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}', - ), - // Degrees Minutes Seconds (DMS) - _detailRowWithCopy( - context, - 'DMS', - _convertToDMS( - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, ), - ), - // Degrees Decimal Minutes (DDM) - _detailRowWithCopy( - context, - 'DDM', - _convertToDDM( - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ), - ), - // MGRS (Military Grid Reference System) - _detailRowWithCopy( - context, - 'MGRS', - _convertToMGRS( - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ), - ), - // Google Plus Code - _detailRowWithCopy( - context, - 'Plus Code', - formatPlusCode( - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ), - ), - const SizedBox(height: 16), - ], - if (contact.telemetry != null) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${AppLocalizations.of(context)!.telemetry}:', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + ], + // Room Login button for room contacts (except Public Channel) + if (contact.type == ContactType.room && + !contact.isPublicChannel) ...[ + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); // Close details first + _showRoomLoginDialog(context, contact); + }, + icon: const Icon(Icons.login), + label: Text( + roomLoginState?.isLoggedIn == true + ? AppLocalizations.of(context)!.reLoginToRoom + : AppLocalizations.of(context)!.loginToRoom, ), - ), - TextButton.icon( - onPressed: isPingInProgress - ? null - : () { - final connectionProvider = context - .read(); - connectionProvider.requestTelemetry( - contact.publicKey, - zeroHop: true, - ); - }, - icon: isPingInProgress - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.refresh, size: 18), - label: Text(AppLocalizations.of(context)!.refresh), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _getTypeColor( + contact.type, + context, ), + foregroundColor: Colors.white, ), ), - ], - ), - const SizedBox(height: 8), - if (contact.telemetry!.batteryMilliVolts != null) - _detailRow( - AppLocalizations.of(context)!.voltage, - '${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V' - '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', - ) - else if (contact.telemetry!.batteryPercentage != null) - _detailRow( - AppLocalizations.of(context)!.battery, - '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%', ), - if (contact.telemetry!.temperature != null) - _detailRow( - AppLocalizations.of(context)!.temperature, - '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C', - ), - if (contact.telemetry!.humidity != null) - _detailRow( - AppLocalizations.of(context)!.humidity, - '${contact.telemetry!.humidity!.toStringAsFixed(1)}%', - ), - if (contact.telemetry!.pressure != null) - _detailRow( - AppLocalizations.of(context)!.pressure, - '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa', - ), - if (contact.telemetry!.gpsLocation != null) - _detailRow( - AppLocalizations.of(context)!.gpsTelemetry, - '${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}', - ), - _detailRow( - AppLocalizations.of(context)!.updated, - '${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})', - ), - ], - // Direct Message button for chat contacts - if (contact.type == ContactType.chat) ...[ - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); // Close details first - _showDirectMessageDialog(context, contact); - }, - icon: const Icon(Icons.message), - label: Text( - AppLocalizations.of(context)!.sendDirectMessage, - ), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - backgroundColor: _getTypeColor(contact.type, context), - foregroundColor: Colors.white, - ), - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () { - connectionProvider.resetPath(contact.publicKey); - }, - icon: const Icon(Icons.route), - label: Text(AppLocalizations.of(context)!.resetPath), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - side: BorderSide( - color: _getTypeColor(contact.type, context), + ], + // Delete Contact button (for all contact types except Public Channel) + if (!contact.isPublicChannel) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => + _showDeleteConfirmation(context, contact), + icon: const Icon(Icons.delete_outline), + label: Text( + AppLocalizations.of(context)!.deleteContact, + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: Colors.red), + foregroundColor: Colors.red, ), - foregroundColor: _getTypeColor(contact.type, context), ), ), - ), + ], ], - // Room Login button for room contacts (except Public Channel) - if (contact.type == ContactType.room && - !contact.isPublicChannel) ...[ - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); // Close details first - _showRoomLoginDialog(context, contact); - }, - icon: const Icon(Icons.login), - label: Text( - roomLoginState?.isLoggedIn == true - ? AppLocalizations.of(context)!.reLoginToRoom - : AppLocalizations.of(context)!.loginToRoom, - ), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - backgroundColor: _getTypeColor(contact.type, context), - foregroundColor: Colors.white, - ), - ), - ), - ], - // Delete Contact button (for all contact types except Public Channel) - if (!contact.isPublicChannel) ...[ - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => - _showDeleteConfirmation(context, contact), - icon: const Icon(Icons.delete_outline), - label: Text( - AppLocalizations.of(context)!.deleteContact, - ), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - side: const BorderSide(color: Colors.red), - foregroundColor: Colors.red, - ), - ), - ), - ], - ], + ), ), - ), ], ); }, @@ -1030,6 +1110,56 @@ class ContactTile extends StatelessWidget { ); } + Future _showSetRouteDialog( + BuildContext context, + Contact contact, + ) async { + final contactsProvider = context.read(); + final connectionProvider = context.read(); + final availableContacts = contactsProvider.contacts + .where((candidate) => candidate.publicKeyHex != contact.publicKeyHex) + .toList(); + + final parsedRoute = await ContactRouteDialog.show( + context, + contact: contact, + availableContacts: availableContacts, + ); + if (parsedRoute == null || !context.mounted) { + return; + } + + final previousSignedPathLen = contact.routeSignedPathLen; + final previousPathBytes = Uint8List.fromList(contact.outPath); + contactsProvider.setContactRouteLocal( + contact.publicKey, + signedEncodedPathLen: parsedRoute.signedEncodedPathLen, + paddedPathBytes: parsedRoute.paddedPathBytes, + ); + + try { + await connectionProvider.setContactRoute( + contact, + signedEncodedPathLen: parsedRoute.signedEncodedPathLen, + paddedPathBytes: parsedRoute.paddedPathBytes, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Route set: ${parsedRoute.canonicalText}')), + ); + } + } catch (error) { + contactsProvider.setContactRouteLocal( + contact.publicKey, + signedEncodedPathLen: previousSignedPathLen, + paddedPathBytes: previousPathBytes, + ); + if (context.mounted) { + ToastLogger.error(context, 'Failed to set route: $error'); + } + } + } + Widget _detailRow(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index df3c672..a5003ac 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -3,11 +3,13 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_avif/flutter_avif.dart'; import 'package:provider/provider.dart'; +import '../../models/contact.dart'; import '../../models/message.dart'; import '../../providers/app_provider.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/image_provider.dart' as ip; +import '../../providers/messages_provider.dart'; import '../../utils/image_message_parser.dart'; import '../../utils/transmission_target_resolver.dart'; import 'transfer_timeout.dart'; @@ -33,7 +35,9 @@ class ImageMessageBubble extends StatefulWidget { class _ImageMessageBubbleState extends State { static const int _maxFetchHops = 3; + static const Duration _recentInboundActivityWindow = Duration(seconds: 3); bool _isRequesting = false; + bool _isPartialRequest = false; String? _errorText; Timer? _requestTimeoutTimer; @@ -59,6 +63,11 @@ class _ImageMessageBubbleState extends State { return Consumer( builder: (context, imageProvider, _) { + final transferCount = context.select( + (provider) => provider.transferCountForSession( + imageSessionId: envelope.sessionId, + ), + ); final contactsProvider = context.read(); final session = imageProvider.session(envelope.sessionId); final sender = TransmissionTargetResolver.resolveLocalTarget( @@ -66,11 +75,10 @@ class _ImageMessageBubbleState extends State { isSentByMe: widget.isSentByMe, recipientPublicKey: widget.message.recipientPublicKey, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, - senderKey6FromEnvelope: envelope.senderKey6, senderName: widget.message.senderName, ); - final effectivePathLen = sender != null && sender.outPathLen >= 0 - ? sender.outPathLen + final effectivePathLen = sender != null && sender.routeHasPath + ? sender.routeHopCount : widget.message.pathLen; final isComplete = imageProvider.isComplete(envelope.sessionId); final eta = imageProvider.estimateRemainingTransferTime( @@ -82,6 +90,7 @@ class _ImageMessageBubbleState extends State { if (mounted) { setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = null; }); } @@ -91,6 +100,17 @@ class _ImageMessageBubbleState extends State { final received = session?.receivedCount ?? 0; final total = session?.total ?? envelope.total; final imageBytes = isComplete ? session?.imageBytes : null; + final fragmentPresence = + session?.fragments.map((fragment) => fragment != null).toList() ?? + List.filled(total, false); + final isReceivingData = + !_isRequesting && + !isComplete && + _hasRecentInboundActivity( + lastReceivedAt: session?.lastFragmentAt, + received: received, + total: total, + ); return GestureDetector( onTap: isComplete @@ -110,8 +130,10 @@ class _ImageMessageBubbleState extends State { imageBytes: imageBytes, isComplete: isComplete, isRequesting: _isRequesting, + isReceivingData: isReceivingData, received: received, total: total, + fragmentPresence: fragmentPresence, envelope: envelope, radioBw: radioBw, radioSf: radioSf, @@ -125,6 +147,8 @@ class _ImageMessageBubbleState extends State { _statusText( isComplete: isComplete, isRequesting: _isRequesting, + isReceivingData: isReceivingData, + isPartialRequest: _isPartialRequest, received: received, total: total, envelope: envelope, @@ -135,6 +159,7 @@ class _ImageMessageBubbleState extends State { isSentByMe: widget.isSentByMe, eta: eta, pathLen: effectivePathLen, + transferCount: transferCount, ), style: TextStyle( fontSize: 11, @@ -156,8 +181,10 @@ class _ImageMessageBubbleState extends State { required Uint8List? imageBytes, required bool isComplete, required bool isRequesting, + required bool isReceivingData, required int received, required int total, + required List fragmentPresence, required ImageEnvelope envelope, required int? radioBw, required int? radioSf, @@ -180,19 +207,28 @@ class _ImageMessageBubbleState extends State { alignment: Alignment.center, children: [ if (isRequesting) ...[ - // Download progress ring. - SizedBox( - width: 48, - height: 48, - child: CircularProgressIndicator( - value: total > 0 ? received / total : null, - strokeWidth: 3, - color: Theme.of(context).colorScheme.primary, + Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(12), + ), + 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( top: 8, @@ -229,16 +265,25 @@ class _ImageMessageBubbleState extends State { ] else ...[ // Tap-to-load icon. IconButton( - onPressed: () => _requestAndFetch( - envelope, - radioBw: radioBw, - radioSf: radioSf, - radioCr: radioCr, - pathLen: pathLen, + onPressed: isReceivingData + ? null + : () => _requestAndFetch( + envelope, + radioBw: radioBw, + 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, - tooltip: 'Load image', + tooltip: isReceivingData + ? 'Image is already being received' + : 'Load image', ), ], ], @@ -255,10 +300,6 @@ class _ImageMessageBubbleState extends State { int pathLen = 0, }) async { if (_isRequesting) return; - setState(() { - _isRequesting = true; - _errorText = null; - }); final conn = context.read(); final imageProvider = context.read(); @@ -271,7 +312,6 @@ class _ImageMessageBubbleState extends State { isSentByMe: widget.isSentByMe, recipientPublicKey: widget.message.recipientPublicKey, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, - senderKey6FromEnvelope: envelope.senderKey6, senderName: widget.message.senderName, maxFetchHops: _maxFetchHops, ); @@ -322,7 +362,6 @@ class _ImageMessageBubbleState extends State { isSentByMe: widget.isSentByMe, recipientPublicKey: widget.message.recipientPublicKey, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, - senderKey6FromEnvelope: envelope.senderKey6, senderName: widget.message.senderName, maxFetchHops: _maxFetchHops, ); @@ -364,9 +403,18 @@ class _ImageMessageBubbleState extends State { } } - 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( - '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 { final missing = imageProvider.missingFragmentIndices(envelope.sessionId); final isPartialResume = missing.isNotEmpty && missing.length < envelope.total; + setState(() { + _isRequesting = true; + _isPartialRequest = isPartialResume; + _errorText = null; + }); final request = isPartialResume ? ImageFetchRequest( sessionId: envelope.sessionId, want: 'missing', missingIndices: missing, requesterKey6: requesterKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, ) : ImageFetchRequest( sessionId: envelope.sessionId, requesterKey6: requesterKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); final payload = request.encodeBinary(); try { 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( contactPath: sender.outPath, - contactPathLen: sender.outPathLen, + contactPathLen: sender.routeSignedPathLen, payload: payload, ); } catch (_) { @@ -419,6 +470,7 @@ class _ImageMessageBubbleState extends State { _showToast('Image fetch failed to send request'); setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = 'Image unavailable right now'; }); } @@ -427,8 +479,8 @@ class _ImageMessageBubbleState extends State { if (!mounted) return; // Timeout = 2× estimated LoRa airtime (min 30s). - final effectivePathLen = sender.outPathLen >= 0 - ? sender.outPathLen + final effectivePathLen = sender.routeHasPath + ? sender.routeHopCount : pathLen; final txEstimate = estimateImageTransmitDuration( fragmentCount: missing.isEmpty ? envelope.total : missing.length, @@ -450,6 +502,7 @@ class _ImageMessageBubbleState extends State { _showToast('Image fetch timed out'); setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = 'Image fetch timed out'; }); } @@ -471,6 +524,7 @@ class _ImageMessageBubbleState extends State { _showToast('Image receive canceled'); setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = 'Image receive canceled'; }); } @@ -479,6 +533,7 @@ class _ImageMessageBubbleState extends State { if (!mounted) return; setState(() { _isRequesting = false; + _isPartialRequest = false; }); } @@ -503,6 +558,8 @@ class _ImageMessageBubbleState extends State { static String _statusText({ required bool isComplete, required bool isRequesting, + required bool isReceivingData, + required bool isPartialRequest, required int received, required int total, required ImageEnvelope envelope, @@ -513,6 +570,7 @@ class _ImageMessageBubbleState extends State { required String? error, required bool isSentByMe, required Duration? eta, + required int transferCount, }) { final txEstimate = estimateImageTransmitDuration( fragmentCount: envelope.total, @@ -527,16 +585,25 @@ class _ImageMessageBubbleState extends State { if (error != null) return error; if (isRequesting) { 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) { final base = '🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}'; return isSentByMe - ? '$base · ${envelope.total} seg · $txEstimateLabel' + ? '$base · ${envelope.total} seg · ${_formatTransferCount(transferCount)} · $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) { @@ -554,6 +621,22 @@ class _ImageMessageBubbleState extends State { 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) { showGeneralDialog( context: context, @@ -600,3 +683,67 @@ class _ImageMessageBubbleState extends State { ); } } + +class _PacketBlockProgress extends StatelessWidget { + final List 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.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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index ee88752..b89facb 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart'; import '../../utils/location_formats.dart'; import '../../l10n/app_localizations.dart'; import '../../utils/message_extensions.dart'; +import '../../models/message_transfer_details.dart'; import 'voice_message_bubble.dart'; import 'image_message_bubble.dart'; import 'tictactoe_message_bubble.dart'; @@ -119,20 +120,9 @@ class _MessageBubbleState extends State { } 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; if (failedMessage.messageType == MessageType.contact) { if (failedMessage.recipientPublicKey == null) { - messagesProvider.markMessageFailed(retryMessageId); ToastLogger.error( context, AppLocalizations.of(context)!.cannotRetryMissingRecipient, @@ -148,13 +138,10 @@ class _MessageBubbleState extends State { }).firstOrNull; } - messagesProvider.addSentMessage(retryMessage, contact: roomContact); - // Resend the message if (failedMessage.messageType == MessageType.contact) { // Direct message retry (for SAR markers sent to rooms) if (failedMessage.recipientPublicKey == null) { - messagesProvider.markMessageFailed(retryMessageId); ToastLogger.error( context, AppLocalizations.of(context)!.cannotRetryMissingRecipient, @@ -162,26 +149,39 @@ class _MessageBubbleState extends State { return; } - // Resend to the same room + final prepared = messagesProvider.prepareMessageForRetry( + failedMessage.id, + ); + if (!prepared) { + return; + } + final sentSuccessfully = await connectionProvider.sendTextMessage( contactPublicKey: failedMessage.recipientPublicKey!, text: failedMessage.text, - messageId: retryMessageId, + messageId: failedMessage.id, contact: roomContact, ); if (!context.mounted) return; if (!sentSuccessfully) { - messagesProvider.markMessageFailed(retryMessageId); + messagesProvider.markMessageFailed(failedMessage.id); ToastLogger.error(context, 'Failed to resend message'); } } else if (failedMessage.messageType == MessageType.channel) { + final prepared = messagesProvider.prepareMessageForRetry( + failedMessage.id, + ); + if (!prepared) { + return; + } + // Channel message retry await connectionProvider.sendChannelMessage( channelIdx: failedMessage.channelIdx ?? 0, text: failedMessage.text, - messageId: retryMessageId, + messageId: failedMessage.id, ); if (!context.mounted) return; @@ -415,9 +415,11 @@ class _MessageBubbleState extends State { final receptionDetails = messagesProvider.getMessageReceptionDetails( widget.message.id, ); + final transferDetails = messagesProvider.getMessageTransferDetails( + widget.message.id, + ); final envelope = VoiceEnvelope.tryParseText(widget.message.text); - final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text); final voiceSession = widget.message.voiceId != null ? voiceProvider.session(widget.message.voiceId!) : null; @@ -454,16 +456,6 @@ class _MessageBubbleState extends State { radioSf: radioSf, 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; final senderPrefixHex = widget.message.senderPublicKeyPrefix @@ -540,10 +532,17 @@ class _MessageBubbleState extends State { '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) { rawLines.add('--- Voice Technical ---'); if (envelope != null) { - rawLines.add('Envelope format: VE1 compact'); + rawLines.add('Envelope format: VE3 compact'); rawLines.add( 'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})', ); @@ -551,17 +550,7 @@ class _MessageBubbleState extends State { rawLines.add( 'Estimated duration ms (envelope): ${envelope.durationMs}', ); - rawLines.add('Envelope senderKey6: ${envelope.senderKey6}'); - rawLines.add('Envelope ts: ${envelope.timestampSec}'); 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 { rawLines.add('Envelope format: unknown'); } @@ -604,8 +593,6 @@ class _MessageBubbleState extends State { rawLines.add( '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}'); if (imageSession != null) { @@ -916,11 +903,7 @@ class _MessageBubbleState extends State { _detailRow( context, label: l10n.envelope, - value: envelope != null - ? 'VE1 compact' - : legacyVoicePacket != null - ? 'Legacy V packet' - : l10n.unknown, + value: envelope != null ? 'VE3 compact' : l10n.unknown, ), if (voiceSession != null) _detailRow( @@ -937,6 +920,21 @@ class _MessageBubbleState extends State { ? l10n.yes : 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) _detailRow( context, @@ -988,6 +986,21 @@ class _MessageBubbleState extends State { ? l10n.yes : 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) _detailRow( context, @@ -1147,6 +1160,20 @@ class _MessageBubbleState extends State { ); } + 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( BuildContext context, { required String label, diff --git a/lib/widgets/messages/message_bubble_header.dart b/lib/widgets/messages/message_bubble_header.dart index f71b6f0..496d739 100644 --- a/lib/widgets/messages/message_bubble_header.dart +++ b/lib/widgets/messages/message_bubble_header.dart @@ -65,8 +65,28 @@ Widget buildBubbleMetaFooter( ).textTheme.labelSmall?.color?.withValues(alpha: 0.68); final items = []; + 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([ Icon(Icons.alt_route, size: 11, color: metaColor), const SizedBox(width: 3), diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index 6adfe7c..f31cfbd 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../models/ble_packet_log.dart'; import '../../models/message.dart'; import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; import '../../services/mesh_map_nodes_service.dart'; class MessageTraceSheet extends StatefulWidget { @@ -30,9 +31,7 @@ class _MessageTraceSheetState extends State { Future<_TraceResult> _loadTrace() async { final connectionProvider = context.read(); - final nodes = await MeshMapNodesService.fetchNodes( - cacheTtl: MeshMapNodesService.traceCacheTtl, - ); + final contactsProvider = context.read(); final packetPath = _extractPathFromPacketLogs( logs: connectionProvider.bleService.packetLogs, message: widget.message, @@ -43,45 +42,27 @@ class _MessageTraceSheetState extends State { ? _toPrefixHex(widget.message.recipientPublicKey) : _toPrefixHex(connectionProvider.deviceInfo.publicKey); - 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 localNodes = _localNodesFromContacts(contactsProvider); + var trace = _buildTraceResult( + nodes: localNodes, + packetPath: packetPath, + senderPrefix: senderPrefix, + recipientPrefix: recipientPrefix, + ); + if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) { + return trace; } - // Fallback when packet path is unavailable. - final inferred = _inferRelaysFromHopCount( - nodes: nodes, - sender: senderNode, - recipient: recipientNode, - relayCount: math.max(0, widget.message.pathLen), + final remoteNodes = await MeshMapNodesService.fetchNodes( + cacheTtl: MeshMapNodesService.traceCacheTtl, ); - final matchedPathNodes = [ - if (senderNode != null) senderNode, - ...inferred, - if (recipientNode != null) recipientNode, - ]; - - return _TraceResult( - mode: TraceMode.hopCountInference, - sender: senderNode, - recipient: recipientNode, - pathHashes: const [], - matchedPathNodes: matchedPathNodes, + trace = _buildTraceResult( + nodes: _mergeNodes(localNodes, remoteNodes), + packetPath: packetPath, + senderPrefix: senderPrefix, + recipientPrefix: recipientPrefix, ); + return trace; } @override @@ -109,12 +90,16 @@ class _MessageTraceSheetState extends State { } final trace = snapshot.data!; - final mapPoints = trace.matchedPathNodes - .whereType() + final routeEntries = _displayRouteEntries(trace); + final concretePathNodes = routeEntries + .where((entry) => entry.node != null) + .map((entry) => entry.node!) + .toList(); + final mapPoints = concretePathNodes .map((n) => LatLng(n.latitude, n.longitude)) .toList(); final hasMapPath = mapPoints.length >= 2; - final relayNodes = _relayNodes(trace.matchedPathNodes); + final relayNodes = _relayNodes(trace); return SizedBox( height: MediaQuery.of(context).size.height * 0.75, @@ -197,9 +182,7 @@ class _MessageTraceSheetState extends State { ], ), flutter_map.MarkerLayer( - markers: trace.matchedPathNodes - .whereType() - .toList() + markers: concretePathNodes .asMap() .entries .map( @@ -216,10 +199,7 @@ class _MessageTraceSheetState extends State { entry.key == 0 ? Colors.green : (entry.key == - trace.matchedPathNodes - .whereType< - MeshMapNode - >() + concretePathNodes .length - 1 ? Colors.red @@ -250,6 +230,48 @@ class _MessageTraceSheetState extends State { ), ), 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: const EdgeInsets.symmetric(horizontal: 16), child: Text( @@ -288,12 +310,71 @@ class _MessageTraceSheetState extends State { ); } - List _relayNodes(List path) { - final concrete = path.whereType().toList(); + List _relayNodes(_TraceResult trace) { + final concrete = trace.matchedPathNodes.whereType().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 []; return concrete.sublist(1, concrete.length - 1); } + List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) { + final pathNodes = trace.matchedPathNodes.whereType().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? key) { if (key == null || key.isEmpty) return null; final take = key.length < 6 ? key.length : 6; @@ -312,6 +393,98 @@ class _MessageTraceSheetState extends State { return matches.isEmpty ? null : matches.first; } + List _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() + .toList(); + } + + List _mergeNodes( + List preferred, + List fallback, + ) { + final merged = {}; + 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 nodes, + required List? 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 = [ + 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().length; + return concreteCount >= expectedRelayCount + 2; + } + List? _extractPathFromPacketLogs({ required List logs, required Message message, @@ -371,11 +544,6 @@ class _MessageTraceSheetState extends State { .where((n) => n.publicKey.startsWith(senderPrefix)) .toList(); 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)); @@ -461,3 +629,23 @@ class _TraceResult { 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)), + ); + } +} diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index 715521d..840c809 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; import '../../models/message.dart'; import '../../providers/app_provider.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; +import '../../providers/messages_provider.dart'; import '../../providers/voice_provider.dart'; import '../../utils/transmission_target_resolver.dart'; import '../../utils/voice_message_parser.dart'; @@ -28,7 +30,9 @@ class VoiceMessageBubble extends StatefulWidget { class _VoiceMessageBubbleState extends State { static const int _maxFetchHops = 3; + static const Duration _recentInboundActivityWindow = Duration(seconds: 3); bool _isRequesting = false; + bool _isPartialRequest = false; bool _autoPlayWhenReady = false; String? _errorText; Timer? _requestTimeoutTimer; @@ -55,6 +59,10 @@ class _VoiceMessageBubbleState extends State { return Consumer( builder: (context, voiceProvider, _) { + final transferCount = context.select( + (provider) => + provider.transferCountForSession(voiceSessionId: voiceId), + ); final contactsProvider = context.read(); final session = voiceProvider.session(voiceId); final envelope = VoiceEnvelope.tryParseText(widget.message.text); @@ -63,11 +71,10 @@ class _VoiceMessageBubbleState extends State { isSentByMe: widget.isSentByMe, recipientPublicKey: widget.message.recipientPublicKey, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, - senderKey6FromEnvelope: envelope?.senderKey6, senderName: widget.message.senderName, ); - final effectivePathLen = sender != null && sender.outPathLen >= 0 - ? sender.outPathLen + final effectivePathLen = sender != null && sender.routeHasPath + ? sender.routeHopCount : widget.message.pathLen; final isPlaying = voiceProvider.isPlaying(voiceId); final isComplete = voiceProvider.isComplete(voiceId); @@ -77,6 +84,7 @@ class _VoiceMessageBubbleState extends State { if (!mounted) return; setState(() { _isRequesting = false; + _isPartialRequest = false; _errorText = null; }); }); @@ -93,9 +101,17 @@ class _VoiceMessageBubbleState extends State { final received = session?.receivedCount ?? 0; final total = session?.total ?? envelope?.total ?? 0; final playbackProgress = voiceProvider.playbackProgress(voiceId); - final requestProgress = total > 0 - ? (received / total).clamp(0.0, 1.0) - : null; + final packetPresence = + session?.packets.map((packet) => packet != null).toList() ?? + List.filled(total, false); + final isReceivingData = + !_isRequesting && + !isComplete && + _hasRecentInboundActivity( + lastReceivedAt: session?.lastPacketAt, + received: received, + total: total, + ); final durationSec = session?.estimatedDurationSeconds ?? ((envelope?.durationMs ?? 0) / 1000.0); @@ -117,32 +133,37 @@ class _VoiceMessageBubbleState extends State { final txEstimateLabel = _formatTransmitEstimate(txEstimate); final eta = voiceProvider.estimateRemainingTransferTime(voiceId); + Future 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( mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: () async { - 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, - ); - }, + onTap: handlePrimaryTap, borderRadius: BorderRadius.circular(24), child: Container( width: 48, @@ -156,11 +177,16 @@ class _VoiceMessageBubbleState extends State { child: Icon( isPlaying ? Icons.stop - : (_isRequesting ? Icons.close : Icons.play_arrow), + : (_isRequesting + ? Icons.close + : (isReceivingData + ? Icons.downloading_rounded + : Icons.play_arrow)), size: 28, color: widget.isSentByMe ? 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 { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (isPlaying || _isRequesting) + if (isPlaying) SizedBox( width: 100, child: LinearProgressIndicator( - value: isPlaying ? playbackProgress : requestProgress, + value: playbackProgress, 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 _WaveformBar(isComplete: isComplete, bars: waveformBars), const SizedBox(height: 4), @@ -189,11 +223,15 @@ class _VoiceMessageBubbleState extends State { total: total, isComplete: isComplete, isRequesting: _isRequesting, + isReceivingData: isReceivingData, + isPartialRequest: _isPartialRequest, errorText: _errorText, requestingLabel: AppLocalizations.of( context, )!.requestingVoice, eta: eta, + isSentByMe: widget.isSentByMe, + transferCount: transferCount, ), style: TextStyle( fontSize: 11, @@ -221,6 +259,7 @@ class _VoiceMessageBubbleState extends State { if (_isRequesting) return; setState(() { _isRequesting = true; + _isPartialRequest = false; _autoPlayWhenReady = true; _errorText = null; }); @@ -236,7 +275,6 @@ class _VoiceMessageBubbleState extends State { isSentByMe: widget.isSentByMe, recipientPublicKey: widget.message.recipientPublicKey, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, - senderKey6FromEnvelope: envelope?.senderKey6, senderName: widget.message.senderName, maxFetchHops: _maxFetchHops, ); @@ -287,7 +325,6 @@ class _VoiceMessageBubbleState extends State { isSentByMe: widget.isSentByMe, recipientPublicKey: widget.message.recipientPublicKey, senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, - senderKey6FromEnvelope: envelope?.senderKey6, senderName: widget.message.senderName, maxFetchHops: _maxFetchHops, ); @@ -329,9 +366,18 @@ class _VoiceMessageBubbleState extends State { } } - 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( - '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 { ); final isPartialResume = missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets; + if (_isPartialRequest != isPartialResume && mounted) { + setState(() { + _isPartialRequest = isPartialResume; + }); + } final request = isPartialResume ? VoiceFetchRequest( sessionId: sessionId, want: 'missing', missingIndices: missing, requesterKey6: requesterKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, - version: 2, ) : VoiceFetchRequest( sessionId: sessionId, requesterKey6: requesterKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, - version: 2, ); try { 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( contactPath: sender.outPath, - contactPathLen: sender.outPathLen, + contactPathLen: sender.routeSignedPathLen, payload: request.encodeBinary(), ); } catch (_) { @@ -388,8 +435,8 @@ class _VoiceMessageBubbleState extends State { } // Timeout = 2× estimated LoRa airtime (min 30s). - final effectivePathLen = sender.outPathLen >= 0 - ? sender.outPathLen + final effectivePathLen = sender.routeHasPath + ? sender.routeHopCount : pathLen; final estimatedDurationMs = envelope != null && @@ -433,6 +480,7 @@ class _VoiceMessageBubbleState extends State { _showToast(AppLocalizations.of(context)!.voiceUnavailable); setState(() { _isRequesting = false; + _isPartialRequest = false; _autoPlayWhenReady = false; _errorText = AppLocalizations.of(context)!.voiceUnavailable; }); @@ -442,6 +490,7 @@ class _VoiceMessageBubbleState extends State { if (!mounted) return; setState(() { _isRequesting = false; + _isPartialRequest = false; _autoPlayWhenReady = false; }); } @@ -453,6 +502,7 @@ class _VoiceMessageBubbleState extends State { _showToast('Voice receive canceled'); setState(() { _isRequesting = false; + _isPartialRequest = false; _autoPlayWhenReady = false; _errorText = 'Voice receive canceled'; }); @@ -497,19 +547,33 @@ class _VoiceMessageBubbleState extends State { required int total, required bool isComplete, required bool isRequesting, + required bool isReceivingData, + required bool isPartialRequest, required String? errorText, required String requestingLabel, required Duration? eta, + required bool isSentByMe, + required int transferCount, }) { if (errorText != null) return errorText; final progress = total > 0 ? ' ($received/$total)' : ''; 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) { - 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 _resolveWaveformBars({ @@ -592,6 +656,86 @@ class _VoiceMessageBubbleState extends State { final seconds = eta.inSeconds % 60; 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 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.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. diff --git a/test/models/contact_route_codec_test.dart b/test/models/contact_route_codec_test.dart new file mode 100644 index 0000000..7595f50 --- /dev/null +++ b/test/models/contact_route_codec_test.dart @@ -0,0 +1,110 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; + +void main() { + Contact buildContact({ + required int signedPathLen, + required Uint8List outPath, + }) { + return Contact( + publicKey: Uint8List.fromList(List.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()), + ); + }); + + test('rejects invalid tokens', () { + expect( + () => ContactRouteCodec.parse('AA,XYZ'), + throwsA(isA()), + ); + expect( + () => ContactRouteCodec.parse('AAA'), + throwsA(isA()), + ); + }); + + test('rejects routes over 64 bytes', () { + final tooLong = List.filled(22, 'AABBCC').join(','); + expect( + () => ContactRouteCodec.parse(tooLong), + throwsA(isA()), + ); + }); + }); + + 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'); + }); + }); +} diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 0c5961b..ef72053 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -233,4 +233,49 @@ void main() { expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); }); }); + + group('ContactsProvider route updates', () { + late ContactsProvider provider; + late Uint8List publicKey; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + publicKey = createPublicKey(64); + provider.addOrUpdateContact( + createContact(key: publicKey, type: ContactType.chat, name: 'Routey'), + ); + }); + + test('optimistically stores a multi-byte route locally', () { + final route = ContactRouteCodec.parse('AABB,CCDD'); + + provider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.routeHasPath, isTrue); + expect(updated.routeHashSize, 2); + expect(updated.routeHopCount, 2); + expect(updated.routeCanonicalText, 'AABB,CCDD'); + }); + + test('resetContactRouteLocal clears route state', () { + final route = ContactRouteCodec.parse('AA,BB,CC'); + provider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + + provider.resetContactRouteLocal(publicKey); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.routeHasPath, isFalse); + expect(updated.routeSummary, 'Flood/Unknown'); + }); + }); } diff --git a/test/providers/helpers/session_metadata_restore_test.dart b/test/providers/helpers/session_metadata_restore_test.dart index e706527..891ab48 100644 --- a/test/providers/helpers/session_metadata_restore_test.dart +++ b/test/providers/helpers/session_metadata_restore_test.dart @@ -1,4 +1,7 @@ +import 'dart:typed_data'; + 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/utils/image_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; @@ -13,8 +16,6 @@ void main() { mode: VoicePacketMode.mode1200, total: 4, durationMs: 4000, - senderKey6: 'AABBCCDDEEFF', - timestampSec: 123456, ); final imageEnvelope = ImageEnvelope( sessionId: '195cb2fb', @@ -23,24 +24,62 @@ void main() { width: 118, height: 256, sizeBytes: 1069, - senderKey6: 'FE8B30EE05FC', - timestampSec: 123457, ); final restored = restoreSessionMetadataFromMessages([ - 'plain text', - voiceEnvelope.encodeText(), - imageEnvelope.encode(), + Message( + id: 'plain', + 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( restored.voiceSenderKeyBySession, equals({'00112233': 'aabbccddeeff'}), ); + expect( + restored.imageSenderKeyBySession, + equals({'195cb2fb': 'fe8b30ee05fc'}), + ); expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'})); expect( - restored.imageEnvelopeBySession['195cb2fb']?.senderKey6, - equals('fe8b30ee05fc'), + restored.imageEnvelopeBySession['195cb2fb']?.sessionId, + equals('195cb2fb'), ); }, ); @@ -53,8 +92,6 @@ void main() { width: 100, height: 100, sizeBytes: 900, - senderKey6: '001122334455', - timestampSec: 100, ); final second = ImageEnvelope( sessionId: '195cb2fb', @@ -63,18 +100,40 @@ void main() { width: 118, height: 256, sizeBytes: 1069, - senderKey6: 'AABBCCDDEEFF', - timestampSec: 101, ); final restored = restoreSessionMetadataFromMessages([ - first.encode(), - second.encode(), + Message( + 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['195cb2fb']?.senderKey6, + restored.imageSenderKeyBySession['195cb2fb'], equals('aabbccddeeff'), ); expect( diff --git a/test/providers/image_provider_cancel_test.dart b/test/providers/image_provider_cancel_test.dart index e60c81a..fe68ad9 100644 --- a/test/providers/image_provider_cancel_test.dart +++ b/test/providers/image_provider_cancel_test.dart @@ -20,8 +20,6 @@ void main() { width: 32, height: 32, sizeBytes: 4, - senderKey6: 'aabbccddeeff', - timestampSec: 1700000000, ); final fragment = ImagePacket( sessionId: sessionId, diff --git a/test/providers/image_provider_swarm_test.dart b/test/providers/image_provider_swarm_test.dart new file mode 100644 index 0000000..2e2647b --- /dev/null +++ b/test/providers/image_provider_swarm_test.dart @@ -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.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 = []; + 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); + }); + }); +} diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index 6d2ab89..7b490c2 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -64,22 +64,54 @@ void main() { 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(); provider.addSentMessage( - _buildDirectMessage('m1b'), + _buildDirectMessage('m1c'), contact: _buildContact(), ); - provider.markMessageSent('m1b', 78, 250); - + provider.markMessageSent('m1c', 0, 0); 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); + expect(provider.messages.single.expectedAckTag, isNull); + + provider.markMessageSent('m1c', 79, 250); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + expect(provider.messages.single.expectedAckTag, 79); + + provider.markMessageDelivered(79, 190); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.delivered, + ); + expect(provider.messages.single.roundTripTimeMs, 190); }); test('channel messages are marked sent immediately', () { @@ -180,6 +212,31 @@ void main() { 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 { final provider = MessagesProvider(); final contact = _buildContact(); @@ -190,19 +247,17 @@ void main() { }; provider.addSentMessage( - _buildDirectMessage('m5').copyWith( - retryAttempt: 3, - usedFloodFallback: true, - ), + _buildDirectMessage( + 'm5', + ).copyWith(retryAttempt: 3, usedFloodFallback: true), contact: contact, ); provider.markMessageFailed('m5'); provider.addSentMessage( - _buildDirectMessage('m6').copyWith( - retryAttempt: 3, - usedFloodFallback: true, - ), + _buildDirectMessage( + 'm6', + ).copyWith(retryAttempt: 3, usedFloodFallback: true), contact: contact, ); provider.markMessageFailed('m6'); @@ -222,26 +277,21 @@ void main() { }; provider.addSentMessage( - _buildDirectMessage('m7').copyWith( - retryAttempt: 3, - usedFloodFallback: true, - ), + _buildDirectMessage( + 'm7', + ).copyWith(retryAttempt: 3, usedFloodFallback: true), contact: contact, ); provider.markMessageFailed('m7'); - provider.addSentMessage( - _buildDirectMessage('m8'), - contact: contact, - ); + provider.addSentMessage(_buildDirectMessage('m8'), contact: contact); provider.markMessageSent('m8', 123, 10); provider.markMessageDelivered(123, 150); provider.addSentMessage( - _buildDirectMessage('m9').copyWith( - retryAttempt: 3, - usedFloodFallback: true, - ), + _buildDirectMessage( + 'm9', + ).copyWith(retryAttempt: 3, usedFloodFallback: true), contact: contact, ); provider.markMessageFailed('m9'); diff --git a/test/providers/messages_provider_voice_test.dart b/test/providers/messages_provider_voice_test.dart index d6c5b45..0a20b74 100644 --- a/test/providers/messages_provider_voice_test.dart +++ b/test/providers/messages_provider_voice_test.dart @@ -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_contact_location.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:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -15,15 +16,13 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - test('marks VE2 envelope messages as voice', () { + test('marks VE3 envelope messages as voice', () { final provider = MessagesProvider(); final envelope = VoiceEnvelope( sessionId: 'deafbead', mode: VoicePacketMode.mode1200, total: 3, durationMs: 2400, - senderKey6: 'aabbccddeeff', - timestampSec: 1700000000, ); final message = Message( @@ -45,7 +44,7 @@ void main() { 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 packet = VoicePacket( sessionId: '00112233', @@ -69,8 +68,8 @@ void main() { provider.addMessage(message); final stored = provider.messages.single; - expect(stored.isVoice, isTrue); - expect(stored.voiceId, equals('00112233')); + expect(stored.isVoice, isFalse); + expect(stored.voiceId, isNull); }); 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.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.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')); + }); }); } diff --git a/test/utils/image_message_parser_test.dart b/test/utils/image_message_parser_test.dart index 6c5512d..26001d6 100644 --- a/test/utils/image_message_parser_test.dart +++ b/test/utils/image_message_parser_test.dart @@ -3,7 +3,7 @@ import 'package:meshcore_sar_app/utils/image_message_parser.dart'; void main() { group('ImageEnvelope', () { - test('encodes and parses IE2 with compressed session id', () { + test('encodes and parses IE4 with compressed session id', () { final env = ImageEnvelope( sessionId: '0000000a', format: ImageFormat.avif, @@ -11,12 +11,10 @@ void main() { width: 256, height: 171, sizeBytes: 2100, - senderKey6: 'aabbccddeeff', - timestampSec: 1700000000, ); final text = env.encode(); - expect(text.startsWith('IE2:'), isTrue); + expect(text.startsWith('IE4:'), isTrue); expect(text.split(':')[1], equals('a')); final parsed = ImageEnvelope.tryParse(text); @@ -27,26 +25,25 @@ void main() { expect(parsed.width, equals(256)); expect(parsed.height, equals(171)); expect(parsed.sizeBytes, equals(2100)); - expect(parsed.senderKey6, equals('aabbccddeeff')); - expect(parsed.version, equals(2)); + expect(parsed.version, equals(4)); }); test('rejects IE1 legacy prefix', () { const legacy = 'IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1'; expect(ImageEnvelope.tryParse(legacy), isNull); }); + }); group('ImageFetchRequest', () { - test('encodes and parses IR2 with compressed sid', () { + test('encodes and parses IR4 with compressed sid', () { final req = ImageFetchRequest( sessionId: '0000000a', requesterKey6: 'ffeeddccbbaa', - timestampSec: 1700000001, ); final text = req.encode(); - expect(text.startsWith('IR2:'), isTrue); + expect(text.startsWith('IR4:'), isTrue); expect(text.split(':')[1], equals('a')); final parsed = ImageFetchRequest.tryParse(text); @@ -54,7 +51,7 @@ void main() { expect(parsed!.sessionId, equals('0000000a')); expect(parsed.want, equals('all')); expect(parsed.requesterKey6, equals('ffeeddccbbaa')); - expect(parsed.version, equals(2)); + expect(parsed.version, equals(4)); }); test('encodes and parses compact missing index ranges', () { @@ -63,7 +60,6 @@ void main() { want: 'missing', missingIndices: const [0, 1, 2, 5, 6, 8], requesterKey6: 'ffeeddccbbaa', - timestampSec: 1700000001, ); final text = req.encode(); @@ -86,7 +82,6 @@ void main() { want: 'missing', missingIndices: const [0, 2, 5], requesterKey6: 'ffeeddccbbaa', - timestampSec: 1700000001, ); final payload = req.encodeBinary(); @@ -98,7 +93,7 @@ void main() { expect(parsed.want, equals('missing')); expect(parsed.missingIndices, equals([0, 2, 5])); expect(parsed.requesterKey6, equals('ffeeddccbbaa')); - expect(parsed.version, equals(2)); + expect(parsed.version, equals(4)); }); }); diff --git a/test/utils/media_swarm_protocol_test.dart b/test/utils/media_swarm_protocol_test.dart new file mode 100644 index 0000000..2bdc505 --- /dev/null +++ b/test/utils/media_swarm_protocol_test.dart @@ -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, + ); + }); + }); +} diff --git a/test/utils/voice_message_parser_test.dart b/test/utils/voice_message_parser_test.dart index 0908f3d..ead89d3 100644 --- a/test/utils/voice_message_parser_test.dart +++ b/test/utils/voice_message_parser_test.dart @@ -10,13 +10,11 @@ void main() { mode: VoicePacketMode.mode1200, total: 4, durationMs: 3000, - senderKey6: 'aabbccddeeff', - timestampSec: 1700000000, ); final text = env.encodeText(); expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue); - expect(text.startsWith('VE2:'), isTrue); + expect(text.startsWith('VE3:'), isTrue); expect(text.split(':')[1], equals('a')); final parsed = VoiceEnvelope.tryParseText(text); @@ -25,12 +23,11 @@ void main() { expect(parsed.mode, equals(VoicePacketMode.mode1200)); expect(parsed.total, equals(4)); expect(parsed.durationMs, equals(3000)); - expect(parsed.senderKey6, equals('aabbccddeeff')); - expect(parsed.version, equals(2)); + expect(parsed.version, equals(3)); }); 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); }); @@ -45,11 +42,10 @@ void main() { final req = VoiceFetchRequest( sessionId: '0000000a', requesterKey6: 'ffeeddccbbaa', - timestampSec: 1700000001, ); final text = req.encodeText(); expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue); - expect(text.startsWith('VR2:'), isTrue); + expect(text.startsWith('VR3:'), isTrue); expect(text.split(':')[1], equals('a')); final parsed = VoiceFetchRequest.tryParseText(text); @@ -57,13 +53,13 @@ void main() { expect(parsed!.sessionId, equals('0000000a')); expect(parsed.want, equals('all')); expect(parsed.requesterKey6, equals('ffeeddccbbaa')); - expect(parsed.version, equals(2)); + expect(parsed.version, equals(3)); }); test('rejects invalid request payload', () { expect( VoiceFetchRequest.tryParseText( - 'VR2:a:chunk:ffeeddccbbaa:s44we9', + 'VR3:a:chunk:ffeeddccbbaa', ), isNull, ); @@ -80,7 +76,6 @@ void main() { want: 'missing', missingIndices: const [0, 1, 2, 3, 7], requesterKey6: 'ffeeddccbbaa', - timestampSec: 1700000001, ); final text = req.encodeText(); expect(text, contains(':m0-3.7:')); @@ -97,7 +92,6 @@ void main() { want: 'missing', missingIndices: const [1, 4], requesterKey6: 'ffeeddccbbaa', - timestampSec: 1700000001, ); final payload = req.encodeBinary(); @@ -109,7 +103,7 @@ void main() { expect(parsed.want, equals('missing')); expect(parsed.missingIndices, equals([1, 4])); 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', () { - 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]))); - }); - + group('VoicePacket binary format', () { test('constructs binary datagram from actual packet data', () { final actualCodec2 = Uint8List.fromList([ 0xD3, @@ -166,17 +144,15 @@ void main() { final datagram = pkt.encodeBinary(); expect(datagram[0], equals(0x56)); // magic 'V' expect(datagram.sublist(1, 5), equals(Uint8List.fromList([1, 2, 3, 4]))); - expect(datagram[5], equals(VoicePacketMode.mode1300.id)); - expect(datagram[6], equals(2)); - expect(datagram[7], equals(5)); - expect(datagram.sublist(8), equals(actualCodec2)); + expect(datagram[5], equals(2)); + expect(datagram.sublist(6), equals(actualCodec2)); final parsed = VoicePacket.tryParseBinary(datagram); expect(parsed, isNotNull); expect(parsed!.sessionId, equals('01020304')); expect(parsed.mode, equals(VoicePacketMode.mode1300)); expect(parsed.index, equals(2)); - expect(parsed.total, equals(5)); + expect(parsed.total, equals(0)); expect(parsed.codec2Data, equals(actualCodec2)); }); });